From 8ae451ece554eedc763e6358bb02d4a806cef7da Mon Sep 17 00:00:00 2001 From: Zeedif Date: Sun, 24 Aug 2025 10:35:47 -0600 Subject: [PATCH] refactor(opds): align feed generation with RFC5005 and OpenSearch specs (#1611) * 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 --- .../moko-resources/values/base/strings.xml | 2 + .../tachidesk/opds/constants/OpdsConstants.kt | 4 +- .../opds/controller/OpdsV1Controller.kt | 18 +++-- .../opds/impl/FeedBuilderInternal.kt | 81 +++++++++++++------ .../tachidesk/opds/impl/OpdsFeedBuilder.kt | 3 + 5 files changed, 75 insertions(+), 33 deletions(-) diff --git a/server/i18n/src/commonMain/moko-resources/values/base/strings.xml b/server/i18n/src/commonMain/moko-resources/values/base/strings.xml index c92603d4..663e045e 100644 --- a/server/i18n/src/commonMain/moko-resources/values/base/strings.xml +++ b/server/i18n/src/commonMain/moko-resources/values/base/strings.xml @@ -91,8 +91,10 @@ Catalog Root Search Catalog + First Page Previous Page Next Page + Last Page Current Feed View on Web Read Online diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/constants/OpdsConstants.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/constants/OpdsConstants.kt index 955736ae..28e4e947 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/constants/OpdsConstants.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/constants/OpdsConstants.kt @@ -25,8 +25,10 @@ object OpdsConstants { const val LINK_REL_ALTERNATE = "alternate" const val LINK_REL_FACET = "http://opds-spec.org/facet" const val LINK_REL_SEARCH = "search" - const val LINK_REL_PREV = "prev" + const val LINK_REL_PREV = "previous" const val LINK_REL_NEXT = "next" + const val LINK_REL_FIRST = "first" + const val LINK_REL_LAST = "last" const val LINK_REL_PSE_STREAM = "http://vaemendis.net/opds-pse/stream" const val LINK_REL_CRAWLABLE = "http://opds-spec.org/crawlable" const val LINK_REL_SORT_NEW = "http://opds-spec.org/sort/new" diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt index 7c65d804..f2dd21ab 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt @@ -34,6 +34,7 @@ object OpdsV1Controller { ctx: Context, pageNum: Int?, criteria: OpdsMangaFilter, + isSearch: Boolean, ) { val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang")) ctx.future { @@ -45,6 +46,7 @@ object OpdsV1Controller { sort = criteria.sort, filter = criteria.filter, locale = locale, + isSearch = isSearch, ) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) @@ -126,7 +128,7 @@ object OpdsV1Controller { UTF-8 + template="$BASE_URL/library/series?query={searchTerms}&lang=${locale.toLanguageTag()}"/> """.trimIndent(), ) @@ -149,8 +151,9 @@ object OpdsV1Controller { val title = ctx.queryParam("title") val lang = ctx.queryParam("lang") val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) + val isSearch = query != null || author != null || title != null - if (query != null || author != null || title != null) { + if (isSearch) { val opdsSearchCriteria = OpdsSearchCriteria(query, author, title) ctx.future { future { @@ -175,6 +178,7 @@ object OpdsV1Controller { ctx, pageNumber, criteria, + isSearch = false, ) } }, @@ -422,7 +426,7 @@ object OpdsV1Controller { behaviorOf = { ctx, sourceId -> ctx.getAttribute(Attribute.TachideskUser).requireUser() val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(sourceId = sourceId, primaryFilter = PrimaryFilterType.SOURCE)) - getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria) + getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false) }, withResults = { httpCode(HttpStatus.OK) @@ -441,7 +445,7 @@ object OpdsV1Controller { ctx.getAttribute(Attribute.TachideskUser).requireUser() val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(categoryId = categoryId, primaryFilter = PrimaryFilterType.CATEGORY)) - getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria) + getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false) }, withResults = { httpCode(HttpStatus.OK) @@ -459,7 +463,7 @@ object OpdsV1Controller { behaviorOf = { ctx, genre -> ctx.getAttribute(Attribute.TachideskUser).requireUser() val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(genre = genre, primaryFilter = PrimaryFilterType.GENRE)) - getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria) + getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false) }, withResults = { httpCode(HttpStatus.OK) @@ -477,7 +481,7 @@ object OpdsV1Controller { behaviorOf = { ctx, statusId -> ctx.getAttribute(Attribute.TachideskUser).requireUser() val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(statusId = statusId, primaryFilter = PrimaryFilterType.STATUS)) - getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria) + getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false) }, withResults = { httpCode(HttpStatus.OK) @@ -501,7 +505,7 @@ object OpdsV1Controller { ctx.getAttribute(Attribute.TachideskUser).requireUser() val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(langCode = langCode, primaryFilter = PrimaryFilterType.LANGUAGE)) - getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria) + getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false) }, withResults = { httpCode(HttpStatus.OK) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/FeedBuilderInternal.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/FeedBuilderInternal.kt index fc9d6550..3b18750e 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/FeedBuilderInternal.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/FeedBuilderInternal.kt @@ -9,20 +9,22 @@ import suwayomi.tachidesk.opds.model.OpdsLinkXml import suwayomi.tachidesk.opds.util.OpdsDateUtil import suwayomi.tachidesk.server.serverConfig import java.util.Locale +import kotlin.math.ceil /** - * Clase de ayuda para construir un OpdsFeedXml. + * Helper class to build an OpdsFeedXml. */ class FeedBuilderInternal( - val baseUrl: String, - val idPath: String, - val title: String, - val locale: Locale, - val feedType: String, - var pageNum: Int? = 1, - var explicitQueryParams: String? = null, - val currentSort: String? = null, - val currentFilter: String? = null, + private val baseUrl: String, + private val idPath: String, + private val title: String, + private val locale: Locale, + private val feedType: String, + private val pageNum: Int? = 1, + private val explicitQueryParams: String? = null, + private val currentSort: String? = null, + private val currentFilter: String? = null, + private val isSearchFeed: Boolean = false, ) { private val opdsItemsPerPageBounded: Int get() = serverConfig.opdsItemsPerPage.value.coerceIn(10, 5000) @@ -55,8 +57,7 @@ class FeedBuilderInternal( } fun build(): OpdsFeedXml { - val actualPageNum = pageNum ?: 1 - val selfLinkHref = buildUrlWithParams(idPath, if (pageNum != null) actualPageNum else null) + val selfLinkHref = buildUrlWithParams(idPath, if (pageNum != null) pageNum else null) val feedLinks = mutableListOf() feedLinks.addAll(this.links) @@ -80,24 +81,54 @@ class FeedBuilderInternal( ), ) + // Add pagination links if needed if (pageNum != null) { - if (actualPageNum > 1) { + val totalPages = ceil(totalResults.toDouble() / opdsItemsPerPageBounded).toInt() + + if (totalPages > 1) { + val currentPage = pageNum.coerceAtLeast(1) + + // Always add 'first' link when there are multiple pages feedLinks.add( OpdsLinkXml( - OpdsConstants.LINK_REL_PREV, - buildUrlWithParams(idPath, actualPageNum - 1), + OpdsConstants.LINK_REL_FIRST, + buildUrlWithParams(idPath, 1), feedType, - MR.strings.opds_linktitle_previous_page.localized(locale), + MR.strings.opds_linktitle_first_page.localized(locale), ), ) - } - if (totalResults > actualPageNum * opdsItemsPerPageBounded) { + + // Add 'prev' link if not on first page + if (currentPage > 1) { + feedLinks.add( + OpdsLinkXml( + OpdsConstants.LINK_REL_PREV, + buildUrlWithParams(idPath, currentPage - 1), + feedType, + MR.strings.opds_linktitle_previous_page.localized(locale), + ), + ) + } + + // Add 'next' link if not on last page + if (currentPage < totalPages) { + feedLinks.add( + OpdsLinkXml( + OpdsConstants.LINK_REL_NEXT, + buildUrlWithParams(idPath, currentPage + 1), + feedType, + MR.strings.opds_linktitle_next_page.localized(locale), + ), + ) + } + + // Always add 'last' link when there are multiple pages feedLinks.add( OpdsLinkXml( - OpdsConstants.LINK_REL_NEXT, - buildUrlWithParams(idPath, actualPageNum + 1), + OpdsConstants.LINK_REL_LAST, + buildUrlWithParams(idPath, totalPages), feedType, - MR.strings.opds_linktitle_next_page.localized(locale), + MR.strings.opds_linktitle_last_page.localized(locale), ), ) } @@ -111,7 +142,7 @@ class FeedBuilderInternal( currentFilter?.let { urnParams.add("filter_$it") } val urnSuffix = if (urnParams.isNotEmpty()) ":${urnParams.joinToString(":")}" else "" - val showPaginationFields = pageNum != null && totalResults > 0 + val showOpenSearchFields = isSearchFeed && pageNum != null && totalResults > 0 return OpdsFeedXml( id = "urn:suwayomi:feed:${idPath.replace('/',':')}$urnSuffix", @@ -121,9 +152,9 @@ class FeedBuilderInternal( author = feedAuthor, links = feedLinks, entries = entries, - totalResults = totalResults.takeIf { showPaginationFields }, - itemsPerPage = if (showPaginationFields) opdsItemsPerPageBounded else null, - startIndex = if (showPaginationFields) ((actualPageNum - 1) * opdsItemsPerPageBounded + 1) else null, + totalResults = totalResults.takeIf { showOpenSearchFields }, + itemsPerPage = if (showOpenSearchFields) opdsItemsPerPageBounded else null, + startIndex = if (showOpenSearchFields) ((pageNum - 1) * opdsItemsPerPageBounded) + 1 else null, ) } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt index c1e2fbc1..cb8acb79 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt @@ -125,6 +125,7 @@ object OpdsFeedBuilder { locale, OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, pageNum, + isSearchFeed = true, ) builder.totalResults = total builder.entries.addAll(mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(it, baseUrl, locale) }) @@ -148,6 +149,7 @@ object OpdsFeedBuilder { sort: String?, filter: String?, locale: Locale, + isSearch: Boolean, ): String { val result = MangaRepository.getLibraryManga(pageNum, sort, filter, criteria) @@ -200,6 +202,7 @@ object OpdsFeedBuilder { currentSort = criteria.sort, currentFilter = criteria.filter, explicitQueryParams = criteria.toCrossFilterQueryParameters(), + isSearchFeed = isSearch, ) builder.totalResults = result.totalCount