feat(opds): Add option to skip metadata feed for direct access (#1879)

* feat(opds): add option to skip chapter metadata feed

Introduces a new server configuration `server.opdsSkipChapterMetadataFeed` (default: false).

When enabled, the OPDS chapter feed generates direct acquisition (CBZ download) and streaming (OPDS-PSE) links within the chapter list entries, bypassing the intermediate metadata subsection. This streamlines the user experience and improves compatibility with OPDS clients like KOReader that rely on direct links for automated downloading features.

* fix: lint

* fix(opds): enrich chapter data and refine sync logic for skip-metadata mode

Refines the `opdsSkipChapterMetadataFeed` implementation to ensure necessary data is available for direct links and handles synchronization logic appropriate for a list view.

- **Refactor ChapterForDownload:** Extract `refreshChapterPageList` and `updateChapterPersistence` to allow reusing page count verification logic outside the download flow.
- **Enrich Chapter Repository:** When skipping metadata, asynchronously verify page counts and calculate CBZ file sizes for chapters in the list. This ensures direct stream/download links are valid even if the chapter wasn't previously fully indexed.
- **KoSync Logic:** Implement synchronization logic in `OpdsEntryBuilder`. Since the user cannot be prompted in the chapter list view, `PROMPT` conflicts are explicitly ignored (prioritizing local progress), while updates are applied if non-conflicting.
- **OPDS Attributes:** Add `length` (file size) to acquisition links and ensure download links only appear for actually downloaded chapters.
- **Documentation:** Update `server.conf` description to clarify KoSync behavior in this mode.

* feat(download): improve chapter download filenames

* feat(opds): append language to source names

* feat(opds): handle empty chapter titles

* fix import org.jetbrains.exposed.v1.core.inList

* refactor(opds): reorganize API routes and update facet count calculations based on active filters

- **API Routing & Controllers**: Reorganize OPDS v1.2 route paths into logical groups in `OpdsAPI`. Centralize request filter extraction into `OpdsMangaFilter.fromContext`.
- **Facet Counting**: Extract `Query.applyOpdsMangaFilter` to apply active filters to facet and navigation queries. Pass the active filters to `NavigationRepository` and `MangaRepository` count queries (using `excludeField` to calculate sibling counts). This ensures category, source, language, status, and genre counts (`thr:count`) are accurately computed based on active selections.
- **Pagination**: Add pagination support to computed navigation feeds in `NavigationRepository` ( statuses and content languages).
- **Builders**: Standardize parameter ordering in `FeedBuilderInternal` and `OpdsEntryBuilder` constructors. Simplify pagination and facet link URL generation.

* fix(opds): remove redundant filter logic to avoid duplicate HAVING clauses

Resolve IllegalStateException crash caused by applying content filters twice in MangaRepository. Filtering is now handled exclusively by `applyOpdsMangaFilter`, allowing `applyMangaLibrarySort` to focus solely on ordering operations.

* revert(download): restore original CBZ filename scheme

* refactor(opds): simplify persistence updates and clean up chapter mapping

- Simplify page count and download checks in ChapterForDownload
- Clean up enriched chapter mapping in ChapterRepository to improve readability

* fix(opds): retrieve chapter archive size without leaving stream open

* perf(opds): avoid redundant DB query when refreshing chapter page list
This commit is contained in:
Zeedif
2026-06-15 12:26:16 -06:00
committed by GitHub
parent 8fbc8fd3d4
commit 98576c6e62
21 changed files with 1133 additions and 675 deletions

View File

@@ -17,6 +17,7 @@ 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.OpdsStringUtil
import suwayomi.tachidesk.opds.util.OpdsXmlUtil
import suwayomi.tachidesk.server.serverConfig
import java.util.Locale
@@ -40,12 +41,11 @@ object OpdsFeedBuilder {
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,
baseUrl = baseUrl,
locale = locale,
idPath = "", // Root path is empty
title = MR.strings.opds_feeds_root.localized(locale),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
)
builder.totalResults = navItems.size.toLong()
builder.entries.addAll(
@@ -73,30 +73,39 @@ object OpdsFeedBuilder {
/**
* 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.
* @param pageNum The page number for pagination.
* @return An XML string representing the history feed.
*/
suspend fun getHistoryFeed(
baseUrl: String,
pageNum: Int,
locale: Locale,
pageNum: Int,
): 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,
baseUrl = baseUrl,
locale = locale,
idPath = "history",
title = MR.strings.opds_feeds_history_title.localized(locale),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum = pageNum,
)
builder.totalResults = total
val skipMetadata = serverConfig.opdsSkipChapterMetadataFeed.value
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)
val mangaDetails =
OpdsMangaDetails(item.mangaId, item.mangaTitle, item.mangaThumbnailUrl, item.mangaAuthor, item.mangaTotalChapters)
OpdsEntryBuilder.createChapterListEntry(
baseUrl,
locale,
item.chapter,
mangaDetails,
true,
skipMetadata,
)
},
)
return OpdsXmlUtil.serializeFeedToString(builder.build())
@@ -104,54 +113,55 @@ object OpdsFeedBuilder {
/**
* 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.
* @param criteria The search criteria.
* @param pageNum The page number for pagination.
* @return An XML string representing the search results feed.
*/
fun getSearchFeed(
criteria: OpdsSearchCriteria,
baseUrl: String,
pageNum: Int,
locale: Locale,
criteria: OpdsSearchCriteria,
pageNum: Int,
): 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,
baseUrl = baseUrl,
locale = locale,
idPath = "library/series",
title = MR.strings.opds_feeds_search_results_title.localized(locale),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum = pageNum,
isSearchFeed = true,
)
builder.totalResults = total
builder.entries.addAll(mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(it, baseUrl, locale) })
builder.entries.addAll(mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(baseUrl, locale, it) })
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 locale The locale for localization.
* @param criteria The filtering criteria.
* @param isSearch Indicates if it's a search feed.
* @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,
locale: Locale,
criteria: OpdsMangaFilter,
isSearch: Boolean,
pageNum: Int,
sort: String?,
filter: String?,
locale: Locale,
isSearch: Boolean,
): String {
val result = MangaRepository.getLibraryManga(pageNum, sort, filter, criteria)
val result = MangaRepository.getLibraryManga(criteria, pageNum, sort, filter)
val feedTitle =
when (criteria.primaryFilter) {
@@ -177,7 +187,12 @@ object OpdsFeedBuilder {
}
PrimaryFilterType.STATUS -> {
val statusName = NavigationRepository.getStatuses(locale).find { it.id == criteria.statusId }?.title
val statusName =
NavigationRepository
.getStatuses(locale, pageNum = null, activeFilters = criteria)
.first
.find { it.id == criteria.statusId }
?.title
MR.strings.opds_feeds_status_specific_title.localized(locale, statusName ?: criteria.statusId.toString())
}
@@ -193,7 +208,7 @@ object OpdsFeedBuilder {
val feedUrl =
when (criteria.primaryFilter) {
PrimaryFilterType.SOURCE -> "library/source/${criteria.sourceId}"
PrimaryFilterType.SOURCE -> "source/${criteria.sourceId}"
PrimaryFilterType.CATEGORY -> "category/${criteria.categoryId}"
PrimaryFilterType.GENRE -> "genre/${criteria.genre}"
PrimaryFilterType.STATUS -> "status/${criteria.statusId}"
@@ -203,23 +218,23 @@ object OpdsFeedBuilder {
val builder =
FeedBuilderInternal(
baseUrl,
feedUrl,
feedTitle,
locale,
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum,
baseUrl = baseUrl,
locale = locale,
idPath = feedUrl,
title = feedTitle,
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum = pageNum,
explicitQueryParams = criteria.toCrossFilterQueryParameters(),
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)
OpdsEntryBuilder.addLibraryFacets(builder, baseUrl, locale, criteria)
builder.entries.addAll(result.mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(it, baseUrl, locale) })
builder.entries.addAll(result.mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(baseUrl, locale, it) })
return OpdsXmlUtil.serializeFeedToString(builder.build())
}
@@ -227,24 +242,24 @@ object OpdsFeedBuilder {
/**
* 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.
* @param pageNum The page number for pagination.
* @return An XML string representing the explore sources feed.
*/
fun getExploreSourcesFeed(
baseUrl: String,
pageNum: Int,
locale: Locale,
pageNum: Int,
): 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,
baseUrl = baseUrl,
locale = locale,
idPath = "sources",
title = MR.strings.opds_feeds_sources_title.localized(locale),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
pageNum = pageNum,
)
builder.totalResults = total
builder.entries.addAll(
@@ -257,7 +272,7 @@ object OpdsFeedBuilder {
listOf(
OpdsLinkXml(
OpdsConstants.LINK_REL_SUBSECTION,
"$baseUrl/source/${entry.id}?sort=popular&lang=${locale.toLanguageTag()}",
"$baseUrl/explore/source/${entry.id}?sort=popular&lang=${locale.toLanguageTag()}",
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
),
),
@@ -270,24 +285,24 @@ object OpdsFeedBuilder {
/**
* 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.
* @param pageNum The page number for pagination.
* @return An XML string representing the library sources feed.
*/
fun getLibrarySourcesFeed(
baseUrl: String,
pageNum: Int,
locale: Locale,
pageNum: Int,
): 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,
baseUrl = baseUrl,
locale = locale,
idPath = "library/sources",
title = MR.strings.opds_feeds_library_sources_title.localized(locale),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
pageNum = pageNum,
)
builder.totalResults = total
builder.entries.addAll(
@@ -300,7 +315,7 @@ object OpdsFeedBuilder {
listOf(
OpdsLinkXml(
OpdsConstants.LINK_REL_SUBSECTION,
"$baseUrl/library/source/${entry.id}?lang=${locale.toLanguageTag()}",
"$baseUrl/source/${entry.id}?lang=${locale.toLanguageTag()}",
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
entry.name,
thrCount = entry.mangaCount?.toInt(),
@@ -314,78 +329,75 @@ object OpdsFeedBuilder {
/**
* 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 locale The locale for localization.
* @param sourceId The ID of the source.
* @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,
locale: Locale,
sourceId: Long,
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 sourceInfo = NavigationRepository.getSourceDetails(sourceId)
val sourceName = sourceInfo?.first ?: sourceId.toString()
val titleRes =
if (sort == "latest") {
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 feedTitle = titleRes.localized(locale, sourceName)
val builder =
FeedBuilderInternal(
baseUrl,
feedUrl,
feedTitle,
locale,
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum,
baseUrl = baseUrl,
locale = locale,
idPath = "explore/source/$sourceId",
title = feedTitle,
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum = pageNum,
currentSort = sort,
)
builder.totalResults =
if (hasNextPage) {
(pageNum * serverConfig.opdsItemsPerPage.value + 1).toLong()
} else {
(
(pageNum - 1) *
serverConfig.opdsItemsPerPage.value +
mangaEntries.size
).toLong()
((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) })
builder.icon = sourceInfo?.second
OpdsEntryBuilder.addSourceSortFacets(builder, "$baseUrl/explore/source/$sourceId", locale, sort)
builder.entries.addAll(mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(baseUrl, locale, it) })
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.
* @param pageNum The page number for pagination.
* @return An XML string representing the categories navigation feed.
*/
fun getCategoriesFeed(
baseUrl: String,
pageNum: Int,
locale: Locale,
pageNum: Int,
): 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,
baseUrl = baseUrl,
locale = locale,
idPath = "library/categories",
title = MR.strings.opds_feeds_categories_title.localized(locale),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
pageNum = pageNum,
)
builder.totalResults = total
builder.entries.addAll(
@@ -413,24 +425,24 @@ object OpdsFeedBuilder {
/**
* 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.
* @param pageNum The page number for pagination.
* @return An XML string representing the genres navigation feed.
*/
fun getGenresFeed(
baseUrl: String,
pageNum: Int,
locale: Locale,
pageNum: Int,
): String {
val (genreNavEntries, total) = NavigationRepository.getGenres(pageNum, locale)
val (genreNavEntries, total) = NavigationRepository.getGenres(locale, pageNum)
val builder =
FeedBuilderInternal(
baseUrl,
"library/genres",
MR.strings.opds_feeds_genres_title.localized(locale),
locale,
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
pageNum,
baseUrl = baseUrl,
locale = locale,
idPath = "library/genres",
title = MR.strings.opds_feeds_genres_title.localized(locale),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
pageNum = pageNum,
)
builder.totalResults = total
builder.entries.addAll(
@@ -458,26 +470,26 @@ object OpdsFeedBuilder {
/**
* 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.
* @param pageNum The page number (currently unused).
* @return An XML string representing the status navigation feed.
*/
fun getStatusFeed(
baseUrl: String,
@Suppress("UNUSED_PARAMETER") pageNum: Int,
locale: Locale,
pageNum: Int,
): String {
val statuses = NavigationRepository.getStatuses(locale)
val (statuses, total) = NavigationRepository.getStatuses(locale, pageNum)
val builder =
FeedBuilderInternal(
baseUrl,
"library/statuses",
MR.strings.opds_feeds_status_title.localized(locale),
locale,
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
null,
baseUrl = baseUrl,
locale = locale,
idPath = "library/statuses",
title = MR.strings.opds_feeds_status_title.localized(locale),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
pageNum = pageNum,
)
builder.totalResults = statuses.size.toLong()
builder.totalResults = total
builder.entries.addAll(
statuses.map { entry ->
OpdsEntryXml(
@@ -503,24 +515,26 @@ object OpdsFeedBuilder {
/**
* 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.
* @param locale The locale for the user interface.
* @param pageNum The page number for pagination.
* @return An XML string representing the languages navigation feed.
*/
fun getLanguagesFeed(
baseUrl: String,
uiLocale: Locale,
locale: Locale,
pageNum: Int,
): String {
val languages = NavigationRepository.getContentLanguages(uiLocale)
val (languages, total) = NavigationRepository.getContentLanguages(locale, pageNum)
val builder =
FeedBuilderInternal(
baseUrl,
"library/languages",
MR.strings.opds_feeds_languages_title.localized(uiLocale),
uiLocale,
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
null,
baseUrl = baseUrl,
locale = locale,
idPath = "library/languages",
title = MR.strings.opds_feeds_languages_title.localized(locale),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
pageNum = pageNum,
)
builder.totalResults = languages.size.toLong()
builder.totalResults = total
builder.entries.addAll(
languages.map { entry ->
OpdsEntryXml(
@@ -531,7 +545,7 @@ object OpdsFeedBuilder {
listOf(
OpdsLinkXml(
OpdsConstants.LINK_REL_SUBSECTION,
"$baseUrl/language/${entry.id}?lang=${uiLocale.toLanguageTag()}",
"$baseUrl/language/${entry.id}?lang=${locale.toLanguageTag()}",
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
entry.title,
thrCount = entry.mangaCount.toInt(),
@@ -544,32 +558,41 @@ object OpdsFeedBuilder {
}
/**
* Generates an acquisition feed for recent chapter updates in the library.
* Generates an acquisition feed of recent chapter updates for series in the library.
* @param baseUrl The base URL for constructing links.
* @param pageNum The page number for pagination.
* @param locale The locale for localization.
* @param pageNum The page number for pagination.
* @return An XML string representing the library updates feed.
*/
suspend fun getLibraryUpdatesFeed(
baseUrl: String,
pageNum: Int,
locale: Locale,
pageNum: Int,
): 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,
baseUrl = baseUrl,
locale = locale,
idPath = "library-updates",
title = MR.strings.opds_feeds_library_updates_title.localized(locale),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum = pageNum,
)
builder.totalResults = total
val skipMetadata = serverConfig.opdsSkipChapterMetadataFeed.value
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)
val mangaDetails =
OpdsMangaDetails(item.mangaId, item.mangaTitle, item.mangaThumbnailUrl, item.mangaAuthor, item.mangaTotalChapters)
OpdsEntryBuilder.createChapterListEntry(
baseUrl,
locale,
item.chapter,
mangaDetails,
true,
skipMetadata,
)
},
)
return OpdsXmlUtil.serializeFeedToString(builder.build())
@@ -577,29 +600,29 @@ object OpdsFeedBuilder {
/**
* 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 locale The locale for localization.
* @param mangaId The ID of the manga.
* @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,
locale: Locale,
mangaId: Int,
pageNum: Int,
sortParam: String?,
filterParam: String?,
locale: Locale,
): String {
val mangaDetails =
MangaRepository.getMangaDetails(mangaId)
?: return buildNotFoundFeed(
baseUrl,
locale,
"series/$mangaId/chapters",
MR.strings.opds_error_manga_not_found.localized(locale, mangaId),
locale,
)
val (sortColumn, currentSortOrder) =
when (sortParam?.lowercase()) {
@@ -610,13 +633,15 @@ object OpdsFeedBuilder {
else -> ChapterTable.sourceOrder to (serverConfig.opdsChapterSortOrder.value)
}
val currentFilter = filterParam?.lowercase() ?: if (serverConfig.opdsShowOnlyUnreadChapters.value) "unread" else "all"
val skipMetadata = serverConfig.opdsSkipChapterMetadataFeed.value
var (chapterEntries, totalChapters) =
ChapterRepository.getChaptersForManga(
mangaId,
pageNum,
sortColumn,
currentSortOrder,
currentFilter,
pageNum,
skipMetadata,
)
// If no chapters are found in the database, attempt to fetch them from the source.
@@ -629,10 +654,11 @@ object OpdsFeedBuilder {
val (refetchedChapters, refetchedTotal) =
ChapterRepository.getChaptersForManga(
mangaId,
pageNum,
sortColumn,
currentSortOrder,
currentFilter,
pageNum,
skipMetadata,
)
chapterEntries = refetchedChapters
totalChapters = refetchedTotal
@@ -651,12 +677,12 @@ object OpdsFeedBuilder {
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,
baseUrl = baseUrl,
locale = locale,
idPath = feedUrl,
title = MR.strings.opds_feeds_manga_chapters.localized(locale, mangaDetails.title),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum = pageNum,
currentSort = actualSortParamForLinks,
currentFilter = currentFilter,
)
@@ -669,14 +695,21 @@ object OpdsFeedBuilder {
OpdsEntryBuilder.addChapterSortAndFilterFacets(
builder,
"$baseUrl/$feedUrl",
locale,
actualSortParamForLinks,
currentFilter,
locale,
filterCounts,
)
builder.entries.addAll(
chapterEntries.map { chapter ->
OpdsEntryBuilder.createChapterListEntry(chapter, mangaDetails, baseUrl, false, locale)
OpdsEntryBuilder.createChapterListEntry(
baseUrl,
locale,
chapter,
mangaDetails,
false,
skipMetadata,
)
},
)
return OpdsXmlUtil.serializeFeedToString(builder.build())
@@ -684,43 +717,43 @@ object OpdsFeedBuilder {
/**
* 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.
* @param mangaId The ID of the manga.
* @param chapterSourceOrder The source order index of the chapter.
* @return An XML string representing the chapter's metadata feed.
*/
suspend fun getChapterMetadataFeed(
mangaId: Int,
chapterSourceOrder: Int,
baseUrl: String,
locale: Locale,
mangaId: Int,
chapterSourceOrder: Int,
): String {
val mangaDetails =
MangaRepository.getMangaDetails(mangaId)
?: return buildNotFoundFeed(
baseUrl,
locale,
"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,
locale,
"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,
baseUrl = baseUrl,
locale = locale,
idPath = "series/$mangaId/chapter/${chapterMetadata.sourceOrder}/metadata",
title = MR.strings.opds_feeds_chapter_details.localized(locale, mangaDetails.title, chapterMetadata.name),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum = null,
)
mangaDetails.thumbnailUrl?.let { proxyThumbnailUrl(mangaDetails.id) }?.also {
@@ -731,10 +764,10 @@ object OpdsFeedBuilder {
val (primaryEntry, conflictEntry) =
OpdsEntryBuilder.createChapterMetadataEntries(
chapter = chapterMetadata,
manga = mangaDetails,
baseUrl = baseUrl,
locale = locale,
chapter = chapterMetadata,
manga = mangaDetails,
)
builder.entries.add(primaryEntry)
@@ -751,19 +784,25 @@ object OpdsFeedBuilder {
/**
* Builds a simple OPDS feed to indicate that a resource was not found.
* @param baseUrl The base URL.
* @param locale The locale for localization.
* @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,
locale: Locale,
idPath: String,
title: String,
locale: Locale,
): String =
FeedBuilderInternal(baseUrl, idPath, title, locale, feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, pageNum = null)
.apply { totalResults = 0L }
FeedBuilderInternal(
baseUrl = baseUrl,
locale = locale,
idPath = idPath,
title = title,
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum = null,
).apply { totalResults = 0L }
.build()
.let(OpdsXmlUtil::serializeFeedToString)
}