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

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

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

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

* fix: lint

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

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

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

* feat(download): improve chapter download filenames

* feat(opds): append language to source names

* feat(opds): handle empty chapter titles

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

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

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

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

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

* revert(download): restore original CBZ filename scheme

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

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

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

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

View File

@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added ### Added
- (**Sync**) Added [SyncYomi](https://github.com/syncyomi/syncyomi) support - (**Sync**) Added [SyncYomi](https://github.com/syncyomi/syncyomi) support
- (**OPDS**) Add option to skip chapter metadata feed providing direct stream/download links
### Changed ### Changed
- (**Database/H2**) Use the latest H2 database engine - (**Database/H2**) Use the latest H2 database engine

View File

@@ -232,6 +232,7 @@ server.opdsShowOnlyUnreadChapters = false
server.opdsShowOnlyDownloadedChapters = false server.opdsShowOnlyDownloadedChapters = false
server.opdsChapterSortOrder = "DESC" server.opdsChapterSortOrder = "DESC"
server.opdsCbzMimetype = "MODERN" 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.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. - `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.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.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.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 ### KOReader Sync
``` ```

View File

@@ -153,4 +153,7 @@
<string name="login_label_login">Log In</string> <string name="login_label_login">Log In</string>
<string name="login_placeholder_username">Type username...</string> <string name="login_placeholder_username">Type username...</string>
<string name="login_placeholder_password">Secret...</string> <string name="login_placeholder_password">Secret...</string>
<string name="opds_chapter_title_oneshot">Oneshot</string>
<string name="opds_chapter_title_fallback">Chapter %1$s</string>
</resources> </resources>

View File

@@ -1101,6 +1101,15 @@ class ServerConfig(
privacySafe = true, privacySafe = true,
) )
val opdsSkipChapterMetadataFeed: MutableStateFlow<Boolean> 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 **/ /** Renamed settings **/

View File

@@ -64,6 +64,11 @@ object ChapterDownloadHelper {
chapterId: Int, chapterId: Int,
): Pair<InputStream, Long> = provider(mangaId, chapterId).getAsArchiveStream() ): Pair<InputStream, Long> = provider(mangaId, chapterId).getAsArchiveStream()
fun getChapterArchiveSize(
mangaId: Int,
chapterId: Int,
): Long = provider(mangaId, chapterId).getArchiveSize()
private fun getChapterWithCbzFileName(chapterId: Int): Pair<ChapterDataClass, String> = private fun getChapterWithCbzFileName(chapterId: Int): Pair<ChapterDataClass, String> =
transaction { transaction {
val row = val row =

View File

@@ -31,6 +31,88 @@ import suwayomi.tachidesk.manga.model.table.PageTable
import suwayomi.tachidesk.manga.model.table.toDataClass import suwayomi.tachidesk.manga.model.table.toDataClass
import kotlin.time.Duration.Companion.minutes 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( suspend fun getChapterDownloadReady(
chapterId: Int? = null, chapterId: Int? = null,
chapterIndex: Int? = null, chapterIndex: Int? = null,
@@ -68,56 +150,31 @@ private class ChapterForDownload(
suspend fun asDownloadReady(): ChapterDataClass { suspend fun asDownloadReady(): ChapterDataClass {
val log = KotlinLogging.logger("${logger.name}::asDownloadReady") val log = KotlinLogging.logger("${logger.name}::asDownloadReady")
val downloadPageCount = val downloadPageCount = runCatching { ChapterDownloadHelper.getImageCount(mangaId, chapterId) }.getOrDefault(0)
try {
ChapterDownloadHelper.getImageCount(mangaId, chapterId)
} catch (_: Exception) {
0
}
val isMarkedAsDownloaded = chapterEntry[ChapterTable.isDownloaded] val isMarkedAsDownloaded = chapterEntry[ChapterTable.isDownloaded]
val dbPageCount = chapterEntry[ChapterTable.pageCount] val dbPageCount = chapterEntry[ChapterTable.pageCount]
val doesDownloadExist = downloadPageCount != 0 val doesDownloadExist = downloadPageCount != 0
val doPageCountsMatch = dbPageCount == downloadPageCount
log.debug { "isMarkedAsDownloaded= $isMarkedAsDownloaded, dbPageCount= $dbPageCount, downloadPageCount= $downloadPageCount" } log.debug { "isMarkedAsDownloaded= $isMarkedAsDownloaded, dbPageCount= $dbPageCount, downloadPageCount= $downloadPageCount" }
return if (!doesDownloadExist) { return if (!doesDownloadExist) {
log.debug { "reset download status and fetch page list" } log.debug { "reset download status and fetch page list" }
updateDownloadStatusAndPageList(false) refreshChapterPageList(mangaId, chapterId, chapterEntry)
chapterEntry = freshChapterEntry(optChapterId = chapterId)
ChapterTable.toDataClass(chapterEntry)
} else { } else {
transaction { if (updateChapterPersistence(
var needsUpdate = false chapterId,
isMarkedAsDownloaded,
if (!isMarkedAsDownloaded) { dbPageCount,
log.debug { "mark as downloaded" } downloadPageCount,
ChapterTable.update({ ChapterTable.id eq chapterId }) { chapterEntry[ChapterTable.lastPageRead],
it[isDownloaded] = true log,
} )
needsUpdate = true ) {
} chapterEntry = freshChapterEntry(optChapterId = chapterId)
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)
} }
ChapterTable.toDataClass(chapterEntry)
} }
} }
@@ -150,59 +207,4 @@ private class ChapterForDownload(
} }
}.first() }.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<Page> {
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) }
}
} }

View File

@@ -13,18 +13,22 @@ object OpdsAPI {
// OPDS Search Description Feed // OPDS Search Description Feed
get("search", OpdsV1Controller.searchFeed) get("search", OpdsV1Controller.searchFeed)
// --- Main Navigation Feeds ---
// Explore Navigation Feed
get("explore", OpdsV1Controller.exploreSourcesFeed)
// Reading History Acquisition Feed // Reading History Acquisition Feed
get("history", OpdsV1Controller.historyFeed) get("history", OpdsV1Controller.historyFeed)
// Library Updates Acquisition Feed // Library Updates Acquisition Feed
get("library-updates", OpdsV1Controller.libraryUpdatesFeed) 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") { path("library") {
// All Series in Library / Search Results Feed (Acquisition) // All Series in Library / Search Results Feed (Acquisition)
get("series", OpdsV1Controller.seriesFeed) get("series", OpdsV1Controller.seriesFeed)
@@ -32,11 +36,6 @@ object OpdsAPI {
// Library Sources Navigation Feed // Library Sources Navigation Feed
get("sources", OpdsV1Controller.librarySourcesFeed) get("sources", OpdsV1Controller.librarySourcesFeed)
// Library Source-Specific Series Acquisition Feed
path("source/{sourceId}") {
get(OpdsV1Controller.librarySourceFeed)
}
// Library Categories Navigation Feed // Library Categories Navigation Feed
get("categories", OpdsV1Controller.categoriesFeed) get("categories", OpdsV1Controller.categoriesFeed)
@@ -50,26 +49,10 @@ object OpdsAPI {
get("languages", OpdsV1Controller.languagesFeed) get("languages", OpdsV1Controller.languagesFeed)
} }
// --- Explore-Specific Feeds --- // --- Library Series Filters ---
// Source-Specific Series Acquisition Feed (Library)
// All Sources Navigation Feed (Explore)
get("sources", OpdsV1Controller.exploreSourcesFeed)
// Source-Specific Series Acquisition Feed (Explore)
path("source/{sourceId}") { path("source/{sourceId}") {
get(OpdsV1Controller.exploreSourceFeed) get(OpdsV1Controller.librarySourceFeed)
}
// --- 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)
} }
// Category-Specific Series Acquisition Feed (Library) // Category-Specific Series Acquisition Feed (Library)
@@ -91,6 +74,16 @@ object OpdsAPI {
path("language/{langCode}") { path("language/{langCode}") {
get(OpdsV1Controller.languageFeed) 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)
}
} }
} }
} }

View File

@@ -32,21 +32,21 @@ object OpdsV1Controller {
*/ */
private fun getLibraryFeed( private fun getLibraryFeed(
ctx: Context, ctx: Context,
pageNum: Int?, locale: Locale,
criteria: OpdsMangaFilter, criteria: OpdsMangaFilter,
isSearch: Boolean, isSearch: Boolean,
pageNum: Int,
) { ) {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
ctx.future { ctx.future {
future { future {
OpdsFeedBuilder.getLibraryFeed( OpdsFeedBuilder.getLibraryFeed(
criteria = criteria, BASE_URL,
baseUrl = BASE_URL, locale,
pageNum = pageNum ?: 1, criteria,
sort = criteria.sort, isSearch,
filter = criteria.filter, pageNum,
locale = locale, criteria.sort,
isSearch = isSearch, criteria.filter,
) )
}.thenApply { xml -> }.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml) ctx.contentType(OPDS_MIME).result(xml)
@@ -94,7 +94,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future { ctx.future {
future { future {
OpdsFeedBuilder.getHistoryFeed(BASE_URL, pageNumber ?: 1, locale) OpdsFeedBuilder.getHistoryFeed(BASE_URL, locale, pageNumber ?: 1)
}.thenApply { xml -> }.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(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. * 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 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 = val seriesFeed =
handler( handler(
@@ -157,29 +158,14 @@ object OpdsV1Controller {
val opdsSearchCriteria = OpdsSearchCriteria(query, author, title) val opdsSearchCriteria = OpdsSearchCriteria(query, author, title)
ctx.future { ctx.future {
future { future {
OpdsFeedBuilder.getSearchFeed(opdsSearchCriteria, BASE_URL, pageNumber ?: 1, locale) OpdsFeedBuilder.getSearchFeed(BASE_URL, locale, opdsSearchCriteria, pageNumber ?: 1)
}.thenApply { xml -> }.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml) ctx.contentType(OPDS_MIME).result(xml)
} }
} }
} else { } else {
val criteria = val criteria = OpdsMangaFilter.fromContext(ctx, PrimaryFilterType.NONE)
OpdsMangaFilter( getLibraryFeed(ctx, locale, criteria, false, pageNumber ?: 1)
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,
)
} }
}, },
withResults = { httpCode(HttpStatus.OK) }, withResults = { httpCode(HttpStatus.OK) },
@@ -203,7 +189,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future { ctx.future {
future { future {
OpdsFeedBuilder.getExploreSourcesFeed(BASE_URL, pageNumber ?: 1, locale) OpdsFeedBuilder.getExploreSourcesFeed(BASE_URL, locale, pageNumber ?: 1)
}.thenApply { xml -> }.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml) ctx.contentType(OPDS_MIME).result(xml)
} }
@@ -230,7 +216,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future { ctx.future {
future { future {
OpdsFeedBuilder.getLibrarySourcesFeed(BASE_URL, pageNumber ?: 1, locale) OpdsFeedBuilder.getLibrarySourcesFeed(BASE_URL, locale, pageNumber ?: 1)
}.thenApply { xml -> }.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml) ctx.contentType(OPDS_MIME).result(xml)
} }
@@ -257,7 +243,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future { ctx.future {
future { future {
OpdsFeedBuilder.getCategoriesFeed(BASE_URL, pageNumber ?: 1, locale) OpdsFeedBuilder.getCategoriesFeed(BASE_URL, locale, pageNumber ?: 1)
}.thenApply { xml -> }.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml) ctx.contentType(OPDS_MIME).result(xml)
} }
@@ -284,7 +270,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future { ctx.future {
future { future {
OpdsFeedBuilder.getGenresFeed(BASE_URL, pageNumber ?: 1, locale) OpdsFeedBuilder.getGenresFeed(BASE_URL, locale, pageNumber ?: 1)
}.thenApply { xml -> }.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml) ctx.contentType(OPDS_MIME).result(xml)
} }
@@ -298,6 +284,7 @@ object OpdsV1Controller {
*/ */
val statusesFeed = val statusesFeed =
handler( handler(
queryParam<Int?>("pageNumber"),
queryParam<String?>("lang"), queryParam<String?>("lang"),
documentWith = { documentWith = {
withOperation { withOperation {
@@ -305,12 +292,12 @@ object OpdsV1Controller {
description("Navigation feed listing series publication statuses for the library.") description("Navigation feed listing series publication statuses for the library.")
} }
}, },
behaviorOf = { ctx, lang -> behaviorOf = { ctx, pageNumber, lang ->
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx) ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future { ctx.future {
future { future {
OpdsFeedBuilder.getStatusFeed(BASE_URL, 1, locale) OpdsFeedBuilder.getStatusFeed(BASE_URL, locale, pageNumber ?: 1)
}.thenApply { xml -> }.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml) ctx.contentType(OPDS_MIME).result(xml)
} }
@@ -324,6 +311,7 @@ object OpdsV1Controller {
*/ */
val languagesFeed = val languagesFeed =
handler( handler(
queryParam<Int?>("pageNumber"),
queryParam<String?>("lang"), queryParam<String?>("lang"),
documentWith = { documentWith = {
withOperation { withOperation {
@@ -331,12 +319,12 @@ object OpdsV1Controller {
description("Navigation feed listing available content languages for series in the library.") 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) ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future { ctx.future {
future { future {
OpdsFeedBuilder.getLanguagesFeed(BASE_URL, locale) OpdsFeedBuilder.getLanguagesFeed(BASE_URL, locale, pageNumber ?: 1)
}.thenApply { xml -> }.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml) ctx.contentType(OPDS_MIME).result(xml)
} }
@@ -363,7 +351,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future { ctx.future {
future { future {
OpdsFeedBuilder.getLibraryUpdatesFeed(BASE_URL, pageNumber ?: 1, locale) OpdsFeedBuilder.getLibraryUpdatesFeed(BASE_URL, locale, pageNumber ?: 1)
}.thenApply { xml -> }.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml) ctx.contentType(OPDS_MIME).result(xml)
} }
@@ -392,7 +380,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future { ctx.future {
future { future {
OpdsFeedBuilder.getExploreSourceFeed(sourceId, BASE_URL, pageNumber ?: 1, sort ?: "popular", locale) OpdsFeedBuilder.getExploreSourceFeed(BASE_URL, locale, sourceId, pageNumber ?: 1, sort ?: "popular")
}.thenApply { xml -> }.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml) ctx.contentType(OPDS_MIME).result(xml)
} }
@@ -425,8 +413,9 @@ object OpdsV1Controller {
documentWith = { withOperation { summary("OPDS Library Source Specific Series Feed") } }, documentWith = { withOperation { summary("OPDS Library Source Specific Series Feed") } },
behaviorOf = { ctx, sourceId -> behaviorOf = { ctx, sourceId ->
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx) 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)) 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 = { withResults = {
httpCode(HttpStatus.OK) httpCode(HttpStatus.OK)
@@ -443,9 +432,10 @@ object OpdsV1Controller {
documentWith = { withOperation { summary("OPDS Category Specific Series Feed") } }, documentWith = { withOperation { summary("OPDS Category Specific Series Feed") } },
behaviorOf = { ctx, categoryId -> behaviorOf = { ctx, categoryId ->
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx) ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
val criteria = val criteria =
buildCriteriaFromContext(ctx, OpdsMangaFilter(categoryId = categoryId, primaryFilter = PrimaryFilterType.CATEGORY)) buildCriteriaFromContext(ctx, OpdsMangaFilter(categoryId = categoryId, primaryFilter = PrimaryFilterType.CATEGORY))
getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false) getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
}, },
withResults = { withResults = {
httpCode(HttpStatus.OK) httpCode(HttpStatus.OK)
@@ -462,8 +452,9 @@ object OpdsV1Controller {
documentWith = { withOperation { summary("OPDS Genre Specific Series Feed") } }, documentWith = { withOperation { summary("OPDS Genre Specific Series Feed") } },
behaviorOf = { ctx, genre -> behaviorOf = { ctx, genre ->
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx) 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)) 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 = { withResults = {
httpCode(HttpStatus.OK) httpCode(HttpStatus.OK)
@@ -480,8 +471,9 @@ object OpdsV1Controller {
documentWith = { withOperation { summary("OPDS Status Specific Series Feed") } }, documentWith = { withOperation { summary("OPDS Status Specific Series Feed") } },
behaviorOf = { ctx, statusId -> behaviorOf = { ctx, statusId ->
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx) 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)) 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 = { withResults = {
httpCode(HttpStatus.OK) httpCode(HttpStatus.OK)
@@ -503,9 +495,10 @@ object OpdsV1Controller {
}, },
behaviorOf = { ctx, langCode -> behaviorOf = { ctx, langCode ->
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx) ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
val criteria = val criteria =
buildCriteriaFromContext(ctx, OpdsMangaFilter(langCode = langCode, primaryFilter = PrimaryFilterType.LANGUAGE)) buildCriteriaFromContext(ctx, OpdsMangaFilter(langCode = langCode, primaryFilter = PrimaryFilterType.LANGUAGE))
getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false) getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
}, },
withResults = { withResults = {
httpCode(HttpStatus.OK) httpCode(HttpStatus.OK)
@@ -534,7 +527,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future { ctx.future {
future { future {
OpdsFeedBuilder.getSeriesChaptersFeed(seriesId, BASE_URL, pageNumber ?: 1, sort, filter, locale) OpdsFeedBuilder.getSeriesChaptersFeed(BASE_URL, locale, seriesId, pageNumber ?: 1, sort, filter)
}.thenApply { xml -> }.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml) ctx.contentType(OPDS_MIME).result(xml)
} }
@@ -565,7 +558,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future { ctx.future {
future { future {
OpdsFeedBuilder.getChapterMetadataFeed(seriesId, chapterIndex, BASE_URL, locale) OpdsFeedBuilder.getChapterMetadataFeed(BASE_URL, locale, seriesId, chapterIndex)
}.thenApply { xml -> }.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml) ctx.contentType(OPDS_MIME).result(xml)
} }

View File

@@ -12,4 +12,6 @@ data class OpdsChapterListAcqEntry(
val lastReadAt: Long, val lastReadAt: Long,
val sourceOrder: Int, val sourceOrder: Int,
val pageCount: Int, // Can be -1 if not known val pageCount: Int, // Can be -1 if not known
val downloaded: Boolean,
val cbzFileSize: Long? = null,
) )

View File

@@ -5,6 +5,7 @@ data class OpdsChapterMetadataAcqEntry(
val mangaId: Int, val mangaId: Int,
val name: String, val name: String,
val uploadDate: Long, val uploadDate: Long,
val chapterNumber: Float, // Added to fallback chapter titles
val scanlator: String?, val scanlator: String?,
val read: Boolean, val read: Boolean,
val lastPageRead: Int, val lastPageRead: Int,
@@ -13,4 +14,5 @@ data class OpdsChapterMetadataAcqEntry(
val downloaded: Boolean, val downloaded: Boolean,
val pageCount: Int, val pageCount: Int,
val url: String?, val url: String?,
val cbzFileSize: Long? = null,
) )

View File

@@ -5,6 +5,7 @@ data class OpdsHistoryAcqEntry(
val mangaTitle: String, val mangaTitle: String,
val mangaAuthor: String?, val mangaAuthor: String?,
val mangaId: Int, val mangaId: Int,
val mangaTotalChapters: Long,
val mangaSourceLang: String?, val mangaSourceLang: String?,
val mangaThumbnailUrl: String?, val mangaThumbnailUrl: String?,
) )

View File

@@ -5,6 +5,7 @@ data class OpdsLibraryUpdateAcqEntry(
val mangaTitle: String, val mangaTitle: String,
val mangaAuthor: String?, val mangaAuthor: String?,
val mangaId: Int, val mangaId: Int,
val mangaTotalChapters: Long,
val mangaSourceLang: String?, val mangaSourceLang: String?,
val mangaThumbnailUrl: String?, val mangaThumbnailUrl: String?,
) )

View File

@@ -5,4 +5,5 @@ data class OpdsMangaDetails( // Kept name, it's specific enough
val title: String, val title: String,
val thumbnailUrl: String?, val thumbnailUrl: String?,
val author: String?, // Added for chapter entry authors val author: String?, // Added for chapter entry authors
val totalChapters: Long, // Added to determine if it's a oneshot
) )

View File

@@ -1,5 +1,6 @@
package suwayomi.tachidesk.opds.dto package suwayomi.tachidesk.opds.dto
import io.javalin.http.Context
import suwayomi.tachidesk.opds.util.OpdsStringUtil.encodeForOpdsURL import suwayomi.tachidesk.opds.util.OpdsStringUtil.encodeForOpdsURL
/** /**
@@ -69,4 +70,24 @@ data class OpdsMangaFilter(
"genre" -> this.copy(genre = value) "genre" -> this.copy(genre = value)
else -> this 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,
)
}
} }

View File

@@ -16,145 +16,131 @@ import kotlin.math.ceil
*/ */
class FeedBuilderInternal( class FeedBuilderInternal(
private val baseUrl: String, private val baseUrl: String,
private val locale: Locale,
private val idPath: String, private val idPath: String,
private val title: String, private val title: String,
private val locale: Locale,
private val feedType: String, private val feedType: String,
private val pageNum: Int? = 1, private val pageNum: Int? = null,
private val explicitQueryParams: String? = null, private val explicitQueryParams: String? = null,
private val currentSort: String? = null, private val currentSort: String? = null,
private val currentFilter: String? = null, private val currentFilter: String? = null,
private val isSearchFeed: Boolean = false, 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 totalResults: Long = 0
var icon: String? = null var icon: String? = null
val links = mutableListOf<OpdsLinkXml>() val links = mutableListOf<OpdsLinkXml>()
val entries = mutableListOf<OpdsEntryXml>() val entries = mutableListOf<OpdsEntryXml>()
private fun buildUrlWithParams( private fun buildUrlWithParams(page: Int? = pageNum): String {
baseHrefPath: String, val queryParams =
page: Int?, listOfNotNull(
): String { explicitQueryParams?.takeIf(String::isNotBlank),
val sb = StringBuilder("$baseUrl/$baseHrefPath") page?.let { "pageNumber=$it" },
val queryParamsList = mutableListOf<String>() currentSort?.let { "sort=$it" },
currentFilter?.let { "filter=$it" },
"lang=${locale.toLanguageTag()}",
).joinToString("&")
explicitQueryParams?.takeIf { it.isNotBlank() }?.let { queryParamsList.add(it) } return "$baseUrl/$idPath" + if (queryParams.isNotEmpty()) "?$queryParams" else ""
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()
} }
fun build(): OpdsFeedXml { fun build(): OpdsFeedXml {
val selfLinkHref = buildUrlWithParams(idPath, if (pageNum != null) pageNum else null) val itemsPerPage = serverConfig.opdsItemsPerPage.value
val feedLinks = mutableListOf<OpdsLinkXml>() val showOpenSearch = isSearchFeed && pageNum != null && totalResults > 0
feedLinks.addAll(this.links) val urnSuffix =
listOfNotNull(
feedLinks.add( locale.toLanguageTag(),
OpdsLinkXml(OpdsConstants.LINK_REL_SELF, selfLinkHref, feedType, MR.strings.opds_linktitle_self_feed.localized(locale)), pageNum?.let { "page$it" },
) explicitQueryParams?.replace("&", ":")?.replace("=", "_"),
feedLinks.add( currentSort?.let { "sort_$it" },
OpdsLinkXml( currentFilter?.let { "filter_$it" },
OpdsConstants.LINK_REL_START, ).joinToString(":")
"$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<String>()
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
return OpdsFeedXml( return OpdsFeedXml(
id = "urn:suwayomi:feed:${idPath.replace('/',':')}$urnSuffix", id = "urn:suwayomi:feed:${idPath.replace('/', ':')}${if (urnSuffix.isNotEmpty()) ":$urnSuffix" else ""}",
title = title, title = title,
updated = feedGeneratedAt, updated = OpdsDateUtil.formatCurrentInstantForOpds(),
icon = icon, icon = icon,
author = feedAuthor, author = OpdsAuthorXml("Suwayomi", "https://suwayomi.org/"),
links = feedLinks, 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, entries = entries,
totalResults = totalResults.takeIf { showOpenSearchFields }, totalResults = totalResults.takeIf { showOpenSearch },
itemsPerPage = if (showOpenSearchFields) opdsItemsPerPageBounded else null, itemsPerPage = itemsPerPage.takeIf { showOpenSearch },
startIndex = if (showOpenSearchFields) ((pageNum - 1) * opdsItemsPerPageBounded) + 1 else null, startIndex = if (showOpenSearch && pageNum != null) ((pageNum - 1) * itemsPerPage) + 1 else null,
) )
} }
} }

View File

@@ -1,10 +1,7 @@
package suwayomi.tachidesk.opds.impl package suwayomi.tachidesk.opds.impl
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import suwayomi.tachidesk.i18n.MR import suwayomi.tachidesk.i18n.MR
import suwayomi.tachidesk.manga.impl.ChapterDownloadHelper
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
import suwayomi.tachidesk.manga.impl.sync.KoreaderSyncService import suwayomi.tachidesk.manga.impl.sync.KoreaderSyncService
import suwayomi.tachidesk.manga.model.table.MangaStatus import suwayomi.tachidesk.manga.model.table.MangaStatus
@@ -93,15 +90,15 @@ object OpdsEntryBuilder {
/** /**
* Converts a manga data object into a full OPDS acquisition entry. * 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 baseUrl The base URL for constructing links.
* @param locale The locale for localization. * @param locale The locale for localization.
* @param entry The manga data object.
* @return An [OpdsEntryXml] object representing the manga. * @return An [OpdsEntryXml] object representing the manga.
*/ */
fun mangaAcqEntryToEntry( fun mangaAcqEntryToEntry(
entry: OpdsMangaAcqEntry,
baseUrl: String, baseUrl: String,
locale: Locale, locale: Locale,
entry: OpdsMangaAcqEntry,
): OpdsEntryXml { ): OpdsEntryXml {
val displayThumbnailUrl = entry.thumbnailUrl?.let { proxyThumbnailUrl(entry.id) } val displayThumbnailUrl = entry.thumbnailUrl?.let { proxyThumbnailUrl(entry.id) }
val categoryScheme = if (entry.inLibrary) "$baseUrl/library/genres" else "$baseUrl/genres" 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. * 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 chapter The chapter data object.
* @param manga The parent manga's details. * @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 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. * @return An [OpdsEntryXml] object for the chapter.
*/ */
suspend fun createChapterListEntry( suspend fun createChapterListEntry(
baseUrl: String,
locale: Locale,
chapter: OpdsChapterListAcqEntry, chapter: OpdsChapterListAcqEntry,
manga: OpdsMangaDetails, manga: OpdsMangaDetails,
baseUrl: String,
addMangaTitle: Boolean, addMangaTitle: Boolean,
locale: Locale, skipMetadataFeed: Boolean,
): OpdsEntryXml { ): 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 = val statusKey =
when { when {
chapter.downloaded -> MR.strings.opds_chapter_status_downloaded
chapter.read -> MR.strings.opds_chapter_status_read 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 else -> MR.strings.opds_chapter_status_unread
} }
val titlePrefix = statusKey.localized(locale) 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 = val details =
buildString { 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 { chapter.scanlator?.takeIf { it.isNotBlank() }?.let {
append(MR.strings.opds_chapter_details_scanlator.localized(locale, it)) append(MR.strings.opds_chapter_details_scanlator.localized(locale, it))
} }
if (chapter.pageCount > 0) { 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<OpdsLinkXml>()
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( return OpdsEntryXml(
id = "urn:suwayomi:chapter:${chapter.id}", id = "urn:suwayomi:chapter:${chapter.id}",
title = entryTitle, title = entryTitle,
@@ -196,33 +304,25 @@ object OpdsEntryBuilder {
chapter.scanlator?.takeIf { it.isNotBlank() }?.let { OpdsAuthorXml(name = it) }, chapter.scanlator?.takeIf { it.isNotBlank() }?.let { OpdsAuthorXml(name = it) },
), ),
summary = OpdsSummaryXml(value = details), summary = OpdsSummaryXml(value = details),
link = link = links,
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),
),
),
) )
} }
/** /**
* Creates one or two OPDS entries for a chapter, handling synchronization conflicts internally. * 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 baseUrl The base URL for constructing links.
* @param locale The locale for localization. * @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 * @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. * second is an optional entry representing the remote progress in case of a conflict.
*/ */
suspend fun createChapterMetadataEntries( suspend fun createChapterMetadataEntries(
chapter: OpdsChapterMetadataAcqEntry,
manga: OpdsMangaDetails,
baseUrl: String, baseUrl: String,
locale: Locale, locale: Locale,
chapter: OpdsChapterMetadataAcqEntry,
manga: OpdsMangaDetails,
): Pair<OpdsEntryXml, OpdsEntryXml?> { ): Pair<OpdsEntryXml, OpdsEntryXml?> {
// Check remote progress before building the entry // Check remote progress before building the entry
val syncResult = KoreaderSyncService.checkAndPullProgress(chapter.id) val syncResult = KoreaderSyncService.checkAndPullProgress(chapter.id)
@@ -234,20 +334,20 @@ object OpdsEntryBuilder {
// Generate two entries: one for local progress and another for remote. // Generate two entries: one for local progress and another for remote.
val localEntry = val localEntry =
buildSingleChapterMetadataEntry( buildSingleChapterMetadataEntry(
chapter,
manga,
baseUrl, baseUrl,
locale, locale,
chapter,
manga,
progressSource = ProgressSource.Local(chapter.lastPageRead, chapter.lastReadAt), progressSource = ProgressSource.Local(chapter.lastPageRead, chapter.lastReadAt),
isConflict = true, isConflict = true,
) )
val remoteEntry = val remoteEntry =
buildSingleChapterMetadataEntry( buildSingleChapterMetadataEntry(
chapter,
manga,
baseUrl, baseUrl,
locale, locale,
chapter,
manga,
progressSource = ProgressSource.Remote(syncResult!!.pageRead, syncResult.timestamp, syncResult.device), progressSource = ProgressSource.Remote(syncResult!!.pageRead, syncResult.timestamp, syncResult.device),
isConflict = true, isConflict = true,
) )
@@ -263,10 +363,10 @@ object OpdsEntryBuilder {
val mainEntry = val mainEntry =
buildSingleChapterMetadataEntry( buildSingleChapterMetadataEntry(
chapter,
manga,
baseUrl, baseUrl,
locale, locale,
chapter,
manga,
progressSource = progressSource, progressSource = progressSource,
isConflict = false, isConflict = false,
) )
@@ -297,10 +397,10 @@ object OpdsEntryBuilder {
* Helper function to build a single OpdsEntryXml for a chapter. * Helper function to build a single OpdsEntryXml for a chapter.
*/ */
private suspend fun buildSingleChapterMetadataEntry( private suspend fun buildSingleChapterMetadataEntry(
chapter: OpdsChapterMetadataAcqEntry,
manga: OpdsMangaDetails,
baseUrl: String, baseUrl: String,
locale: Locale, locale: Locale,
chapter: OpdsChapterMetadataAcqEntry,
manga: OpdsMangaDetails,
progressSource: ProgressSource, progressSource: ProgressSource,
isConflict: Boolean, isConflict: Boolean,
): OpdsEntryXml { ): OpdsEntryXml {
@@ -337,14 +437,7 @@ object OpdsEntryBuilder {
} }
val entryTitle = "$titlePrefix ${chapter.name}" val entryTitle = "$titlePrefix ${chapter.name}"
val cbzFileSize = val cbzFileSize = chapter.cbzFileSize
if (chapter.downloaded) {
withContext(Dispatchers.IO) {
runCatching { ChapterDownloadHelper.getArchiveStreamWithSize(manga.id, chapter.id).second }.getOrNull()
}
} else {
null
}
val links = mutableListOf<OpdsLinkXml>() val links = mutableListOf<OpdsLinkXml>()
chapter.url?.let { chapter.url?.let {
@@ -449,8 +542,8 @@ object OpdsEntryBuilder {
fun addSourceSortFacets( fun addSourceSortFacets(
feedBuilder: FeedBuilderInternal, feedBuilder: FeedBuilderInternal,
baseUrl: String, baseUrl: String,
currentSort: String,
locale: Locale, locale: Locale,
currentSort: String,
) { ) {
val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale) val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale)
val addFacet = { href: String, titleKey: StringResource, isActive: Boolean -> val addFacet = { href: String, titleKey: StringResource, isActive: Boolean ->
@@ -475,9 +568,9 @@ object OpdsEntryBuilder {
fun addChapterSortAndFilterFacets( fun addChapterSortAndFilterFacets(
feedBuilder: FeedBuilderInternal, feedBuilder: FeedBuilderInternal,
baseUrl: String, baseUrl: String,
locale: Locale,
currentSort: String, currentSort: String,
currentFilter: String, currentFilter: String,
locale: Locale,
filterCounts: Map<String, Long>? = null, filterCounts: Map<String, Long>? = null,
) { ) {
val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale) val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale)
@@ -554,15 +647,15 @@ object OpdsEntryBuilder {
fun addLibraryFacets( fun addLibraryFacets(
feedBuilder: FeedBuilderInternal, feedBuilder: FeedBuilderInternal,
baseUrl: String, baseUrl: String,
activeFilters: OpdsMangaFilter,
locale: Locale, locale: Locale,
activeFilters: OpdsMangaFilter,
) { ) {
val currentSort = activeFilters.sort ?: "alpha_asc" val currentSort = activeFilters.sort ?: "alpha_asc"
val currentFilter = activeFilters.filter ?: "all" val currentFilter = activeFilters.filter ?: "all"
val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale) val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale)
val filterGroup = MR.strings.opds_facetgroup_filter_content.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 buildUrl = { newFilters: OpdsMangaFilter, newSort: String, newFilter: String ->
val crossFilterParams = newFilters.toCrossFilterQueryParameters() val crossFilterParams = newFilters.toCrossFilterQueryParameters()
@@ -667,7 +760,7 @@ object OpdsEntryBuilder {
// --- Cross-Filter Facets --- // --- Cross-Filter Facets ---
if (activeFilters.primaryFilter != PrimaryFilterType.SOURCE) { if (activeFilters.primaryFilter != PrimaryFilterType.SOURCE) {
val sources = NavigationRepository.getLibrarySources(1).first val sources = NavigationRepository.getLibrarySources(pageNum = null, activeFilters = activeFilters).first
addFacet( addFacet(
feedBuilder, feedBuilder,
buildUrl(activeFilters.without("source_id"), currentSort, currentFilter), buildUrl(activeFilters.without("source_id"), currentSort, currentFilter),
@@ -688,7 +781,7 @@ object OpdsEntryBuilder {
} }
} }
if (activeFilters.primaryFilter != PrimaryFilterType.CATEGORY) { if (activeFilters.primaryFilter != PrimaryFilterType.CATEGORY) {
val categories = NavigationRepository.getCategories(1).first val categories = NavigationRepository.getCategories(pageNum = null, activeFilters = activeFilters).first
addFacet( addFacet(
feedBuilder, feedBuilder,
buildUrl(activeFilters.without("category_id"), currentSort, currentFilter), buildUrl(activeFilters.without("category_id"), currentSort, currentFilter),
@@ -709,7 +802,7 @@ object OpdsEntryBuilder {
} }
} }
if (activeFilters.primaryFilter != PrimaryFilterType.STATUS) { if (activeFilters.primaryFilter != PrimaryFilterType.STATUS) {
val statuses = NavigationRepository.getStatuses(locale) val statuses = NavigationRepository.getStatuses(locale, pageNum = null, activeFilters = activeFilters).first
addFacet( addFacet(
feedBuilder, feedBuilder,
buildUrl(activeFilters.without("status_id"), currentSort, currentFilter), buildUrl(activeFilters.without("status_id"), currentSort, currentFilter),
@@ -730,7 +823,7 @@ object OpdsEntryBuilder {
} }
} }
if (activeFilters.primaryFilter != PrimaryFilterType.LANGUAGE) { if (activeFilters.primaryFilter != PrimaryFilterType.LANGUAGE) {
val languages = NavigationRepository.getContentLanguages(locale) val languages = NavigationRepository.getContentLanguages(locale, pageNum = null, activeFilters = activeFilters).first
addFacet( addFacet(
feedBuilder, feedBuilder,
buildUrl(activeFilters.without("lang_code"), currentSort, currentFilter), buildUrl(activeFilters.without("lang_code"), currentSort, currentFilter),
@@ -751,7 +844,7 @@ object OpdsEntryBuilder {
} }
} }
if (activeFilters.primaryFilter != PrimaryFilterType.GENRE) { if (activeFilters.primaryFilter != PrimaryFilterType.GENRE) {
val genres = NavigationRepository.getGenres(1, locale).first val genres = NavigationRepository.getGenres(locale, pageNum = null, activeFilters = activeFilters).first
addFacet( addFacet(
feedBuilder, feedBuilder,
buildUrl(activeFilters.without("genre"), currentSort, currentFilter), buildUrl(activeFilters.without("genre"), currentSort, currentFilter),

View File

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

View File

@@ -1,17 +1,29 @@
package suwayomi.tachidesk.opds.repository 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.Column
import org.jetbrains.exposed.v1.core.JoinType import org.jetbrains.exposed.v1.core.JoinType
import org.jetbrains.exposed.v1.core.Op import org.jetbrains.exposed.v1.core.Op
import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.SortOrder import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.and 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.eq
import org.jetbrains.exposed.v1.core.greater 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.andWhere
import org.jetbrains.exposed.v1.jdbc.select import org.jetbrains.exposed.v1.jdbc.select
import org.jetbrains.exposed.v1.jdbc.transactions.transaction 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.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.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.SourceTable import suwayomi.tachidesk.manga.model.table.SourceTable
@@ -24,6 +36,7 @@ import suwayomi.tachidesk.server.serverConfig
object ChapterRepository { object ChapterRepository {
private val opdsItemsPerPageBounded: Int private val opdsItemsPerPageBounded: Int
get() = serverConfig.opdsItemsPerPage.value get() = serverConfig.opdsItemsPerPage.value
private val logger = KotlinLogging.logger {}
private fun ResultRow.toOpdsChapterListAcqEntry(): OpdsChapterListAcqEntry = private fun ResultRow.toOpdsChapterListAcqEntry(): OpdsChapterListAcqEntry =
OpdsChapterListAcqEntry( OpdsChapterListAcqEntry(
@@ -38,69 +51,163 @@ object ChapterRepository {
lastReadAt = this[ChapterTable.lastReadAt], lastReadAt = this[ChapterTable.lastReadAt],
sourceOrder = this[ChapterTable.sourceOrder], sourceOrder = this[ChapterTable.sourceOrder],
pageCount = this[ChapterTable.pageCount], pageCount = this[ChapterTable.pageCount],
downloaded = this[ChapterTable.isDownloaded],
) )
fun getChaptersForManga( suspend fun getChaptersForManga(
mangaId: Int, mangaId: Int,
pageNum: Int,
sortColumn: Column<*>, sortColumn: Column<*>,
sortOrder: SortOrder, sortOrder: SortOrder,
filter: String, filter: String,
): Pair<List<OpdsChapterListAcqEntry>, Long> = pageNum: Int,
transaction { skipMetadata: Boolean,
val conditions = mutableListOf<Op<Boolean>>() ): Pair<List<OpdsChapterListAcqEntry>, Long> {
conditions.add(ChapterTable.manga eq mangaId) val (rawChapters, totalCount) =
transaction {
val conditions = mutableListOf<Op<Boolean>>()
conditions.add(ChapterTable.manga eq mangaId)
when (filter) { when (filter) {
"unread" -> conditions.add(ChapterTable.isRead eq false) "unread" -> conditions.add(ChapterTable.isRead eq false)
"read" -> conditions.add(ChapterTable.isRead eq true) "read" -> conditions.add(ChapterTable.isRead eq true)
} }
if (serverConfig.opdsShowOnlyDownloadedChapters.value) { if (serverConfig.opdsShowOnlyDownloadedChapters.value) {
conditions.add(ChapterTable.isDownloaded eq true) 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 // If not skipping metadata, return basic DTOs
if (!skipMetadata) {
val baseQuery = return Pair(rawChapters, totalCount)
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 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( suspend fun getChapterDetailsForMetadataFeed(
mangaId: Int, mangaId: Int,
chapterSourceOrder: Int, chapterSourceOrder: Int,
): OpdsChapterMetadataAcqEntry? = ): OpdsChapterMetadataAcqEntry? {
try { val chapterDataClass =
val chapterDataClass = getChapterDownloadReady(chapterIndex = chapterSourceOrder, mangaId = mangaId) try {
OpdsChapterMetadataAcqEntry( getChapterDownloadReady(chapterIndex = chapterSourceOrder, mangaId = mangaId)
id = chapterDataClass.id, } catch (e: Exception) {
mangaId = chapterDataClass.mangaId, return null
name = chapterDataClass.name, }
uploadDate = chapterDataClass.uploadDate,
scanlator = chapterDataClass.scanlator, return OpdsChapterMetadataAcqEntry(
read = chapterDataClass.read, id = chapterDataClass.id,
lastPageRead = chapterDataClass.lastPageRead, mangaId = chapterDataClass.mangaId,
lastReadAt = chapterDataClass.lastReadAt, name = chapterDataClass.name,
sourceOrder = chapterDataClass.index, uploadDate = chapterDataClass.uploadDate,
downloaded = chapterDataClass.downloaded, chapterNumber = chapterDataClass.chapterNumber,
pageCount = chapterDataClass.pageCount, scanlator = chapterDataClass.scanlator,
url = chapterDataClass.realUrl, read = chapterDataClass.read,
) lastPageRead = chapterDataClass.lastPageRead,
} catch (e: Exception) { lastReadAt = chapterDataClass.lastReadAt,
null 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<List<OpdsLibraryUpdateAcqEntry>, Long> = fun getLibraryUpdates(pageNum: Int): Pair<List<OpdsLibraryUpdateAcqEntry>, Long> =
transaction { transaction {
@@ -115,21 +222,38 @@ object ChapterRepository {
val totalCount = query.count() val totalCount = query.count()
val items = val rawItems =
query query
.orderBy(ChapterTable.fetchedAt to SortOrder.DESC, ChapterTable.sourceOrder to SortOrder.DESC) .orderBy(ChapterTable.fetchedAt to SortOrder.DESC, ChapterTable.sourceOrder to SortOrder.DESC)
.limit(opdsItemsPerPageBounded) .limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { .toList()
OpdsLibraryUpdateAcqEntry(
chapter = it.toOpdsChapterListAcqEntry(), val mangaIds = rawItems.map { it[MangaTable.id].value }.distinct()
mangaTitle = it[MangaTable.title], val chapterCounts =
mangaAuthor = it[MangaTable.author], if (mangaIds.isNotEmpty()) {
mangaId = it[MangaTable.id].value, ChapterTable
mangaSourceLang = it[SourceTable.lang], .select(ChapterTable.manga, ChapterTable.id.count())
mangaThumbnailUrl = it[MangaTable.thumbnail_url], .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) Pair(items, totalCount)
} }
@@ -146,21 +270,38 @@ object ChapterRepository {
val totalCount = query.count() val totalCount = query.count()
val items = val rawItems =
query query
.orderBy(ChapterTable.lastReadAt to SortOrder.DESC) .orderBy(ChapterTable.lastReadAt to SortOrder.DESC)
.limit(opdsItemsPerPageBounded) .limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { .toList()
OpdsHistoryAcqEntry(
chapter = it.toOpdsChapterListAcqEntry(), val mangaIds = rawItems.map { it[MangaTable.id].value }.distinct()
mangaTitle = it[MangaTable.title], val chapterCounts =
mangaAuthor = it[MangaTable.author], if (mangaIds.isNotEmpty()) {
mangaId = it[MangaTable.id].value, ChapterTable
mangaSourceLang = it[SourceTable.lang], .select(ChapterTable.manga, ChapterTable.id.count())
mangaThumbnailUrl = it[MangaTable.thumbnail_url], .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) Pair(items, totalCount)
} }

View File

@@ -36,8 +36,57 @@ import suwayomi.tachidesk.opds.dto.OpdsMangaDetails
import suwayomi.tachidesk.opds.dto.OpdsMangaFilter import suwayomi.tachidesk.opds.dto.OpdsMangaFilter
import suwayomi.tachidesk.opds.dto.OpdsSearchCriteria import suwayomi.tachidesk.opds.dto.OpdsSearchCriteria
import suwayomi.tachidesk.opds.dto.PrimaryFilterType import suwayomi.tachidesk.opds.dto.PrimaryFilterType
import suwayomi.tachidesk.opds.util.OpdsStringUtil.formatSourceName
import suwayomi.tachidesk.server.serverConfig 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. * Repository for fetching manga data tailored for OPDS feeds.
*/ */
@@ -60,24 +109,24 @@ object MangaRepository {
sourceLang = this[SourceTable.lang], sourceLang = this[SourceTable.lang],
inLibrary = this[MangaTable.inLibrary], inLibrary = this[MangaTable.inLibrary],
status = this[MangaTable.status], status = this[MangaTable.status],
sourceName = this[SourceTable.name], sourceName = formatSourceName(this[SourceTable.name], this[SourceTable.lang]),
lastFetchedAt = this[MangaTable.lastFetchedAt], lastFetchedAt = this[MangaTable.lastFetchedAt],
url = this[MangaTable.realUrl], url = this[MangaTable.realUrl],
) )
/** /**
* Centralized function to retrieve paginated, sorted, and filtered manga from the library. * 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 pageNum The page number for pagination.
* @param sort The sorting parameter. * @param sort The sorting parameter.
* @param filter The filtering 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. * @return An [OpdsLibraryFeedResult] containing the list of manga, total count, and the specific filter name.
*/ */
fun getLibraryManga( fun getLibraryManga(
criteria: OpdsMangaFilter,
pageNum: Int, pageNum: Int,
sort: String?, sort: String?,
filter: String?, filter: String?,
criteria: OpdsMangaFilter,
): OpdsLibraryFeedResult = ): OpdsLibraryFeedResult =
transaction { transaction {
val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum() 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) .join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
.select(MangaTable.columns + SourceTable.lang + SourceTable.name + unreadCount) .select(MangaTable.columns + SourceTable.lang + SourceTable.name + unreadCount)
.where { MangaTable.inLibrary eq true } .where { MangaTable.inLibrary eq true }
.groupBy(MangaTable.id, SourceTable.lang, SourceTable.name)
// Apply specific filters from criteria query.applyOpdsMangaFilter(criteria)
criteria.sourceId?.let { query.andWhere { MangaTable.sourceReference eq it } } applyMangaLibrarySort(query, sort)
criteria.categoryId?.let { query.andWhere { CategoryMangaTable.category eq it } }
criteria.statusId?.let { query.andWhere { MangaTable.status eq it } } query.groupBy(MangaTable.id, SourceTable.lang, SourceTable.name)
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 }
}
// Efficiently get the name of the primary filter item // Efficiently get the name of the primary filter item
val specificFilterName = val specificFilterName =
@@ -114,10 +152,10 @@ object MangaRepository {
PrimaryFilterType.SOURCE -> { PrimaryFilterType.SOURCE -> {
criteria.sourceId?.let { criteria.sourceId?.let {
SourceTable SourceTable
.select(SourceTable.name) .select(SourceTable.name, SourceTable.lang)
.where { SourceTable.id eq it } .where { SourceTable.id eq it }
.firstOrNull() .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 totalCount = query.count()
val mangas = val mangas =
query query
@@ -245,6 +281,7 @@ object MangaRepository {
*/ */
fun getMangaDetails(mangaId: Int): OpdsMangaDetails? = fun getMangaDetails(mangaId: Int): OpdsMangaDetails? =
transaction { transaction {
val chapterCount = ChapterTable.select(ChapterTable.id).where { ChapterTable.manga eq mangaId }.count()
MangaTable MangaTable
.select(MangaTable.id, MangaTable.title, MangaTable.thumbnail_url, MangaTable.author) .select(MangaTable.id, MangaTable.title, MangaTable.thumbnail_url, MangaTable.author)
.where { MangaTable.id eq mangaId } .where { MangaTable.id eq mangaId }
@@ -255,6 +292,7 @@ object MangaRepository {
title = it[MangaTable.title], title = it[MangaTable.title],
thumbnailUrl = it[MangaTable.thumbnail_url], thumbnailUrl = it[MangaTable.thumbnail_url],
author = it[MangaTable.author], author = it[MangaTable.author],
totalChapters = chapterCount,
) )
} }
} }
@@ -265,24 +303,14 @@ object MangaRepository {
* @param sort The sorting parameter. * @param sort The sorting parameter.
* @param filter The filtering parameter. * @param filter The filtering parameter.
*/ */
private fun applyMangaLibrarySortAndFilter( private fun applyMangaLibrarySort(
query: Query, query: Query,
sort: String?, sort: String?,
filter: String?,
) { ) {
val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum() 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 lastReadAtExpr = ChapterTable.lastReadAt.max()
val latestChapterDateExpr = ChapterTable.date_upload.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 // Apply sorting
when (sort) { when (sort) {
"alpha_asc" -> query.orderBy(MangaTable.title to SortOrder.ASC) "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. * @return A map where keys are filter names and values are the counts.
*/ */
fun getLibraryFilterCounts(): Map<String, Long> = fun getLibraryFilterCounts(activeFilters: OpdsMangaFilter): Map<String, Long> =
transaction { transaction {
val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum() 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 downloadedCountExpr = Case().When(ChapterTable.isDownloaded eq true, intLiteral(1)).Else(intLiteral(0)).sum()
@@ -307,14 +336,28 @@ object MangaRepository {
val baseQuery = val baseQuery =
MangaTable MangaTable
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga) .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) .select(MangaTable.id)
.where { MangaTable.inLibrary eq true } .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 unreadCount = baseQuery.copy().having { unreadCountExpr greater 0 }.count()
val downloadedCount = baseQuery.copy().having { downloadedCountExpr 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 ongoingCount = statusBaseQuery.copy().andWhere { MangaTable.status eq MangaStatus.ONGOING.value }.count()
val completedCount = statusBaseQuery.copy().andWhere { MangaTable.status eq MangaStatus.COMPLETED.value }.count() val completedCount = statusBaseQuery.copy().andWhere { MangaTable.status eq MangaStatus.COMPLETED.value }.count()

View File

@@ -13,6 +13,7 @@ import suwayomi.tachidesk.i18n.MR
import suwayomi.tachidesk.manga.impl.extension.Extension import suwayomi.tachidesk.manga.impl.extension.Extension
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
import suwayomi.tachidesk.manga.model.table.CategoryTable 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.ExtensionTable
import suwayomi.tachidesk.manga.model.table.MangaStatus import suwayomi.tachidesk.manga.model.table.MangaStatus
import suwayomi.tachidesk.manga.model.table.MangaTable 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.OpdsCategoryNavEntry
import suwayomi.tachidesk.opds.dto.OpdsGenreNavEntry import suwayomi.tachidesk.opds.dto.OpdsGenreNavEntry
import suwayomi.tachidesk.opds.dto.OpdsLanguageNavEntry import suwayomi.tachidesk.opds.dto.OpdsLanguageNavEntry
import suwayomi.tachidesk.opds.dto.OpdsMangaFilter
import suwayomi.tachidesk.opds.dto.OpdsRootNavEntry import suwayomi.tachidesk.opds.dto.OpdsRootNavEntry
import suwayomi.tachidesk.opds.dto.OpdsSourceNavEntry import suwayomi.tachidesk.opds.dto.OpdsSourceNavEntry
import suwayomi.tachidesk.opds.dto.OpdsStatusNavEntry import suwayomi.tachidesk.opds.dto.OpdsStatusNavEntry
import suwayomi.tachidesk.opds.util.OpdsStringUtil.encodeForOpdsURL import suwayomi.tachidesk.opds.util.OpdsStringUtil.encodeForOpdsURL
import suwayomi.tachidesk.opds.util.OpdsStringUtil.formatSourceName
import suwayomi.tachidesk.server.serverConfig import suwayomi.tachidesk.server.serverConfig
import java.util.Locale import java.util.Locale
@@ -131,15 +134,14 @@ object NavigationRepository {
) )
} }
// ... (El resto del archivo permanece sin cambios)
fun getExploreSources(pageNum: Int): Pair<List<OpdsSourceNavEntry>, Long> = fun getExploreSources(pageNum: Int): Pair<List<OpdsSourceNavEntry>, Long> =
transaction { transaction {
val query = val query =
SourceTable SourceTable
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id) .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 } .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) .orderBy(SourceTable.name to SortOrder.ASC)
val totalCount = query.count() val totalCount = query.count()
@@ -150,7 +152,7 @@ object NavigationRepository {
.map { .map {
OpdsSourceNavEntry( OpdsSourceNavEntry(
id = it[SourceTable.id].value, 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) }, iconUrl = it[ExtensionTable.apkName].let { apkName -> Extension.getExtensionIconUrl(apkName) },
mangaCount = null, mangaCount = null,
) )
@@ -158,7 +160,10 @@ object NavigationRepository {
Pair(sources, totalCount) Pair(sources, totalCount)
} }
fun getLibrarySources(pageNum: Int): Pair<List<OpdsSourceNavEntry>, Long> = fun getLibrarySources(
pageNum: Int? = null,
activeFilters: OpdsMangaFilter = OpdsMangaFilter(),
): Pair<List<OpdsSourceNavEntry>, Long> =
transaction { transaction {
val mangaCount = MangaTable.id.countDistinct().alias("manga_count") val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
@@ -166,28 +171,55 @@ object NavigationRepository {
SourceTable SourceTable
.join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference) .join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference)
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id) .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 } .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 totalCount = query.count()
val sources =
if (pageNum != null) {
query query
.limit(opdsItemsPerPageBounded) .limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { }
OpdsSourceNavEntry(
id = it[SourceTable.id].value, val sources =
name = it[SourceTable.name], query.map {
iconUrl = it[ExtensionTable.apkName].let { apkName -> Extension.getExtensionIconUrl(apkName) }, OpdsSourceNavEntry(
mangaCount = it[mangaCount], 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) Pair(sources, totalCount)
} }
fun getCategories(pageNum: Int): Pair<List<OpdsCategoryNavEntry>, Long> = fun getSourceDetails(sourceId: Long): Pair<String, String?>? =
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<List<OpdsCategoryNavEntry>, Long> =
transaction { transaction {
val mangaCount = MangaTable.id.countDistinct().alias("manga_count") val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
@@ -195,35 +227,54 @@ object NavigationRepository {
CategoryTable CategoryTable
.join(CategoryMangaTable, JoinType.INNER, CategoryTable.id, CategoryMangaTable.category) .join(CategoryMangaTable, JoinType.INNER, CategoryTable.id, CategoryMangaTable.category)
.join(MangaTable, JoinType.INNER, CategoryMangaTable.manga, MangaTable.id) .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) .select(CategoryTable.id, CategoryTable.name, mangaCount)
.where { MangaTable.inLibrary eq true } .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 totalCount = query.count()
val categories =
if (pageNum != null) {
query query
.limit(opdsItemsPerPageBounded) .limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { }
OpdsCategoryNavEntry(
id = it[CategoryTable.id].value, val categories =
name = it[CategoryTable.name], query.map {
mangaCount = it[mangaCount], OpdsCategoryNavEntry(
) id = it[CategoryTable.id].value,
} name = it[CategoryTable.name],
mangaCount = it[mangaCount],
)
}
Pair(categories, totalCount) Pair(categories, totalCount)
} }
fun getGenres( fun getGenres(
pageNum: Int,
locale: Locale, locale: Locale,
pageNum: Int? = null,
activeFilters: OpdsMangaFilter = OpdsMangaFilter(),
): Pair<List<OpdsGenreNavEntry>, Long> = ): Pair<List<OpdsGenreNavEntry>, Long> =
transaction { transaction {
val allGenres = val query =
MangaTable 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) .select(MangaTable.genre)
.where { MangaTable.inLibrary eq true } .where { MangaTable.inLibrary eq true }
query.applyOpdsMangaFilter(activeFilters, excludeField = "genre")
val allGenres =
query
.mapNotNull { it[MangaTable.genre] } .mapNotNull { it[MangaTable.genre] }
.flatMap { it.split(",").map(String::trim).filterNot(String::isBlank) } .flatMap { it.split(",").map(String::trim).filterNot(String::isBlank) }
@@ -231,21 +282,32 @@ object NavigationRepository {
val distinctGenres = genreCounts.keys.sorted() val distinctGenres = genreCounts.keys.sorted()
val totalCount = distinctGenres.size.toLong() 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 = val paginatedGenres =
(if (fromIndex < distinctGenres.size) distinctGenres.subList(fromIndex, toIndex) else emptyList()) finalGenres.map { genreName ->
.map { genreName -> OpdsGenreNavEntry(
OpdsGenreNavEntry( id = genreName.encodeForOpdsURL(),
id = genreName.encodeForOpdsURL(), title = genreName,
title = genreName, mangaCount = genreCounts[genreName]?.toLong() ?: 0L,
mangaCount = genreCounts[genreName]?.toLong() ?: 0L, )
) }
}
Pair(paginatedGenres, totalCount) Pair(paginatedGenres, totalCount)
} }
fun getStatuses(locale: Locale): List<OpdsStatusNavEntry> { fun getStatuses(
locale: Locale,
pageNum: Int? = null,
activeFilters: OpdsMangaFilter = OpdsMangaFilter(),
): Pair<List<OpdsStatusNavEntry>, Long> {
val statusStringResources: Map<MangaStatus, StringResource> = val statusStringResources: Map<MangaStatus, StringResource> =
mapOf( mapOf(
MangaStatus.UNKNOWN to MR.strings.manga_status_unknown, MangaStatus.UNKNOWN to MR.strings.manga_status_unknown,
@@ -259,43 +321,88 @@ object NavigationRepository {
val statusCounts = val statusCounts =
transaction { transaction {
MangaTable val countExpr = MangaTable.id.countDistinct().alias("manga_count")
.select(MangaTable.status, MangaTable.id.count()) val query =
.where { MangaTable.inLibrary eq true } 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) .groupBy(MangaTable.status)
.associate { it[MangaTable.status] to it[MangaTable.id.count()] } .associate { it[MangaTable.status] to it[countExpr] }
} }
return MangaStatus.entries val allStatuses =
.map { mangaStatus -> MangaStatus.entries
val titleRes = statusStringResources[mangaStatus] ?: MR.strings.manga_status_unknown .map { mangaStatus ->
OpdsStatusNavEntry( val titleRes = statusStringResources[mangaStatus] ?: MR.strings.manga_status_unknown
id = mangaStatus.value, OpdsStatusNavEntry(
title = titleRes.localized(locale), id = mangaStatus.value,
mangaCount = statusCounts[mangaStatus.value] ?: 0L, title = titleRes.localized(locale),
) mangaCount = statusCounts[mangaStatus.value] ?: 0L,
}.sortedBy { it.id } )
}.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<OpdsLanguageNavEntry> = fun getContentLanguages(
locale: Locale,
pageNum: Int? = null,
activeFilters: OpdsMangaFilter = OpdsMangaFilter(),
): Pair<List<OpdsLanguageNavEntry>, Long> =
transaction { transaction {
val mangaCount = MangaTable.id.countDistinct().alias("manga_count") val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
SourceTable val query =
.join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference) SourceTable
.select(SourceTable.lang, mangaCount) .join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference)
.where { MangaTable.inLibrary eq true } .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) .groupBy(SourceTable.lang)
.orderBy(SourceTable.lang to SortOrder.ASC) .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] val langCode = it[SourceTable.lang]
OpdsLanguageNavEntry( OpdsLanguageNavEntry(
id = langCode, id = langCode,
title = title =
Locale.forLanguageTag(langCode).getDisplayName(uiLocale).replaceFirstChar { char -> Locale.forLanguageTag(langCode).getDisplayName(locale).replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase(uiLocale) else char.toString() if (char.isLowerCase()) char.titlecase(locale) else char.toString()
}, },
mangaCount = it[mangaCount], mangaCount = it[mangaCount],
) )
} }
Pair(languages, totalCount)
} }
} }

View File

@@ -33,6 +33,19 @@ object OpdsStringUtil {
return slug 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. * 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. * Uses binary (KiB, MiB, GiB, TiB) or decimal (KB, MB, GB, TB) units based on server configuration.