diff --git a/CHANGELOG.md b/CHANGELOG.md
index db00a6f5f..ad8f7898e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added
- (**Sync**) Added [SyncYomi](https://github.com/syncyomi/syncyomi) support
+- (**OPDS**) Add option to skip chapter metadata feed providing direct stream/download links
### Changed
- (**Database/H2**) Use the latest H2 database engine
diff --git a/docs/Configuring-Suwayomi‐Server.md b/docs/Configuring-Suwayomi‐Server.md
index 02c832177..86d601b7d 100644
--- a/docs/Configuring-Suwayomi‐Server.md
+++ b/docs/Configuring-Suwayomi‐Server.md
@@ -232,6 +232,7 @@ server.opdsShowOnlyUnreadChapters = false
server.opdsShowOnlyDownloadedChapters = false
server.opdsChapterSortOrder = "DESC"
server.opdsCbzMimetype = "MODERN"
+server.opdsSkipChapterMetadataFeed = false
```
- `server.opdsUseBinaryFileSizes = false` controls if Suwayomi should display file sizes in binary units (KiB, MiB, GiB) or decimal (KB, MB, GB) in OPDS listings.
- `server.opdsItemsPerPage = 50` sets the number of items per page in OPDS listings. Range: 10 <= n <= 5000.
@@ -241,6 +242,7 @@ server.opdsCbzMimetype = "MODERN"
- `server.opdsShowOnlyDownloadedChapters = false` controls if OPDS listings should only include downloaded chapters.
- `server.opdsChapterSortOrder = "DESC"` sets the default chapter sort order in OPDS listings, either `"ASC"` or `"DESC"`
- `server.opdsCbzMimetype = "MODERN"` controls which mimetype to use for CBZ downloads. This affects the offered link in OPDS, as well as the content type of the CBZ download. Allowed is MODERN (current IANA standard), LEGACY (deprecated mimetype for .cbz) and COMPATIBLE (deprecated mimetype for all comic archives). Use LEGACY or COMPATIBLE if older clients don't offer the chapter download (note that the chapter needs to first be downloaded in Suwayomi, before it is available in OPDS).
+- `server.opdsSkipChapterMetadataFeed = false` controls if the metadata feed should be skipped. When enabled, download and streaming links are provided directly in the chapter list. This improves compatibility with automated downloaders (like KOReader). KoSync strategies are applied, but `PROMPT` conflicts are ignored (treating local progress as priority).
### KOReader Sync
```
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 663e045ea..0e599ff49 100644
--- a/server/i18n/src/commonMain/moko-resources/values/base/strings.xml
+++ b/server/i18n/src/commonMain/moko-resources/values/base/strings.xml
@@ -153,4 +153,7 @@
Log In
Type username...
Secret...
+
+ Oneshot
+ Chapter %1$s
diff --git a/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt
index 93b42e891..65b2866d7 100644
--- a/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt
+++ b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt
@@ -1101,6 +1101,15 @@ class ServerConfig(
privacySafe = true,
)
+ val opdsSkipChapterMetadataFeed: MutableStateFlow by BooleanSetting(
+ protoNumber = 96,
+ group = SettingGroup.OPDS,
+ privacySafe = true,
+ defaultValue = false,
+ description = "Skips the metadata feed and provides download/stream links directly in the chapter list. Improves compatibility with KOReader auto-downloader. KoSync strategies are applied, but PROMPT conflicts are ignored (treating local progress as priority)."
+
+ )
+
/** ****************************************************************** **/
/** **/
/** Renamed settings **/
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/ChapterDownloadHelper.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/ChapterDownloadHelper.kt
index d4fa60ea0..9d203557f 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/ChapterDownloadHelper.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/ChapterDownloadHelper.kt
@@ -64,6 +64,11 @@ object ChapterDownloadHelper {
chapterId: Int,
): Pair = provider(mangaId, chapterId).getAsArchiveStream()
+ fun getChapterArchiveSize(
+ mangaId: Int,
+ chapterId: Int,
+ ): Long = provider(mangaId, chapterId).getArchiveSize()
+
private fun getChapterWithCbzFileName(chapterId: Int): Pair =
transaction {
val row =
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/chapter/ChapterForDownload.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/chapter/ChapterForDownload.kt
index 76e9045ef..405002a01 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/chapter/ChapterForDownload.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/chapter/ChapterForDownload.kt
@@ -31,6 +31,88 @@ import suwayomi.tachidesk.manga.model.table.PageTable
import suwayomi.tachidesk.manga.model.table.toDataClass
import kotlin.time.Duration.Companion.minutes
+/**
+ * Updates chapter download status and page count in the database if they differ from the file system.
+ */
+fun updateChapterPersistence(
+ chapterId: Int,
+ isMarkedAsDownloaded: Boolean,
+ dbPageCount: Int,
+ downloadPageCount: Int,
+ lastPageRead: Int,
+ logger: KLogger,
+): Boolean {
+ if (isMarkedAsDownloaded && dbPageCount == downloadPageCount) {
+ return false
+ }
+
+ return transaction {
+ var needsUpdate = false
+ if (!isMarkedAsDownloaded) {
+ logger.debug { "mark as downloaded" }
+ ChapterTable.update({ ChapterTable.id eq chapterId }) {
+ it[isDownloaded] = true
+ }
+ needsUpdate = true
+ }
+
+ if (dbPageCount != downloadPageCount) {
+ logger.debug { "use page count of downloaded chapter" }
+ ChapterTable.update({ ChapterTable.id eq chapterId }) {
+ it[pageCount] = downloadPageCount
+ it[ChapterTable.lastPageRead] = lastPageRead.coerceAtMost(downloadPageCount - 1).coerceAtLeast(0)
+ }
+ needsUpdate = true
+ }
+ needsUpdate
+ }
+}
+
+suspend fun refreshChapterPageList(
+ mangaId: Int,
+ chapterId: Int,
+ existingChapterEntry: ResultRow? = null,
+): Int {
+ val mutex = mutexByChapterId.get(chapterId) { Mutex() }
+ return mutex.withLock {
+ val chapterEntry = existingChapterEntry ?: transaction { ChapterTable.selectAll().where { ChapterTable.id eq chapterId }.first() }
+ val mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
+ val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
+
+ val pageList =
+ source
+ .getPageList(
+ SChapter.create().apply {
+ url = chapterEntry[ChapterTable.url]
+ name = chapterEntry[ChapterTable.name]
+ scanlator = chapterEntry[ChapterTable.scanlator]
+ chapter_number = chapterEntry[ChapterTable.chapter_number]
+ date_upload = chapterEntry[ChapterTable.date_upload]
+ },
+ ).mapIndexed { index, page -> Page(index, page.url, page.imageUrl, page.uri) }
+
+ transaction {
+ ChapterTable.update({ ChapterTable.id eq chapterId }) {
+ it[isDownloaded] = false
+ }
+
+ PageTable.deleteWhere { PageTable.chapter eq chapterId }
+ PageTable.batchInsert(pageList) { page ->
+ this[PageTable.index] = page.index
+ this[PageTable.url] = page.url
+ this[PageTable.imageUrl] = page.imageUrl
+ this[PageTable.chapter] = chapterId
+ }
+
+ ChapterTable.update({ ChapterTable.id eq chapterId }) {
+ it[pageCount] = pageList.size
+ it[lastPageRead] = chapterEntry[ChapterTable.lastPageRead].coerceAtMost(pageList.size - 1).coerceAtLeast(0)
+ }
+ }
+ pageList.size
+ }
+}
+
suspend fun getChapterDownloadReady(
chapterId: Int? = null,
chapterIndex: Int? = null,
@@ -68,56 +150,31 @@ private class ChapterForDownload(
suspend fun asDownloadReady(): ChapterDataClass {
val log = KotlinLogging.logger("${logger.name}::asDownloadReady")
- val downloadPageCount =
- try {
- ChapterDownloadHelper.getImageCount(mangaId, chapterId)
- } catch (_: Exception) {
- 0
- }
+ val downloadPageCount = runCatching { ChapterDownloadHelper.getImageCount(mangaId, chapterId) }.getOrDefault(0)
val isMarkedAsDownloaded = chapterEntry[ChapterTable.isDownloaded]
val dbPageCount = chapterEntry[ChapterTable.pageCount]
val doesDownloadExist = downloadPageCount != 0
- val doPageCountsMatch = dbPageCount == downloadPageCount
log.debug { "isMarkedAsDownloaded= $isMarkedAsDownloaded, dbPageCount= $dbPageCount, downloadPageCount= $downloadPageCount" }
return if (!doesDownloadExist) {
log.debug { "reset download status and fetch page list" }
- updateDownloadStatusAndPageList(false)
+ refreshChapterPageList(mangaId, chapterId, chapterEntry)
+ chapterEntry = freshChapterEntry(optChapterId = chapterId)
+ ChapterTable.toDataClass(chapterEntry)
} else {
- transaction {
- var needsUpdate = false
-
- if (!isMarkedAsDownloaded) {
- log.debug { "mark as downloaded" }
- ChapterTable.update({ ChapterTable.id eq chapterId }) {
- it[isDownloaded] = true
- }
- needsUpdate = true
- }
-
- if (!doPageCountsMatch) {
- log.debug { "use page count of downloaded chapter" }
- ChapterTable.update({ ChapterTable.id eq chapterId }) {
- it[pageCount] = downloadPageCount
- it[lastPageRead] = chapterEntry[ChapterTable.lastPageRead].coerceAtMost(downloadPageCount - 1).coerceAtLeast(0)
- }
- needsUpdate = true
- }
-
- // Return updated chapter data
- val updatedRow =
- ChapterTable
- .selectAll()
- .where { ChapterTable.id eq chapterId }
- .first()
-
- if (needsUpdate) {
- chapterEntry = updatedRow
- }
-
- ChapterTable.toDataClass(updatedRow)
+ if (updateChapterPersistence(
+ chapterId,
+ isMarkedAsDownloaded,
+ dbPageCount,
+ downloadPageCount,
+ chapterEntry[ChapterTable.lastPageRead],
+ log,
+ )
+ ) {
+ chapterEntry = freshChapterEntry(optChapterId = chapterId)
}
+ ChapterTable.toDataClass(chapterEntry)
}
}
@@ -150,59 +207,4 @@ private class ChapterForDownload(
}
}.first()
}
-
- private suspend fun updateDownloadStatusAndPageList(downloaded: Boolean): ChapterDataClass {
- val mutex = mutexByChapterId.get(chapterId) { Mutex() }
- return mutex.withLock {
- val pageList = fetchPageList()
-
- transaction {
- // Update download status
- ChapterTable.update({ ChapterTable.id eq chapterId }) {
- it[isDownloaded] = downloaded
- }
-
- // Clear existing pages and insert new ones
- PageTable.deleteWhere { PageTable.chapter eq chapterId }
- PageTable.batchInsert(pageList) { page ->
- this[PageTable.index] = page.index
- this[PageTable.url] = page.url
- this[PageTable.imageUrl] = page.imageUrl
- this[PageTable.chapter] = chapterId
- }
-
- // Update page count
- ChapterTable.update({ ChapterTable.id eq chapterId }) {
- it[pageCount] = pageList.size
- it[lastPageRead] = chapterEntry[ChapterTable.lastPageRead].coerceAtMost(pageList.size - 1).coerceAtLeast(0)
- }
-
- // Get updated chapter data
- val updatedRow =
- ChapterTable
- .selectAll()
- .where { ChapterTable.id eq chapterId }
- .first()
-
- chapterEntry = updatedRow
- ChapterTable.toDataClass(updatedRow)
- }
- }
- }
-
- private suspend fun fetchPageList(): List {
- val mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
- val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
-
- return source
- .getPageList(
- SChapter.create().apply {
- url = chapterEntry[ChapterTable.url]
- name = chapterEntry[ChapterTable.name]
- scanlator = chapterEntry[ChapterTable.scanlator]
- chapter_number = chapterEntry[ChapterTable.chapter_number]
- date_upload = chapterEntry[ChapterTable.date_upload]
- },
- ).mapIndexed { index, page -> Page(index, page.url, page.imageUrl, page.uri) }
- }
}
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt
index 290f8126b..8e6610e30 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt
@@ -13,18 +13,22 @@ object OpdsAPI {
// OPDS Search Description Feed
get("search", OpdsV1Controller.searchFeed)
- // --- Main Navigation Feeds ---
-
- // Explore Navigation Feed
- get("explore", OpdsV1Controller.exploreSourcesFeed)
-
// Reading History Acquisition Feed
get("history", OpdsV1Controller.historyFeed)
// Library Updates Acquisition Feed
get("library-updates", OpdsV1Controller.libraryUpdatesFeed)
- // --- Library-Specific Feeds ---
+ // --- Remote Catalog Exploration ---
+ // List of available online sources
+ get("explore", OpdsV1Controller.exploreSourcesFeed)
+
+ // Browse series from a specific online source
+ path("explore/source/{sourceId}") {
+ get(OpdsV1Controller.exploreSourceFeed)
+ }
+
+ // --- Library Navigation Feeds ---
path("library") {
// All Series in Library / Search Results Feed (Acquisition)
get("series", OpdsV1Controller.seriesFeed)
@@ -32,11 +36,6 @@ object OpdsAPI {
// Library Sources Navigation Feed
get("sources", OpdsV1Controller.librarySourcesFeed)
- // Library Source-Specific Series Acquisition Feed
- path("source/{sourceId}") {
- get(OpdsV1Controller.librarySourceFeed)
- }
-
// Library Categories Navigation Feed
get("categories", OpdsV1Controller.categoriesFeed)
@@ -50,26 +49,10 @@ object OpdsAPI {
get("languages", OpdsV1Controller.languagesFeed)
}
- // --- Explore-Specific Feeds ---
-
- // All Sources Navigation Feed (Explore)
- get("sources", OpdsV1Controller.exploreSourcesFeed)
-
- // Source-Specific Series Acquisition Feed (Explore)
+ // --- Library Series Filters ---
+ // Source-Specific Series Acquisition Feed (Library)
path("source/{sourceId}") {
- get(OpdsV1Controller.exploreSourceFeed)
- }
-
- // --- Item-Specific Feeds (Apply to both Library and Explore contexts) ---
-
- // Series Chapters Acquisition Feed
- path("series/{seriesId}/chapters") {
- get(OpdsV1Controller.seriesChaptersFeed)
- }
-
- // Chapter Metadata Acquisition Feed
- path("series/{seriesId}/chapter/{chapterIndex}/metadata") {
- get(OpdsV1Controller.chapterMetadataFeed)
+ get(OpdsV1Controller.librarySourceFeed)
}
// Category-Specific Series Acquisition Feed (Library)
@@ -91,6 +74,16 @@ object OpdsAPI {
path("language/{langCode}") {
get(OpdsV1Controller.languageFeed)
}
+
+ // --- Item Specific Feeds ---
+ // Series Chapters Acquisition Feed
+ path("series/{seriesId}/chapters") {
+ get(OpdsV1Controller.seriesChaptersFeed)
+ }
+ // Chapter Metadata Acquisition Feed
+ path("series/{seriesId}/chapter/{chapterIndex}/metadata") {
+ get(OpdsV1Controller.chapterMetadataFeed)
+ }
}
}
}
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 0b9fe5a36..9a8aed09d 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt
@@ -32,21 +32,21 @@ object OpdsV1Controller {
*/
private fun getLibraryFeed(
ctx: Context,
- pageNum: Int?,
+ locale: Locale,
criteria: OpdsMangaFilter,
isSearch: Boolean,
+ pageNum: Int,
) {
- val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
ctx.future {
future {
OpdsFeedBuilder.getLibraryFeed(
- criteria = criteria,
- baseUrl = BASE_URL,
- pageNum = pageNum ?: 1,
- sort = criteria.sort,
- filter = criteria.filter,
- locale = locale,
- isSearch = isSearch,
+ BASE_URL,
+ locale,
+ criteria,
+ isSearch,
+ pageNum,
+ criteria.sort,
+ criteria.filter,
)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
@@ -94,7 +94,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
- OpdsFeedBuilder.getHistoryFeed(BASE_URL, pageNumber ?: 1, locale)
+ OpdsFeedBuilder.getHistoryFeed(BASE_URL, locale, pageNumber ?: 1)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
@@ -139,6 +139,7 @@ object OpdsV1Controller {
/**
* Serves an acquisition feed for all series in the library or search results.
* This endpoint handles both general library browsing and specific search queries.
+ * This is the ONLY feed that extracts all the cross-filters from the context.
*/
val seriesFeed =
handler(
@@ -157,29 +158,14 @@ object OpdsV1Controller {
val opdsSearchCriteria = OpdsSearchCriteria(query, author, title)
ctx.future {
future {
- OpdsFeedBuilder.getSearchFeed(opdsSearchCriteria, BASE_URL, pageNumber ?: 1, locale)
+ OpdsFeedBuilder.getSearchFeed(BASE_URL, locale, opdsSearchCriteria, pageNumber ?: 1)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
}
} else {
- val criteria =
- OpdsMangaFilter(
- sourceId = ctx.queryParam("source_id")?.toLongOrNull(),
- categoryId = ctx.queryParam("category_id")?.toIntOrNull(),
- statusId = ctx.queryParam("status_id")?.toIntOrNull(),
- genre = ctx.queryParam("genre"),
- langCode = ctx.queryParam("lang_code"),
- sort = ctx.queryParam("sort"),
- filter = ctx.queryParam("filter"),
- primaryFilter = PrimaryFilterType.NONE,
- )
- getLibraryFeed(
- ctx,
- pageNumber,
- criteria,
- isSearch = false,
- )
+ val criteria = OpdsMangaFilter.fromContext(ctx, PrimaryFilterType.NONE)
+ getLibraryFeed(ctx, locale, criteria, false, pageNumber ?: 1)
}
},
withResults = { httpCode(HttpStatus.OK) },
@@ -203,7 +189,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
- OpdsFeedBuilder.getExploreSourcesFeed(BASE_URL, pageNumber ?: 1, locale)
+ OpdsFeedBuilder.getExploreSourcesFeed(BASE_URL, locale, pageNumber ?: 1)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
@@ -230,7 +216,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
- OpdsFeedBuilder.getLibrarySourcesFeed(BASE_URL, pageNumber ?: 1, locale)
+ OpdsFeedBuilder.getLibrarySourcesFeed(BASE_URL, locale, pageNumber ?: 1)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
@@ -257,7 +243,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
- OpdsFeedBuilder.getCategoriesFeed(BASE_URL, pageNumber ?: 1, locale)
+ OpdsFeedBuilder.getCategoriesFeed(BASE_URL, locale, pageNumber ?: 1)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
@@ -284,7 +270,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
- OpdsFeedBuilder.getGenresFeed(BASE_URL, pageNumber ?: 1, locale)
+ OpdsFeedBuilder.getGenresFeed(BASE_URL, locale, pageNumber ?: 1)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
@@ -298,6 +284,7 @@ object OpdsV1Controller {
*/
val statusesFeed =
handler(
+ queryParam("pageNumber"),
queryParam("lang"),
documentWith = {
withOperation {
@@ -305,12 +292,12 @@ object OpdsV1Controller {
description("Navigation feed listing series publication statuses for the library.")
}
},
- behaviorOf = { ctx, lang ->
+ behaviorOf = { ctx, pageNumber, lang ->
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
- OpdsFeedBuilder.getStatusFeed(BASE_URL, 1, locale)
+ OpdsFeedBuilder.getStatusFeed(BASE_URL, locale, pageNumber ?: 1)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
@@ -324,6 +311,7 @@ object OpdsV1Controller {
*/
val languagesFeed =
handler(
+ queryParam("pageNumber"),
queryParam("lang"),
documentWith = {
withOperation {
@@ -331,12 +319,12 @@ object OpdsV1Controller {
description("Navigation feed listing available content languages for series in the library.")
}
},
- behaviorOf = { ctx, lang ->
+ behaviorOf = { ctx, pageNumber, lang ->
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
- OpdsFeedBuilder.getLanguagesFeed(BASE_URL, locale)
+ OpdsFeedBuilder.getLanguagesFeed(BASE_URL, locale, pageNumber ?: 1)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
@@ -363,7 +351,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
- OpdsFeedBuilder.getLibraryUpdatesFeed(BASE_URL, pageNumber ?: 1, locale)
+ OpdsFeedBuilder.getLibraryUpdatesFeed(BASE_URL, locale, pageNumber ?: 1)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
@@ -392,7 +380,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
- OpdsFeedBuilder.getExploreSourceFeed(sourceId, BASE_URL, pageNumber ?: 1, sort ?: "popular", locale)
+ OpdsFeedBuilder.getExploreSourceFeed(BASE_URL, locale, sourceId, pageNumber ?: 1, sort ?: "popular")
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
@@ -425,8 +413,9 @@ object OpdsV1Controller {
documentWith = { withOperation { summary("OPDS Library Source Specific Series Feed") } },
behaviorOf = { ctx, sourceId ->
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
+ val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(sourceId = sourceId, primaryFilter = PrimaryFilterType.SOURCE))
- getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false)
+ getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
},
withResults = {
httpCode(HttpStatus.OK)
@@ -443,9 +432,10 @@ object OpdsV1Controller {
documentWith = { withOperation { summary("OPDS Category Specific Series Feed") } },
behaviorOf = { ctx, categoryId ->
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
+ val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
val criteria =
buildCriteriaFromContext(ctx, OpdsMangaFilter(categoryId = categoryId, primaryFilter = PrimaryFilterType.CATEGORY))
- getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false)
+ getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
},
withResults = {
httpCode(HttpStatus.OK)
@@ -462,8 +452,9 @@ object OpdsV1Controller {
documentWith = { withOperation { summary("OPDS Genre Specific Series Feed") } },
behaviorOf = { ctx, genre ->
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
+ val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(genre = genre, primaryFilter = PrimaryFilterType.GENRE))
- getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false)
+ getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
},
withResults = {
httpCode(HttpStatus.OK)
@@ -480,8 +471,9 @@ object OpdsV1Controller {
documentWith = { withOperation { summary("OPDS Status Specific Series Feed") } },
behaviorOf = { ctx, statusId ->
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
+ val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(statusId = statusId, primaryFilter = PrimaryFilterType.STATUS))
- getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false)
+ getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
},
withResults = {
httpCode(HttpStatus.OK)
@@ -503,9 +495,10 @@ object OpdsV1Controller {
},
behaviorOf = { ctx, langCode ->
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
+ val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
val criteria =
buildCriteriaFromContext(ctx, OpdsMangaFilter(langCode = langCode, primaryFilter = PrimaryFilterType.LANGUAGE))
- getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false)
+ getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
},
withResults = {
httpCode(HttpStatus.OK)
@@ -534,7 +527,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
- OpdsFeedBuilder.getSeriesChaptersFeed(seriesId, BASE_URL, pageNumber ?: 1, sort, filter, locale)
+ OpdsFeedBuilder.getSeriesChaptersFeed(BASE_URL, locale, seriesId, pageNumber ?: 1, sort, filter)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
@@ -565,7 +558,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
- OpdsFeedBuilder.getChapterMetadataFeed(seriesId, chapterIndex, BASE_URL, locale)
+ OpdsFeedBuilder.getChapterMetadataFeed(BASE_URL, locale, seriesId, chapterIndex)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterListAcqEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterListAcqEntry.kt
index 3081e09ec..af0316829 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterListAcqEntry.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterListAcqEntry.kt
@@ -12,4 +12,6 @@ data class OpdsChapterListAcqEntry(
val lastReadAt: Long,
val sourceOrder: Int,
val pageCount: Int, // Can be -1 if not known
+ val downloaded: Boolean,
+ val cbzFileSize: Long? = null,
)
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterMetadataAcqEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterMetadataAcqEntry.kt
index 6b65a3bcb..ee55c5154 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterMetadataAcqEntry.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterMetadataAcqEntry.kt
@@ -5,6 +5,7 @@ data class OpdsChapterMetadataAcqEntry(
val mangaId: Int,
val name: String,
val uploadDate: Long,
+ val chapterNumber: Float, // Added to fallback chapter titles
val scanlator: String?,
val read: Boolean,
val lastPageRead: Int,
@@ -13,4 +14,5 @@ data class OpdsChapterMetadataAcqEntry(
val downloaded: Boolean,
val pageCount: Int,
val url: String?,
+ val cbzFileSize: Long? = null,
)
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsHistoryAcqEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsHistoryAcqEntry.kt
index a070ad6fa..dc575e3a9 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsHistoryAcqEntry.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsHistoryAcqEntry.kt
@@ -5,6 +5,7 @@ data class OpdsHistoryAcqEntry(
val mangaTitle: String,
val mangaAuthor: String?,
val mangaId: Int,
+ val mangaTotalChapters: Long,
val mangaSourceLang: String?,
val mangaThumbnailUrl: String?,
)
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsLibraryUpdateAcqEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsLibraryUpdateAcqEntry.kt
index 68e2972b1..fe769fbfd 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsLibraryUpdateAcqEntry.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsLibraryUpdateAcqEntry.kt
@@ -5,6 +5,7 @@ data class OpdsLibraryUpdateAcqEntry(
val mangaTitle: String,
val mangaAuthor: String?,
val mangaId: Int,
+ val mangaTotalChapters: Long,
val mangaSourceLang: String?,
val mangaThumbnailUrl: String?,
)
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaDetails.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaDetails.kt
index 0d7377180..c9aab0c31 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaDetails.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaDetails.kt
@@ -5,4 +5,5 @@ data class OpdsMangaDetails( // Kept name, it's specific enough
val title: String,
val thumbnailUrl: String?,
val author: String?, // Added for chapter entry authors
+ val totalChapters: Long, // Added to determine if it's a oneshot
)
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaFilter.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaFilter.kt
index fac7ada63..cb2241105 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaFilter.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaFilter.kt
@@ -1,5 +1,6 @@
package suwayomi.tachidesk.opds.dto
+import io.javalin.http.Context
import suwayomi.tachidesk.opds.util.OpdsStringUtil.encodeForOpdsURL
/**
@@ -69,4 +70,24 @@ data class OpdsMangaFilter(
"genre" -> this.copy(genre = value)
else -> this
}
+
+ companion object {
+ /**
+ * Creates an OpdsMangaFilter directly from the Javalin Context, capturing all cross-filters.
+ */
+ fun fromContext(
+ ctx: Context,
+ primaryFilter: PrimaryFilterType = PrimaryFilterType.NONE,
+ ): OpdsMangaFilter =
+ OpdsMangaFilter(
+ sourceId = ctx.queryParam("source_id")?.toLongOrNull(),
+ categoryId = ctx.queryParam("category_id")?.toIntOrNull(),
+ statusId = ctx.queryParam("status_id")?.toIntOrNull(),
+ genre = ctx.queryParam("genre"),
+ langCode = ctx.queryParam("lang_code"),
+ sort = ctx.queryParam("sort"),
+ filter = ctx.queryParam("filter"),
+ primaryFilter = primaryFilter,
+ )
+ }
}
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 63c156ff9..74f4ee6ae 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/FeedBuilderInternal.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/FeedBuilderInternal.kt
@@ -16,145 +16,131 @@ import kotlin.math.ceil
*/
class FeedBuilderInternal(
private val baseUrl: String,
+ private val locale: Locale,
private val idPath: String,
private val title: String,
- private val locale: Locale,
private val feedType: String,
- private val pageNum: Int? = 1,
+ private val pageNum: Int? = null,
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
-
- private val feedAuthor = OpdsAuthorXml("Suwayomi", "https://suwayomi.org/")
- private val feedGeneratedAt: String = OpdsDateUtil.formatCurrentInstantForOpds()
-
var totalResults: Long = 0
var icon: String? = null
val links = mutableListOf()
val entries = mutableListOf()
- private fun buildUrlWithParams(
- baseHrefPath: String,
- page: Int?,
- ): String {
- val sb = StringBuilder("$baseUrl/$baseHrefPath")
- val queryParamsList = mutableListOf()
+ private fun buildUrlWithParams(page: Int? = pageNum): String {
+ val queryParams =
+ listOfNotNull(
+ explicitQueryParams?.takeIf(String::isNotBlank),
+ page?.let { "pageNumber=$it" },
+ currentSort?.let { "sort=$it" },
+ currentFilter?.let { "filter=$it" },
+ "lang=${locale.toLanguageTag()}",
+ ).joinToString("&")
- explicitQueryParams?.takeIf { it.isNotBlank() }?.let { queryParamsList.add(it) }
- page?.let { queryParamsList.add("pageNumber=$it") }
- currentSort?.let { queryParamsList.add("sort=$it") }
- currentFilter?.let { queryParamsList.add("filter=$it") }
- queryParamsList.add("lang=${locale.toLanguageTag()}")
-
- if (queryParamsList.isNotEmpty()) {
- sb.append("?").append(queryParamsList.joinToString("&"))
- }
- return sb.toString()
+ return "$baseUrl/$idPath" + if (queryParams.isNotEmpty()) "?$queryParams" else ""
}
fun build(): OpdsFeedXml {
- val selfLinkHref = buildUrlWithParams(idPath, if (pageNum != null) pageNum else null)
- val feedLinks = mutableListOf()
- feedLinks.addAll(this.links)
-
- feedLinks.add(
- OpdsLinkXml(OpdsConstants.LINK_REL_SELF, selfLinkHref, feedType, MR.strings.opds_linktitle_self_feed.localized(locale)),
- )
- feedLinks.add(
- OpdsLinkXml(
- OpdsConstants.LINK_REL_START,
- "$baseUrl?lang=${locale.toLanguageTag()}",
- OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
- MR.strings.opds_linktitle_catalog_root.localized(locale),
- ),
- )
- feedLinks.add(
- OpdsLinkXml(
- OpdsConstants.LINK_REL_SEARCH,
- "$baseUrl/search?lang=${locale.toLanguageTag()}",
- OpdsConstants.TYPE_OPENSEARCH_DESCRIPTION,
- MR.strings.opds_linktitle_search_catalog.localized(locale),
- ),
- )
-
- // Add pagination links if needed
- if (pageNum != null) {
- 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(
- 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_LAST,
- buildUrlWithParams(idPath, totalPages),
- feedType,
- MR.strings.opds_linktitle_last_page.localized(locale),
- ),
- )
- }
- }
-
- val urnParams = mutableListOf()
- urnParams.add(locale.toLanguageTag())
- pageNum?.let { urnParams.add("page$it") }
- explicitQueryParams?.let { urnParams.add(it.replace("&", ":").replace("=", "_")) }
- currentSort?.let { urnParams.add("sort_$it") }
- currentFilter?.let { urnParams.add("filter_$it") }
- val urnSuffix = if (urnParams.isNotEmpty()) ":${urnParams.joinToString(":")}" else ""
-
- val showOpenSearchFields = isSearchFeed && pageNum != null && totalResults > 0
+ val itemsPerPage = serverConfig.opdsItemsPerPage.value
+ val showOpenSearch = isSearchFeed && pageNum != null && totalResults > 0
+ val urnSuffix =
+ listOfNotNull(
+ locale.toLanguageTag(),
+ pageNum?.let { "page$it" },
+ explicitQueryParams?.replace("&", ":")?.replace("=", "_"),
+ currentSort?.let { "sort_$it" },
+ currentFilter?.let { "filter_$it" },
+ ).joinToString(":")
return OpdsFeedXml(
- id = "urn:suwayomi:feed:${idPath.replace('/',':')}$urnSuffix",
+ id = "urn:suwayomi:feed:${idPath.replace('/', ':')}${if (urnSuffix.isNotEmpty()) ":$urnSuffix" else ""}",
title = title,
- updated = feedGeneratedAt,
+ updated = OpdsDateUtil.formatCurrentInstantForOpds(),
icon = icon,
- author = feedAuthor,
- links = feedLinks,
+ author = OpdsAuthorXml("Suwayomi", "https://suwayomi.org/"),
+ links =
+ buildList {
+ addAll(this@FeedBuilderInternal.links)
+ add(
+ OpdsLinkXml(
+ OpdsConstants.LINK_REL_SELF,
+ buildUrlWithParams(),
+ feedType,
+ MR.strings.opds_linktitle_self_feed.localized(locale),
+ ),
+ )
+ add(
+ OpdsLinkXml(
+ OpdsConstants.LINK_REL_START,
+ "$baseUrl?lang=${locale.toLanguageTag()}",
+ OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
+ MR.strings.opds_linktitle_catalog_root.localized(locale),
+ ),
+ )
+ add(
+ OpdsLinkXml(
+ OpdsConstants.LINK_REL_SEARCH,
+ "$baseUrl/search?lang=${locale.toLanguageTag()}",
+ OpdsConstants.TYPE_OPENSEARCH_DESCRIPTION,
+ MR.strings.opds_linktitle_search_catalog.localized(locale),
+ ),
+ )
+
+ if (pageNum != null) {
+ val totalPages = ceil(totalResults.toDouble() / itemsPerPage).toInt()
+ if (totalPages > 1) {
+ val currentPage = pageNum.coerceAtLeast(1)
+ add(
+ OpdsLinkXml(
+ OpdsConstants.LINK_REL_FIRST,
+ buildUrlWithParams(1),
+ feedType,
+ MR.strings.opds_linktitle_first_page.localized(locale),
+ ),
+ )
+ if (currentPage >
+ 1
+ ) {
+ add(
+ OpdsLinkXml(
+ OpdsConstants.LINK_REL_PREV,
+ buildUrlWithParams(currentPage - 1),
+ feedType,
+ MR.strings.opds_linktitle_previous_page.localized(locale),
+ ),
+ )
+ }
+ if (currentPage <
+ totalPages
+ ) {
+ add(
+ OpdsLinkXml(
+ OpdsConstants.LINK_REL_NEXT,
+ buildUrlWithParams(currentPage + 1),
+ feedType,
+ MR.strings.opds_linktitle_next_page.localized(locale),
+ ),
+ )
+ }
+ add(
+ OpdsLinkXml(
+ OpdsConstants.LINK_REL_LAST,
+ buildUrlWithParams(totalPages),
+ feedType,
+ MR.strings.opds_linktitle_last_page.localized(locale),
+ ),
+ )
+ }
+ }
+ },
entries = entries,
- totalResults = totalResults.takeIf { showOpenSearchFields },
- itemsPerPage = if (showOpenSearchFields) opdsItemsPerPageBounded else null,
- startIndex = if (showOpenSearchFields) ((pageNum - 1) * opdsItemsPerPageBounded) + 1 else null,
+ totalResults = totalResults.takeIf { showOpenSearch },
+ itemsPerPage = itemsPerPage.takeIf { showOpenSearch },
+ startIndex = if (showOpenSearch && pageNum != null) ((pageNum - 1) * itemsPerPage) + 1 else null,
)
}
}
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsEntryBuilder.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsEntryBuilder.kt
index e3bed66f8..2393b9c40 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsEntryBuilder.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsEntryBuilder.kt
@@ -1,10 +1,7 @@
package suwayomi.tachidesk.opds.impl
import dev.icerock.moko.resources.StringResource
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
import suwayomi.tachidesk.i18n.MR
-import suwayomi.tachidesk.manga.impl.ChapterDownloadHelper
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
import suwayomi.tachidesk.manga.impl.sync.KoreaderSyncService
import suwayomi.tachidesk.manga.model.table.MangaStatus
@@ -93,15 +90,15 @@ object OpdsEntryBuilder {
/**
* Converts a manga data object into a full OPDS acquisition entry.
- * @param entry The manga data object.
* @param baseUrl The base URL for constructing links.
* @param locale The locale for localization.
+ * @param entry The manga data object.
* @return An [OpdsEntryXml] object representing the manga.
*/
fun mangaAcqEntryToEntry(
- entry: OpdsMangaAcqEntry,
baseUrl: String,
locale: Locale,
+ entry: OpdsMangaAcqEntry,
): OpdsEntryXml {
val displayThumbnailUrl = entry.thumbnailUrl?.let { proxyThumbnailUrl(entry.id) }
val categoryScheme = if (entry.inLibrary) "$baseUrl/library/genres" else "$baseUrl/genres"
@@ -152,40 +149,151 @@ object OpdsEntryBuilder {
)
}
+ /**
+ * Resolves the display title for a chapter, ensuring an empty name falls back to
+ * a generic "Chapter X" or "Oneshot" to keep the OPDS feed clean and usable.
+ */
+ private fun resolveChapterTitle(
+ chapterName: String,
+ chapterNumber: Float,
+ sourceOrder: Int,
+ totalChapters: Long,
+ locale: Locale,
+ ): String {
+ val trimmedName = chapterName.trim()
+ if (trimmedName.isNotEmpty()) {
+ return trimmedName
+ }
+ return if (totalChapters <= 1L) {
+ MR.strings.opds_chapter_title_oneshot.localized(locale)
+ } else {
+ val formatNumber =
+ if (chapterNumber >= 0f) {
+ if (chapterNumber % 1 == 0f) chapterNumber.toInt().toString() else chapterNumber.toString()
+ } else {
+ sourceOrder.toString()
+ }
+ MR.strings.opds_chapter_title_fallback.localized(locale, formatNumber)
+ }
+ }
+
/**
* Creates an OPDS entry for a chapter, including acquisition and streaming links.
+ * @param baseUrl The base URL for constructing links.
+ * @param locale The locale for localization.
* @param chapter The chapter data object.
* @param manga The parent manga's details.
- * @param baseUrl The base URL for constructing links.
* @param addMangaTitle Whether to prepend the manga title to the entry title.
- * @param locale The locale for localization.
+ * @param skipMetadataFeed Whether to skip the metadata feed.
* @return An [OpdsEntryXml] object for the chapter.
*/
suspend fun createChapterListEntry(
+ baseUrl: String,
+ locale: Locale,
chapter: OpdsChapterListAcqEntry,
manga: OpdsMangaDetails,
- baseUrl: String,
addMangaTitle: Boolean,
- locale: Locale,
+ skipMetadataFeed: Boolean,
): OpdsEntryXml {
+ var effectiveLastPageRead = chapter.lastPageRead
+ var effectiveLastReadAt = chapter.lastReadAt
+
+ if (skipMetadataFeed) {
+ val syncResult = KoreaderSyncService.checkAndPullProgress(chapter.id)
+
+ // If sync strategy dictates an update (e.g. KEEP_REMOTE), use remote data.
+ // If sync strategy is PROMPT (isConflict=true), we ignore it here (effectively KEEP_LOCAL/DISABLED)
+ // because we cannot show a prompt in the chapter list feed.
+ if (syncResult != null && syncResult.shouldUpdate) {
+ effectiveLastPageRead = syncResult.pageRead
+ effectiveLastReadAt = syncResult.timestamp
+ }
+ }
+
val statusKey =
when {
+ chapter.downloaded -> MR.strings.opds_chapter_status_downloaded
chapter.read -> MR.strings.opds_chapter_status_read
- chapter.lastPageRead > 0 -> MR.strings.opds_chapter_status_in_progress
+ effectiveLastPageRead > 0 -> MR.strings.opds_chapter_status_in_progress
else -> MR.strings.opds_chapter_status_unread
}
val titlePrefix = statusKey.localized(locale)
- val entryTitle = titlePrefix + (if (addMangaTitle) " ${manga.title}:" else "") + " ${chapter.name}"
+ val chapterName = resolveChapterTitle(chapter.name, chapter.chapterNumber, chapter.sourceOrder, manga.totalChapters, locale)
+ val entryTitle = titlePrefix + (if (addMangaTitle) " ${manga.title}:" else "") + " $chapterName"
val details =
buildString {
- append(MR.strings.opds_chapter_details_base.localized(locale, manga.title, chapter.name))
+ append(MR.strings.opds_chapter_details_base.localized(locale, manga.title, chapterName))
chapter.scanlator?.takeIf { it.isNotBlank() }?.let {
append(MR.strings.opds_chapter_details_scanlator.localized(locale, it))
}
if (chapter.pageCount > 0) {
- append(MR.strings.opds_chapter_details_progress.localized(locale, chapter.lastPageRead, chapter.pageCount))
+ append(MR.strings.opds_chapter_details_progress.localized(locale, effectiveLastPageRead, chapter.pageCount))
}
}
+
+ val links = mutableListOf()
+
+ if (skipMetadataFeed) {
+ // Provide Acquisition Link (Download CBZ) if downloaded
+ if (chapter.downloaded) {
+ links.add(
+ OpdsLinkXml(
+ OpdsConstants.LINK_REL_ACQUISITION_OPEN_ACCESS,
+ "/api/v1/chapter/${chapter.id}/download?markAsRead=${serverConfig.opdsMarkAsReadOnDownload.value}",
+ serverConfig.opdsCbzMimetype.value.mediaType,
+ MR.strings.opds_linktitle_download_cbz.localized(locale),
+ length = chapter.cbzFileSize,
+ ),
+ )
+ }
+
+ // Provide Stream Link (OPDS-PSE) if page count is known
+ if (chapter.pageCount > 0) {
+ val basePageHref =
+ "/api/v1/manga/${manga.id}/chapter/${chapter.sourceOrder}/page/{pageNumber}" +
+ "?updateProgress=${serverConfig.opdsEnablePageReadProgress.value}&opds=true"
+
+ val titleRes =
+ if (effectiveLastPageRead > 0) {
+ MR.strings.opds_linktitle_stream_pages_continue
+ } else {
+ MR.strings.opds_linktitle_stream_pages_start
+ }
+
+ links.add(
+ OpdsLinkXml(
+ rel = OpdsConstants.LINK_REL_PSE_STREAM,
+ href = basePageHref,
+ type = OpdsConstants.TYPE_IMAGE_JPEG,
+ title = titleRes.localized(locale),
+ pseCount = chapter.pageCount,
+ pseLastRead = effectiveLastPageRead.takeIf { it > 0 },
+ pseLastReadDate = effectiveLastReadAt.takeIf { it > 0 }?.let { OpdsDateUtil.formatEpochMillisForOpds(it * 1000) },
+ ),
+ )
+
+ // Page 0 Cover
+ links.add(
+ OpdsLinkXml(
+ rel = OpdsConstants.LINK_REL_IMAGE,
+ href = "/api/v1/manga/${manga.id}/chapter/${chapter.sourceOrder}/page/0",
+ type = OpdsConstants.TYPE_IMAGE_JPEG,
+ title = MR.strings.opds_linktitle_chapter_cover.localized(locale),
+ ),
+ )
+ }
+ } else {
+ // Link to Metadata Feed
+ links.add(
+ OpdsLinkXml(
+ rel = OpdsConstants.LINK_REL_SUBSECTION,
+ href = "$baseUrl/series/${manga.id}/chapter/${chapter.sourceOrder}/metadata?lang=${locale.toLanguageTag()}",
+ type = OpdsConstants.TYPE_ATOM_XML_ENTRY_PROFILE_OPDS,
+ title = MR.strings.opds_linktitle_view_chapter_details.localized(locale),
+ ),
+ )
+ }
+
return OpdsEntryXml(
id = "urn:suwayomi:chapter:${chapter.id}",
title = entryTitle,
@@ -196,33 +304,25 @@ object OpdsEntryBuilder {
chapter.scanlator?.takeIf { it.isNotBlank() }?.let { OpdsAuthorXml(name = it) },
),
summary = OpdsSummaryXml(value = details),
- link =
- listOf(
- OpdsLinkXml(
- rel = OpdsConstants.LINK_REL_SUBSECTION,
- href = "$baseUrl/series/${manga.id}/chapter/${chapter.sourceOrder}/metadata?lang=${locale.toLanguageTag()}",
- type = OpdsConstants.TYPE_ATOM_XML_ENTRY_PROFILE_OPDS,
- title = MR.strings.opds_linktitle_view_chapter_details.localized(locale),
- ),
- ),
+ link = links,
)
}
/**
* Creates one or two OPDS entries for a chapter, handling synchronization conflicts internally.
*
- * @param chapter The chapter metadata object.
- * @param manga The parent manga's details.
* @param baseUrl The base URL for constructing links.
* @param locale The locale for localization.
+ * @param chapter The chapter metadata object.
+ * @param manga The parent manga's details.
* @return A `Pair` where the first element is the primary entry (always present) and the
* second is an optional entry representing the remote progress in case of a conflict.
*/
suspend fun createChapterMetadataEntries(
- chapter: OpdsChapterMetadataAcqEntry,
- manga: OpdsMangaDetails,
baseUrl: String,
locale: Locale,
+ chapter: OpdsChapterMetadataAcqEntry,
+ manga: OpdsMangaDetails,
): Pair {
// Check remote progress before building the entry
val syncResult = KoreaderSyncService.checkAndPullProgress(chapter.id)
@@ -234,20 +334,20 @@ object OpdsEntryBuilder {
// Generate two entries: one for local progress and another for remote.
val localEntry =
buildSingleChapterMetadataEntry(
- chapter,
- manga,
baseUrl,
locale,
+ chapter,
+ manga,
progressSource = ProgressSource.Local(chapter.lastPageRead, chapter.lastReadAt),
isConflict = true,
)
val remoteEntry =
buildSingleChapterMetadataEntry(
- chapter,
- manga,
baseUrl,
locale,
+ chapter,
+ manga,
progressSource = ProgressSource.Remote(syncResult!!.pageRead, syncResult.timestamp, syncResult.device),
isConflict = true,
)
@@ -263,10 +363,10 @@ object OpdsEntryBuilder {
val mainEntry =
buildSingleChapterMetadataEntry(
- chapter,
- manga,
baseUrl,
locale,
+ chapter,
+ manga,
progressSource = progressSource,
isConflict = false,
)
@@ -297,10 +397,10 @@ object OpdsEntryBuilder {
* Helper function to build a single OpdsEntryXml for a chapter.
*/
private suspend fun buildSingleChapterMetadataEntry(
- chapter: OpdsChapterMetadataAcqEntry,
- manga: OpdsMangaDetails,
baseUrl: String,
locale: Locale,
+ chapter: OpdsChapterMetadataAcqEntry,
+ manga: OpdsMangaDetails,
progressSource: ProgressSource,
isConflict: Boolean,
): OpdsEntryXml {
@@ -337,14 +437,7 @@ object OpdsEntryBuilder {
}
val entryTitle = "$titlePrefix ${chapter.name}"
- val cbzFileSize =
- if (chapter.downloaded) {
- withContext(Dispatchers.IO) {
- runCatching { ChapterDownloadHelper.getArchiveStreamWithSize(manga.id, chapter.id).second }.getOrNull()
- }
- } else {
- null
- }
+ val cbzFileSize = chapter.cbzFileSize
val links = mutableListOf()
chapter.url?.let {
@@ -449,8 +542,8 @@ object OpdsEntryBuilder {
fun addSourceSortFacets(
feedBuilder: FeedBuilderInternal,
baseUrl: String,
- currentSort: String,
locale: Locale,
+ currentSort: String,
) {
val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale)
val addFacet = { href: String, titleKey: StringResource, isActive: Boolean ->
@@ -475,9 +568,9 @@ object OpdsEntryBuilder {
fun addChapterSortAndFilterFacets(
feedBuilder: FeedBuilderInternal,
baseUrl: String,
+ locale: Locale,
currentSort: String,
currentFilter: String,
- locale: Locale,
filterCounts: Map? = null,
) {
val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale)
@@ -554,15 +647,15 @@ object OpdsEntryBuilder {
fun addLibraryFacets(
feedBuilder: FeedBuilderInternal,
baseUrl: String,
- activeFilters: OpdsMangaFilter,
locale: Locale,
+ activeFilters: OpdsMangaFilter,
) {
val currentSort = activeFilters.sort ?: "alpha_asc"
val currentFilter = activeFilters.filter ?: "all"
val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale)
val filterGroup = MR.strings.opds_facetgroup_filter_content.localized(locale)
- val filterCounts = MangaRepository.getLibraryFilterCounts()
+ val filterCounts = MangaRepository.getLibraryFilterCounts(activeFilters)
val buildUrl = { newFilters: OpdsMangaFilter, newSort: String, newFilter: String ->
val crossFilterParams = newFilters.toCrossFilterQueryParameters()
@@ -667,7 +760,7 @@ object OpdsEntryBuilder {
// --- Cross-Filter Facets ---
if (activeFilters.primaryFilter != PrimaryFilterType.SOURCE) {
- val sources = NavigationRepository.getLibrarySources(1).first
+ val sources = NavigationRepository.getLibrarySources(pageNum = null, activeFilters = activeFilters).first
addFacet(
feedBuilder,
buildUrl(activeFilters.without("source_id"), currentSort, currentFilter),
@@ -688,7 +781,7 @@ object OpdsEntryBuilder {
}
}
if (activeFilters.primaryFilter != PrimaryFilterType.CATEGORY) {
- val categories = NavigationRepository.getCategories(1).first
+ val categories = NavigationRepository.getCategories(pageNum = null, activeFilters = activeFilters).first
addFacet(
feedBuilder,
buildUrl(activeFilters.without("category_id"), currentSort, currentFilter),
@@ -709,7 +802,7 @@ object OpdsEntryBuilder {
}
}
if (activeFilters.primaryFilter != PrimaryFilterType.STATUS) {
- val statuses = NavigationRepository.getStatuses(locale)
+ val statuses = NavigationRepository.getStatuses(locale, pageNum = null, activeFilters = activeFilters).first
addFacet(
feedBuilder,
buildUrl(activeFilters.without("status_id"), currentSort, currentFilter),
@@ -730,7 +823,7 @@ object OpdsEntryBuilder {
}
}
if (activeFilters.primaryFilter != PrimaryFilterType.LANGUAGE) {
- val languages = NavigationRepository.getContentLanguages(locale)
+ val languages = NavigationRepository.getContentLanguages(locale, pageNum = null, activeFilters = activeFilters).first
addFacet(
feedBuilder,
buildUrl(activeFilters.without("lang_code"), currentSort, currentFilter),
@@ -751,7 +844,7 @@ object OpdsEntryBuilder {
}
}
if (activeFilters.primaryFilter != PrimaryFilterType.GENRE) {
- val genres = NavigationRepository.getGenres(1, locale).first
+ val genres = NavigationRepository.getGenres(locale, pageNum = null, activeFilters = activeFilters).first
addFacet(
feedBuilder,
buildUrl(activeFilters.without("genre"), currentSort, currentFilter),
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 595ae64f6..6451a23cb 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt
@@ -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)
}
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/ChapterRepository.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/ChapterRepository.kt
index 735f07788..df7ca43ea 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/ChapterRepository.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/ChapterRepository.kt
@@ -1,17 +1,29 @@
package suwayomi.tachidesk.opds.repository
+import io.github.oshai.kotlinlogging.KotlinLogging
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.withContext
import org.jetbrains.exposed.v1.core.Column
import org.jetbrains.exposed.v1.core.JoinType
import org.jetbrains.exposed.v1.core.Op
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.and
+import org.jetbrains.exposed.v1.core.count
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.greater
+import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.jdbc.andWhere
import org.jetbrains.exposed.v1.jdbc.select
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
+import org.jetbrains.exposed.v1.jdbc.update
+import suwayomi.tachidesk.manga.impl.ChapterDownloadHelper
import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReady
+import suwayomi.tachidesk.manga.impl.chapter.refreshChapterPageList
+import suwayomi.tachidesk.manga.impl.chapter.updateChapterPersistence
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.SourceTable
@@ -24,6 +36,7 @@ import suwayomi.tachidesk.server.serverConfig
object ChapterRepository {
private val opdsItemsPerPageBounded: Int
get() = serverConfig.opdsItemsPerPage.value
+ private val logger = KotlinLogging.logger {}
private fun ResultRow.toOpdsChapterListAcqEntry(): OpdsChapterListAcqEntry =
OpdsChapterListAcqEntry(
@@ -38,69 +51,163 @@ object ChapterRepository {
lastReadAt = this[ChapterTable.lastReadAt],
sourceOrder = this[ChapterTable.sourceOrder],
pageCount = this[ChapterTable.pageCount],
+ downloaded = this[ChapterTable.isDownloaded],
)
- fun getChaptersForManga(
+ suspend fun getChaptersForManga(
mangaId: Int,
- pageNum: Int,
sortColumn: Column<*>,
sortOrder: SortOrder,
filter: String,
- ): Pair, Long> =
- transaction {
- val conditions = mutableListOf>()
- conditions.add(ChapterTable.manga eq mangaId)
+ pageNum: Int,
+ skipMetadata: Boolean,
+ ): Pair, Long> {
+ val (rawChapters, totalCount) =
+ transaction {
+ val conditions = mutableListOf>()
+ conditions.add(ChapterTable.manga eq mangaId)
- when (filter) {
- "unread" -> conditions.add(ChapterTable.isRead eq false)
- "read" -> conditions.add(ChapterTable.isRead eq true)
- }
- if (serverConfig.opdsShowOnlyDownloadedChapters.value) {
- conditions.add(ChapterTable.isDownloaded eq true)
+ when (filter) {
+ "unread" -> conditions.add(ChapterTable.isRead eq false)
+ "read" -> conditions.add(ChapterTable.isRead eq true)
+ }
+ if (serverConfig.opdsShowOnlyDownloadedChapters.value) {
+ conditions.add(ChapterTable.isDownloaded eq true)
+ }
+
+ val finalCondition = conditions.reduceOrNull { acc, op -> acc and op } ?: Op.TRUE
+
+ val baseQuery =
+ ChapterTable
+ .select(ChapterTable.columns)
+ .where(finalCondition)
+
+ val totalCount = baseQuery.count()
+
+ val chapters =
+ baseQuery
+ .orderBy(sortColumn to sortOrder)
+ .limit(opdsItemsPerPageBounded)
+ .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
+ .map { it.toOpdsChapterListAcqEntry() }
+
+ Pair(chapters, totalCount)
}
- val finalCondition = conditions.reduceOrNull { acc, op -> acc and op } ?: Op.TRUE
-
- val baseQuery =
- ChapterTable
- .select(ChapterTable.columns)
- .where(finalCondition)
-
- val totalCount = baseQuery.count()
-
- val chapters =
- baseQuery
- .orderBy(sortColumn to sortOrder)
- .limit(opdsItemsPerPageBounded)
- .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
- .map { it.toOpdsChapterListAcqEntry() }
-
- Pair(chapters, totalCount)
+ // If not skipping metadata, return basic DTOs
+ if (!skipMetadata) {
+ return Pair(rawChapters, totalCount)
}
+ // If skipping metadata, enrich DTOs with page count and file size
+ val enrichedChapters =
+ coroutineScope {
+ rawChapters.map { entry ->
+ async(Dispatchers.IO) {
+ var pageCount = entry.pageCount
+ var isDownloaded = entry.downloaded
+
+ // Verify physical files if page count is unknown or the DB marks it as downloaded
+ if (pageCount <= 0 || isDownloaded) {
+ val physicalPageCount =
+ runCatching {
+ ChapterDownloadHelper.getImageCount(entry.mangaId, entry.id)
+ }.getOrDefault(0)
+
+ if (physicalPageCount > 0) {
+ // Files exist! Sync DB if needed
+ if (updateChapterPersistence(
+ chapterId = entry.id,
+ isMarkedAsDownloaded = isDownloaded,
+ dbPageCount = pageCount,
+ downloadPageCount = physicalPageCount,
+ lastPageRead = entry.lastPageRead,
+ logger = logger,
+ )
+ ) {
+ pageCount = physicalPageCount
+ isDownloaded = true
+ }
+ } else {
+ if (isDownloaded) {
+ // Fix DB state if marked as downloaded but physical files are missing
+ transaction {
+ ChapterTable.update({ ChapterTable.id eq entry.id }) {
+ it[ChapterTable.isDownloaded] = false
+ }
+ }
+ isDownloaded = false
+ }
+
+ if (pageCount <= 0) {
+ // No files, and DB has no page count. Fetch from network
+ pageCount =
+ runCatching {
+ refreshChapterPageList(entry.mangaId, entry.id)
+ }.onFailure {
+ logger.warn(it) { "Failed to fetch page count for chapter ${entry.id}" }
+ }.getOrDefault(0)
+ }
+ }
+ }
+
+ // Calculate CBZ size if downloaded
+ val cbzFileSize =
+ if (isDownloaded) {
+ runCatching {
+ ChapterDownloadHelper.getChapterArchiveSize(entry.mangaId, entry.id)
+ }.getOrNull()
+ } else {
+ null
+ }
+
+ entry.copy(
+ pageCount = pageCount,
+ downloaded = isDownloaded,
+ cbzFileSize = cbzFileSize,
+ )
+ }
+ }
+ }.awaitAll()
+
+ return Pair(enrichedChapters, totalCount)
+ }
+
suspend fun getChapterDetailsForMetadataFeed(
mangaId: Int,
chapterSourceOrder: Int,
- ): OpdsChapterMetadataAcqEntry? =
- try {
- val chapterDataClass = getChapterDownloadReady(chapterIndex = chapterSourceOrder, mangaId = mangaId)
- OpdsChapterMetadataAcqEntry(
- id = chapterDataClass.id,
- mangaId = chapterDataClass.mangaId,
- name = chapterDataClass.name,
- uploadDate = chapterDataClass.uploadDate,
- scanlator = chapterDataClass.scanlator,
- read = chapterDataClass.read,
- lastPageRead = chapterDataClass.lastPageRead,
- lastReadAt = chapterDataClass.lastReadAt,
- sourceOrder = chapterDataClass.index,
- downloaded = chapterDataClass.downloaded,
- pageCount = chapterDataClass.pageCount,
- url = chapterDataClass.realUrl,
- )
- } catch (e: Exception) {
- null
- }
+ ): OpdsChapterMetadataAcqEntry? {
+ val chapterDataClass =
+ try {
+ getChapterDownloadReady(chapterIndex = chapterSourceOrder, mangaId = mangaId)
+ } catch (e: Exception) {
+ return null
+ }
+
+ return OpdsChapterMetadataAcqEntry(
+ id = chapterDataClass.id,
+ mangaId = chapterDataClass.mangaId,
+ name = chapterDataClass.name,
+ uploadDate = chapterDataClass.uploadDate,
+ chapterNumber = chapterDataClass.chapterNumber,
+ scanlator = chapterDataClass.scanlator,
+ read = chapterDataClass.read,
+ lastPageRead = chapterDataClass.lastPageRead,
+ lastReadAt = chapterDataClass.lastReadAt,
+ sourceOrder = chapterDataClass.index,
+ downloaded = chapterDataClass.downloaded,
+ pageCount = chapterDataClass.pageCount,
+ url = chapterDataClass.realUrl,
+ cbzFileSize =
+ if (chapterDataClass.downloaded) {
+ withContext(Dispatchers.IO) {
+ runCatching { ChapterDownloadHelper.getChapterArchiveSize(mangaId, chapterDataClass.id) }.getOrNull()
+ }
+ } else {
+ null
+ },
+ )
+ }
fun getLibraryUpdates(pageNum: Int): Pair, Long> =
transaction {
@@ -115,21 +222,38 @@ object ChapterRepository {
val totalCount = query.count()
- val items =
+ val rawItems =
query
.orderBy(ChapterTable.fetchedAt to SortOrder.DESC, ChapterTable.sourceOrder to SortOrder.DESC)
.limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
- .map {
- OpdsLibraryUpdateAcqEntry(
- chapter = it.toOpdsChapterListAcqEntry(),
- mangaTitle = it[MangaTable.title],
- mangaAuthor = it[MangaTable.author],
- mangaId = it[MangaTable.id].value,
- mangaSourceLang = it[SourceTable.lang],
- mangaThumbnailUrl = it[MangaTable.thumbnail_url],
- )
- }
+ .toList()
+
+ val mangaIds = rawItems.map { it[MangaTable.id].value }.distinct()
+ val chapterCounts =
+ if (mangaIds.isNotEmpty()) {
+ ChapterTable
+ .select(ChapterTable.manga, ChapterTable.id.count())
+ .where { ChapterTable.manga inList mangaIds }
+ .groupBy(ChapterTable.manga)
+ .associate { it[ChapterTable.manga].value to it[ChapterTable.id.count()] }
+ } else {
+ emptyMap()
+ }
+
+ val items =
+ rawItems.map {
+ val mId = it[MangaTable.id].value
+ OpdsLibraryUpdateAcqEntry(
+ chapter = it.toOpdsChapterListAcqEntry(),
+ mangaTitle = it[MangaTable.title],
+ mangaAuthor = it[MangaTable.author],
+ mangaId = mId,
+ mangaSourceLang = it[SourceTable.lang],
+ mangaThumbnailUrl = it[MangaTable.thumbnail_url],
+ mangaTotalChapters = chapterCounts[mId] ?: 0L,
+ )
+ }
Pair(items, totalCount)
}
@@ -146,21 +270,38 @@ object ChapterRepository {
val totalCount = query.count()
- val items =
+ val rawItems =
query
.orderBy(ChapterTable.lastReadAt to SortOrder.DESC)
.limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
- .map {
- OpdsHistoryAcqEntry(
- chapter = it.toOpdsChapterListAcqEntry(),
- mangaTitle = it[MangaTable.title],
- mangaAuthor = it[MangaTable.author],
- mangaId = it[MangaTable.id].value,
- mangaSourceLang = it[SourceTable.lang],
- mangaThumbnailUrl = it[MangaTable.thumbnail_url],
- )
- }
+ .toList()
+
+ val mangaIds = rawItems.map { it[MangaTable.id].value }.distinct()
+ val chapterCounts =
+ if (mangaIds.isNotEmpty()) {
+ ChapterTable
+ .select(ChapterTable.manga, ChapterTable.id.count())
+ .where { ChapterTable.manga inList mangaIds }
+ .groupBy(ChapterTable.manga)
+ .associate { it[ChapterTable.manga].value to it[ChapterTable.id.count()] }
+ } else {
+ emptyMap()
+ }
+
+ val items =
+ rawItems.map {
+ val mId = it[MangaTable.id].value
+ OpdsHistoryAcqEntry(
+ chapter = it.toOpdsChapterListAcqEntry(),
+ mangaTitle = it[MangaTable.title],
+ mangaAuthor = it[MangaTable.author],
+ mangaId = mId,
+ mangaSourceLang = it[SourceTable.lang],
+ mangaThumbnailUrl = it[MangaTable.thumbnail_url],
+ mangaTotalChapters = chapterCounts[mId] ?: 0L,
+ )
+ }
Pair(items, totalCount)
}
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/MangaRepository.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/MangaRepository.kt
index 433aef3d8..2e8245fcb 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/MangaRepository.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/MangaRepository.kt
@@ -36,8 +36,57 @@ 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.util.OpdsStringUtil.formatSourceName
import suwayomi.tachidesk.server.serverConfig
+/**
+ * Applies dynamic filters based on the current user configuration and cross-filters.
+ * Allows excluding a specific field to calculate mutual exclusion facet counts efficiently.
+ *
+ * @param criteria The filtering criteria.
+ * @param excludeField The field to exclude from filtering.
+ */
+fun Query.applyOpdsMangaFilter(
+ criteria: OpdsMangaFilter,
+ excludeField: String? = null,
+) {
+ if (excludeField != "source_id") {
+ criteria.sourceId?.let { andWhere { MangaTable.sourceReference eq it } }
+ }
+ if (excludeField != "category_id") {
+ criteria.categoryId?.let { andWhere { CategoryMangaTable.category eq it } }
+ }
+ if (excludeField != "status_id") {
+ criteria.statusId?.let { andWhere { MangaTable.status eq it } }
+ }
+ if (excludeField != "lang_code") {
+ criteria.langCode?.let { andWhere { SourceTable.lang eq it } }
+ }
+ if (excludeField != "genre") {
+ criteria.genre?.let { genre ->
+ val genreTrimmed = genre.trim()
+ andWhere {
+ (MangaTable.genre like "%, $genreTrimmed, %") or
+ (MangaTable.genre like "$genreTrimmed, %") or
+ (MangaTable.genre like "%, $genreTrimmed") or
+ (MangaTable.genre eq genreTrimmed)
+ }
+ }
+ }
+ if (excludeField != "filter") {
+ criteria.filter?.let { filterVal ->
+ val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum()
+ val downloadedCountExpr = Case().When(ChapterTable.isDownloaded eq true, intLiteral(1)).Else(intLiteral(0)).sum()
+ when (filterVal) {
+ "unread" -> having { unreadCountExpr greater 0 }
+ "downloaded" -> having { downloadedCountExpr greater 0 }
+ "ongoing" -> andWhere { MangaTable.status eq MangaStatus.ONGOING.value }
+ "completed" -> andWhere { MangaTable.status eq MangaStatus.COMPLETED.value }
+ }
+ }
+ }
+}
+
/**
* Repository for fetching manga data tailored for OPDS feeds.
*/
@@ -60,24 +109,24 @@ object MangaRepository {
sourceLang = this[SourceTable.lang],
inLibrary = this[MangaTable.inLibrary],
status = this[MangaTable.status],
- sourceName = this[SourceTable.name],
+ sourceName = formatSourceName(this[SourceTable.name], this[SourceTable.lang]),
lastFetchedAt = this[MangaTable.lastFetchedAt],
url = this[MangaTable.realUrl],
)
/**
* Centralized function to retrieve paginated, sorted, and filtered manga from the library.
+ * @param criteria Additional filtering criteria for categories, sources, etc.
* @param pageNum The page number for pagination.
* @param sort The sorting parameter.
* @param filter The filtering parameter.
- * @param criteria Additional filtering criteria for categories, sources, etc.
* @return An [OpdsLibraryFeedResult] containing the list of manga, total count, and the specific filter name.
*/
fun getLibraryManga(
+ criteria: OpdsMangaFilter,
pageNum: Int,
sort: String?,
filter: String?,
- criteria: OpdsMangaFilter,
): OpdsLibraryFeedResult =
transaction {
val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum()
@@ -91,22 +140,11 @@ object MangaRepository {
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
.select(MangaTable.columns + SourceTable.lang + SourceTable.name + unreadCount)
.where { MangaTable.inLibrary eq true }
- .groupBy(MangaTable.id, SourceTable.lang, SourceTable.name)
- // Apply specific filters from criteria
- criteria.sourceId?.let { query.andWhere { MangaTable.sourceReference eq it } }
- criteria.categoryId?.let { query.andWhere { CategoryMangaTable.category eq it } }
- criteria.statusId?.let { query.andWhere { MangaTable.status eq it } }
- criteria.langCode?.let { query.andWhere { SourceTable.lang eq it } }
- criteria.genre?.let { genre ->
- val genreTrimmed = genre.trim()
- val genreCondition =
- (MangaTable.genre like "%, $genreTrimmed, %") or
- (MangaTable.genre like "$genreTrimmed, %") or
- (MangaTable.genre like "%, $genreTrimmed") or
- (MangaTable.genre eq genreTrimmed)
- query.andWhere { genreCondition }
- }
+ query.applyOpdsMangaFilter(criteria)
+ applyMangaLibrarySort(query, sort)
+
+ query.groupBy(MangaTable.id, SourceTable.lang, SourceTable.name)
// Efficiently get the name of the primary filter item
val specificFilterName =
@@ -114,10 +152,10 @@ object MangaRepository {
PrimaryFilterType.SOURCE -> {
criteria.sourceId?.let {
SourceTable
- .select(SourceTable.name)
+ .select(SourceTable.name, SourceTable.lang)
.where { SourceTable.id eq it }
.firstOrNull()
- ?.get(SourceTable.name)
+ ?.let { formatSourceName(it[SourceTable.name], it[SourceTable.lang]) }
}
}
@@ -150,8 +188,6 @@ object MangaRepository {
}
}
- applyMangaLibrarySortAndFilter(query, sort, filter)
-
val totalCount = query.count()
val mangas =
query
@@ -245,6 +281,7 @@ object MangaRepository {
*/
fun getMangaDetails(mangaId: Int): OpdsMangaDetails? =
transaction {
+ val chapterCount = ChapterTable.select(ChapterTable.id).where { ChapterTable.manga eq mangaId }.count()
MangaTable
.select(MangaTable.id, MangaTable.title, MangaTable.thumbnail_url, MangaTable.author)
.where { MangaTable.id eq mangaId }
@@ -255,6 +292,7 @@ object MangaRepository {
title = it[MangaTable.title],
thumbnailUrl = it[MangaTable.thumbnail_url],
author = it[MangaTable.author],
+ totalChapters = chapterCount,
)
}
}
@@ -265,24 +303,14 @@ object MangaRepository {
* @param sort The sorting parameter.
* @param filter The filtering parameter.
*/
- private fun applyMangaLibrarySortAndFilter(
+ private fun applyMangaLibrarySort(
query: Query,
sort: String?,
- filter: String?,
) {
val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum()
- val downloadedCountExpr = Case().When(ChapterTable.isDownloaded eq true, intLiteral(1)).Else(intLiteral(0)).sum()
val lastReadAtExpr = ChapterTable.lastReadAt.max()
val latestChapterDateExpr = ChapterTable.date_upload.max()
- // Apply filtering using HAVING for aggregate functions or WHERE for direct columns
- when (filter) {
- "unread" -> query.having { unreadCountExpr greater 0 }
- "downloaded" -> query.having { downloadedCountExpr greater 0 }
- "ongoing" -> query.andWhere { MangaTable.status eq MangaStatus.ONGOING.value }
- "completed" -> query.andWhere { MangaTable.status eq MangaStatus.COMPLETED.value }
- }
-
// Apply sorting
when (sort) {
"alpha_asc" -> query.orderBy(MangaTable.title to SortOrder.ASC)
@@ -296,10 +324,11 @@ object MangaRepository {
}
/**
- * Calculates the count of manga for various library filter facets.
+ * Calculates the count of manga for various library filter facets, respecting other active cross-filters.
+ * @param activeFilters The currently active filters to respect during count calculation.
* @return A map where keys are filter names and values are the counts.
*/
- fun getLibraryFilterCounts(): Map =
+ fun getLibraryFilterCounts(activeFilters: OpdsMangaFilter): Map =
transaction {
val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum()
val downloadedCountExpr = Case().When(ChapterTable.isDownloaded eq true, intLiteral(1)).Else(intLiteral(0)).sum()
@@ -307,14 +336,28 @@ object MangaRepository {
val baseQuery =
MangaTable
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
+ .join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
+ .join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
.select(MangaTable.id)
.where { MangaTable.inLibrary eq true }
- .groupBy(MangaTable.id)
+
+ baseQuery.applyOpdsMangaFilter(activeFilters, excludeField = "filter")
+ baseQuery.groupBy(MangaTable.id)
val unreadCount = baseQuery.copy().having { unreadCountExpr greater 0 }.count()
val downloadedCount = baseQuery.copy().having { downloadedCountExpr greater 0 }.count()
- val statusBaseQuery = MangaTable.select(MangaTable.id).where { MangaTable.inLibrary eq true }
+ val statusBaseQuery =
+ MangaTable
+ .join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
+ .join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
+ .join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
+ .select(MangaTable.id)
+ .where { MangaTable.inLibrary eq true }
+
+ statusBaseQuery.applyOpdsMangaFilter(activeFilters, excludeField = "filter")
+ statusBaseQuery.groupBy(MangaTable.id)
+
val ongoingCount = statusBaseQuery.copy().andWhere { MangaTable.status eq MangaStatus.ONGOING.value }.count()
val completedCount = statusBaseQuery.copy().andWhere { MangaTable.status eq MangaStatus.COMPLETED.value }.count()
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/NavigationRepository.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/NavigationRepository.kt
index dca5ab4af..4bed7a9a9 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/NavigationRepository.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/NavigationRepository.kt
@@ -13,6 +13,7 @@ import suwayomi.tachidesk.i18n.MR
import suwayomi.tachidesk.manga.impl.extension.Extension
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
import suwayomi.tachidesk.manga.model.table.CategoryTable
+import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.MangaStatus
import suwayomi.tachidesk.manga.model.table.MangaTable
@@ -21,10 +22,12 @@ import suwayomi.tachidesk.opds.constants.OpdsConstants
import suwayomi.tachidesk.opds.dto.OpdsCategoryNavEntry
import suwayomi.tachidesk.opds.dto.OpdsGenreNavEntry
import suwayomi.tachidesk.opds.dto.OpdsLanguageNavEntry
+import suwayomi.tachidesk.opds.dto.OpdsMangaFilter
import suwayomi.tachidesk.opds.dto.OpdsRootNavEntry
import suwayomi.tachidesk.opds.dto.OpdsSourceNavEntry
import suwayomi.tachidesk.opds.dto.OpdsStatusNavEntry
import suwayomi.tachidesk.opds.util.OpdsStringUtil.encodeForOpdsURL
+import suwayomi.tachidesk.opds.util.OpdsStringUtil.formatSourceName
import suwayomi.tachidesk.server.serverConfig
import java.util.Locale
@@ -131,15 +134,14 @@ object NavigationRepository {
)
}
- // ... (El resto del archivo permanece sin cambios)
fun getExploreSources(pageNum: Int): Pair, Long> =
transaction {
val query =
SourceTable
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
- .select(SourceTable.id, SourceTable.name, ExtensionTable.apkName)
+ .select(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.apkName)
.where { ExtensionTable.isInstalled eq true }
- .groupBy(SourceTable.id, SourceTable.name, ExtensionTable.apkName)
+ .groupBy(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.apkName)
.orderBy(SourceTable.name to SortOrder.ASC)
val totalCount = query.count()
@@ -150,7 +152,7 @@ object NavigationRepository {
.map {
OpdsSourceNavEntry(
id = it[SourceTable.id].value,
- name = it[SourceTable.name],
+ name = formatSourceName(it[SourceTable.name], it[SourceTable.lang]),
iconUrl = it[ExtensionTable.apkName].let { apkName -> Extension.getExtensionIconUrl(apkName) },
mangaCount = null,
)
@@ -158,7 +160,10 @@ object NavigationRepository {
Pair(sources, totalCount)
}
- fun getLibrarySources(pageNum: Int): Pair, Long> =
+ fun getLibrarySources(
+ pageNum: Int? = null,
+ activeFilters: OpdsMangaFilter = OpdsMangaFilter(),
+ ): Pair, Long> =
transaction {
val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
@@ -166,28 +171,55 @@ object NavigationRepository {
SourceTable
.join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference)
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
- .select(SourceTable.id, SourceTable.name, ExtensionTable.apkName, mangaCount)
+ .join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
+ .join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
+ .select(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.apkName, mangaCount)
.where { MangaTable.inLibrary eq true }
- .groupBy(SourceTable.id, SourceTable.name, ExtensionTable.apkName)
- .orderBy(SourceTable.name to SortOrder.ASC)
+
+ query.applyOpdsMangaFilter(activeFilters, excludeField = "source_id")
+
+ query
+ .groupBy(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.apkName)
+ .orderBy(SourceTable.name to SortOrder.ASC)
val totalCount = query.count()
- val sources =
+
+ if (pageNum != null) {
query
.limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
- .map {
- OpdsSourceNavEntry(
- id = it[SourceTable.id].value,
- name = it[SourceTable.name],
- iconUrl = it[ExtensionTable.apkName].let { apkName -> Extension.getExtensionIconUrl(apkName) },
- mangaCount = it[mangaCount],
- )
- }
+ }
+
+ val sources =
+ query.map {
+ OpdsSourceNavEntry(
+ id = it[SourceTable.id].value,
+ name = formatSourceName(it[SourceTable.name], it[SourceTable.lang]),
+ iconUrl = it[ExtensionTable.apkName].let { apkName -> Extension.getExtensionIconUrl(apkName) },
+ mangaCount = it[mangaCount],
+ )
+ }
Pair(sources, totalCount)
}
- fun getCategories(pageNum: Int): Pair, Long> =
+ fun getSourceDetails(sourceId: Long): Pair? =
+ transaction {
+ SourceTable
+ .join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
+ .select(SourceTable.name, SourceTable.lang, ExtensionTable.apkName)
+ .where { SourceTable.id eq sourceId }
+ .firstOrNull()
+ ?.let {
+ val name = formatSourceName(it[SourceTable.name], it[SourceTable.lang])
+ val icon = Extension.getExtensionIconUrl(it[ExtensionTable.apkName])
+ Pair(name, icon)
+ }
+ }
+
+ fun getCategories(
+ pageNum: Int? = null,
+ activeFilters: OpdsMangaFilter = OpdsMangaFilter(),
+ ): Pair, Long> =
transaction {
val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
@@ -195,35 +227,54 @@ object NavigationRepository {
CategoryTable
.join(CategoryMangaTable, JoinType.INNER, CategoryTable.id, CategoryMangaTable.category)
.join(MangaTable, JoinType.INNER, CategoryMangaTable.manga, MangaTable.id)
+ .join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
+ .join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
.select(CategoryTable.id, CategoryTable.name, mangaCount)
.where { MangaTable.inLibrary eq true }
- .groupBy(CategoryTable.id, CategoryTable.name)
- .orderBy(CategoryTable.order to SortOrder.ASC)
+
+ query.applyOpdsMangaFilter(activeFilters, excludeField = "category_id")
+
+ query
+ .groupBy(CategoryTable.id, CategoryTable.name)
+ .orderBy(CategoryTable.order to SortOrder.ASC)
val totalCount = query.count()
- val categories =
+
+ if (pageNum != null) {
query
.limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
- .map {
- OpdsCategoryNavEntry(
- id = it[CategoryTable.id].value,
- name = it[CategoryTable.name],
- mangaCount = it[mangaCount],
- )
- }
+ }
+
+ val categories =
+ query.map {
+ OpdsCategoryNavEntry(
+ id = it[CategoryTable.id].value,
+ name = it[CategoryTable.name],
+ mangaCount = it[mangaCount],
+ )
+ }
Pair(categories, totalCount)
}
fun getGenres(
- pageNum: Int,
locale: Locale,
+ pageNum: Int? = null,
+ activeFilters: OpdsMangaFilter = OpdsMangaFilter(),
): Pair, Long> =
transaction {
- val allGenres =
+ val query =
MangaTable
+ .join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
+ .join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
+ .join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
.select(MangaTable.genre)
.where { MangaTable.inLibrary eq true }
+
+ query.applyOpdsMangaFilter(activeFilters, excludeField = "genre")
+
+ val allGenres =
+ query
.mapNotNull { it[MangaTable.genre] }
.flatMap { it.split(",").map(String::trim).filterNot(String::isBlank) }
@@ -231,21 +282,32 @@ object NavigationRepository {
val distinctGenres = genreCounts.keys.sorted()
val totalCount = distinctGenres.size.toLong()
- val fromIndex = ((pageNum - 1) * opdsItemsPerPageBounded)
- val toIndex = minOf(fromIndex + opdsItemsPerPageBounded, distinctGenres.size)
+
+ val finalGenres =
+ if (pageNum != null) {
+ val fromIndex = ((pageNum - 1) * opdsItemsPerPageBounded)
+ val toIndex = minOf(fromIndex + opdsItemsPerPageBounded, distinctGenres.size)
+ if (fromIndex < distinctGenres.size) distinctGenres.subList(fromIndex, toIndex) else emptyList()
+ } else {
+ distinctGenres
+ }
+
val paginatedGenres =
- (if (fromIndex < distinctGenres.size) distinctGenres.subList(fromIndex, toIndex) else emptyList())
- .map { genreName ->
- OpdsGenreNavEntry(
- id = genreName.encodeForOpdsURL(),
- title = genreName,
- mangaCount = genreCounts[genreName]?.toLong() ?: 0L,
- )
- }
+ finalGenres.map { genreName ->
+ OpdsGenreNavEntry(
+ id = genreName.encodeForOpdsURL(),
+ title = genreName,
+ mangaCount = genreCounts[genreName]?.toLong() ?: 0L,
+ )
+ }
Pair(paginatedGenres, totalCount)
}
- fun getStatuses(locale: Locale): List {
+ fun getStatuses(
+ locale: Locale,
+ pageNum: Int? = null,
+ activeFilters: OpdsMangaFilter = OpdsMangaFilter(),
+ ): Pair, Long> {
val statusStringResources: Map =
mapOf(
MangaStatus.UNKNOWN to MR.strings.manga_status_unknown,
@@ -259,43 +321,88 @@ object NavigationRepository {
val statusCounts =
transaction {
- MangaTable
- .select(MangaTable.status, MangaTable.id.count())
- .where { MangaTable.inLibrary eq true }
+ val countExpr = MangaTable.id.countDistinct().alias("manga_count")
+ val query =
+ MangaTable
+ .join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
+ .join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
+ .join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
+ .select(MangaTable.status, countExpr)
+ .where { MangaTable.inLibrary eq true }
+
+ query.applyOpdsMangaFilter(activeFilters, excludeField = "status_id")
+
+ query
.groupBy(MangaTable.status)
- .associate { it[MangaTable.status] to it[MangaTable.id.count()] }
+ .associate { it[MangaTable.status] to it[countExpr] }
}
- return MangaStatus.entries
- .map { mangaStatus ->
- val titleRes = statusStringResources[mangaStatus] ?: MR.strings.manga_status_unknown
- OpdsStatusNavEntry(
- id = mangaStatus.value,
- title = titleRes.localized(locale),
- mangaCount = statusCounts[mangaStatus.value] ?: 0L,
- )
- }.sortedBy { it.id }
+ val allStatuses =
+ MangaStatus.entries
+ .map { mangaStatus ->
+ val titleRes = statusStringResources[mangaStatus] ?: MR.strings.manga_status_unknown
+ OpdsStatusNavEntry(
+ id = mangaStatus.value,
+ title = titleRes.localized(locale),
+ mangaCount = statusCounts[mangaStatus.value] ?: 0L,
+ )
+ }.sortedBy { it.id }
+
+ val totalCount = allStatuses.size.toLong()
+
+ val paginatedStatuses =
+ if (pageNum != null) {
+ val fromIndex = ((pageNum - 1) * opdsItemsPerPageBounded)
+ val toIndex = minOf(fromIndex + opdsItemsPerPageBounded, allStatuses.size)
+ if (fromIndex < allStatuses.size) allStatuses.subList(fromIndex, toIndex) else emptyList()
+ } else {
+ allStatuses
+ }
+
+ return Pair(paginatedStatuses, totalCount)
}
- fun getContentLanguages(uiLocale: Locale): List =
+ fun getContentLanguages(
+ locale: Locale,
+ pageNum: Int? = null,
+ activeFilters: OpdsMangaFilter = OpdsMangaFilter(),
+ ): Pair, Long> =
transaction {
val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
- SourceTable
- .join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference)
- .select(SourceTable.lang, mangaCount)
- .where { MangaTable.inLibrary eq true }
+ val query =
+ SourceTable
+ .join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference)
+ .join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
+ .join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
+ .select(SourceTable.lang, mangaCount)
+ .where { MangaTable.inLibrary eq true }
+
+ query.applyOpdsMangaFilter(activeFilters, excludeField = "lang_code")
+
+ query
.groupBy(SourceTable.lang)
.orderBy(SourceTable.lang to SortOrder.ASC)
- .map {
+
+ val totalCount = query.count()
+
+ if (pageNum != null) {
+ query
+ .limit(opdsItemsPerPageBounded)
+ .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
+ }
+
+ val languages =
+ query.map {
val langCode = it[SourceTable.lang]
OpdsLanguageNavEntry(
id = langCode,
title =
- Locale.forLanguageTag(langCode).getDisplayName(uiLocale).replaceFirstChar { char ->
- if (char.isLowerCase()) char.titlecase(uiLocale) else char.toString()
+ Locale.forLanguageTag(langCode).getDisplayName(locale).replaceFirstChar { char ->
+ if (char.isLowerCase()) char.titlecase(locale) else char.toString()
},
mangaCount = it[mangaCount],
)
}
+ Pair(languages, totalCount)
}
}
diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/util/OpdsStringUtil.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/util/OpdsStringUtil.kt
index 364e611e7..61ceb8c5f 100644
--- a/server/src/main/kotlin/suwayomi/tachidesk/opds/util/OpdsStringUtil.kt
+++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/util/OpdsStringUtil.kt
@@ -33,6 +33,19 @@ object OpdsStringUtil {
return slug
}
+ /**
+ * Formats the source name appending the language code if applicable.
+ */
+ fun formatSourceName(
+ name: String,
+ lang: String?,
+ ): String =
+ if (lang.isNullOrBlank() || lang == "all") {
+ name
+ } else {
+ "$name (${lang.uppercase()})"
+ }
+
/**
* Formats a size in bytes to a human-readable representation.
* Uses binary (KiB, MiB, GiB, TiB) or decimal (KB, MB, GB, TB) units based on server configuration.