mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-06-30 17:34:39 -05:00
* refactor(opds): align feed generation with RFC5005 and OpenSearch specs This commit refactors the OPDS feed generation to strictly adhere to official specifications for search and pagination. Previously, OpenSearch response elements (totalResults, itemsPerPage, startIndex) were incorrectly included in all acquisition feeds. According to the OPDS 1.2 and OpenSearch 1.1 specifications, these elements should only be present in feeds that are a direct response to a search query. This change restricts their inclusion to search result feeds only, ensuring spec compliance. Additionally, pagination link relations were not fully implemented as per RFC 5005. This commit enhances all paginated feeds to include `first` and `last` links, in addition to the existing `prev` and `next` links. This provides a complete and standard-compliant navigation experience for OPDS clients. - `FeedBuilderInternal` now accepts an `isSearchFeed` flag to conditionally add OpenSearch elements. - All feed generation methods in `OpdsFeedBuilder` and `OpdsV1Controller` now correctly identify search contexts. - RFC 5005 pagination links (`first`, `last`, `prev`, `next`) are now generated for all paginated feeds. - Added necessary link relation constants to `OpdsConstants`. * feat(opds): improve pagination navigation and code organization
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 ?: SortOrder.ASC)
|
|
}
|
|
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)
|
|
}
|