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 (#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
This commit is contained in:
@@ -91,8 +91,10 @@
|
|||||||
<!-- OPDS link texts -->
|
<!-- OPDS link texts -->
|
||||||
<string name="opds_linktitle_catalog_root">Catalog Root</string>
|
<string name="opds_linktitle_catalog_root">Catalog Root</string>
|
||||||
<string name="opds_linktitle_search_catalog">Search Catalog</string>
|
<string name="opds_linktitle_search_catalog">Search Catalog</string>
|
||||||
|
<string name="opds_linktitle_first_page">First Page</string>
|
||||||
<string name="opds_linktitle_previous_page">Previous Page</string>
|
<string name="opds_linktitle_previous_page">Previous Page</string>
|
||||||
<string name="opds_linktitle_next_page">Next Page</string>
|
<string name="opds_linktitle_next_page">Next Page</string>
|
||||||
|
<string name="opds_linktitle_last_page">Last Page</string>
|
||||||
<string name="opds_linktitle_self_feed">Current Feed</string>
|
<string name="opds_linktitle_self_feed">Current Feed</string>
|
||||||
<string name="opds_linktitle_view_on_web">View on Web</string>
|
<string name="opds_linktitle_view_on_web">View on Web</string>
|
||||||
<string name="opds_linktitle_stream_pages_start">Read Online</string>
|
<string name="opds_linktitle_stream_pages_start">Read Online</string>
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ object OpdsConstants {
|
|||||||
const val LINK_REL_ALTERNATE = "alternate"
|
const val LINK_REL_ALTERNATE = "alternate"
|
||||||
const val LINK_REL_FACET = "http://opds-spec.org/facet"
|
const val LINK_REL_FACET = "http://opds-spec.org/facet"
|
||||||
const val LINK_REL_SEARCH = "search"
|
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_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_PSE_STREAM = "http://vaemendis.net/opds-pse/stream"
|
||||||
const val LINK_REL_CRAWLABLE = "http://opds-spec.org/crawlable"
|
const val LINK_REL_CRAWLABLE = "http://opds-spec.org/crawlable"
|
||||||
const val LINK_REL_SORT_NEW = "http://opds-spec.org/sort/new"
|
const val LINK_REL_SORT_NEW = "http://opds-spec.org/sort/new"
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ object OpdsV1Controller {
|
|||||||
ctx: Context,
|
ctx: Context,
|
||||||
pageNum: Int?,
|
pageNum: Int?,
|
||||||
criteria: OpdsMangaFilter,
|
criteria: OpdsMangaFilter,
|
||||||
|
isSearch: Boolean,
|
||||||
) {
|
) {
|
||||||
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
|
||||||
ctx.future {
|
ctx.future {
|
||||||
@@ -45,6 +46,7 @@ object OpdsV1Controller {
|
|||||||
sort = criteria.sort,
|
sort = criteria.sort,
|
||||||
filter = criteria.filter,
|
filter = criteria.filter,
|
||||||
locale = locale,
|
locale = locale,
|
||||||
|
isSearch = isSearch,
|
||||||
)
|
)
|
||||||
}.thenApply { xml ->
|
}.thenApply { xml ->
|
||||||
ctx.contentType(OPDS_MIME).result(xml)
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
@@ -126,7 +128,7 @@ object OpdsV1Controller {
|
|||||||
<OutputEncoding>UTF-8</OutputEncoding>
|
<OutputEncoding>UTF-8</OutputEncoding>
|
||||||
<Url type="${OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION}"
|
<Url type="${OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION}"
|
||||||
rel="results"
|
rel="results"
|
||||||
template="$BASE_URL/library/series?query={searchTerms}&lang=${locale.toLanguageTag()}"/>
|
template="$BASE_URL/library/series?query={searchTerms}&lang=${locale.toLanguageTag()}"/>
|
||||||
</OpenSearchDescription>
|
</OpenSearchDescription>
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
)
|
)
|
||||||
@@ -149,8 +151,9 @@ object OpdsV1Controller {
|
|||||||
val title = ctx.queryParam("title")
|
val title = ctx.queryParam("title")
|
||||||
val lang = ctx.queryParam("lang")
|
val lang = ctx.queryParam("lang")
|
||||||
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, 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)
|
val opdsSearchCriteria = OpdsSearchCriteria(query, author, title)
|
||||||
ctx.future {
|
ctx.future {
|
||||||
future {
|
future {
|
||||||
@@ -175,6 +178,7 @@ object OpdsV1Controller {
|
|||||||
ctx,
|
ctx,
|
||||||
pageNumber,
|
pageNumber,
|
||||||
criteria,
|
criteria,
|
||||||
|
isSearch = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -422,7 +426,7 @@ object OpdsV1Controller {
|
|||||||
behaviorOf = { ctx, sourceId ->
|
behaviorOf = { ctx, sourceId ->
|
||||||
ctx.getAttribute(Attribute.TachideskUser).requireUser()
|
ctx.getAttribute(Attribute.TachideskUser).requireUser()
|
||||||
val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(sourceId = sourceId, primaryFilter = PrimaryFilterType.SOURCE))
|
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 = {
|
withResults = {
|
||||||
httpCode(HttpStatus.OK)
|
httpCode(HttpStatus.OK)
|
||||||
@@ -441,7 +445,7 @@ object OpdsV1Controller {
|
|||||||
ctx.getAttribute(Attribute.TachideskUser).requireUser()
|
ctx.getAttribute(Attribute.TachideskUser).requireUser()
|
||||||
val criteria =
|
val criteria =
|
||||||
buildCriteriaFromContext(ctx, OpdsMangaFilter(categoryId = categoryId, primaryFilter = PrimaryFilterType.CATEGORY))
|
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 = {
|
withResults = {
|
||||||
httpCode(HttpStatus.OK)
|
httpCode(HttpStatus.OK)
|
||||||
@@ -459,7 +463,7 @@ object OpdsV1Controller {
|
|||||||
behaviorOf = { ctx, genre ->
|
behaviorOf = { ctx, genre ->
|
||||||
ctx.getAttribute(Attribute.TachideskUser).requireUser()
|
ctx.getAttribute(Attribute.TachideskUser).requireUser()
|
||||||
val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(genre = genre, primaryFilter = PrimaryFilterType.GENRE))
|
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 = {
|
withResults = {
|
||||||
httpCode(HttpStatus.OK)
|
httpCode(HttpStatus.OK)
|
||||||
@@ -477,7 +481,7 @@ object OpdsV1Controller {
|
|||||||
behaviorOf = { ctx, statusId ->
|
behaviorOf = { ctx, statusId ->
|
||||||
ctx.getAttribute(Attribute.TachideskUser).requireUser()
|
ctx.getAttribute(Attribute.TachideskUser).requireUser()
|
||||||
val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(statusId = statusId, primaryFilter = PrimaryFilterType.STATUS))
|
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 = {
|
withResults = {
|
||||||
httpCode(HttpStatus.OK)
|
httpCode(HttpStatus.OK)
|
||||||
@@ -501,7 +505,7 @@ object OpdsV1Controller {
|
|||||||
ctx.getAttribute(Attribute.TachideskUser).requireUser()
|
ctx.getAttribute(Attribute.TachideskUser).requireUser()
|
||||||
val criteria =
|
val criteria =
|
||||||
buildCriteriaFromContext(ctx, OpdsMangaFilter(langCode = langCode, primaryFilter = PrimaryFilterType.LANGUAGE))
|
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 = {
|
withResults = {
|
||||||
httpCode(HttpStatus.OK)
|
httpCode(HttpStatus.OK)
|
||||||
|
|||||||
@@ -9,20 +9,22 @@ import suwayomi.tachidesk.opds.model.OpdsLinkXml
|
|||||||
import suwayomi.tachidesk.opds.util.OpdsDateUtil
|
import suwayomi.tachidesk.opds.util.OpdsDateUtil
|
||||||
import suwayomi.tachidesk.server.serverConfig
|
import suwayomi.tachidesk.server.serverConfig
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlin.math.ceil
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clase de ayuda para construir un OpdsFeedXml.
|
* Helper class to build an OpdsFeedXml.
|
||||||
*/
|
*/
|
||||||
class FeedBuilderInternal(
|
class FeedBuilderInternal(
|
||||||
val baseUrl: String,
|
private val baseUrl: String,
|
||||||
val idPath: String,
|
private val idPath: String,
|
||||||
val title: String,
|
private val title: String,
|
||||||
val locale: Locale,
|
private val locale: Locale,
|
||||||
val feedType: String,
|
private val feedType: String,
|
||||||
var pageNum: Int? = 1,
|
private val pageNum: Int? = 1,
|
||||||
var explicitQueryParams: String? = null,
|
private val explicitQueryParams: String? = null,
|
||||||
val currentSort: String? = null,
|
private val currentSort: String? = null,
|
||||||
val currentFilter: String? = null,
|
private val currentFilter: String? = null,
|
||||||
|
private val isSearchFeed: Boolean = false,
|
||||||
) {
|
) {
|
||||||
private val opdsItemsPerPageBounded: Int
|
private val opdsItemsPerPageBounded: Int
|
||||||
get() = serverConfig.opdsItemsPerPage.value.coerceIn(10, 5000)
|
get() = serverConfig.opdsItemsPerPage.value.coerceIn(10, 5000)
|
||||||
@@ -55,8 +57,7 @@ class FeedBuilderInternal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun build(): OpdsFeedXml {
|
fun build(): OpdsFeedXml {
|
||||||
val actualPageNum = pageNum ?: 1
|
val selfLinkHref = buildUrlWithParams(idPath, if (pageNum != null) pageNum else null)
|
||||||
val selfLinkHref = buildUrlWithParams(idPath, if (pageNum != null) actualPageNum else null)
|
|
||||||
val feedLinks = mutableListOf<OpdsLinkXml>()
|
val feedLinks = mutableListOf<OpdsLinkXml>()
|
||||||
feedLinks.addAll(this.links)
|
feedLinks.addAll(this.links)
|
||||||
|
|
||||||
@@ -80,27 +81,57 @@ class FeedBuilderInternal(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Add pagination links if needed
|
||||||
if (pageNum != null) {
|
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_FIRST,
|
||||||
|
buildUrlWithParams(idPath, 1),
|
||||||
|
feedType,
|
||||||
|
MR.strings.opds_linktitle_first_page.localized(locale),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add 'prev' link if not on first page
|
||||||
|
if (currentPage > 1) {
|
||||||
feedLinks.add(
|
feedLinks.add(
|
||||||
OpdsLinkXml(
|
OpdsLinkXml(
|
||||||
OpdsConstants.LINK_REL_PREV,
|
OpdsConstants.LINK_REL_PREV,
|
||||||
buildUrlWithParams(idPath, actualPageNum - 1),
|
buildUrlWithParams(idPath, currentPage - 1),
|
||||||
feedType,
|
feedType,
|
||||||
MR.strings.opds_linktitle_previous_page.localized(locale),
|
MR.strings.opds_linktitle_previous_page.localized(locale),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (totalResults > actualPageNum * opdsItemsPerPageBounded) {
|
|
||||||
|
// Add 'next' link if not on last page
|
||||||
|
if (currentPage < totalPages) {
|
||||||
feedLinks.add(
|
feedLinks.add(
|
||||||
OpdsLinkXml(
|
OpdsLinkXml(
|
||||||
OpdsConstants.LINK_REL_NEXT,
|
OpdsConstants.LINK_REL_NEXT,
|
||||||
buildUrlWithParams(idPath, actualPageNum + 1),
|
buildUrlWithParams(idPath, currentPage + 1),
|
||||||
feedType,
|
feedType,
|
||||||
MR.strings.opds_linktitle_next_page.localized(locale),
|
MR.strings.opds_linktitle_next_page.localized(locale),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always add 'last' link when there are multiple pages
|
||||||
|
feedLinks.add(
|
||||||
|
OpdsLinkXml(
|
||||||
|
OpdsConstants.LINK_REL_LAST,
|
||||||
|
buildUrlWithParams(idPath, totalPages),
|
||||||
|
feedType,
|
||||||
|
MR.strings.opds_linktitle_last_page.localized(locale),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val urnParams = mutableListOf<String>()
|
val urnParams = mutableListOf<String>()
|
||||||
@@ -111,7 +142,7 @@ class FeedBuilderInternal(
|
|||||||
currentFilter?.let { urnParams.add("filter_$it") }
|
currentFilter?.let { urnParams.add("filter_$it") }
|
||||||
val urnSuffix = if (urnParams.isNotEmpty()) ":${urnParams.joinToString(":")}" else ""
|
val urnSuffix = if (urnParams.isNotEmpty()) ":${urnParams.joinToString(":")}" else ""
|
||||||
|
|
||||||
val showPaginationFields = pageNum != null && totalResults > 0
|
val showOpenSearchFields = isSearchFeed && pageNum != null && totalResults > 0
|
||||||
|
|
||||||
return OpdsFeedXml(
|
return OpdsFeedXml(
|
||||||
id = "urn:suwayomi:feed:${idPath.replace('/',':')}$urnSuffix",
|
id = "urn:suwayomi:feed:${idPath.replace('/',':')}$urnSuffix",
|
||||||
@@ -121,9 +152,9 @@ class FeedBuilderInternal(
|
|||||||
author = feedAuthor,
|
author = feedAuthor,
|
||||||
links = feedLinks,
|
links = feedLinks,
|
||||||
entries = entries,
|
entries = entries,
|
||||||
totalResults = totalResults.takeIf { showPaginationFields },
|
totalResults = totalResults.takeIf { showOpenSearchFields },
|
||||||
itemsPerPage = if (showPaginationFields) opdsItemsPerPageBounded else null,
|
itemsPerPage = if (showOpenSearchFields) opdsItemsPerPageBounded else null,
|
||||||
startIndex = if (showPaginationFields) ((actualPageNum - 1) * opdsItemsPerPageBounded + 1) else null,
|
startIndex = if (showOpenSearchFields) ((pageNum - 1) * opdsItemsPerPageBounded) + 1 else null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ object OpdsFeedBuilder {
|
|||||||
locale,
|
locale,
|
||||||
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
||||||
pageNum,
|
pageNum,
|
||||||
|
isSearchFeed = true,
|
||||||
)
|
)
|
||||||
builder.totalResults = total
|
builder.totalResults = total
|
||||||
builder.entries.addAll(mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(it, baseUrl, locale) })
|
builder.entries.addAll(mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(it, baseUrl, locale) })
|
||||||
@@ -148,6 +149,7 @@ object OpdsFeedBuilder {
|
|||||||
sort: String?,
|
sort: String?,
|
||||||
filter: String?,
|
filter: String?,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
isSearch: Boolean,
|
||||||
): String {
|
): String {
|
||||||
val result = MangaRepository.getLibraryManga(pageNum, sort, filter, criteria)
|
val result = MangaRepository.getLibraryManga(pageNum, sort, filter, criteria)
|
||||||
|
|
||||||
@@ -200,6 +202,7 @@ object OpdsFeedBuilder {
|
|||||||
currentSort = criteria.sort,
|
currentSort = criteria.sort,
|
||||||
currentFilter = criteria.filter,
|
currentFilter = criteria.filter,
|
||||||
explicitQueryParams = criteria.toCrossFilterQueryParameters(),
|
explicitQueryParams = criteria.toCrossFilterQueryParameters(),
|
||||||
|
isSearchFeed = isSearch,
|
||||||
)
|
)
|
||||||
builder.totalResults = result.totalCount
|
builder.totalResults = result.totalCount
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user