8ef2877040
* Cleanup graphql setting mutation
* Validate values read from config
* Generate server-reference.conf files from ServerConfig
* Remove unnecessary enum value handling in config value update
Commit df0078b725 introduced the usage of config4k, which handles enums automatically. Thus, this handling is outdated and not needed anymore
* Generate gql SettingsType from ServerConfig
* Extract settings backup logic
* Generate settings backup files
* Move "group" arg to second position
To make it easier to detect and have it at the same position consistently for all settings.
* Remove setting generation from compilation
* Extract setting generation code into new module
* Extract pure setting generation code into new module
* Remove generated settings files from src tree
* Force each setting to set a default value
760 lines
30 KiB
Kotlin
760 lines
30 KiB
Kotlin
package suwayomi.tachidesk.opds.impl
|
|
|
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
import org.jetbrains.exposed.sql.SortOrder
|
|
import suwayomi.tachidesk.i18n.MR
|
|
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
|
|
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
|
import suwayomi.tachidesk.opds.constants.OpdsConstants
|
|
import suwayomi.tachidesk.opds.dto.OpdsMangaDetails
|
|
import suwayomi.tachidesk.opds.dto.OpdsMangaFilter
|
|
import suwayomi.tachidesk.opds.dto.OpdsSearchCriteria
|
|
import suwayomi.tachidesk.opds.dto.PrimaryFilterType
|
|
import suwayomi.tachidesk.opds.model.OpdsContentXml
|
|
import suwayomi.tachidesk.opds.model.OpdsEntryXml
|
|
import suwayomi.tachidesk.opds.model.OpdsLinkXml
|
|
import suwayomi.tachidesk.opds.repository.ChapterRepository
|
|
import suwayomi.tachidesk.opds.repository.MangaRepository
|
|
import suwayomi.tachidesk.opds.repository.NavigationRepository
|
|
import suwayomi.tachidesk.opds.util.OpdsDateUtil
|
|
import suwayomi.tachidesk.opds.util.OpdsXmlUtil
|
|
import suwayomi.tachidesk.server.serverConfig
|
|
import java.util.Locale
|
|
|
|
/**
|
|
* Builds OPDS feeds by fetching data from repositories and converting it into XML format.
|
|
*/
|
|
object OpdsFeedBuilder {
|
|
private fun currentFormattedTime() = OpdsDateUtil.formatCurrentInstantForOpds()
|
|
|
|
/**
|
|
* Generates the root navigation feed for the OPDS catalog.
|
|
* @param baseUrl The base URL for constructing links.
|
|
* @param locale The locale for localization.
|
|
* @return An XML string representing the root feed.
|
|
*/
|
|
fun getRootFeed(
|
|
baseUrl: String,
|
|
locale: Locale,
|
|
): String {
|
|
val navItems = NavigationRepository.getRootNavigationItems(locale)
|
|
val builder =
|
|
FeedBuilderInternal(
|
|
baseUrl,
|
|
"", // Root path is empty
|
|
MR.strings.opds_feeds_root.localized(locale),
|
|
locale,
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
|
null,
|
|
)
|
|
builder.totalResults = navItems.size.toLong()
|
|
builder.entries.addAll(
|
|
navItems.map { item ->
|
|
OpdsEntryXml(
|
|
id = "urn:suwayomi:navigation:root:${item.id}",
|
|
title = item.title,
|
|
updated = currentFormattedTime(),
|
|
link =
|
|
listOf(
|
|
OpdsLinkXml(
|
|
rel = OpdsConstants.LINK_REL_SUBSECTION,
|
|
href = "$baseUrl/${item.id}?lang=${locale.toLanguageTag()}",
|
|
type = item.linkType,
|
|
title = item.title,
|
|
),
|
|
),
|
|
content = OpdsContentXml(type = "text", value = item.description),
|
|
)
|
|
},
|
|
)
|
|
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
|
}
|
|
|
|
/**
|
|
* Generates the history feed showing recently read chapters.
|
|
* @param baseUrl The base URL for constructing links.
|
|
* @param pageNum The page number for pagination.
|
|
* @param locale The locale for localization.
|
|
* @return An XML string representing the history feed.
|
|
*/
|
|
suspend fun getHistoryFeed(
|
|
baseUrl: String,
|
|
pageNum: Int,
|
|
locale: Locale,
|
|
): String {
|
|
val (historyItems, total) = ChapterRepository.getHistory(pageNum)
|
|
val builder =
|
|
FeedBuilderInternal(
|
|
baseUrl,
|
|
"history",
|
|
MR.strings.opds_feeds_history_title.localized(locale),
|
|
locale,
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
|
pageNum,
|
|
)
|
|
builder.totalResults = total
|
|
builder.entries.addAll(
|
|
historyItems.map { item ->
|
|
val mangaDetails = OpdsMangaDetails(item.mangaId, item.mangaTitle, item.mangaThumbnailUrl, item.mangaAuthor)
|
|
OpdsEntryBuilder.createChapterListEntry(item.chapter, mangaDetails, baseUrl, true, locale)
|
|
},
|
|
)
|
|
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
|
}
|
|
|
|
/**
|
|
* Generates a feed for search results based on the provided criteria.
|
|
* @param criteria The search criteria.
|
|
* @param baseUrl The base URL for constructing links.
|
|
* @param pageNum The page number for pagination.
|
|
* @param locale The locale for localization.
|
|
* @return An XML string representing the search results feed.
|
|
*/
|
|
fun getSearchFeed(
|
|
criteria: OpdsSearchCriteria,
|
|
baseUrl: String,
|
|
pageNum: Int,
|
|
locale: Locale,
|
|
): String {
|
|
val (mangaEntries, total) = MangaRepository.findMangaByCriteria(criteria)
|
|
val builder =
|
|
FeedBuilderInternal(
|
|
baseUrl,
|
|
"library/series",
|
|
MR.strings.opds_feeds_search_results_title.localized(locale),
|
|
locale,
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
|
pageNum,
|
|
isSearchFeed = true,
|
|
)
|
|
builder.totalResults = total
|
|
builder.entries.addAll(mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(it, baseUrl, locale) })
|
|
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
|
}
|
|
|
|
/**
|
|
* Generates a generic library feed based on various filtering and sorting criteria.
|
|
* @param criteria The filtering criteria.
|
|
* @param baseUrl The base URL for constructing links.
|
|
* @param pageNum The page number for pagination.
|
|
* @param sort The sorting parameter.
|
|
* @param filter The filtering parameter.
|
|
* @param locale The locale for localization.
|
|
* @return An XML string representing the library feed.
|
|
*/
|
|
fun getLibraryFeed(
|
|
criteria: OpdsMangaFilter,
|
|
baseUrl: String,
|
|
pageNum: Int,
|
|
sort: String?,
|
|
filter: String?,
|
|
locale: Locale,
|
|
isSearch: Boolean,
|
|
): String {
|
|
val result = MangaRepository.getLibraryManga(pageNum, sort, filter, criteria)
|
|
|
|
val feedTitle =
|
|
when (criteria.primaryFilter) {
|
|
PrimaryFilterType.SOURCE ->
|
|
MR.strings.opds_feeds_library_source_specific_title.localized(
|
|
locale,
|
|
result.feedTitleComponent ?: criteria.sourceId.toString(),
|
|
)
|
|
PrimaryFilterType.CATEGORY ->
|
|
MR.strings.opds_feeds_category_specific_title.localized(
|
|
locale,
|
|
result.feedTitleComponent ?: criteria.categoryId.toString(),
|
|
)
|
|
PrimaryFilterType.GENRE ->
|
|
MR.strings.opds_feeds_genre_specific_title.localized(
|
|
locale,
|
|
result.feedTitleComponent ?: "Unknown",
|
|
)
|
|
PrimaryFilterType.STATUS -> {
|
|
val statusName = NavigationRepository.getStatuses(locale).find { it.id == criteria.statusId }?.title
|
|
MR.strings.opds_feeds_status_specific_title.localized(locale, statusName ?: criteria.statusId.toString())
|
|
}
|
|
PrimaryFilterType.LANGUAGE -> {
|
|
val langName = Locale.forLanguageTag(criteria.langCode ?: "").getDisplayName(locale)
|
|
MR.strings.opds_feeds_language_specific_title.localized(locale, langName)
|
|
}
|
|
else -> MR.strings.opds_feeds_all_series_in_library_title.localized(locale)
|
|
}
|
|
|
|
val feedUrl =
|
|
when (criteria.primaryFilter) {
|
|
PrimaryFilterType.SOURCE -> "library/source/${criteria.sourceId}"
|
|
PrimaryFilterType.CATEGORY -> "category/${criteria.categoryId}"
|
|
PrimaryFilterType.GENRE -> "genre/${criteria.genre}"
|
|
PrimaryFilterType.STATUS -> "status/${criteria.statusId}"
|
|
PrimaryFilterType.LANGUAGE -> "language/${criteria.langCode}"
|
|
else -> "library/series"
|
|
}
|
|
|
|
val builder =
|
|
FeedBuilderInternal(
|
|
baseUrl,
|
|
feedUrl,
|
|
feedTitle,
|
|
locale,
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
|
pageNum,
|
|
currentSort = criteria.sort,
|
|
currentFilter = criteria.filter,
|
|
explicitQueryParams = criteria.toCrossFilterQueryParameters(),
|
|
isSearchFeed = isSearch,
|
|
)
|
|
builder.totalResults = result.totalCount
|
|
|
|
// Add all library facets (sort, filter, and cross-filtering)
|
|
OpdsEntryBuilder.addLibraryFacets(builder, baseUrl, criteria, locale)
|
|
|
|
builder.entries.addAll(result.mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(it, baseUrl, locale) })
|
|
|
|
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
|
}
|
|
|
|
/**
|
|
* Generates a navigation feed listing all available sources for exploration.
|
|
* @param baseUrl The base URL for constructing links.
|
|
* @param pageNum The page number for pagination.
|
|
* @param locale The locale for localization.
|
|
* @return An XML string representing the explore sources feed.
|
|
*/
|
|
fun getExploreSourcesFeed(
|
|
baseUrl: String,
|
|
pageNum: Int,
|
|
locale: Locale,
|
|
): String {
|
|
val (sourceNavEntries, total) = NavigationRepository.getExploreSources(pageNum)
|
|
val builder =
|
|
FeedBuilderInternal(
|
|
baseUrl,
|
|
"sources",
|
|
MR.strings.opds_feeds_sources_title.localized(locale),
|
|
locale,
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
|
pageNum,
|
|
)
|
|
builder.totalResults = total
|
|
builder.entries.addAll(
|
|
sourceNavEntries.map { entry ->
|
|
OpdsEntryXml(
|
|
id = "urn:suwayomi:navigation:sources:${entry.id}",
|
|
title = entry.name,
|
|
updated = currentFormattedTime(),
|
|
link =
|
|
listOf(
|
|
OpdsLinkXml(
|
|
OpdsConstants.LINK_REL_SUBSECTION,
|
|
"$baseUrl/source/${entry.id}?sort=popular&lang=${locale.toLanguageTag()}",
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
|
),
|
|
),
|
|
)
|
|
},
|
|
)
|
|
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
|
}
|
|
|
|
/**
|
|
* Generates a navigation feed listing sources for series present in the library.
|
|
* @param baseUrl The base URL for constructing links.
|
|
* @param pageNum The page number for pagination.
|
|
* @param locale The locale for localization.
|
|
* @return An XML string representing the library sources feed.
|
|
*/
|
|
fun getLibrarySourcesFeed(
|
|
baseUrl: String,
|
|
pageNum: Int,
|
|
locale: Locale,
|
|
): String {
|
|
val (sourceNavEntries, total) = NavigationRepository.getLibrarySources(pageNum)
|
|
val builder =
|
|
FeedBuilderInternal(
|
|
baseUrl,
|
|
"library/sources",
|
|
MR.strings.opds_feeds_library_sources_title.localized(locale),
|
|
locale,
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
|
pageNum,
|
|
)
|
|
builder.totalResults = total
|
|
builder.entries.addAll(
|
|
sourceNavEntries.map { entry ->
|
|
OpdsEntryXml(
|
|
id = "urn:suwayomi:navigation:library:sources:${entry.id}",
|
|
title = entry.name,
|
|
updated = currentFormattedTime(),
|
|
link =
|
|
listOf(
|
|
OpdsLinkXml(
|
|
OpdsConstants.LINK_REL_SUBSECTION,
|
|
"$baseUrl/library/source/${entry.id}?lang=${locale.toLanguageTag()}",
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
|
entry.name,
|
|
thrCount = entry.mangaCount?.toInt(),
|
|
),
|
|
),
|
|
)
|
|
},
|
|
)
|
|
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
|
}
|
|
|
|
/**
|
|
* Generates an acquisition feed for manga from a specific source (explore context).
|
|
* @param sourceId The ID of the source.
|
|
* @param baseUrl The base URL for constructing links.
|
|
* @param pageNum The page number for pagination.
|
|
* @param sort The sorting parameter ('popular' or 'latest').
|
|
* @param locale The locale for localization.
|
|
* @return An XML string representing the source-specific feed.
|
|
*/
|
|
suspend fun getExploreSourceFeed(
|
|
sourceId: Long,
|
|
baseUrl: String,
|
|
pageNum: Int,
|
|
sort: String,
|
|
locale: Locale,
|
|
): String {
|
|
val (mangaEntries, hasNextPage) = MangaRepository.getMangaBySource(sourceId, pageNum, sort)
|
|
val sourceNavEntry = NavigationRepository.getExploreSources(1).first.find { it.id == sourceId }
|
|
val sourceNameOrId = sourceNavEntry?.name ?: sourceId.toString()
|
|
val titleRes =
|
|
if (sort == "latest") {
|
|
MR.strings.opds_feeds_source_specific_latest_title
|
|
} else {
|
|
MR.strings.opds_feeds_source_specific_popular_title
|
|
}
|
|
val feedTitle = titleRes.localized(locale, sourceNameOrId)
|
|
val feedUrl = "source/$sourceId"
|
|
val builder =
|
|
FeedBuilderInternal(
|
|
baseUrl,
|
|
feedUrl,
|
|
feedTitle,
|
|
locale,
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
|
pageNum,
|
|
currentSort = sort,
|
|
)
|
|
builder.totalResults =
|
|
if (hasNextPage) {
|
|
(pageNum * serverConfig.opdsItemsPerPage.value + 1).toLong()
|
|
} else {
|
|
(
|
|
(pageNum - 1) *
|
|
serverConfig.opdsItemsPerPage.value +
|
|
mangaEntries.size
|
|
).toLong()
|
|
}
|
|
builder.icon = sourceNavEntry?.iconUrl
|
|
OpdsEntryBuilder.addSourceSortFacets(builder, "$baseUrl/$feedUrl", sort, locale)
|
|
builder.entries.addAll(mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(it, baseUrl, locale) })
|
|
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
|
}
|
|
|
|
/**
|
|
* Generates a navigation feed for library categories.
|
|
* @param baseUrl The base URL for constructing links.
|
|
* @param pageNum The page number for pagination.
|
|
* @param locale The locale for localization.
|
|
* @return An XML string representing the categories navigation feed.
|
|
*/
|
|
fun getCategoriesFeed(
|
|
baseUrl: String,
|
|
pageNum: Int,
|
|
locale: Locale,
|
|
): String {
|
|
val (categoryNavEntries, total) = NavigationRepository.getCategories(pageNum)
|
|
val builder =
|
|
FeedBuilderInternal(
|
|
baseUrl,
|
|
"library/categories",
|
|
MR.strings.opds_feeds_categories_title.localized(locale),
|
|
locale,
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
|
pageNum,
|
|
)
|
|
builder.totalResults = total
|
|
builder.entries.addAll(
|
|
categoryNavEntries.map { entry ->
|
|
OpdsEntryXml(
|
|
id = "urn:suwayomi:navigation:categories:${entry.id}",
|
|
title = entry.name,
|
|
updated = currentFormattedTime(),
|
|
link =
|
|
listOf(
|
|
OpdsLinkXml(
|
|
OpdsConstants.LINK_REL_SUBSECTION,
|
|
"$baseUrl/category/${entry.id}?lang=${locale.toLanguageTag()}",
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
|
entry.name,
|
|
thrCount = entry.mangaCount.toInt(),
|
|
),
|
|
),
|
|
)
|
|
},
|
|
)
|
|
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
|
}
|
|
|
|
/**
|
|
* Generates a navigation feed for library genres.
|
|
* @param baseUrl The base URL for constructing links.
|
|
* @param pageNum The page number for pagination.
|
|
* @param locale The locale for localization.
|
|
* @return An XML string representing the genres navigation feed.
|
|
*/
|
|
fun getGenresFeed(
|
|
baseUrl: String,
|
|
pageNum: Int,
|
|
locale: Locale,
|
|
): String {
|
|
val (genreNavEntries, total) = NavigationRepository.getGenres(pageNum, locale)
|
|
val builder =
|
|
FeedBuilderInternal(
|
|
baseUrl,
|
|
"library/genres",
|
|
MR.strings.opds_feeds_genres_title.localized(locale),
|
|
locale,
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
|
pageNum,
|
|
)
|
|
builder.totalResults = total
|
|
builder.entries.addAll(
|
|
genreNavEntries.map { entry ->
|
|
OpdsEntryXml(
|
|
id = "urn:suwayomi:navigation:genres:${entry.id}",
|
|
title = entry.title,
|
|
updated = currentFormattedTime(),
|
|
link =
|
|
listOf(
|
|
OpdsLinkXml(
|
|
OpdsConstants.LINK_REL_SUBSECTION,
|
|
"$baseUrl/genre/${entry.id}?lang=${locale.toLanguageTag()}",
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
|
entry.title,
|
|
thrCount = entry.mangaCount.toInt(),
|
|
),
|
|
),
|
|
)
|
|
},
|
|
)
|
|
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
|
}
|
|
|
|
/**
|
|
* Generates a navigation feed for manga publication statuses.
|
|
* @param baseUrl The base URL for constructing links.
|
|
* @param pageNum The page number (currently unused).
|
|
* @param locale The locale for localization.
|
|
* @return An XML string representing the status navigation feed.
|
|
*/
|
|
fun getStatusFeed(
|
|
baseUrl: String,
|
|
@Suppress("UNUSED_PARAMETER") pageNum: Int,
|
|
locale: Locale,
|
|
): String {
|
|
val statuses = NavigationRepository.getStatuses(locale)
|
|
val builder =
|
|
FeedBuilderInternal(
|
|
baseUrl,
|
|
"library/statuses",
|
|
MR.strings.opds_feeds_status_title.localized(locale),
|
|
locale,
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
|
null,
|
|
)
|
|
builder.totalResults = statuses.size.toLong()
|
|
builder.entries.addAll(
|
|
statuses.map { entry ->
|
|
OpdsEntryXml(
|
|
id = "urn:suwayomi:navigation:status:${entry.id}",
|
|
title = entry.title,
|
|
updated = currentFormattedTime(),
|
|
link =
|
|
listOf(
|
|
OpdsLinkXml(
|
|
OpdsConstants.LINK_REL_SUBSECTION,
|
|
"$baseUrl/status/${entry.id}?lang=${locale.toLanguageTag()}",
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
|
entry.title,
|
|
thrCount = entry.mangaCount.toInt(),
|
|
),
|
|
),
|
|
)
|
|
},
|
|
)
|
|
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
|
}
|
|
|
|
/**
|
|
* Generates a navigation feed for content languages available in the library.
|
|
* @param baseUrl The base URL for constructing links.
|
|
* @param uiLocale The locale for the user interface.
|
|
* @return An XML string representing the languages navigation feed.
|
|
*/
|
|
fun getLanguagesFeed(
|
|
baseUrl: String,
|
|
uiLocale: Locale,
|
|
): String {
|
|
val languages = NavigationRepository.getContentLanguages(uiLocale)
|
|
val builder =
|
|
FeedBuilderInternal(
|
|
baseUrl,
|
|
"library/languages",
|
|
MR.strings.opds_feeds_languages_title.localized(uiLocale),
|
|
uiLocale,
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
|
null,
|
|
)
|
|
builder.totalResults = languages.size.toLong()
|
|
builder.entries.addAll(
|
|
languages.map { entry ->
|
|
OpdsEntryXml(
|
|
id = "urn:suwayomi:navigation:language:${entry.id}",
|
|
title = entry.title,
|
|
updated = currentFormattedTime(),
|
|
link =
|
|
listOf(
|
|
OpdsLinkXml(
|
|
OpdsConstants.LINK_REL_SUBSECTION,
|
|
"$baseUrl/language/${entry.id}?lang=${uiLocale.toLanguageTag()}",
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
|
entry.title,
|
|
thrCount = entry.mangaCount.toInt(),
|
|
),
|
|
),
|
|
)
|
|
},
|
|
)
|
|
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
|
}
|
|
|
|
/**
|
|
* Generates an acquisition feed for recent chapter updates in the library.
|
|
* @param baseUrl The base URL for constructing links.
|
|
* @param pageNum The page number for pagination.
|
|
* @param locale The locale for localization.
|
|
* @return An XML string representing the library updates feed.
|
|
*/
|
|
suspend fun getLibraryUpdatesFeed(
|
|
baseUrl: String,
|
|
pageNum: Int,
|
|
locale: Locale,
|
|
): String {
|
|
val (updateItems, total) = ChapterRepository.getLibraryUpdates(pageNum)
|
|
val builder =
|
|
FeedBuilderInternal(
|
|
baseUrl,
|
|
"library-updates",
|
|
MR.strings.opds_feeds_library_updates_title.localized(locale),
|
|
locale,
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
|
pageNum,
|
|
)
|
|
builder.totalResults = total
|
|
builder.entries.addAll(
|
|
updateItems.map { item ->
|
|
val mangaDetails = OpdsMangaDetails(item.mangaId, item.mangaTitle, item.mangaThumbnailUrl, item.mangaAuthor)
|
|
OpdsEntryBuilder.createChapterListEntry(item.chapter, mangaDetails, baseUrl, true, locale)
|
|
},
|
|
)
|
|
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
|
}
|
|
|
|
/**
|
|
* Generates an acquisition feed for all chapters of a specific manga.
|
|
* @param mangaId The ID of the manga.
|
|
* @param baseUrl The base URL for constructing links.
|
|
* @param pageNum The page number for pagination.
|
|
* @param sortParam The sorting parameter for chapters.
|
|
* @param filterParam The filtering parameter for chapters.
|
|
* @param locale The locale for localization.
|
|
* @return An XML string representing the series' chapters feed.
|
|
*/
|
|
suspend fun getSeriesChaptersFeed(
|
|
mangaId: Int,
|
|
baseUrl: String,
|
|
pageNum: Int,
|
|
sortParam: String?,
|
|
filterParam: String?,
|
|
locale: Locale,
|
|
): String {
|
|
val mangaDetails =
|
|
MangaRepository.getMangaDetails(mangaId)
|
|
?: return buildNotFoundFeed(
|
|
baseUrl,
|
|
"series/$mangaId/chapters",
|
|
MR.strings.opds_error_manga_not_found.localized(locale, mangaId),
|
|
locale,
|
|
)
|
|
val (sortColumn, currentSortOrder) =
|
|
when (sortParam?.lowercase()) {
|
|
"asc", "number_asc" -> ChapterTable.sourceOrder to SortOrder.ASC
|
|
"desc", "number_desc" -> ChapterTable.sourceOrder to SortOrder.DESC
|
|
"date_asc" -> ChapterTable.date_upload to SortOrder.ASC
|
|
"date_desc" -> ChapterTable.date_upload to SortOrder.DESC
|
|
else -> ChapterTable.sourceOrder to (serverConfig.opdsChapterSortOrder.value)
|
|
}
|
|
val currentFilter = filterParam?.lowercase() ?: if (serverConfig.opdsShowOnlyUnreadChapters.value) "unread" else "all"
|
|
var (chapterEntries, totalChapters) =
|
|
ChapterRepository.getChaptersForManga(
|
|
mangaId,
|
|
pageNum,
|
|
sortColumn,
|
|
currentSortOrder,
|
|
currentFilter,
|
|
)
|
|
|
|
// If no chapters are found in the database, attempt to fetch them from the source.
|
|
if (chapterEntries.isEmpty() && totalChapters == 0L) {
|
|
try {
|
|
suwayomi.tachidesk.manga.impl.Chapter
|
|
.fetchChapterList(mangaId)
|
|
|
|
// Re-query after fetching.
|
|
val (refetchedChapters, refetchedTotal) =
|
|
ChapterRepository.getChaptersForManga(
|
|
mangaId,
|
|
pageNum,
|
|
sortColumn,
|
|
currentSortOrder,
|
|
currentFilter,
|
|
)
|
|
chapterEntries = refetchedChapters
|
|
totalChapters = refetchedTotal
|
|
} catch (e: Exception) {
|
|
KotlinLogging.logger { }.error(e) { "Failed to fetch chapters online for mangaId: $mangaId" }
|
|
}
|
|
}
|
|
|
|
val actualSortParamForLinks =
|
|
sortParam ?: run {
|
|
val prefix = if (sortColumn == ChapterTable.sourceOrder) "number" else "date"
|
|
val suffix = if (currentSortOrder == SortOrder.ASC) "asc" else "desc"
|
|
"${prefix}_$suffix"
|
|
}
|
|
val filterCounts = ChapterRepository.getChapterFilterCounts(mangaId)
|
|
val feedUrl = "series/$mangaId/chapters"
|
|
val builder =
|
|
FeedBuilderInternal(
|
|
baseUrl,
|
|
feedUrl,
|
|
MR.strings.opds_feeds_manga_chapters.localized(locale, mangaDetails.title),
|
|
locale,
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
|
pageNum,
|
|
currentSort = actualSortParamForLinks,
|
|
currentFilter = currentFilter,
|
|
)
|
|
builder.totalResults = totalChapters
|
|
mangaDetails.thumbnailUrl?.let { proxyThumbnailUrl(mangaDetails.id) }?.also {
|
|
builder.icon = it
|
|
builder.links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE, it, OpdsConstants.TYPE_IMAGE_JPEG))
|
|
builder.links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE_THUMBNAIL, it, OpdsConstants.TYPE_IMAGE_JPEG))
|
|
}
|
|
OpdsEntryBuilder.addChapterSortAndFilterFacets(
|
|
builder,
|
|
"$baseUrl/$feedUrl",
|
|
actualSortParamForLinks,
|
|
currentFilter,
|
|
locale,
|
|
filterCounts,
|
|
)
|
|
builder.entries.addAll(
|
|
chapterEntries.map { chapter ->
|
|
OpdsEntryBuilder.createChapterListEntry(chapter, mangaDetails, baseUrl, false, locale)
|
|
},
|
|
)
|
|
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
|
}
|
|
|
|
/**
|
|
* Generates an acquisition feed with detailed metadata for a single chapter.
|
|
* @param mangaId The ID of the manga.
|
|
* @param chapterSourceOrder The source order index of the chapter.
|
|
* @param baseUrl The base URL for constructing links.
|
|
* @param locale The locale for localization.
|
|
* @return An XML string representing the chapter's metadata feed.
|
|
*/
|
|
suspend fun getChapterMetadataFeed(
|
|
mangaId: Int,
|
|
chapterSourceOrder: Int,
|
|
baseUrl: String,
|
|
locale: Locale,
|
|
): String {
|
|
val mangaDetails =
|
|
MangaRepository.getMangaDetails(mangaId)
|
|
?: return buildNotFoundFeed(
|
|
baseUrl,
|
|
"series/$mangaId/chapter/$chapterSourceOrder/metadata",
|
|
MR.strings.opds_error_manga_not_found.localized(locale, mangaId),
|
|
locale,
|
|
)
|
|
val chapterMetadata =
|
|
ChapterRepository.getChapterDetailsForMetadataFeed(mangaId, chapterSourceOrder)
|
|
?: return buildNotFoundFeed(
|
|
baseUrl,
|
|
"series/$mangaId/chapter/$chapterSourceOrder/metadata",
|
|
MR.strings.opds_error_chapter_not_found.localized(locale, chapterSourceOrder),
|
|
locale,
|
|
)
|
|
|
|
val builder =
|
|
FeedBuilderInternal(
|
|
baseUrl,
|
|
"series/$mangaId/chapter/${chapterMetadata.sourceOrder}/metadata",
|
|
MR.strings.opds_feeds_chapter_details.localized(locale, mangaDetails.title, chapterMetadata.name),
|
|
locale,
|
|
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
|
null,
|
|
)
|
|
|
|
mangaDetails.thumbnailUrl?.let { proxyThumbnailUrl(mangaDetails.id) }?.also {
|
|
builder.icon = it
|
|
builder.links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE, it, OpdsConstants.TYPE_IMAGE_JPEG))
|
|
builder.links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE_THUMBNAIL, it, OpdsConstants.TYPE_IMAGE_JPEG))
|
|
}
|
|
|
|
val (primaryEntry, conflictEntry) =
|
|
OpdsEntryBuilder.createChapterMetadataEntries(
|
|
chapter = chapterMetadata,
|
|
manga = mangaDetails,
|
|
baseUrl = baseUrl,
|
|
locale = locale,
|
|
)
|
|
|
|
builder.entries.add(primaryEntry)
|
|
if (conflictEntry != null) {
|
|
builder.entries.add(conflictEntry)
|
|
builder.totalResults = 2
|
|
} else {
|
|
builder.totalResults = 1
|
|
}
|
|
|
|
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
|
}
|
|
|
|
/**
|
|
* Builds a simple OPDS feed to indicate that a resource was not found.
|
|
* @param baseUrl The base URL.
|
|
* @param idPath The path that was not found.
|
|
* @param title The title for the feed (e.g., an error message).
|
|
* @param locale The locale for localization.
|
|
* @return An XML string representing the 'not found' feed.
|
|
*/
|
|
fun buildNotFoundFeed(
|
|
baseUrl: String,
|
|
idPath: String,
|
|
title: String,
|
|
locale: Locale,
|
|
): String =
|
|
FeedBuilderInternal(baseUrl, idPath, title, locale, feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, pageNum = null)
|
|
.apply { totalResults = 0L }
|
|
.build()
|
|
.let(OpdsXmlUtil::serializeFeedToString)
|
|
}
|