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