From 87aae46a1fbee43bb7b5222a3c31bf2adf53414d Mon Sep 17 00:00:00 2001 From: Zeedif Date: Thu, 31 Jul 2025 17:53:40 -0600 Subject: [PATCH] Overhaul OPDS feeds for discovery, filtering, and enhanced UX (#1543) * fix: correct chapter facets URL to include /chapters endpoint Update addChapterSortAndFilterFacets to use the correct URL path from `/manga/{id}` to `/manga/{id}/chapters` for proper routing. * feat(opds): restructure feeds and add exploration capabilities This commit completely refactors the OPDS v1.2 implementation to align it more closely with the WebUI experience, separating "Library" browsing from "Explore" functionality. Key changes include: - The root feed is now a navigation feed directing to distinct "Library" and "Explore" sections. - A new "History" feed has been added to the root to show recently read chapters. - The "Explore" section now allows browsing all available sources, not just those with manga in the library. - Feeds for exploring a source now support faceting by "Popular" and "Latest", mirroring the WebUI. - The "Library" section retains all previous browsing methods (by category, genre, status, etc.). - Facet link generation has been corrected to use the proper base URL, fixing broken navigation in chapter lists. - The `OpdsFeedBuilder.kt` file has been refactorized into smaller, more manageable helper files (`OpdsEntryBuilder.kt`, `OpdsFeedHelper.kt`) to resolve a `java.lang.OutOfMemoryError: GC overhead limit exceeded` error during compilation. - All OPDS-related strings (`strings.xml`) have been updated to reflect the new structure and improve clarity. This new structure provides a much more intuitive and powerful browsing experience for OPDS clients, enabling content discovery in addition to library management. * feat(opds)!: implement advanced filtering and sorting for library feeds This commit significantly enhances the OPDS library feeds by introducing advanced sorting and filtering capabilities, mirroring the features available in the WebUI. It also standardizes the terminology from "manga" to "series" across all user-facing OPDS feeds for better clarity and consistency. Key Features & Changes: - **Library Facets:** All library feeds (All Series, By Source, By Category, By Genre, etc.) now include OPDS facets for: - **Sorting:** By title (A-Z, Z-A), last read, latest chapter, date added, and total unread chapters. - **Filtering:** By content status including unread, downloaded, ongoing, and completed. - **Terminology Update:** The term "manga" has been replaced with "series" in all user-facing OPDS titles, descriptions, and endpoints to align with the frontend terminology. - **Code Refactoring:** - `MangaRepository` has been updated with the correct Exposed SQL syntax (`Case`/`sum` for conditional counts, `having` clause for filtering on aggregates) to support the new facets. - `OpdsEntryBuilder` now includes a new function `addLibraryMangaSortAndFilterFacets` to generate the facet links. - `OpdsV1Controller` and `OpdsFeedBuilder` have been updated to handle the new `sort` and `filter` parameters and to call the new facet generation logic. BREAKING CHANGE: The API endpoints for manga have been renamed to use 'series'. Any client implementation will need to update its routes. For example, `/api/opds/v1.2/manga/{id}/chapters` is now `/api/opds/v1.2/series/{id}/chapters`. * feat(opds): add item counts (thr:count) to navigation and facet links This change enhances the OPDS feeds by including the number of items for various navigation links and filter facets, adhering to the OPDS 1.2 specification. The `thr:count` attribute provides a hint to clients about the number of entries in a linked feed, significantly improving the user experience by showing counts upfront. - Navigation Feeds (Categories, Sources, Genres, Statuses, Languages) now display the total number of manga for each entry in their respective links. - Acquisition Feeds for the library and chapters now include counts for their filter facets (e.g., Unread, Downloaded, Completed). This required updating DTOs to carry count data, modifying repository queries to calculate these counts efficiently, and adjusting the feed builders to include the `thr:count` attribute in the generated XML. * refactor(opds)!: simplify root feed by removing library sub-level The OPDS feed navigation was previously nested, requiring users to first select "Library" and then navigate to a subsection like "All Series" or "Categories". This extra step is cumbersome for OPDS clients and complicates the user experience. This change elevates all library-related navigation entries directly to the root feed, flattening the hierarchy and making content more accessible. As part of this refactoring: - The `getLibraryFeed` builder and its corresponding controller/API endpoints have been removed. - Unused string resources for the "Library" entry have been deleted. BREAKING CHANGE: The `/api/opds/v1.2/library` endpoint has been removed. Clients should now discover library sections directly from the root feed at `/api/opds/v1.2`. * feat(opds): enhance feeds with comprehensive manga and chapter details This commit significantly enriches the OPDS feeds to provide a more detailed and compliant user experience. - Refactored `OpdsMangaAcqEntry` and `OpdsChapterMetadataAcqEntry` to include additional fields such as status, source information, author, description, and web URLs. - The OPDS entry builder (`OpdsEntryBuilder`) now populates entries with this richer metadata, including summaries, content descriptions, authors, and categories, aligning more closely with the OPDS Catalog specification. - Added OPDS constants for 'popular' and 'new' sort relations to align with the specification. - Included "alternate" links for both manga and chapters, allowing clients to open the item on its source website ("View on web"). - Updated internationalization strings and constants to support the new features and metadata. * fix(opds): fetch chapters for non-library manga in feed Previously, when accessing the OPDS chapter feed for a manga discovered via the "Explore" feature (and thus not yet in the library), the feed would be empty. This was because the feed generation logic only queried the local database, which had no chapter entries for these manga. This commit resolves the issue by modifying `getSeriesChaptersFeed` to be a suspend function. It now implements a fallback mechanism: - It first attempts to load chapters from the local database. - If no chapters are found, it triggers an online fetch from the source to populate the database. - It then re-queries the local data to build the complete chapter feed. This ensures that chapter lists are correctly displayed for all manga, whether they are in the library or being explored for the first time. Additionally, this commit includes a minor correction to the URN identifier for the root feed to better align with its path. * feat(opds): provide direct stream and acquisition links when page count is known Previously, the OPDS chapter feed always provided a single link to a separate metadata feed for each chapter. This was done to defer the costly operation of fetching the page count for undownloaded chapters, ensuring the main chapter list loaded quickly. This commit introduces a more efficient, conditional approach. If a chapter's page count is already known (e.g., because it's downloaded or has been previously fetched), the chapter feed entry now includes direct links for: - OPDS-PSE page streaming (`pse:stream`). - CBZ file acquisition (`acquisition/open-access`). - Chapter cover image (`image`). If the page count is not known, the entry falls back to the previous behavior, linking to the metadata feed to perform the page count lookup on-demand. This significantly improves the user experience for OPDS clients by reducing the number of requests needed to start reading or downloading chapters that are already available on the server, making navigation faster and more fluid. * fix(opds): resolve suspend calls and add missing lastReadAt for OPDS feeds The OPDS feed generation was failing to compile due to two main issues: 1. The `OpdsChapterListAcqEntry` DTO was missing the `lastReadAt` property, which is required for the OPDS-PSE `lastReadDate` attribute. 2. Several functions in `OpdsFeedBuilder` were attempting to call the `suspend` function `createChapterListEntry` from a non-coroutine context. This commit resolves these issues by: - Adding the `lastReadAt` field to `OpdsChapterListAcqEntry` and populating it correctly from the database in the `ChapterRepository`. - Refactoring `getHistoryFeed`, `getLibraryUpdatesFeed`, and `getSeriesChaptersFeed` in `OpdsFeedBuilder` to be `suspend` functions. - Wrapping the entry creation logic in `withContext(Dispatchers.IO)` to provide the necessary coroutine scope for the suspend call and to perform the mapping on a background thread. * refactor(opds): standardize library feed generation and enhance facets This commit refactors the OPDS v1.2 feed generation logic to improve code structure, correctness, and feature capability. The primary changes include: - A new private `getLibraryFeed` helper function in `OpdsV1Controller` has been introduced to centralize and DRY up the logic for creating library-specific acquisition feeds. - A new `OpdsMangaFilter` DTO now encapsulates all filtering, sorting, and pagination parameters, simplifying the controller handlers and making them more maintainable. - URL generation for category, genre, status, and language feeds has been corrected. Links now correctly point to root-level paths (e.g., `/opds/v1.2/genre/{name}`) instead of being incorrectly nested under `/library/`. - The OPDS facet system is enhanced with more specific facet groups and "All" links for a better user experience when clearing filters. Associated changes: - i18n strings in `strings.xml` have been reorganized with comments and new strings have been added to support the enhanced facet groups. - The route for the publication status feed has been renamed from `/status/{id}` to `/statuses` for consistency. - KDoc comments have been added and improved throughout the affected files for better code documentation. * fix(opds): revert direct acquisition links in chapter feeds to improve performance This reverts commit 33cdc0d534292760a3225cee18e274df542f0778. The previous change introduced direct stream and download links in chapter list feeds when the page count was known. While convenient, this caused a significant performance degradation on feeds with many chapters, as it required checking for the existence of a CBZ file for every single entry. This commit restores the original behavior where chapter list entries always link to a dedicated metadata feed. This approach defers expensive I/O operations until a user explicitly requests a single chapter's details, ensuring that chapter list feeds load quickly and efficiently. Direct acquisition and streaming links are now exclusively generated within the metadata feed. --- .../moko-resources/values/base/strings.xml | 96 +- .../kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt | 84 +- .../tachidesk/opds/constants/OpdsConstants.kt | 2 + .../opds/controller/OpdsV1Controller.kt | 443 +++--- .../opds/dto/OpdsCategoryNavEntry.kt | 1 + .../opds/dto/OpdsChapterListAcqEntry.kt | 1 + .../opds/dto/OpdsChapterMetadataAcqEntry.kt | 1 + .../tachidesk/opds/dto/OpdsGenreNavEntry.kt | 1 + .../tachidesk/opds/dto/OpdsHistoryAcqEntry.kt | 10 + .../opds/dto/OpdsLanguageNavEntry.kt | 1 + .../opds/dto/OpdsLibraryFeedResult.kt | 14 + .../tachidesk/opds/dto/OpdsMangaAcqEntry.kt | 14 +- .../tachidesk/opds/dto/OpdsMangaFilter.kt | 72 + .../tachidesk/opds/dto/OpdsSourceNavEntry.kt | 1 + .../tachidesk/opds/dto/OpdsStatusNavEntry.kt | 1 + .../opds/impl/FeedBuilderInternal.kt | 129 ++ .../tachidesk/opds/impl/OpdsEntryBuilder.kt | 640 ++++++++ .../tachidesk/opds/impl/OpdsFeedBuilder.kt | 1372 +++++++---------- .../opds/repository/ChapterRepository.kt | 53 +- .../opds/repository/MangaRepository.kt | 316 ++-- .../opds/repository/NavigationRepository.kt | 161 +- 21 files changed, 2173 insertions(+), 1240 deletions(-) create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsHistoryAcqEntry.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsLibraryFeedResult.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaFilter.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/impl/FeedBuilderInternal.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsEntryBuilder.kt diff --git a/server/i18n/src/commonMain/moko-resources/values/base/strings.xml b/server/i18n/src/commonMain/moko-resources/values/base/strings.xml index 3162ce19..5a5c026c 100644 --- a/server/i18n/src/commonMain/moko-resources/values/base/strings.xml +++ b/server/i18n/src/commonMain/moko-resources/values/base/strings.xml @@ -1,85 +1,127 @@ - Suwayomi OPDS Search - Search manga in the catalog - + Suwayomi OPDS Catalog %1$s Chapters %1$s | %2$s | Details - All Manga - Browse all manga in your library + Explore + Explore new series from your sources - Search Results + History + Recently read chapters - Sources - Browse manga by source + All Series + Browse all series saved in your library + + Sources + Browse series in your library filtered by source Categories - Browse manga organized by categories + Browse series organized by categories Genres - Browse manga by genre tags + Browse series by genre tags Status - Browse manga by publication status + Browse series by publication status Languages - Browse manga by content language + Browse series by content language Library Update History Recently updated chapters from your library + Search Results + All Sources Category: %1$s Genre: %1$s Status: %1$s Language: %1$s Source: %1$s - - Manga with ID %1$d not found - Chapter with index %1$d not found + Library - Source: %1$s + Source: %1$s - Popular + Source: %1$s - Latest + + Suwayomi OPDS Search + Search for series in the catalog. + + + Series with ID %1$d not found. + Chapter with index %1$d not found. + + Sort Order - Read Status + Filter by Read Status + Filter Content + Filter by Source + Filter by Category + Filter by Status + Filter by Language + Filter by Genre Oldest First Newest First Date ascending Date descending + Popular + Latest + Alphabetical A-Z + Alphabetical Z-A + Last Read + Latest Chapter + Date Added + Unread chapters + All All Chapters - Unread Only - Read Only + Unread + Read + Downloaded + Ongoing + Completed - View Chapter Details & Get Pages - Download CBZ - View Pages (Streaming) - Chapter Cover - Current Page + All Sources + All Categories + All Statuses + All Languages + All Genres + + Catalog Root Search Catalog Previous Page Next Page Current Feed + View on Web + View Pages (Streaming) + Download CBZ + Chapter Cover + View Chapter Details & Get Pages - ⬇️ + - ⚠️ - + ⬇️ %1$s | %2$s | By %1$s | Progress: %1$d of %2$d - Unknown + Status: %1$s + Source: %1$s + Language: %1$s + + Ongoing Completed Licensed Publishing Finished Cancelled On Hiatus + Unknown Error Version %1$s diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt index 1673aaed..290f8126 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt @@ -13,65 +13,81 @@ object OpdsAPI { // OPDS Search Description Feed get("search", OpdsV1Controller.searchFeed) - // Complete feed for crawlers - // get("complete", OpdsV1Controller.completeFeed) + // --- Main Navigation Feeds --- - // --- Main Navigation & Broad Acquisition Feeds --- + // Explore Navigation Feed + get("explore", OpdsV1Controller.exploreSourcesFeed) - // All Mangas / Search Results Feed (Acquisition) - get("mangas", OpdsV1Controller.mangasFeed) - - // Sources Navigation Feed - get("sources", OpdsV1Controller.sourcesFeed) - - // Categories Navigation Feed - get("categories", OpdsV1Controller.categoriesFeed) - - // Genres Navigation Feed - get("genres", OpdsV1Controller.genresFeed) - - // Status Navigation Feed - get("status", OpdsV1Controller.statusFeed) - - // Content Languages Navigation Feed - get("languages", OpdsV1Controller.languagesFeed) + // Reading History Acquisition Feed + get("history", OpdsV1Controller.historyFeed) // Library Updates Acquisition Feed get("library-updates", OpdsV1Controller.libraryUpdatesFeed) - // --- Filtered & Item-Specific Acquisition Feeds --- + // --- Library-Specific Feeds --- + path("library") { + // All Series in Library / Search Results Feed (Acquisition) + get("series", OpdsV1Controller.seriesFeed) - // Manga Chapters Acquisition Feed - path("manga/{mangaId}/chapters") { - get(OpdsV1Controller.mangaFeed) + // Library Sources Navigation Feed + get("sources", OpdsV1Controller.librarySourcesFeed) + + // Library Source-Specific Series Acquisition Feed + path("source/{sourceId}") { + get(OpdsV1Controller.librarySourceFeed) + } + + // Library Categories Navigation Feed + get("categories", OpdsV1Controller.categoriesFeed) + + // Library Genres Navigation Feed + get("genres", OpdsV1Controller.genresFeed) + + // Library Status Navigation Feed + get("statuses", OpdsV1Controller.statusesFeed) + + // Library Content Languages Navigation Feed + get("languages", OpdsV1Controller.languagesFeed) + } + + // --- Explore-Specific Feeds --- + + // All Sources Navigation Feed (Explore) + get("sources", OpdsV1Controller.exploreSourcesFeed) + + // Source-Specific Series Acquisition Feed (Explore) + path("source/{sourceId}") { + get(OpdsV1Controller.exploreSourceFeed) + } + + // --- Item-Specific Feeds (Apply to both Library and Explore contexts) --- + + // Series Chapters Acquisition Feed + path("series/{seriesId}/chapters") { + get(OpdsV1Controller.seriesChaptersFeed) } // Chapter Metadata Acquisition Feed - path("manga/{mangaId}/chapter/{chapterIndex}/metadata") { + path("series/{seriesId}/chapter/{chapterIndex}/metadata") { get(OpdsV1Controller.chapterMetadataFeed) } - // Source-Specific Manga Acquisition Feed - path("source/{sourceId}") { - get(OpdsV1Controller.sourceFeed) - } - - // Category-Specific Manga Acquisition Feed + // Category-Specific Series Acquisition Feed (Library) path("category/{categoryId}") { get(OpdsV1Controller.categoryFeed) } - // Genre-Specific Manga Acquisition Feed + // Genre-Specific Series Acquisition Feed (Library) path("genre/{genre}") { get(OpdsV1Controller.genreFeed) } - // Status-Specific Manga Acquisition Feed + // Status-Specific Series Acquisition Feed (Library) path("status/{statusId}") { get(OpdsV1Controller.statusMangaFeed) } - // Language-Specific Manga Acquisition Feed + // Language-Specific Series Acquisition Feed (Library) path("language/{langCode}") { get(OpdsV1Controller.languageFeed) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/constants/OpdsConstants.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/constants/OpdsConstants.kt index edbb915b..955736ae 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/constants/OpdsConstants.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/constants/OpdsConstants.kt @@ -29,6 +29,8 @@ object OpdsConstants { const val LINK_REL_NEXT = "next" const val LINK_REL_PSE_STREAM = "http://vaemendis.net/opds-pse/stream" const val LINK_REL_CRAWLABLE = "http://opds-spec.org/crawlable" + const val LINK_REL_SORT_NEW = "http://opds-spec.org/sort/new" + const val LINK_REL_SORT_POPULAR = "http://opds-spec.org/sort/popular" // Media Types const val TYPE_ATOM_XML_FEED_NAVIGATION = "application/atom+xml;profile=opds-catalog;kind=navigation" diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt index 40552913..385f3ec1 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt @@ -1,10 +1,13 @@ package suwayomi.tachidesk.opds.controller +import io.javalin.http.Context import io.javalin.http.HttpStatus import suwayomi.tachidesk.i18n.LocalizationHelper import suwayomi.tachidesk.i18n.MR import suwayomi.tachidesk.opds.constants.OpdsConstants +import suwayomi.tachidesk.opds.dto.OpdsMangaFilter import suwayomi.tachidesk.opds.dto.OpdsSearchCriteria +import suwayomi.tachidesk.opds.dto.PrimaryFilterType import suwayomi.tachidesk.opds.impl.OpdsFeedBuilder import suwayomi.tachidesk.server.JavalinSetup.future import suwayomi.tachidesk.server.util.handler @@ -13,11 +16,42 @@ import suwayomi.tachidesk.server.util.queryParam import suwayomi.tachidesk.server.util.withOperation import java.util.Locale +/** + * Controller for handling OPDS v1.2 feed requests. + */ object OpdsV1Controller { private const val OPDS_MIME = "application/xml;profile=opds-catalog;charset=UTF-8" private const val BASE_URL = "/api/opds/v1.2" - // OPDS Catalog Root Feed + /** + * Helper function to generate and send a library feed response. + * It asynchronously builds the feed and sets the response content type. + */ + private fun getLibraryFeed( + ctx: Context, + pageNum: Int?, + criteria: OpdsMangaFilter, + ) { + val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang")) + ctx.future { + future { + OpdsFeedBuilder.getLibraryFeed( + criteria = criteria, + baseUrl = BASE_URL, + pageNum = pageNum ?: 1, + sort = criteria.sort, + filter = criteria.filter, + locale = locale, + ) + }.thenApply { xml -> + ctx.contentType(OPDS_MIME).result(xml) + } + } + } + + /** + * Serves the root navigation feed for the OPDS catalog. + */ val rootFeed = handler( queryParam("lang"), @@ -28,21 +62,43 @@ object OpdsV1Controller { } }, behaviorOf = { ctx, lang -> + val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) + ctx.contentType(OPDS_MIME).result(OpdsFeedBuilder.getRootFeed(BASE_URL, locale)) + }, + withResults = { httpCode(HttpStatus.OK) }, + ) + + // --- Main Navigation Feeds --- + + /** + * Serves an acquisition feed listing recently read chapters. + */ + val historyFeed = + handler( + queryParam("pageNumber"), + queryParam("lang"), + documentWith = { + withOperation { + summary("OPDS History Feed") + description("Acquisition feed listing recently read chapters.") + } + }, + behaviorOf = { ctx, pageNumber, lang -> val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { - OpdsFeedBuilder.getRootFeed(BASE_URL, locale) + OpdsFeedBuilder.getHistoryFeed(BASE_URL, pageNumber ?: 1, locale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } } }, - withResults = { - httpCode(HttpStatus.OK) - }, + withResults = { httpCode(HttpStatus.OK) }, ) - // OPDS Search Description Feed + /** + * Serves the OpenSearch description document for catalog integration. + */ val searchFeed = handler( queryParam("lang"), @@ -54,7 +110,6 @@ object OpdsV1Controller { }, behaviorOf = { ctx, lang -> val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) - ctx.contentType("application/opensearchdescription+xml").result( """ ${MR.strings.opds_search_description.localized(locale)} UTF-8 UTF-8 - + """.trimIndent(), ) }, - withResults = { - httpCode(HttpStatus.OK) - }, + withResults = { httpCode(HttpStatus.OK) }, ) - // --- Main Navigation & Broad Acquisition Feeds --- - - // All Mangas / Search Results Feed - val mangasFeed = + /** + * Serves an acquisition feed for all series in the library or search results. + * This endpoint handles both general library browsing and specific search queries. + */ + val seriesFeed = handler( - queryParam("pageNumber"), - queryParam("query"), - queryParam("author"), - queryParam("title"), - queryParam("lang"), - documentWith = { - withOperation { - summary("OPDS Mangas Feed") - description( - "Provides a list of manga entries. Can be paginated and supports search via query parameters " + - "(query, author, title). If search parameters are present, it acts as a search results feed.", + documentWith = { withOperation { summary("OPDS Series in Library Feed") } }, + behaviorOf = { ctx -> + val pageNumber = ctx.queryParam("pageNumber")?.toIntOrNull() + val query = ctx.queryParam("query") + val author = ctx.queryParam("author") + val title = ctx.queryParam("title") + val lang = ctx.queryParam("lang") + val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) + + if (query != null || author != null || title != null) { + val opdsSearchCriteria = OpdsSearchCriteria(query, author, title) + ctx.future { + future { + OpdsFeedBuilder.getSearchFeed(opdsSearchCriteria, BASE_URL, pageNumber ?: 1, locale) + }.thenApply { xml -> + ctx.contentType(OPDS_MIME).result(xml) + } + } + } else { + val criteria = + OpdsMangaFilter( + sourceId = ctx.queryParam("source_id")?.toLongOrNull(), + categoryId = ctx.queryParam("category_id")?.toIntOrNull(), + statusId = ctx.queryParam("status_id")?.toIntOrNull(), + genre = ctx.queryParam("genre"), + langCode = ctx.queryParam("lang_code"), + sort = ctx.queryParam("sort"), + filter = ctx.queryParam("filter"), + primaryFilter = PrimaryFilterType.NONE, + ) + getLibraryFeed( + ctx, + pageNumber, + criteria, ) } }, - behaviorOf = { ctx, pageNumber, query, author, title, lang -> - val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) - val opdsSearchCriteria = - if (query != null || author != null || title != null) { - OpdsSearchCriteria(query, author, title) - } else { - null - } - val effectivePageNumber = if (opdsSearchCriteria != null) 1 else pageNumber ?: 1 - - ctx.future { - future { - OpdsFeedBuilder.getMangasFeed(opdsSearchCriteria, BASE_URL, effectivePageNumber, locale) - }.thenApply { xml -> - ctx.contentType(OPDS_MIME).result(xml) - } - } - }, - withResults = { - httpCode(HttpStatus.OK) - }, + withResults = { httpCode(HttpStatus.OK) }, ) - // Sources Navigation Feed - val sourcesFeed = + /** + * Serves a navigation feed listing all available manga sources for exploration. + */ + val exploreSourcesFeed = handler( queryParam("pageNumber"), queryParam("lang"), documentWith = { withOperation { - summary("OPDS Sources Navigation Feed") - description("Navigation feed listing available manga sources. Each entry links to a feed for a specific source.") + summary("OPDS All Sources Navigation Feed") + description("Navigation feed listing all available manga sources.") } }, behaviorOf = { ctx, pageNumber, lang -> val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { - OpdsFeedBuilder.getSourcesFeed(BASE_URL, pageNumber ?: 1, locale) + OpdsFeedBuilder.getExploreSourcesFeed(BASE_URL, pageNumber ?: 1, locale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } } }, - withResults = { - httpCode(HttpStatus.OK) - }, + withResults = { httpCode(HttpStatus.OK) }, ) - // Categories Navigation Feed + /** + * Serves a navigation feed listing only the sources for series present in the library. + */ + val librarySourcesFeed = + handler( + queryParam("pageNumber"), + queryParam("lang"), + documentWith = { + withOperation { + summary("OPDS Library Sources Navigation Feed") + description("Navigation feed listing sources for series currently in the library.") + } + }, + behaviorOf = { ctx, pageNumber, lang -> + val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) + ctx.future { + future { + OpdsFeedBuilder.getLibrarySourcesFeed(BASE_URL, pageNumber ?: 1, locale) + }.thenApply { xml -> + ctx.contentType(OPDS_MIME).result(xml) + } + } + }, + withResults = { httpCode(HttpStatus.OK) }, + ) + + /** + * Serves a navigation feed for browsing manga categories within the library. + */ val categoriesFeed = handler( queryParam("pageNumber"), @@ -151,7 +236,7 @@ object OpdsV1Controller { documentWith = { withOperation { summary("OPDS Categories Navigation Feed") - description("Navigation feed listing available manga categories. Each entry links to a feed for a specific category.") + description("Navigation feed listing available manga categories for the library.") } }, behaviorOf = { ctx, pageNumber, lang -> @@ -164,12 +249,12 @@ object OpdsV1Controller { } } }, - withResults = { - httpCode(HttpStatus.OK) - }, + withResults = { httpCode(HttpStatus.OK) }, ) - // Genres Navigation Feed + /** + * Serves a navigation feed for browsing manga genres within the library. + */ val genresFeed = handler( queryParam("pageNumber"), @@ -177,7 +262,7 @@ object OpdsV1Controller { documentWith = { withOperation { summary("OPDS Genres Navigation Feed") - description("Navigation feed listing available manga genres. Each entry links to a feed for a specific genre.") + description("Navigation feed listing available manga genres in the library.") } }, behaviorOf = { ctx, pageNumber, lang -> @@ -190,51 +275,44 @@ object OpdsV1Controller { } } }, - withResults = { - httpCode(HttpStatus.OK) - }, + withResults = { httpCode(HttpStatus.OK) }, ) - // Status Navigation Feed - val statusFeed = + /** + * Serves a navigation feed for browsing series by their publication status. + */ + val statusesFeed = handler( - queryParam("pageNumber"), queryParam("lang"), documentWith = { withOperation { - summary("OPDS Status Navigation Feed") - description( - "Navigation feed listing manga publication statuses. Each entry links to a feed for manga with a specific status.", - ) + summary("OPDS Statuses Navigation Feed") + description("Navigation feed listing series publication statuses for the library.") } }, - behaviorOf = { ctx, pageNumber, lang -> + behaviorOf = { ctx, lang -> val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { - // Ignoramos pageNumber aquí, siempre usamos 1 OpdsFeedBuilder.getStatusFeed(BASE_URL, 1, locale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } } }, - withResults = { - httpCode(HttpStatus.OK) - }, + withResults = { httpCode(HttpStatus.OK) }, ) - // Content Languages Navigation Feed + /** + * Serves a navigation feed for browsing series by their content language. + */ val languagesFeed = handler( queryParam("lang"), documentWith = { withOperation { summary("OPDS Content Languages Navigation Feed") - description( - "Navigation feed listing available content languages for manga. " + - "Each entry links to a feed for manga in a specific content language.", - ) + description("Navigation feed listing available content languages for series in the library.") } }, behaviorOf = { ctx, lang -> @@ -247,12 +325,12 @@ object OpdsV1Controller { } } }, - withResults = { - httpCode(HttpStatus.OK) - }, + withResults = { httpCode(HttpStatus.OK) }, ) - // Library Updates Acquisition Feed + /** + * Serves an acquisition feed of recent chapter updates for series in the library. + */ val libraryUpdatesFeed = handler( queryParam("pageNumber"), @@ -260,7 +338,7 @@ object OpdsV1Controller { documentWith = { withOperation { summary("OPDS Library Updates Feed") - description("Acquisition feed listing recent chapter updates for manga in the library. Supports pagination.") + description("Acquisition feed listing recent chapter updates for series in the library.") } }, behaviorOf = { ctx, pageNumber, lang -> @@ -273,30 +351,29 @@ object OpdsV1Controller { } } }, - withResults = { - httpCode(HttpStatus.OK) - }, + withResults = { httpCode(HttpStatus.OK) }, ) - // --- Filtered Acquisition Feeds --- - - // Source-Specific Manga Acquisition Feed - val sourceFeed = + /** + * Serves an acquisition feed for all series from a specific source. + */ + val exploreSourceFeed = handler( pathParam("sourceId"), queryParam("pageNumber"), + queryParam("sort"), queryParam("lang"), documentWith = { withOperation { - summary("OPDS Source Specific Manga Feed") - description("Acquisition feed listing manga from a specific source. Supports pagination.") + summary("OPDS Source Specific Series Feed (Explore)") + description("Acquisition feed listing all series from a specific source.") } }, - behaviorOf = { ctx, sourceId, pageNumber, lang -> + behaviorOf = { ctx, sourceId, pageNumber, sort, lang -> val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { - OpdsFeedBuilder.getSourceFeed(sourceId, BASE_URL, pageNumber ?: 1, locale) + OpdsFeedBuilder.getExploreSourceFeed(sourceId, BASE_URL, pageNumber ?: 1, sort ?: "popular", locale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } @@ -308,27 +385,46 @@ object OpdsV1Controller { }, ) - // Category-Specific Manga Acquisition Feed + /** + * Builds an [OpdsMangaFilter] from the current request context, inheriting existing filters. + */ + private fun buildCriteriaFromContext( + ctx: Context, + initialCriteria: OpdsMangaFilter, + ): OpdsMangaFilter = + initialCriteria.copy( + sort = ctx.queryParam("sort"), + filter = ctx.queryParam("filter"), + ) + + /** + * Serves an acquisition feed for series in the library from a specific source. + */ + val librarySourceFeed = + handler( + pathParam("sourceId"), + documentWith = { withOperation { summary("OPDS Library Source Specific Series Feed") } }, + behaviorOf = { ctx, sourceId -> + val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(sourceId = sourceId, primaryFilter = PrimaryFilterType.SOURCE)) + getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria) + }, + withResults = { + httpCode(HttpStatus.OK) + httpCode(HttpStatus.NOT_FOUND) + }, + ) + + /** + * Serves an acquisition feed for series in a specific category. + */ val categoryFeed = handler( pathParam("categoryId"), - queryParam("pageNumber"), - queryParam("lang"), - documentWith = { - withOperation { - summary("OPDS Category Specific Manga Feed") - description("Acquisition feed listing manga belonging to a specific category. Supports pagination.") - } - }, - behaviorOf = { ctx, categoryId, pageNumber, lang -> - val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) - ctx.future { - future { - OpdsFeedBuilder.getCategoryFeed(categoryId, BASE_URL, pageNumber ?: 1, locale) - }.thenApply { xml -> - ctx.contentType(OPDS_MIME).result(xml) - } - } + documentWith = { withOperation { summary("OPDS Category Specific Series Feed") } }, + behaviorOf = { ctx, categoryId -> + val criteria = + buildCriteriaFromContext(ctx, OpdsMangaFilter(categoryId = categoryId, primaryFilter = PrimaryFilterType.CATEGORY)) + getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria) }, withResults = { httpCode(HttpStatus.OK) @@ -336,27 +432,16 @@ object OpdsV1Controller { }, ) - // Genre-Specific Manga Acquisition Feed + /** + * Serves an acquisition feed for series belonging to a specific genre. + */ val genreFeed = handler( pathParam("genre"), - queryParam("pageNumber"), - queryParam("lang"), - documentWith = { - withOperation { - summary("OPDS Genre Specific Manga Feed") - description("Acquisition feed listing manga belonging to a specific genre. Supports pagination.") - } - }, - behaviorOf = { ctx, genre, pageNumber, lang -> - val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) - ctx.future { - future { - OpdsFeedBuilder.getGenreFeed(genre, BASE_URL, pageNumber ?: 1, locale) - }.thenApply { xml -> - ctx.contentType(OPDS_MIME).result(xml) - } - } + documentWith = { withOperation { summary("OPDS Genre Specific Series Feed") } }, + behaviorOf = { ctx, genre -> + val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(genre = genre, primaryFilter = PrimaryFilterType.GENRE)) + getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria) }, withResults = { httpCode(HttpStatus.OK) @@ -364,27 +449,16 @@ object OpdsV1Controller { }, ) - // Status-Specific Manga Acquisition Feed + /** + * Serves an acquisition feed for series with a specific publication status. + */ val statusMangaFeed = handler( - pathParam("statusId"), - queryParam("pageNumber"), - queryParam("lang"), - documentWith = { - withOperation { - summary("OPDS Status Specific Manga Feed") - description("Acquisition feed listing manga with a specific publication status. Supports pagination.") - } - }, - behaviorOf = { ctx, statusId, pageNumber, lang -> - val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) - ctx.future { - future { - OpdsFeedBuilder.getStatusMangaFeed(statusId, BASE_URL, pageNumber ?: 1, locale) - }.thenApply { xml -> - ctx.contentType(OPDS_MIME).result(xml) - } - } + pathParam("statusId"), + documentWith = { withOperation { summary("OPDS Status Specific Series Feed") } }, + behaviorOf = { ctx, statusId -> + val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(statusId = statusId, primaryFilter = PrimaryFilterType.STATUS)) + getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria) }, withResults = { httpCode(HttpStatus.OK) @@ -392,27 +466,22 @@ object OpdsV1Controller { }, ) - // Language-Specific Manga Acquisition Feed + /** + * Serves an acquisition feed for series of a specific content language. + */ val languageFeed = handler( pathParam("langCode"), - queryParam("pageNumber"), - queryParam("lang"), documentWith = { withOperation { - summary("OPDS Content Language Specific Manga Feed") - description("Acquisition feed listing manga of a specific content language. Supports pagination.") + summary("OPDS Content Language Specific Series Feed") + description("Acquisition feed listing series of a specific content language.") } }, - behaviorOf = { ctx, contentLangCodePath, pageNumber, uiLangParam -> - val uiLocale: Locale = LocalizationHelper.ctxToLocale(ctx, uiLangParam) - ctx.future { - future { - OpdsFeedBuilder.getLanguageFeed(contentLangCodePath, BASE_URL, pageNumber ?: 1, uiLocale) - }.thenApply { xml -> - ctx.contentType(OPDS_MIME).result(xml) - } - } + behaviorOf = { ctx, langCode -> + val criteria = + buildCriteriaFromContext(ctx, OpdsMangaFilter(langCode = langCode, primaryFilter = PrimaryFilterType.LANGUAGE)) + getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria) }, withResults = { httpCode(HttpStatus.OK) @@ -420,30 +489,27 @@ object OpdsV1Controller { }, ) - // --- Item-Specific Acquisition Feeds --- - - // Manga Chapters Acquisition Feed - val mangaFeed = + /** + * Serves an acquisition feed listing chapters for a specific series. + */ + val seriesChaptersFeed = handler( - pathParam("mangaId"), + pathParam("seriesId"), queryParam("pageNumber"), queryParam("sort"), queryParam("filter"), queryParam("lang"), documentWith = { withOperation { - summary("OPDS Manga Chapters Feed") - description( - "Acquisition feed listing chapters for a specific manga. Supports pagination, sorting, and filtering. " + - "Facets for sorting and filtering are provided.", - ) + summary("OPDS Series Chapters Feed") + description("Acquisition feed listing chapters for a specific series. Supports pagination, sorting, and filtering.") } }, - behaviorOf = { ctx, mangaId, pageNumber, sort, filter, lang -> + behaviorOf = { ctx, seriesId, pageNumber, sort, filter, lang -> val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { - OpdsFeedBuilder.getMangaFeed(mangaId, BASE_URL, pageNumber ?: 1, sort, filter, locale) + OpdsFeedBuilder.getSeriesChaptersFeed(seriesId, BASE_URL, pageNumber ?: 1, sort, filter, locale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } @@ -455,26 +521,25 @@ object OpdsV1Controller { }, ) - // Chapter Metadata Acquisition Feed + /** + * Serves an acquisition feed with detailed metadata for a single chapter. + */ val chapterMetadataFeed = handler( - pathParam("mangaId"), + pathParam("seriesId"), pathParam("chapterIndex"), queryParam("lang"), documentWith = { withOperation { summary("OPDS Chapter Details Feed") - description( - "Acquisition feed providing detailed metadata for a specific chapter, " + - "including download and streaming links if available.", - ) + description("Acquisition feed providing detailed metadata for a specific chapter.") } }, - behaviorOf = { ctx, mangaId, chapterIndex, lang -> + behaviorOf = { ctx, seriesId, chapterIndex, lang -> val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { - OpdsFeedBuilder.getChapterMetadataFeed(mangaId, chapterIndex, BASE_URL, locale) + OpdsFeedBuilder.getChapterMetadataFeed(seriesId, chapterIndex, BASE_URL, locale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsCategoryNavEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsCategoryNavEntry.kt index 571c233b..2b677db0 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsCategoryNavEntry.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsCategoryNavEntry.kt @@ -3,4 +3,5 @@ package suwayomi.tachidesk.opds.dto data class OpdsCategoryNavEntry( val id: Int, val name: String, + val mangaCount: Long, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterListAcqEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterListAcqEntry.kt index fe491b22..3081e09e 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterListAcqEntry.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterListAcqEntry.kt @@ -9,6 +9,7 @@ data class OpdsChapterListAcqEntry( val scanlator: String?, val read: Boolean, val lastPageRead: Int, + val lastReadAt: Long, val sourceOrder: Int, val pageCount: Int, // Can be -1 if not known ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterMetadataAcqEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterMetadataAcqEntry.kt index 3f160e75..6b65a3bc 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterMetadataAcqEntry.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterMetadataAcqEntry.kt @@ -12,4 +12,5 @@ data class OpdsChapterMetadataAcqEntry( val sourceOrder: Int, val downloaded: Boolean, val pageCount: Int, + val url: String?, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsGenreNavEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsGenreNavEntry.kt index fa80153f..76580beb 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsGenreNavEntry.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsGenreNavEntry.kt @@ -3,4 +3,5 @@ package suwayomi.tachidesk.opds.dto data class OpdsGenreNavEntry( val id: String, // Name encoded for OPDS URL (e.g., "Action%20Adventure") val title: String, // e.g., "Action & Adventure" + val mangaCount: Long, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsHistoryAcqEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsHistoryAcqEntry.kt new file mode 100644 index 00000000..a070ad6f --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsHistoryAcqEntry.kt @@ -0,0 +1,10 @@ +package suwayomi.tachidesk.opds.dto + +data class OpdsHistoryAcqEntry( + val chapter: OpdsChapterListAcqEntry, + val mangaTitle: String, + val mangaAuthor: String?, + val mangaId: Int, + val mangaSourceLang: String?, + val mangaThumbnailUrl: String?, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsLanguageNavEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsLanguageNavEntry.kt index d2273bea..08017d27 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsLanguageNavEntry.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsLanguageNavEntry.kt @@ -3,4 +3,5 @@ package suwayomi.tachidesk.opds.dto data class OpdsLanguageNavEntry( val id: String, // langCode (e.g., "en") val title: String, // Localized (e.g., "English") + val mangaCount: Long, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsLibraryFeedResult.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsLibraryFeedResult.kt new file mode 100644 index 00000000..4038d88d --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsLibraryFeedResult.kt @@ -0,0 +1,14 @@ +package suwayomi.tachidesk.opds.dto + +/** + * DTO to encapsulate the results of a library feed query. + * + * @property mangaEntries The list of manga entries for the current page. + * @property totalCount The total number of mangas that match the filter criteria. + * @property feedTitleComponent The specific name of the applied filter (e.g., the name of a category or source). + */ +data class OpdsLibraryFeedResult( + val mangaEntries: List, + val totalCount: Long, + val feedTitleComponent: String?, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaAcqEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaAcqEntry.kt index 0480dec9..01692c64 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaAcqEntry.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaAcqEntry.kt @@ -3,10 +3,14 @@ package suwayomi.tachidesk.opds.dto data class OpdsMangaAcqEntry( val id: Int, val title: String, - val author: String?, - val genres: List, // Raw genres, will be processed in builder - val description: String?, - val thumbnailUrl: String?, // Raw thumbnail URL from DB - val sourceLang: String?, + val thumbnailUrl: String?, + val lastFetchedAt: Long, val inLibrary: Boolean, + val author: String?, + val genres: List, + val description: String?, + val status: Int, + val sourceName: String, + val sourceLang: String, + val url: String?, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaFilter.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaFilter.kt new file mode 100644 index 00000000..fac7ada6 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaFilter.kt @@ -0,0 +1,72 @@ +package suwayomi.tachidesk.opds.dto + +import suwayomi.tachidesk.opds.util.OpdsStringUtil.encodeForOpdsURL + +/** + * Enum to represent the primary filter type of a feed, usually determined by the URL path. + */ +enum class PrimaryFilterType { + NONE, + SOURCE, + CATEGORY, + GENRE, + STATUS, + LANGUAGE, +} + +/** + * Data class to hold all possible filter parameters for library feeds. + */ +data class OpdsMangaFilter( + val sourceId: Long? = null, + val categoryId: Int? = null, + val statusId: Int? = null, + val langCode: String? = null, + val genre: String? = null, + val sort: String? = null, + val filter: String? = null, + val pageNumber: Int? = null, + val primaryFilter: PrimaryFilterType = PrimaryFilterType.NONE, +) { + /** + * Creates a query parameter string from the active cross-filters (source, category, etc.). + * Excludes sort and regular filter parameters. + */ + fun toCrossFilterQueryParameters(): String = + buildList { + sourceId?.let { add("source_id=$it") } + categoryId?.let { add("category_id=$it") } + statusId?.let { add("status_id=$it") } + langCode?.let { add("lang_code=${it.encodeForOpdsURL()}") } + genre?.let { add("genre=${it.encodeForOpdsURL()}") } + }.joinToString("&") + + /** + * Creates a new filter set by removing a filter. Used for "None" links. + */ + fun without(key: String): OpdsMangaFilter = + when (key) { + "source_id" -> this.copy(sourceId = null) + "category_id" -> this.copy(categoryId = null) + "status_id" -> this.copy(statusId = null) + "lang_code" -> this.copy(langCode = null) + "genre" -> this.copy(genre = null) + else -> this + } + + /** + * Creates a new filter set by adding or replacing a filter. + */ + fun with( + key: String, + value: String, + ): OpdsMangaFilter = + when (key) { + "source_id" -> this.copy(sourceId = value.toLongOrNull()) + "category_id" -> this.copy(categoryId = value.toIntOrNull()) + "status_id" -> this.copy(statusId = value.toIntOrNull()) + "lang_code" -> this.copy(langCode = value) + "genre" -> this.copy(genre = value) + else -> this + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsSourceNavEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsSourceNavEntry.kt index f9e49077..eb4809ba 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsSourceNavEntry.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsSourceNavEntry.kt @@ -4,4 +4,5 @@ data class OpdsSourceNavEntry( val id: Long, val name: String, // Not localized val iconUrl: String?, + val mangaCount: Long?, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsStatusNavEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsStatusNavEntry.kt index e12fe96c..bc765d21 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsStatusNavEntry.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsStatusNavEntry.kt @@ -3,4 +3,5 @@ package suwayomi.tachidesk.opds.dto data class OpdsStatusNavEntry( val id: Int, val title: String, // Localized + val mangaCount: Long, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/FeedBuilderInternal.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/FeedBuilderInternal.kt new file mode 100644 index 00000000..fc9d6550 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/FeedBuilderInternal.kt @@ -0,0 +1,129 @@ +package suwayomi.tachidesk.opds.impl + +import suwayomi.tachidesk.i18n.MR +import suwayomi.tachidesk.opds.constants.OpdsConstants +import suwayomi.tachidesk.opds.model.OpdsAuthorXml +import suwayomi.tachidesk.opds.model.OpdsEntryXml +import suwayomi.tachidesk.opds.model.OpdsFeedXml +import suwayomi.tachidesk.opds.model.OpdsLinkXml +import suwayomi.tachidesk.opds.util.OpdsDateUtil +import suwayomi.tachidesk.server.serverConfig +import java.util.Locale + +/** + * Clase de ayuda para construir un OpdsFeedXml. + */ +class FeedBuilderInternal( + val baseUrl: String, + val idPath: String, + val title: String, + val locale: Locale, + val feedType: String, + var pageNum: Int? = 1, + var explicitQueryParams: String? = null, + val currentSort: String? = null, + val currentFilter: String? = null, +) { + private val opdsItemsPerPageBounded: Int + get() = serverConfig.opdsItemsPerPage.value.coerceIn(10, 5000) + + private val feedAuthor = OpdsAuthorXml("Suwayomi", "https://suwayomi.org/") + private val feedGeneratedAt: String = OpdsDateUtil.formatCurrentInstantForOpds() + + var totalResults: Long = 0 + var icon: String? = null + val links = mutableListOf() + val entries = mutableListOf() + + private fun buildUrlWithParams( + baseHrefPath: String, + page: Int?, + ): String { + val sb = StringBuilder("$baseUrl/$baseHrefPath") + val queryParamsList = mutableListOf() + + explicitQueryParams?.takeIf { it.isNotBlank() }?.let { queryParamsList.add(it) } + page?.let { queryParamsList.add("pageNumber=$it") } + currentSort?.let { queryParamsList.add("sort=$it") } + currentFilter?.let { queryParamsList.add("filter=$it") } + queryParamsList.add("lang=${locale.toLanguageTag()}") + + if (queryParamsList.isNotEmpty()) { + sb.append("?").append(queryParamsList.joinToString("&")) + } + return sb.toString() + } + + fun build(): OpdsFeedXml { + val actualPageNum = pageNum ?: 1 + val selfLinkHref = buildUrlWithParams(idPath, if (pageNum != null) actualPageNum else null) + val feedLinks = mutableListOf() + feedLinks.addAll(this.links) + + feedLinks.add( + OpdsLinkXml(OpdsConstants.LINK_REL_SELF, selfLinkHref, feedType, MR.strings.opds_linktitle_self_feed.localized(locale)), + ) + feedLinks.add( + OpdsLinkXml( + OpdsConstants.LINK_REL_START, + "$baseUrl?lang=${locale.toLanguageTag()}", + OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, + MR.strings.opds_linktitle_catalog_root.localized(locale), + ), + ) + feedLinks.add( + OpdsLinkXml( + OpdsConstants.LINK_REL_SEARCH, + "$baseUrl/search?lang=${locale.toLanguageTag()}", + OpdsConstants.TYPE_OPENSEARCH_DESCRIPTION, + MR.strings.opds_linktitle_search_catalog.localized(locale), + ), + ) + + if (pageNum != null) { + if (actualPageNum > 1) { + feedLinks.add( + OpdsLinkXml( + OpdsConstants.LINK_REL_PREV, + buildUrlWithParams(idPath, actualPageNum - 1), + feedType, + MR.strings.opds_linktitle_previous_page.localized(locale), + ), + ) + } + if (totalResults > actualPageNum * opdsItemsPerPageBounded) { + feedLinks.add( + OpdsLinkXml( + OpdsConstants.LINK_REL_NEXT, + buildUrlWithParams(idPath, actualPageNum + 1), + feedType, + MR.strings.opds_linktitle_next_page.localized(locale), + ), + ) + } + } + + val urnParams = mutableListOf() + urnParams.add(locale.toLanguageTag()) + pageNum?.let { urnParams.add("page$it") } + explicitQueryParams?.let { urnParams.add(it.replace("&", ":").replace("=", "_")) } + currentSort?.let { urnParams.add("sort_$it") } + currentFilter?.let { urnParams.add("filter_$it") } + val urnSuffix = if (urnParams.isNotEmpty()) ":${urnParams.joinToString(":")}" else "" + + val showPaginationFields = pageNum != null && totalResults > 0 + + return OpdsFeedXml( + id = "urn:suwayomi:feed:${idPath.replace('/',':')}$urnSuffix", + title = title, + updated = feedGeneratedAt, + icon = icon, + author = feedAuthor, + links = feedLinks, + entries = entries, + totalResults = totalResults.takeIf { showPaginationFields }, + itemsPerPage = if (showPaginationFields) opdsItemsPerPageBounded else null, + startIndex = if (showPaginationFields) ((actualPageNum - 1) * opdsItemsPerPageBounded + 1) else null, + ) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsEntryBuilder.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsEntryBuilder.kt new file mode 100644 index 00000000..b867343c --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsEntryBuilder.kt @@ -0,0 +1,640 @@ +package suwayomi.tachidesk.opds.impl + +import dev.icerock.moko.resources.StringResource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import suwayomi.tachidesk.i18n.MR +import suwayomi.tachidesk.manga.impl.ChapterDownloadHelper +import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl +import suwayomi.tachidesk.manga.model.table.MangaStatus +import suwayomi.tachidesk.opds.constants.OpdsConstants +import suwayomi.tachidesk.opds.dto.OpdsChapterListAcqEntry +import suwayomi.tachidesk.opds.dto.OpdsChapterMetadataAcqEntry +import suwayomi.tachidesk.opds.dto.OpdsMangaAcqEntry +import suwayomi.tachidesk.opds.dto.OpdsMangaDetails +import suwayomi.tachidesk.opds.dto.OpdsMangaFilter +import suwayomi.tachidesk.opds.dto.PrimaryFilterType +import suwayomi.tachidesk.opds.model.OpdsAuthorXml +import suwayomi.tachidesk.opds.model.OpdsCategoryXml +import suwayomi.tachidesk.opds.model.OpdsContentXml +import suwayomi.tachidesk.opds.model.OpdsEntryXml +import suwayomi.tachidesk.opds.model.OpdsLinkXml +import suwayomi.tachidesk.opds.model.OpdsSummaryXml +import suwayomi.tachidesk.opds.repository.MangaRepository +import suwayomi.tachidesk.opds.repository.NavigationRepository +import suwayomi.tachidesk.opds.util.OpdsDateUtil +import suwayomi.tachidesk.opds.util.OpdsStringUtil.formatFileSizeForOpds +import suwayomi.tachidesk.server.serverConfig +import java.util.Locale + +/** + * A builder class responsible for creating OPDS Entry XML objects from data transfer objects. + */ +object OpdsEntryBuilder { + private fun currentFormattedTime() = OpdsDateUtil.formatCurrentInstantForOpds() + + /** + * Builds a concise summary for a manga entry, including status, source, and language. + * @param entry The manga data object. + * @param locale The locale for localization. + * @return A formatted summary string. + */ + private fun buildMangaSummary( + entry: OpdsMangaAcqEntry, + locale: Locale, + ): String { + val summaryParts = mutableListOf() + val statusKey = + when (MangaStatus.valueOf(entry.status)) { + MangaStatus.ONGOING -> MR.strings.manga_status_ongoing + MangaStatus.COMPLETED -> MR.strings.manga_status_completed + MangaStatus.LICENSED -> MR.strings.manga_status_licensed + MangaStatus.PUBLISHING_FINISHED -> MR.strings.manga_status_publishing_finished + MangaStatus.CANCELLED -> MR.strings.manga_status_cancelled + MangaStatus.ON_HIATUS -> MR.strings.manga_status_on_hiatus + else -> MR.strings.manga_status_unknown + } + summaryParts.add(MR.strings.opds_manga_summary_status.localized(locale, statusKey.localized(locale))) + summaryParts.add(MR.strings.opds_manga_summary_source.localized(locale, entry.sourceName)) + summaryParts.add(MR.strings.opds_manga_summary_language.localized(locale, entry.sourceLang)) + return summaryParts.joinToString(" | ") + } + + /** + * Adds a facet link to the feed builder. + * @param feedBuilder The feed builder to add the link to. + * @param href The URL for the facet link. + * @param title The title of the facet. + * @param group The group this facet belongs to. + * @param isActive Whether this facet is currently active. + * @param count The number of items in this facet. + */ + private fun addFacet( + feedBuilder: FeedBuilderInternal, + href: String, + title: String, + group: String, + isActive: Boolean, + count: Long?, + ) { + feedBuilder.links.add( + OpdsLinkXml( + OpdsConstants.LINK_REL_FACET, + href, + OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, + title, + facetGroup = group, + activeFacet = isActive, + thrCount = count?.toInt(), + ), + ) + } + + /** + * Converts a manga data object into a full OPDS acquisition entry. + * @param entry The manga data object. + * @param baseUrl The base URL for constructing links. + * @param locale The locale for localization. + * @return An [OpdsEntryXml] object representing the manga. + */ + fun mangaAcqEntryToEntry( + entry: OpdsMangaAcqEntry, + baseUrl: String, + locale: Locale, + ): OpdsEntryXml { + val displayThumbnailUrl = entry.thumbnailUrl?.let { proxyThumbnailUrl(entry.id) } + val categoryScheme = if (entry.inLibrary) "$baseUrl/library/genres" else "$baseUrl/genres" + + val links = mutableListOf() + links.add( + OpdsLinkXml( + OpdsConstants.LINK_REL_SUBSECTION, + "$baseUrl/series/${entry.id}/chapters?lang=${locale.toLanguageTag()}", + OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, + entry.title, + ), + ) + entry.url?.let { + links.add( + OpdsLinkXml( + OpdsConstants.LINK_REL_ALTERNATE, + it, + "text/html", + MR.strings.opds_linktitle_view_on_web.localized(locale), + ), + ) + } + displayThumbnailUrl?.let { + links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE, it, OpdsConstants.TYPE_IMAGE_JPEG)) + links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE_THUMBNAIL, it, OpdsConstants.TYPE_IMAGE_JPEG)) + } + + val summaryText = buildMangaSummary(entry, locale) + return OpdsEntryXml( + id = "urn:suwayomi:manga:${entry.id}", + title = entry.title, + updated = OpdsDateUtil.formatEpochMillisForOpds(entry.lastFetchedAt * 1000), + authors = entry.author?.let { listOf(OpdsAuthorXml(name = it)) }, + categories = + entry.genres.filter { it.isNotBlank() }.map { genre -> + OpdsCategoryXml( + term = genre.lowercase().replace(" ", "_"), + label = genre, + scheme = categoryScheme, + ) + }, + summary = OpdsSummaryXml(value = summaryText), + content = entry.description?.let { OpdsContentXml(type = "text", value = it) }, + link = links, + publisher = entry.sourceName, + language = entry.sourceLang, + ) + } + + /** + * Creates an OPDS entry for a chapter, including acquisition and streaming links. + * @param chapter The chapter data object. + * @param manga The parent manga's details. + * @param baseUrl The base URL for constructing links. + * @param addMangaTitle Whether to prepend the manga title to the entry title. + * @param locale The locale for localization. + * @return An [OpdsEntryXml] object for the chapter. + */ + suspend fun createChapterListEntry( + chapter: OpdsChapterListAcqEntry, + manga: OpdsMangaDetails, + baseUrl: String, + addMangaTitle: Boolean, + locale: Locale, + ): OpdsEntryXml { + val statusKey = + when { + chapter.read -> MR.strings.opds_chapter_status_read + chapter.lastPageRead > 0 -> MR.strings.opds_chapter_status_in_progress + else -> MR.strings.opds_chapter_status_unread + } + val titlePrefix = statusKey.localized(locale) + val entryTitle = titlePrefix + (if (addMangaTitle) " ${manga.title}:" else "") + " ${chapter.name}" + val details = + buildString { + append(MR.strings.opds_chapter_details_base.localized(locale, manga.title, chapter.name)) + chapter.scanlator?.takeIf { it.isNotBlank() }?.let { + append(MR.strings.opds_chapter_details_scanlator.localized(locale, it)) + } + if (chapter.pageCount > 0) { + append(MR.strings.opds_chapter_details_progress.localized(locale, chapter.lastPageRead, chapter.pageCount)) + } + } + + return OpdsEntryXml( + id = "urn:suwayomi:chapter:${chapter.id}", + title = entryTitle, + updated = OpdsDateUtil.formatEpochMillisForOpds(chapter.uploadDate), + authors = + listOfNotNull( + manga.author?.let { OpdsAuthorXml(name = it) }, + chapter.scanlator?.takeIf { it.isNotBlank() }?.let { OpdsAuthorXml(name = it) }, + ), + summary = OpdsSummaryXml(value = details), + link = + listOf( + OpdsLinkXml( + rel = OpdsConstants.LINK_REL_SUBSECTION, + href = "$baseUrl/series/${manga.id}/chapter/${chapter.sourceOrder}/metadata?lang=${locale.toLanguageTag()}", + type = OpdsConstants.TYPE_ATOM_XML_ENTRY_PROFILE_OPDS, + title = MR.strings.opds_linktitle_view_chapter_details.localized(locale), + ), + ), + ) + } + + /** + * Creates an OPDS entry for a chapter's metadata, used when page count is not initially available. + * @param chapter The chapter metadata object. + * @param manga The parent manga's details. + * @param baseUrl The base URL for constructing links. + * @param locale The locale for localization. + * @return An [OpdsEntryXml] object for the chapter's metadata. + */ + suspend fun createChapterMetadataEntry( + chapter: OpdsChapterMetadataAcqEntry, + manga: OpdsMangaDetails, + baseUrl: String, + locale: Locale, + ): OpdsEntryXml { + val statusKey = + when { + chapter.downloaded -> MR.strings.opds_chapter_status_downloaded + chapter.read -> MR.strings.opds_chapter_status_read + chapter.lastPageRead > 0 -> MR.strings.opds_chapter_status_in_progress + else -> MR.strings.opds_chapter_status_unread + } + val titlePrefix = statusKey.localized(locale) + val entryTitle = "$titlePrefix ${chapter.name}" + val details = + buildString { + append(MR.strings.opds_chapter_details_base.localized(locale, manga.title, chapter.name)) + chapter.scanlator?.takeIf { it.isNotBlank() }?.let { + append(MR.strings.opds_chapter_details_scanlator.localized(locale, it)) + } + val pageCountDisplay = chapter.pageCount.takeIf { it > 0 } ?: "?" + append(MR.strings.opds_chapter_details_progress.localized(locale, chapter.lastPageRead, pageCountDisplay)) + } + val links = mutableListOf() + var cbzFileSize: Long? = null + chapter.url?.let { + links.add( + OpdsLinkXml( + OpdsConstants.LINK_REL_ALTERNATE, + it, + "text/html", + MR.strings.opds_linktitle_view_on_web.localized(locale), + ), + ) + } + if (chapter.downloaded) { + val cbzStreamPair = + withContext( + Dispatchers.IO, + ) { runCatching { ChapterDownloadHelper.getArchiveStreamWithSize(manga.id, chapter.id) }.getOrNull() } + cbzFileSize = cbzStreamPair?.second + cbzStreamPair?.let { + links.add( + OpdsLinkXml( + OpdsConstants.LINK_REL_ACQUISITION_OPEN_ACCESS, + "/api/v1/chapter/${chapter.id}/download?markAsRead=${serverConfig.opdsMarkAsReadOnDownload.value}", + OpdsConstants.TYPE_CBZ, + MR.strings.opds_linktitle_download_cbz.localized(locale), + ), + ) + } + } + if (chapter.pageCount > 0) { + links.add( + OpdsLinkXml( + rel = OpdsConstants.LINK_REL_PSE_STREAM, + href = + "/api/v1/manga/${manga.id}/chapter/${chapter.sourceOrder}/page/{pageNumber}" + + "?updateProgress=${serverConfig.opdsEnablePageReadProgress.value}", + type = OpdsConstants.TYPE_IMAGE_JPEG, + title = MR.strings.opds_linktitle_stream_pages.localized(locale), + pseCount = chapter.pageCount, + pseLastRead = chapter.lastPageRead.takeIf { it > 0 }, + pseLastReadDate = chapter.lastReadAt.takeIf { it > 0 }?.let { OpdsDateUtil.formatEpochMillisForOpds(it * 1000) }, + ), + ) + 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), + ), + ) + } + return OpdsEntryXml( + id = "urn:suwayomi:chapter:${chapter.id}:metadata", + title = entryTitle, + updated = OpdsDateUtil.formatEpochMillisForOpds(chapter.uploadDate), + authors = + listOfNotNull( + manga.author?.let { OpdsAuthorXml(name = it) }, + chapter.scanlator?.takeIf { it.isNotBlank() }?.let { OpdsAuthorXml(name = it) }, + ), + summary = OpdsSummaryXml(value = details), + link = links, + extent = cbzFileSize?.let { formatFileSizeForOpds(it) }, + format = if (cbzFileSize != null) "CBZ" else null, + ) + } + + /** + * Adds sorting facet links for an 'Explore Source' feed. + */ + fun addSourceSortFacets( + feedBuilder: FeedBuilderInternal, + baseUrl: String, + currentSort: String, + locale: Locale, + ) { + val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale) + val addFacet = { href: String, titleKey: StringResource, isActive: Boolean -> + feedBuilder.links.add( + OpdsLinkXml( + OpdsConstants.LINK_REL_FACET, + href, + OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, + titleKey.localized(locale), + facetGroup = sortGroup, + activeFacet = isActive, + ), + ) + } + addFacet("$baseUrl?sort=popular&lang=${locale.toLanguageTag()}", MR.strings.opds_facet_sort_popular, currentSort == "popular") + addFacet("$baseUrl?sort=latest&lang=${locale.toLanguageTag()}", MR.strings.opds_facet_sort_latest, currentSort == "latest") + } + + /** + * Adds sorting and filtering facet links for a chapter feed. + */ + fun addChapterSortAndFilterFacets( + feedBuilder: FeedBuilderInternal, + baseUrl: String, + currentSort: String, + currentFilter: String, + locale: Locale, + filterCounts: Map? = null, + ) { + val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale) + val filterGroup = MR.strings.opds_facetgroup_filter_read_status.localized(locale) + + val addSortFacet = { href: String, titleKey: StringResource, group: String, isActive: Boolean -> + feedBuilder.links.add( + OpdsLinkXml( + OpdsConstants.LINK_REL_FACET, + href, + OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, + titleKey.localized(locale), + facetGroup = group, + activeFacet = isActive, + ), + ) + } + + addSortFacet( + "$baseUrl?sort=number_asc&filter=$currentFilter&lang=${locale.toLanguageTag()}", + MR.strings.opds_facet_sort_oldest_first, + sortGroup, + currentSort == "number_asc", + ) + addSortFacet( + "$baseUrl?sort=number_desc&filter=$currentFilter&lang=${locale.toLanguageTag()}", + MR.strings.opds_facet_sort_newest_first, + sortGroup, + currentSort == "number_desc", + ) + addSortFacet( + "$baseUrl?sort=date_asc&filter=$currentFilter&lang=${locale.toLanguageTag()}", + MR.strings.opds_facet_sort_date_asc, + sortGroup, + currentSort == "date_asc", + ) + addSortFacet( + "$baseUrl?sort=date_desc&filter=$currentFilter&lang=${locale.toLanguageTag()}", + MR.strings.opds_facet_sort_date_desc, + sortGroup, + currentSort == "date_desc", + ) + + addFacet( + feedBuilder, + "$baseUrl?filter=all&sort=$currentSort&lang=${locale.toLanguageTag()}", + MR.strings.opds_facet_filter_all_chapters.localized(locale), + filterGroup, + currentFilter == "all", + filterCounts?.get("all"), + ) + addFacet( + feedBuilder, + "$baseUrl?filter=unread&sort=$currentSort&lang=${locale.toLanguageTag()}", + MR.strings.opds_facet_filter_unread_only.localized(locale), + filterGroup, + currentFilter == "unread", + filterCounts?.get("unread"), + ) + addFacet( + feedBuilder, + "$baseUrl?filter=read&sort=$currentSort&lang=${locale.toLanguageTag()}", + MR.strings.opds_facet_filter_read_only.localized(locale), + filterGroup, + currentFilter == "read", + filterCounts?.get("read"), + ) + } + + /** + * Adds a comprehensive set of facet links for library feeds, covering sorting, content filtering, + * and cross-filtering by source, category, status, language, and genre. + */ + fun addLibraryFacets( + feedBuilder: FeedBuilderInternal, + baseUrl: String, + activeFilters: OpdsMangaFilter, + locale: Locale, + ) { + val currentSort = activeFilters.sort ?: "alpha_asc" + val currentFilter = activeFilters.filter ?: "all" + + val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale) + val filterGroup = MR.strings.opds_facetgroup_filter_content.localized(locale) + val filterCounts = MangaRepository.getLibraryFilterCounts() + + val buildUrl = { newFilters: OpdsMangaFilter, newSort: String, newFilter: String -> + val crossFilterParams = newFilters.toCrossFilterQueryParameters() + val sortParam = "sort=$newSort" + val filterParam = "filter=$newFilter" + val langParam = "lang=${locale.toLanguageTag()}" + val allParams = listOfNotNull(crossFilterParams, sortParam, filterParam, langParam).filter { it.isNotEmpty() } + "$baseUrl/library/series?${allParams.joinToString("&")}" + } + + // --- Sort Facets --- + addFacet( + feedBuilder, + buildUrl(activeFilters, "alpha_asc", currentFilter), + MR.strings.opds_facet_sort_alpha_asc.localized(locale), + sortGroup, + currentSort == "alpha_asc", + null, + ) + addFacet( + feedBuilder, + buildUrl(activeFilters, "alpha_desc", currentFilter), + MR.strings.opds_facet_sort_alpha_desc.localized(locale), + sortGroup, + currentSort == "alpha_desc", + null, + ) + addFacet( + feedBuilder, + buildUrl(activeFilters, "last_read_desc", currentFilter), + MR.strings.opds_facet_sort_last_read_desc.localized(locale), + sortGroup, + currentSort == "last_read_desc", + null, + ) + addFacet( + feedBuilder, + buildUrl(activeFilters, "latest_chapter_desc", currentFilter), + MR.strings.opds_facet_sort_latest_chapter_desc.localized(locale), + sortGroup, + currentSort == "latest_chapter_desc", + null, + ) + addFacet( + feedBuilder, + buildUrl(activeFilters, "date_added_desc", currentFilter), + MR.strings.opds_facet_sort_date_added_desc.localized(locale), + sortGroup, + currentSort == "date_added_desc", + null, + ) + addFacet( + feedBuilder, + buildUrl(activeFilters, "unread_desc", currentFilter), + MR.strings.opds_facet_sort_unread_desc.localized(locale), + sortGroup, + currentSort == "unread_desc", + null, + ) + + // --- Filter Facets --- + addFacet( + feedBuilder, + buildUrl(activeFilters, currentSort, "all"), + MR.strings.opds_facet_filter_all.localized(locale), + filterGroup, + currentFilter == "all", + null, + ) + addFacet( + feedBuilder, + buildUrl(activeFilters, currentSort, "unread"), + MR.strings.opds_facet_filter_unread_only.localized(locale), + filterGroup, + currentFilter == "unread", + filterCounts["unread"], + ) + addFacet( + feedBuilder, + buildUrl(activeFilters, currentSort, "downloaded"), + MR.strings.opds_facet_filter_downloaded.localized(locale), + filterGroup, + currentFilter == "downloaded", + filterCounts["downloaded"], + ) + addFacet( + feedBuilder, + buildUrl(activeFilters, currentSort, "ongoing"), + MR.strings.opds_facet_filter_ongoing.localized(locale), + filterGroup, + currentFilter == "ongoing", + filterCounts["ongoing"], + ) + addFacet( + feedBuilder, + buildUrl(activeFilters, currentSort, "completed"), + MR.strings.opds_facet_filter_completed.localized(locale), + filterGroup, + currentFilter == "completed", + filterCounts["completed"], + ) + + // --- Cross-Filter Facets --- + if (activeFilters.primaryFilter != PrimaryFilterType.SOURCE) { + val sources = NavigationRepository.getLibrarySources(1).first + addFacet( + feedBuilder, + buildUrl(activeFilters.without("source_id"), currentSort, currentFilter), + MR.strings.opds_facet_all_sources.localized(locale), + MR.strings.opds_facetgroup_filter_source.localized(locale), + activeFilters.sourceId == null, + null, + ) + sources.forEach { + addFacet( + feedBuilder, + buildUrl(activeFilters.with("source_id", it.id.toString()), currentSort, currentFilter), + it.name, + MR.strings.opds_facetgroup_filter_source.localized(locale), + activeFilters.sourceId == it.id, + it.mangaCount, + ) + } + } + if (activeFilters.primaryFilter != PrimaryFilterType.CATEGORY) { + val categories = NavigationRepository.getCategories(1).first + addFacet( + feedBuilder, + buildUrl(activeFilters.without("category_id"), currentSort, currentFilter), + MR.strings.opds_facet_all_categories.localized(locale), + MR.strings.opds_facetgroup_filter_category.localized(locale), + activeFilters.categoryId == null, + null, + ) + categories.forEach { + addFacet( + feedBuilder, + buildUrl(activeFilters.with("category_id", it.id.toString()), currentSort, currentFilter), + it.name, + MR.strings.opds_facetgroup_filter_category.localized(locale), + activeFilters.categoryId == it.id, + it.mangaCount, + ) + } + } + if (activeFilters.primaryFilter != PrimaryFilterType.STATUS) { + val statuses = NavigationRepository.getStatuses(locale) + addFacet( + feedBuilder, + buildUrl(activeFilters.without("status_id"), currentSort, currentFilter), + MR.strings.opds_facet_all_statuses.localized(locale), + MR.strings.opds_facetgroup_filter_status.localized(locale), + activeFilters.statusId == null, + null, + ) + statuses.forEach { + addFacet( + feedBuilder, + buildUrl(activeFilters.with("status_id", it.id.toString()), currentSort, currentFilter), + it.title, + MR.strings.opds_facetgroup_filter_status.localized(locale), + activeFilters.statusId == it.id, + it.mangaCount, + ) + } + } + if (activeFilters.primaryFilter != PrimaryFilterType.LANGUAGE) { + val languages = NavigationRepository.getContentLanguages(locale) + addFacet( + feedBuilder, + buildUrl(activeFilters.without("lang_code"), currentSort, currentFilter), + MR.strings.opds_facet_all_languages.localized(locale), + MR.strings.opds_facetgroup_filter_language.localized(locale), + activeFilters.langCode == null, + null, + ) + languages.forEach { + addFacet( + feedBuilder, + buildUrl(activeFilters.with("lang_code", it.id), currentSort, currentFilter), + it.title, + MR.strings.opds_facetgroup_filter_language.localized(locale), + activeFilters.langCode == it.id, + it.mangaCount, + ) + } + } + if (activeFilters.primaryFilter != PrimaryFilterType.GENRE) { + val genres = NavigationRepository.getGenres(1, locale).first + addFacet( + feedBuilder, + buildUrl(activeFilters.without("genre"), currentSort, currentFilter), + MR.strings.opds_facet_all_genres.localized(locale), + MR.strings.opds_facetgroup_filter_genre.localized(locale), + activeFilters.genre == null, + null, + ) + genres.forEach { + addFacet( + feedBuilder, + buildUrl(activeFilters.with("genre", it.id), currentSort, currentFilter), + it.title, + MR.strings.opds_facetgroup_filter_genre.localized(locale), + activeFilters.genre == it.id, + it.mangaCount, + ) + } + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt index ae5197d2..9d09ca1a 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt @@ -1,52 +1,38 @@ package suwayomi.tachidesk.opds.impl -import dev.icerock.moko.resources.StringResource -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import io.github.oshai.kotlinlogging.KotlinLogging import org.jetbrains.exposed.sql.SortOrder import suwayomi.tachidesk.i18n.MR -import suwayomi.tachidesk.manga.impl.ChapterDownloadHelper import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl import suwayomi.tachidesk.manga.model.table.ChapterTable import suwayomi.tachidesk.opds.constants.OpdsConstants -import suwayomi.tachidesk.opds.dto.OpdsCategoryNavEntry -import suwayomi.tachidesk.opds.dto.OpdsChapterListAcqEntry -import suwayomi.tachidesk.opds.dto.OpdsChapterMetadataAcqEntry -import suwayomi.tachidesk.opds.dto.OpdsGenreNavEntry -import suwayomi.tachidesk.opds.dto.OpdsLanguageNavEntry -import suwayomi.tachidesk.opds.dto.OpdsMangaAcqEntry import suwayomi.tachidesk.opds.dto.OpdsMangaDetails -import suwayomi.tachidesk.opds.dto.OpdsRootNavEntry +import suwayomi.tachidesk.opds.dto.OpdsMangaFilter import suwayomi.tachidesk.opds.dto.OpdsSearchCriteria -import suwayomi.tachidesk.opds.dto.OpdsSourceNavEntry -import suwayomi.tachidesk.opds.dto.OpdsStatusNavEntry -import suwayomi.tachidesk.opds.model.OpdsAuthorXml -import suwayomi.tachidesk.opds.model.OpdsCategoryXml +import suwayomi.tachidesk.opds.dto.PrimaryFilterType import suwayomi.tachidesk.opds.model.OpdsContentXml import suwayomi.tachidesk.opds.model.OpdsEntryXml -import suwayomi.tachidesk.opds.model.OpdsFeedXml import suwayomi.tachidesk.opds.model.OpdsLinkXml -import suwayomi.tachidesk.opds.model.OpdsSummaryXml import suwayomi.tachidesk.opds.repository.ChapterRepository import suwayomi.tachidesk.opds.repository.MangaRepository import suwayomi.tachidesk.opds.repository.NavigationRepository import suwayomi.tachidesk.opds.util.OpdsDateUtil -import suwayomi.tachidesk.opds.util.OpdsStringUtil.encodeForOpdsURL -import suwayomi.tachidesk.opds.util.OpdsStringUtil.formatFileSizeForOpds import suwayomi.tachidesk.opds.util.OpdsXmlUtil import suwayomi.tachidesk.server.serverConfig import java.util.Locale +/** + * Builds OPDS feeds by fetching data from repositories and converting it into XML format. + */ object OpdsFeedBuilder { - private val opdsItemsPerPageBounded: Int - get() = serverConfig.opdsItemsPerPage.value.coerceIn(10, 5000) - - private val feedAuthor = OpdsAuthorXml("Suwayomi", "https://suwayomi.org/") - private fun currentFormattedTime() = OpdsDateUtil.formatCurrentInstantForOpds() - // --- Main Feed Generators --- - + /** + * Generates the root navigation feed for the OPDS catalog. + * @param baseUrl The base URL for constructing links. + * @param locale The locale for localization. + * @return An XML string representing the root feed. + */ fun getRootFeed( baseUrl: String, locale: Locale, @@ -54,136 +40,325 @@ object OpdsFeedBuilder { val navItems = NavigationRepository.getRootNavigationItems(locale) val builder = FeedBuilderInternal( - baseUrl = baseUrl, - idPath = "root", - title = MR.strings.opds_feeds_root.localized(locale), - locale = locale, - feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, - pageNum = null, // Root is never paginated - ).apply { - totalResults = navItems.size.toLong() - entries.addAll( - navItems.map { item: OpdsRootNavEntry -> - OpdsEntryXml( - id = "urn:suwayomi:navigation:root:${item.id}", - title = item.title, - updated = currentFormattedTime(), - link = - listOf( - OpdsLinkXml( - rel = OpdsConstants.LINK_REL_SUBSECTION, - href = "$baseUrl/${item.id}?lang=${locale.toLanguageTag()}", - type = item.linkType, - title = item.title, - ), - ), - content = OpdsContentXml(type = "text", value = item.description), - ) - }, + baseUrl, + "", // Root path is empty + MR.strings.opds_feeds_root.localized(locale), + locale, + OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, + null, + ) + builder.totalResults = navItems.size.toLong() + builder.entries.addAll( + navItems.map { item -> + OpdsEntryXml( + id = "urn:suwayomi:navigation:root:${item.id}", + title = item.title, + updated = currentFormattedTime(), + link = + listOf( + OpdsLinkXml( + rel = OpdsConstants.LINK_REL_SUBSECTION, + href = "$baseUrl/${item.id}?lang=${locale.toLanguageTag()}", + type = item.linkType, + title = item.title, + ), + ), + content = OpdsContentXml(type = "text", value = item.description), ) - } + }, + ) return OpdsXmlUtil.serializeFeedToString(builder.build()) } - fun getMangasFeed( - criteria: OpdsSearchCriteria?, - baseUrl: String, - pageNum: Int, - locale: Locale, - ): String = - if (criteria != null) { - getMangaSearchResultsFeed(criteria, baseUrl, locale) - } else { - getAllMangasFeed(baseUrl, pageNum, locale) - } - - private fun getAllMangasFeed( + /** + * Generates the history feed showing recently read chapters. + * @param baseUrl The base URL for constructing links. + * @param pageNum The page number for pagination. + * @param locale The locale for localization. + * @return An XML string representing the history feed. + */ + suspend fun getHistoryFeed( baseUrl: String, pageNum: Int, locale: Locale, ): String { - val (mangaEntries, total) = MangaRepository.getAllManga(pageNum) + val (historyItems, total) = ChapterRepository.getHistory(pageNum) val builder = FeedBuilderInternal( - baseUrl = baseUrl, - idPath = "mangas", - title = MR.strings.opds_feeds_all_manga_title.localized(locale), - locale = locale, - pageNum = pageNum, - feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, - ).apply { - totalResults = total - entries.addAll(mangaEntries.map { mangaAcqEntryToEntry(it, baseUrl, locale) }) - } + baseUrl, + "history", + MR.strings.opds_feeds_history_title.localized(locale), + locale, + OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, + pageNum, + ) + builder.totalResults = total + builder.entries.addAll( + historyItems.map { item -> + val mangaDetails = OpdsMangaDetails(item.mangaId, item.mangaTitle, item.mangaThumbnailUrl, item.mangaAuthor) + OpdsEntryBuilder.createChapterListEntry(item.chapter, mangaDetails, baseUrl, true, locale) + }, + ) return OpdsXmlUtil.serializeFeedToString(builder.build()) } - private fun getMangaSearchResultsFeed( + /** + * Generates a feed for search results based on the provided criteria. + * @param criteria The search criteria. + * @param baseUrl The base URL for constructing links. + * @param pageNum The page number for pagination. + * @param locale The locale for localization. + * @return An XML string representing the search results feed. + */ + fun getSearchFeed( criteria: OpdsSearchCriteria, baseUrl: String, + pageNum: Int, locale: Locale, ): String { val (mangaEntries, total) = MangaRepository.findMangaByCriteria(criteria) - val queryParams = mutableListOf() - criteria.query?.let { queryParams.add("query=${it.encodeForOpdsURL()}") } - criteria.author?.let { queryParams.add("author=${it.encodeForOpdsURL()}") } - criteria.title?.let { queryParams.add("title=${it.encodeForOpdsURL()}") } - val builder = FeedBuilderInternal( - baseUrl = baseUrl, - idPath = "mangas", - title = MR.strings.opds_feeds_search_results.localized(locale), - locale = locale, - feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, - explicitQueryParams = queryParams.joinToString("&").ifEmpty { null }, - pageNum = 1, // Search results always start at page 1 for this link - ).apply { - totalResults = total - entries.addAll(mangaEntries.map { mangaAcqEntryToEntry(it, baseUrl, locale) }) - } + baseUrl, + "library/series", + MR.strings.opds_feeds_search_results_title.localized(locale), + locale, + OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, + pageNum, + ) + builder.totalResults = total + builder.entries.addAll(mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(it, baseUrl, locale) }) return OpdsXmlUtil.serializeFeedToString(builder.build()) } - fun getSourcesFeed( + /** + * 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 pageNum The page number for pagination. + * @param sort The sorting parameter. + * @param filter The filtering parameter. + * @param locale The locale for localization. + * @return An XML string representing the library feed. + */ + fun getLibraryFeed( + criteria: OpdsMangaFilter, + baseUrl: String, + pageNum: Int, + sort: String?, + filter: String?, + locale: Locale, + ): String { + val result = MangaRepository.getLibraryManga(pageNum, sort, filter, criteria) + + val feedTitle = + when (criteria.primaryFilter) { + PrimaryFilterType.SOURCE -> + MR.strings.opds_feeds_library_source_specific_title.localized( + locale, + result.feedTitleComponent ?: criteria.sourceId.toString(), + ) + PrimaryFilterType.CATEGORY -> + MR.strings.opds_feeds_category_specific_title.localized( + locale, + result.feedTitleComponent ?: criteria.categoryId.toString(), + ) + PrimaryFilterType.GENRE -> + MR.strings.opds_feeds_genre_specific_title.localized( + locale, + result.feedTitleComponent ?: "Unknown", + ) + PrimaryFilterType.STATUS -> { + val statusName = NavigationRepository.getStatuses(locale).find { it.id == criteria.statusId }?.title + MR.strings.opds_feeds_status_specific_title.localized(locale, statusName ?: criteria.statusId.toString()) + } + PrimaryFilterType.LANGUAGE -> { + val langName = Locale.forLanguageTag(criteria.langCode ?: "").getDisplayName(locale) + MR.strings.opds_feeds_language_specific_title.localized(locale, langName) + } + else -> MR.strings.opds_feeds_all_series_in_library_title.localized(locale) + } + + val feedUrl = + when (criteria.primaryFilter) { + PrimaryFilterType.SOURCE -> "library/source/${criteria.sourceId}" + PrimaryFilterType.CATEGORY -> "category/${criteria.categoryId}" + PrimaryFilterType.GENRE -> "genre/${criteria.genre}" + PrimaryFilterType.STATUS -> "status/${criteria.statusId}" + PrimaryFilterType.LANGUAGE -> "language/${criteria.langCode}" + else -> "library/series" + } + + val builder = + FeedBuilderInternal( + baseUrl, + feedUrl, + feedTitle, + locale, + OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, + pageNum, + currentSort = criteria.sort, + currentFilter = criteria.filter, + explicitQueryParams = criteria.toCrossFilterQueryParameters(), + ) + builder.totalResults = result.totalCount + + // Add all library facets (sort, filter, and cross-filtering) + OpdsEntryBuilder.addLibraryFacets(builder, baseUrl, criteria, locale) + + builder.entries.addAll(result.mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(it, baseUrl, locale) }) + + return OpdsXmlUtil.serializeFeedToString(builder.build()) + } + + /** + * Generates a navigation feed listing all available sources for exploration. + * @param baseUrl The base URL for constructing links. + * @param pageNum The page number for pagination. + * @param locale The locale for localization. + * @return An XML string representing the explore sources feed. + */ + fun getExploreSourcesFeed( baseUrl: String, pageNum: Int, locale: Locale, ): String { - val (sourceNavEntries, total) = NavigationRepository.getSources(pageNum) + val (sourceNavEntries, total) = NavigationRepository.getExploreSources(pageNum) val builder = FeedBuilderInternal( - baseUrl = baseUrl, - idPath = "sources", - title = MR.strings.opds_feeds_sources_title.localized(locale), - locale = locale, - pageNum = pageNum, - feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, - ).apply { - totalResults = total - entries.addAll( - sourceNavEntries.map { entry: OpdsSourceNavEntry -> - OpdsEntryXml( - id = "urn:suwayomi:navigation:sources:${entry.id}", - title = entry.name, - updated = currentFormattedTime(), - link = - listOf( - OpdsLinkXml( - OpdsConstants.LINK_REL_SUBSECTION, - "$baseUrl/source/${entry.id}?lang=${locale.toLanguageTag()}", - OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, - entry.name, - ), - ), - // Consider adding icon as artwork link if needed - ) - }, + baseUrl, + "sources", + MR.strings.opds_feeds_sources_title.localized(locale), + locale, + OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, + pageNum, + ) + builder.totalResults = total + builder.entries.addAll( + sourceNavEntries.map { entry -> + OpdsEntryXml( + id = "urn:suwayomi:navigation:sources:${entry.id}", + title = entry.name, + updated = currentFormattedTime(), + link = + listOf( + OpdsLinkXml( + OpdsConstants.LINK_REL_SUBSECTION, + "$baseUrl/source/${entry.id}?sort=popular&lang=${locale.toLanguageTag()}", + OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, + ), + ), ) - } + }, + ) return OpdsXmlUtil.serializeFeedToString(builder.build()) } + /** + * Generates a navigation feed listing sources for series present in the library. + * @param baseUrl The base URL for constructing links. + * @param pageNum The page number for pagination. + * @param locale The locale for localization. + * @return An XML string representing the library sources feed. + */ + fun getLibrarySourcesFeed( + baseUrl: String, + pageNum: Int, + locale: Locale, + ): String { + val (sourceNavEntries, total) = NavigationRepository.getLibrarySources(pageNum) + val builder = + FeedBuilderInternal( + baseUrl, + "library/sources", + MR.strings.opds_feeds_library_sources_title.localized(locale), + locale, + OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, + pageNum, + ) + builder.totalResults = total + builder.entries.addAll( + sourceNavEntries.map { entry -> + OpdsEntryXml( + id = "urn:suwayomi:navigation:library:sources:${entry.id}", + title = entry.name, + updated = currentFormattedTime(), + link = + listOf( + OpdsLinkXml( + OpdsConstants.LINK_REL_SUBSECTION, + "$baseUrl/library/source/${entry.id}?lang=${locale.toLanguageTag()}", + OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, + entry.name, + thrCount = entry.mangaCount?.toInt(), + ), + ), + ) + }, + ) + return OpdsXmlUtil.serializeFeedToString(builder.build()) + } + + /** + * 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 pageNum The page number for pagination. + * @param sort The sorting parameter ('popular' or 'latest'). + * @param locale The locale for localization. + * @return An XML string representing the source-specific feed. + */ + suspend fun getExploreSourceFeed( + sourceId: Long, + baseUrl: String, + pageNum: Int, + sort: String, + locale: Locale, + ): String { + val (mangaEntries, hasNextPage) = MangaRepository.getMangaBySource(sourceId, pageNum, sort) + val sourceNavEntry = NavigationRepository.getExploreSources(1).first.find { it.id == sourceId } + val sourceNameOrId = sourceNavEntry?.name ?: sourceId.toString() + val titleRes = + if (sort == "latest") { + MR.strings.opds_feeds_source_specific_latest_title + } else { + MR.strings.opds_feeds_source_specific_popular_title + } + val feedTitle = titleRes.localized(locale, sourceNameOrId) + val feedUrl = "source/$sourceId" + val builder = + FeedBuilderInternal( + baseUrl, + feedUrl, + feedTitle, + locale, + OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, + pageNum, + currentSort = sort, + ) + builder.totalResults = + if (hasNextPage) { + (pageNum * serverConfig.opdsItemsPerPage.value + 1).toLong() + } else { + ( + (pageNum - 1) * + serverConfig.opdsItemsPerPage.value + + mangaEntries.size + ).toLong() + } + builder.icon = sourceNavEntry?.iconUrl + OpdsEntryBuilder.addSourceSortFacets(builder, "$baseUrl/$feedUrl", sort, locale) + builder.entries.addAll(mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(it, baseUrl, locale) }) + return OpdsXmlUtil.serializeFeedToString(builder.build()) + } + + /** + * Generates a navigation feed for library categories. + * @param baseUrl The base URL for constructing links. + * @param pageNum The page number for pagination. + * @param locale The locale for localization. + * @return An XML string representing the categories navigation feed. + */ fun getCategoriesFeed( baseUrl: String, pageNum: Int, @@ -192,36 +367,43 @@ object OpdsFeedBuilder { val (categoryNavEntries, total) = NavigationRepository.getCategories(pageNum) val builder = FeedBuilderInternal( - baseUrl = baseUrl, - idPath = "categories", - title = MR.strings.opds_feeds_categories_title.localized(locale), - locale = locale, - pageNum = pageNum, - feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, - ).apply { - totalResults = total - entries.addAll( - categoryNavEntries.map { entry: OpdsCategoryNavEntry -> - OpdsEntryXml( - id = "urn:suwayomi:navigation:categories:${entry.id}", - title = entry.name, - updated = currentFormattedTime(), - link = - listOf( - OpdsLinkXml( - OpdsConstants.LINK_REL_SUBSECTION, - "$baseUrl/category/${entry.id}?lang=${locale.toLanguageTag()}", - OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, - entry.name, - ), - ), - ) - }, + baseUrl, + "library/categories", + MR.strings.opds_feeds_categories_title.localized(locale), + locale, + OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, + pageNum, + ) + builder.totalResults = total + builder.entries.addAll( + categoryNavEntries.map { entry -> + OpdsEntryXml( + id = "urn:suwayomi:navigation:categories:${entry.id}", + title = entry.name, + updated = currentFormattedTime(), + link = + listOf( + OpdsLinkXml( + OpdsConstants.LINK_REL_SUBSECTION, + "$baseUrl/category/${entry.id}?lang=${locale.toLanguageTag()}", + OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, + entry.name, + thrCount = entry.mangaCount.toInt(), + ), + ), ) - } + }, + ) return OpdsXmlUtil.serializeFeedToString(builder.build()) } + /** + * Generates a navigation feed for library genres. + * @param baseUrl The base URL for constructing links. + * @param pageNum The page number for pagination. + * @param locale The locale for localization. + * @return An XML string representing the genres navigation feed. + */ fun getGenresFeed( baseUrl: String, pageNum: Int, @@ -230,37 +412,43 @@ object OpdsFeedBuilder { val (genreNavEntries, total) = NavigationRepository.getGenres(pageNum, locale) val builder = FeedBuilderInternal( - baseUrl = baseUrl, - idPath = "genres", - title = MR.strings.opds_feeds_genres_title.localized(locale), - locale = locale, - pageNum = pageNum, - feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, - ).apply { - totalResults = total - entries.addAll( - genreNavEntries.map { entry: OpdsGenreNavEntry -> - OpdsEntryXml( - id = "urn:suwayomi:navigation:genres:${entry.id}", // Already encoded - title = entry.title, - updated = currentFormattedTime(), - link = - listOf( - OpdsLinkXml( - OpdsConstants.LINK_REL_SUBSECTION, - "$baseUrl/genre/${entry.id}?lang=${locale.toLanguageTag()}", - OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, - entry.title, - ), - ), - ) - }, + baseUrl, + "library/genres", + MR.strings.opds_feeds_genres_title.localized(locale), + locale, + OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, + pageNum, + ) + builder.totalResults = total + builder.entries.addAll( + genreNavEntries.map { entry -> + OpdsEntryXml( + id = "urn:suwayomi:navigation:genres:${entry.id}", + title = entry.title, + updated = currentFormattedTime(), + link = + listOf( + OpdsLinkXml( + OpdsConstants.LINK_REL_SUBSECTION, + "$baseUrl/genre/${entry.id}?lang=${locale.toLanguageTag()}", + OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, + entry.title, + thrCount = entry.mangaCount.toInt(), + ), + ), ) - } + }, + ) return OpdsXmlUtil.serializeFeedToString(builder.build()) } - // `pageNum` is ignored, always fetches all, and sets pageNum = null in builder. + /** + * Generates a navigation feed for manga publication statuses. + * @param baseUrl The base URL for constructing links. + * @param pageNum The page number (currently unused). + * @param locale The locale for localization. + * @return An XML string representing the status navigation feed. + */ fun getStatusFeed( baseUrl: String, @Suppress("UNUSED_PARAMETER") pageNum: Int, @@ -269,36 +457,42 @@ object OpdsFeedBuilder { val statuses = NavigationRepository.getStatuses(locale) val builder = FeedBuilderInternal( - baseUrl = baseUrl, - idPath = "status", - title = MR.strings.opds_feeds_status_title.localized(locale), - locale = locale, - pageNum = null, // Status feed is not paginated - feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, - ).apply { - totalResults = statuses.size.toLong() - entries.addAll( - statuses.map { entry: OpdsStatusNavEntry -> - OpdsEntryXml( - id = "urn:suwayomi:navigation:status:${entry.id}", - title = entry.title, - updated = currentFormattedTime(), - link = - listOf( - OpdsLinkXml( - OpdsConstants.LINK_REL_SUBSECTION, - "$baseUrl/status/${entry.id}?lang=${locale.toLanguageTag()}", - OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, - entry.title, - ), - ), - ) - }, + baseUrl, + "library/statuses", + MR.strings.opds_feeds_status_title.localized(locale), + locale, + OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, + null, + ) + builder.totalResults = statuses.size.toLong() + builder.entries.addAll( + statuses.map { entry -> + OpdsEntryXml( + id = "urn:suwayomi:navigation:status:${entry.id}", + title = entry.title, + updated = currentFormattedTime(), + link = + listOf( + OpdsLinkXml( + OpdsConstants.LINK_REL_SUBSECTION, + "$baseUrl/status/${entry.id}?lang=${locale.toLanguageTag()}", + OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, + entry.title, + thrCount = entry.mangaCount.toInt(), + ), + ), ) - } + }, + ) return OpdsXmlUtil.serializeFeedToString(builder.build()) } + /** + * Generates a navigation feed for content languages available in the library. + * @param baseUrl The base URL for constructing links. + * @param uiLocale The locale for the user interface. + * @return An XML string representing the languages navigation feed. + */ fun getLanguagesFeed( baseUrl: String, uiLocale: Locale, @@ -306,39 +500,79 @@ object OpdsFeedBuilder { val languages = NavigationRepository.getContentLanguages(uiLocale) val builder = FeedBuilderInternal( - baseUrl = baseUrl, - idPath = "languages", - title = MR.strings.opds_feeds_languages_title.localized(uiLocale), - locale = uiLocale, - pageNum = null, // Language feed is not paginated - feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, - ).apply { - totalResults = languages.size.toLong() - entries.addAll( - languages.map { entry: OpdsLanguageNavEntry -> - OpdsEntryXml( - id = "urn:suwayomi:navigation:language:${entry.id}", - title = entry.title, - updated = currentFormattedTime(), - link = - listOf( - OpdsLinkXml( - OpdsConstants.LINK_REL_SUBSECTION, - "$baseUrl/language/${entry.id}?lang=${uiLocale.toLanguageTag()}", - OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, - entry.title, - ), - ), - ) - }, + baseUrl, + "library/languages", + MR.strings.opds_feeds_languages_title.localized(uiLocale), + uiLocale, + OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, + null, + ) + builder.totalResults = languages.size.toLong() + builder.entries.addAll( + languages.map { entry -> + OpdsEntryXml( + id = "urn:suwayomi:navigation:language:${entry.id}", + title = entry.title, + updated = currentFormattedTime(), + link = + listOf( + OpdsLinkXml( + OpdsConstants.LINK_REL_SUBSECTION, + "$baseUrl/language/${entry.id}?lang=${uiLocale.toLanguageTag()}", + OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, + entry.title, + thrCount = entry.mangaCount.toInt(), + ), + ), ) - } + }, + ) return OpdsXmlUtil.serializeFeedToString(builder.build()) } - // --- Specific Acquisition Feed Generators --- + /** + * Generates an acquisition feed for recent chapter updates in the library. + * @param baseUrl The base URL for constructing links. + * @param pageNum The page number for pagination. + * @param locale The locale for localization. + * @return An XML string representing the library updates feed. + */ + suspend fun getLibraryUpdatesFeed( + baseUrl: String, + pageNum: Int, + locale: Locale, + ): String { + val (updateItems, total) = ChapterRepository.getLibraryUpdates(pageNum) + val builder = + FeedBuilderInternal( + baseUrl, + "library-updates", + MR.strings.opds_feeds_library_updates_title.localized(locale), + locale, + OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, + pageNum, + ) + builder.totalResults = total + builder.entries.addAll( + updateItems.map { item -> + val mangaDetails = OpdsMangaDetails(item.mangaId, item.mangaTitle, item.mangaThumbnailUrl, item.mangaAuthor) + OpdsEntryBuilder.createChapterListEntry(item.chapter, mangaDetails, baseUrl, true, locale) + }, + ) + return OpdsXmlUtil.serializeFeedToString(builder.build()) + } - fun getMangaFeed( + /** + * 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 pageNum The page number for pagination. + * @param sortParam The sorting parameter for chapters. + * @param filterParam The filtering parameter for chapters. + * @param locale The locale for localization. + * @return An XML string representing the series' chapters feed. + */ + suspend fun getSeriesChaptersFeed( mangaId: Int, baseUrl: String, pageNum: Int, @@ -350,11 +584,10 @@ object OpdsFeedBuilder { MangaRepository.getMangaDetails(mangaId) ?: return buildNotFoundFeed( baseUrl, - "manga/$mangaId", + "series/$mangaId/chapters", MR.strings.opds_error_manga_not_found.localized(locale, mangaId), locale, ) - val (sortColumn, currentSortOrder) = when (sortParam?.lowercase()) { "asc", "number_asc" -> ChapterTable.sourceOrder to SortOrder.ASC @@ -364,8 +597,7 @@ object OpdsFeedBuilder { else -> ChapterTable.sourceOrder to (serverConfig.opdsChapterSortOrder.value ?: SortOrder.ASC) } val currentFilter = filterParam?.lowercase() ?: if (serverConfig.opdsShowOnlyUnreadChapters.value) "unread" else "all" - - val (chapterEntries, totalChapters) = + var (chapterEntries, totalChapters) = ChapterRepository.getChaptersForManga( mangaId, pageNum, @@ -374,44 +606,77 @@ object OpdsFeedBuilder { currentFilter, ) + // If no chapters are found in the database, attempt to fetch them from the source. + if (chapterEntries.isEmpty() && totalChapters == 0L) { + try { + suwayomi.tachidesk.manga.impl.Chapter + .fetchChapterList(mangaId) + + // Re-query after fetching. + val (refetchedChapters, refetchedTotal) = + ChapterRepository.getChaptersForManga( + mangaId, + pageNum, + sortColumn, + currentSortOrder, + currentFilter, + ) + chapterEntries = refetchedChapters + totalChapters = refetchedTotal + } catch (e: Exception) { + KotlinLogging.logger { }.error(e) { "Failed to fetch chapters online for mangaId: $mangaId" } + } + } + val actualSortParamForLinks = sortParam ?: run { val prefix = if (sortColumn == ChapterTable.sourceOrder) "number" else "date" val suffix = if (currentSortOrder == SortOrder.ASC) "asc" else "desc" "${prefix}_$suffix" } - + val filterCounts = ChapterRepository.getChapterFilterCounts(mangaId) + val feedUrl = "series/$mangaId/chapters" val builder = FeedBuilderInternal( - baseUrl = baseUrl, - idPath = "manga/$mangaId/chapters", - title = MR.strings.opds_feeds_manga_chapters.localized(locale, mangaDetails.title), - locale = locale, - pageNum = pageNum, - feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, + baseUrl, + feedUrl, + MR.strings.opds_feeds_manga_chapters.localized(locale, mangaDetails.title), + locale, + OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, + pageNum, currentSort = actualSortParamForLinks, currentFilter = currentFilter, - ).apply { - totalResults = totalChapters - icon = mangaDetails.thumbnailUrl?.let { proxyThumbnailUrl(mangaDetails.id) } - icon?.let { - links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE, it, OpdsConstants.TYPE_IMAGE_JPEG)) - links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE_THUMBNAIL, it, OpdsConstants.TYPE_IMAGE_JPEG)) - } - addChapterSortAndFilterFacets( - this, - "$baseUrl/manga/$mangaId", - actualSortParamForLinks, - currentFilter, - locale, - sortColumn, - currentSortOrder, - ) - entries.addAll(chapterEntries.map { chapter -> createChapterListEntry(chapter, mangaDetails, baseUrl, false, locale) }) - } + ) + builder.totalResults = totalChapters + mangaDetails.thumbnailUrl?.let { proxyThumbnailUrl(mangaDetails.id) }?.also { + builder.icon = it + builder.links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE, it, OpdsConstants.TYPE_IMAGE_JPEG)) + builder.links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE_THUMBNAIL, it, OpdsConstants.TYPE_IMAGE_JPEG)) + } + OpdsEntryBuilder.addChapterSortAndFilterFacets( + builder, + "$baseUrl/$feedUrl", + actualSortParamForLinks, + currentFilter, + locale, + filterCounts, + ) + builder.entries.addAll( + chapterEntries.map { chapter -> + OpdsEntryBuilder.createChapterListEntry(chapter, mangaDetails, baseUrl, false, locale) + }, + ) return OpdsXmlUtil.serializeFeedToString(builder.build()) } + /** + * Generates an acquisition feed with detailed metadata for a single chapter. + * @param mangaId The ID of the manga. + * @param chapterSourceOrder The source order index of the chapter. + * @param baseUrl The base URL for constructing links. + * @param locale The locale for localization. + * @return An XML string representing the chapter's metadata feed. + */ suspend fun getChapterMetadataFeed( mangaId: Int, chapterSourceOrder: Int, @@ -422,466 +687,46 @@ object OpdsFeedBuilder { MangaRepository.getMangaDetails(mangaId) ?: return buildNotFoundFeed( baseUrl, - "manga/$mangaId/chapter/$chapterSourceOrder/metadata", + "series/$mangaId/chapter/$chapterSourceOrder/metadata", MR.strings.opds_error_manga_not_found.localized(locale, mangaId), locale, ) - val chapterMetadata = ChapterRepository.getChapterDetailsForMetadataFeed(mangaId, chapterSourceOrder) ?: return buildNotFoundFeed( baseUrl, - "manga/$mangaId/chapter/$chapterSourceOrder/metadata", + "series/$mangaId/chapter/$chapterSourceOrder/metadata", MR.strings.opds_error_chapter_not_found.localized(locale, chapterSourceOrder), locale, ) - val builder = FeedBuilderInternal( - baseUrl = baseUrl, - idPath = "manga/$mangaId/chapter/${chapterMetadata.sourceOrder}/metadata", - title = MR.strings.opds_feeds_chapter_details.localized(locale, mangaDetails.title, chapterMetadata.name), - locale = locale, - feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, - pageNum = null, // Metadata feed is single entry, not paginated - ).apply { - totalResults = 1 - icon = mangaDetails.thumbnailUrl?.let { proxyThumbnailUrl(mangaDetails.id) } - icon?.let { - links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE, it, OpdsConstants.TYPE_IMAGE_JPEG)) - links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE_THUMBNAIL, it, OpdsConstants.TYPE_IMAGE_JPEG)) - } - entries.add(createChapterMetadataEntry(chapterMetadata, mangaDetails, locale)) - } - return OpdsXmlUtil.serializeFeedToString(builder.build()) - } - - fun getSourceFeed( - sourceId: Long, - baseUrl: String, - pageNum: Int, - locale: Locale, - ): String { - val (mangaEntries, total) = MangaRepository.getMangaBySource(sourceId, pageNum) - val sourceNavEntry = NavigationRepository.getSources(1).first.find { it.id == sourceId } - val sourceNameOrId = sourceNavEntry?.name ?: sourceId.toString() - val feedTitle = - MR.strings.opds_feeds_source_specific_title.localized( + baseUrl, + "series/$mangaId/chapter/${chapterMetadata.sourceOrder}/metadata", + MR.strings.opds_feeds_chapter_details.localized(locale, mangaDetails.title, chapterMetadata.name), locale, - sourceNameOrId, + OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, + null, ) - - val builder = - FeedBuilderInternal( - baseUrl, - "source/$sourceId", - feedTitle, - locale = locale, - pageNum = pageNum, - feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, - ).apply { - totalResults = total - icon = sourceNavEntry?.iconUrl - entries.addAll(mangaEntries.map { mangaAcqEntryToEntry(it, baseUrl, locale) }) - } - return OpdsXmlUtil.serializeFeedToString(builder.build()) - } - - fun getCategoryFeed( - categoryId: Int, - baseUrl: String, - pageNum: Int, - locale: Locale, - ): String { - val (mangaEntries, total) = MangaRepository.getMangaByCategory(categoryId, pageNum) - val categoryNavEntry = NavigationRepository.getCategories(1).first.find { it.id == categoryId } - val feedTitle = MR.strings.opds_feeds_category_specific_title.localized(locale, categoryNavEntry?.name ?: categoryId.toString()) - - val builder = - FeedBuilderInternal( - baseUrl, - "category/$categoryId", - feedTitle, - locale = locale, - pageNum = pageNum, - feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, - ).apply { - totalResults = total - entries.addAll(mangaEntries.map { mangaAcqEntryToEntry(it, baseUrl, locale) }) - } - return OpdsXmlUtil.serializeFeedToString(builder.build()) - } - - fun getGenreFeed( - genre: String, - baseUrl: String, - pageNum: Int, - locale: Locale, - ): String { - val (mangaEntries, total) = MangaRepository.getMangaByGenre(genre, pageNum) - val feedTitle = MR.strings.opds_feeds_genre_specific_title.localized(locale, genre) - - val builder = - FeedBuilderInternal( - baseUrl, - "genre/${genre.encodeForOpdsURL()}", - feedTitle, - locale = locale, - pageNum = pageNum, - feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, - ).apply { - totalResults = total - entries.addAll(mangaEntries.map { mangaAcqEntryToEntry(it, baseUrl, locale) }) - } - return OpdsXmlUtil.serializeFeedToString(builder.build()) - } - - fun getStatusMangaFeed( - statusDbId: Long, - baseUrl: String, - pageNum: Int, - locale: Locale, - ): String { - val statusNavEntry = NavigationRepository.getStatuses(locale).find { it.id == statusDbId.toInt() } - val statusName = statusNavEntry?.title ?: statusDbId.toString() - val (mangaEntries, total) = MangaRepository.getMangaByStatus(statusDbId.toInt(), pageNum) - val feedTitle = MR.strings.opds_feeds_status_specific_title.localized(locale, statusName) - - val builder = - FeedBuilderInternal( - baseUrl, - "status/$statusDbId", - feedTitle, - locale = locale, - pageNum = pageNum, - feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, - ).apply { - totalResults = total - entries.addAll(mangaEntries.map { mangaAcqEntryToEntry(it, baseUrl, locale) }) - } - return OpdsXmlUtil.serializeFeedToString(builder.build()) - } - - fun getLanguageFeed( - contentLangCode: String, - baseUrl: String, - pageNum: Int, - uiLocale: Locale, - ): String { - val (mangaEntries, total) = MangaRepository.getMangaByContentLanguage(contentLangCode, pageNum) - val contentLanguageDisplayName = Locale.forLanguageTag(contentLangCode).getDisplayName(uiLocale) - val feedTitle = MR.strings.opds_feeds_language_specific_title.localized(uiLocale, contentLanguageDisplayName) - - val builder = - FeedBuilderInternal( - baseUrl, - "language/$contentLangCode", - feedTitle, - locale = uiLocale, - pageNum = pageNum, - feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, - ).apply { - totalResults = total - entries.addAll(mangaEntries.map { mangaAcqEntryToEntry(it, baseUrl, uiLocale) }) - } - return OpdsXmlUtil.serializeFeedToString(builder.build()) - } - - fun getLibraryUpdatesFeed( - baseUrl: String, - pageNum: Int, - locale: Locale, - ): String { - val (updateItems, total) = ChapterRepository.getLibraryUpdates(pageNum) - val builder = - FeedBuilderInternal( - baseUrl, - "library-updates", - MR.strings.opds_feeds_library_updates_title.localized(locale), - locale = locale, - pageNum = pageNum, - feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, - ).apply { - totalResults = total - entries.addAll( - updateItems.map { item -> - val mangaDetails = OpdsMangaDetails(item.mangaId, item.mangaTitle, item.mangaThumbnailUrl, item.mangaAuthor) - createChapterListEntry(item.chapter, mangaDetails, baseUrl, true, locale) - }, - ) - } - return OpdsXmlUtil.serializeFeedToString(builder.build()) - } - - // --- Entry Creation Helpers --- - - private fun mangaAcqEntryToEntry( - entry: OpdsMangaAcqEntry, - baseUrl: String, - locale: Locale, - ): OpdsEntryXml { - val displayThumbnailUrl = entry.thumbnailUrl?.let { proxyThumbnailUrl(entry.id) } - return OpdsEntryXml( - id = "urn:suwayomi:manga:${entry.id}", - title = entry.title, - updated = currentFormattedTime(), - authors = entry.author?.let { listOf(OpdsAuthorXml(name = it)) }, - categories = - entry.genres.filter { it.isNotBlank() }.map { genre -> - OpdsCategoryXml( - term = genre.lowercase().replace(" ", "_"), - label = genre, - scheme = "$baseUrl/genres", - ) - }, - summary = entry.description?.let { OpdsSummaryXml(value = it) }, - link = - listOfNotNull( - OpdsLinkXml( - OpdsConstants.LINK_REL_SUBSECTION, - "$baseUrl/manga/${entry.id}/chapters?lang=${locale.toLanguageTag()}", - OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, - entry.title, - ), - displayThumbnailUrl?.let { OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE, it, OpdsConstants.TYPE_IMAGE_JPEG) }, - displayThumbnailUrl?.let { OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE_THUMBNAIL, it, OpdsConstants.TYPE_IMAGE_JPEG) }, - ), - language = entry.sourceLang, - ) - } - - private fun createChapterListEntry( - chapter: OpdsChapterListAcqEntry, - manga: OpdsMangaDetails, - baseUrl: String, - addMangaTitle: Boolean, - locale: Locale, - ): OpdsEntryXml { - val statusKey = - when { - chapter.read -> MR.strings.opds_chapter_status_read - chapter.lastPageRead > 0 -> MR.strings.opds_chapter_status_in_progress - else -> MR.strings.opds_chapter_status_unread - } - val titlePrefix = statusKey.localized(locale) - val entryTitle = titlePrefix + (if (addMangaTitle) " ${manga.title}:" else "") + " ${chapter.name}" - - val details = - buildString { - append(MR.strings.opds_chapter_details_base.localized(locale, manga.title, chapter.name)) - chapter.scanlator?.takeIf { it.isNotBlank() }?.let { - append( - MR.strings.opds_chapter_details_scanlator.localized(locale, it), - ) - } - if (chapter.pageCount > 0) { - append( - MR.strings.opds_chapter_details_progress.localized( - locale, - chapter.lastPageRead, - chapter.pageCount, - ), - ) - } - } - - return OpdsEntryXml( - id = "urn:suwayomi:chapter:${chapter.id}", - title = entryTitle, - updated = OpdsDateUtil.formatEpochMillisForOpds(chapter.uploadDate), - authors = - listOfNotNull( - manga.author?.let { OpdsAuthorXml(name = it) }, - chapter.scanlator?.takeIf { it.isNotBlank() }?.let { OpdsAuthorXml(name = it) }, - ), - summary = OpdsSummaryXml(value = details), - link = - listOf( - OpdsLinkXml( - rel = OpdsConstants.LINK_REL_SUBSECTION, - href = "$baseUrl/manga/${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), - ), - ), - ) - } - - private suspend fun createChapterMetadataEntry( - chapter: OpdsChapterMetadataAcqEntry, - manga: OpdsMangaDetails, - locale: Locale, - ): OpdsEntryXml { - val statusKey = - when { - chapter.downloaded -> MR.strings.opds_chapter_status_downloaded - chapter.read -> MR.strings.opds_chapter_status_read - chapter.lastPageRead > 0 -> MR.strings.opds_chapter_status_in_progress - else -> MR.strings.opds_chapter_status_unread - } - val titlePrefix = statusKey.localized(locale) - val entryTitle = "$titlePrefix ${chapter.name}" - - val details = - buildString { - append(MR.strings.opds_chapter_details_base.localized(locale, manga.title, chapter.name)) - chapter.scanlator?.takeIf { it.isNotBlank() }?.let { - append( - MR.strings.opds_chapter_details_scanlator.localized(locale, it), - ) - } - val pageCountDisplay = chapter.pageCount.takeIf { it > 0 } ?: "?" - append( - MR.strings.opds_chapter_details_progress.localized( - locale, - chapter.lastPageRead, - pageCountDisplay, - ), - ) - } - - val links = mutableListOf() - var cbzFileSize: Long? = null - - if (chapter.downloaded) { - val cbzStreamPair = - withContext( - Dispatchers.IO, - ) { runCatching { ChapterDownloadHelper.getArchiveStreamWithSize(manga.id, chapter.id) }.getOrNull() } - cbzFileSize = cbzStreamPair?.second - cbzStreamPair?.let { - links.add( - OpdsLinkXml( - OpdsConstants.LINK_REL_ACQUISITION_OPEN_ACCESS, - "/api/v1/chapter/${chapter.id}/download?markAsRead=${serverConfig.opdsMarkAsReadOnDownload.value}", - OpdsConstants.TYPE_CBZ, - MR.strings.opds_linktitle_download_cbz.localized(locale), - ), - ) - } + builder.totalResults = 1 + mangaDetails.thumbnailUrl?.let { proxyThumbnailUrl(mangaDetails.id) }?.also { + builder.icon = it + builder.links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE, it, OpdsConstants.TYPE_IMAGE_JPEG)) + builder.links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE_THUMBNAIL, it, OpdsConstants.TYPE_IMAGE_JPEG)) } - - if (chapter.pageCount > 0) { - links.add( - OpdsLinkXml( - OpdsConstants.LINK_REL_PSE_STREAM, - "/api/v1/manga/${manga.id}/chapter/${chapter.sourceOrder}/page/{pageNumber}?updateProgress=${serverConfig.opdsEnablePageReadProgress.value}", - OpdsConstants.TYPE_IMAGE_JPEG, - MR.strings.opds_linktitle_stream_pages.localized(locale), - pseCount = chapter.pageCount, - pseLastRead = - chapter.lastPageRead.takeIf { - it > 0 - }, - pseLastReadDate = chapter.lastReadAt.takeIf { it > 0 }?.let { OpdsDateUtil.formatEpochMillisForOpds(it * 1000) }, - ), - ) - links.add( - OpdsLinkXml( - OpdsConstants.LINK_REL_IMAGE, - "/api/v1/manga/${manga.id}/chapter/${chapter.sourceOrder}/page/0", - OpdsConstants.TYPE_IMAGE_JPEG, - MR.strings.opds_linktitle_chapter_cover.localized(locale), - ), - ) - } - - return OpdsEntryXml( - id = "urn:suwayomi:chapter:${chapter.id}:metadata", - title = entryTitle, - updated = OpdsDateUtil.formatEpochMillisForOpds(chapter.uploadDate), - authors = - listOfNotNull( - manga.author?.let { OpdsAuthorXml(name = it) }, - chapter.scanlator?.takeIf { it.isNotBlank() }?.let { OpdsAuthorXml(name = it) }, - ), - summary = OpdsSummaryXml(value = details), - link = links, - extent = cbzFileSize?.let { formatFileSizeForOpds(it) }, - format = if (cbzFileSize != null) "CBZ" else null, - ) + builder.entries.add(OpdsEntryBuilder.createChapterMetadataEntry(chapterMetadata, mangaDetails, baseUrl, locale)) + return OpdsXmlUtil.serializeFeedToString(builder.build()) } - // --- Helpers & Internal Builder --- - - private fun addChapterSortAndFilterFacets( - feedBuilder: FeedBuilderInternal, - baseMangaUrl: String, - currentSort: String, - currentFilter: String, - locale: Locale, - sortColumn: org.jetbrains.exposed.sql.Column<*>, - currentSortOrder: SortOrder, - ) { - val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale) - val filterGroup = MR.strings.opds_facetgroup_read_status.localized(locale) - - val addFacet = { rel: String, href: String, titleKey: StringResource, group: String, isActive: Boolean -> - feedBuilder.links.add( - OpdsLinkXml( - rel, - href, - OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, - titleKey.localized(locale), - facetGroup = group, - activeFacet = isActive, - ), - ) - } - - addFacet( - OpdsConstants.LINK_REL_FACET, - "$baseMangaUrl?sort=number_asc&filter=$currentFilter&lang=${locale.toLanguageTag()}", - MR.strings.opds_facet_sort_oldest_first, - sortGroup, - sortColumn == ChapterTable.sourceOrder && currentSortOrder == SortOrder.ASC, - ) - addFacet( - OpdsConstants.LINK_REL_FACET, - "$baseMangaUrl?sort=number_desc&filter=$currentFilter&lang=${locale.toLanguageTag()}", - MR.strings.opds_facet_sort_newest_first, - sortGroup, - sortColumn == ChapterTable.sourceOrder && currentSortOrder == SortOrder.DESC, - ) - addFacet( - OpdsConstants.LINK_REL_FACET, - "$baseMangaUrl?sort=date_asc&filter=$currentFilter&lang=${locale.toLanguageTag()}", - MR.strings.opds_facet_sort_date_asc, - sortGroup, - sortColumn == ChapterTable.date_upload && currentSortOrder == SortOrder.ASC, - ) - addFacet( - OpdsConstants.LINK_REL_FACET, - "$baseMangaUrl?sort=date_desc&filter=$currentFilter&lang=${locale.toLanguageTag()}", - MR.strings.opds_facet_sort_date_desc, - sortGroup, - sortColumn == ChapterTable.date_upload && currentSortOrder == SortOrder.DESC, - ) - - addFacet( - OpdsConstants.LINK_REL_FACET, - "$baseMangaUrl?filter=all&sort=$currentSort&lang=${locale.toLanguageTag()}", - MR.strings.opds_facet_filter_all_chapters, - filterGroup, - currentFilter == "all", - ) - addFacet( - OpdsConstants.LINK_REL_FACET, - "$baseMangaUrl?filter=unread&sort=$currentSort&lang=${locale.toLanguageTag()}", - MR.strings.opds_facet_filter_unread_only, - filterGroup, - currentFilter == "unread", - ) - addFacet( - OpdsConstants.LINK_REL_FACET, - "$baseMangaUrl?filter=read&sort=$currentSort&lang=${locale.toLanguageTag()}", - MR.strings.opds_facet_filter_read_only, - filterGroup, - currentFilter == "read", - ) - } - - private fun buildNotFoundFeed( + /** + * Builds a simple OPDS feed to indicate that a resource was not found. + * @param baseUrl The base URL. + * @param idPath The path that was not found. + * @param title The title for the feed (e.g., an error message). + * @param locale The locale for localization. + * @return An XML string representing the 'not found' feed. + */ + fun buildNotFoundFeed( baseUrl: String, idPath: String, title: String, @@ -891,127 +736,4 @@ object OpdsFeedBuilder { .apply { totalResults = 0L } .build() .let(OpdsXmlUtil::serializeFeedToString) - - private class FeedBuilderInternal( - val baseUrl: String, - val idPath: String, - val title: String, - val locale: Locale, - val feedType: String, - var pageNum: Int? = 1, // Nullable, default to 1 if needed, null means no pagination - var explicitQueryParams: String? = null, - val currentSort: String? = null, - val currentFilter: String? = null, - ) { - val feedGeneratedAt: String = currentFormattedTime() - var totalResults: Long = 0 - var icon: String? = null - val links = mutableListOf() - val entries = mutableListOf() - - private fun buildUrlWithParams( - baseHrefPath: String, - page: Int?, - ): String { - val sb = StringBuilder("$baseUrl/$baseHrefPath") - val queryParamsList = mutableListOf() - - explicitQueryParams?.takeIf { it.isNotBlank() }?.let { - queryParamsList.add(it) - } - // Only add pageNumber if pagination is active (pageNum is not null) - 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 { - val actualPageNum = pageNum ?: 1 - // val needsPagination = pageNum != null && totalResults > opdsItemsPerPageBounded - - val selfLinkHref = buildUrlWithParams(idPath, if (pageNum != null) actualPageNum else null) - val feedLinks = mutableListOf() - feedLinks.addAll(this.links) - - feedLinks.add( - OpdsLinkXml( - OpdsConstants.LINK_REL_SELF, - selfLinkHref, - feedType, - MR.strings.opds_linktitle_self_feed.localized(locale), - ), - ) - feedLinks.add( - OpdsLinkXml( - OpdsConstants.LINK_REL_START, - "$baseUrl?lang=${locale.toLanguageTag()}", - OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, - MR.strings.opds_linktitle_catalog_root.localized(locale), - ), - ) - feedLinks.add( - OpdsLinkXml( - OpdsConstants.LINK_REL_SEARCH, - "$baseUrl/search?lang=${locale.toLanguageTag()}", - OpdsConstants.TYPE_OPENSEARCH_DESCRIPTION, - MR.strings.opds_linktitle_search_catalog.localized(locale), - ), - ) - - if (pageNum != null) { // Only add pagination links if pageNum was provided (meaning it's paginatable) - if (actualPageNum > 1) { - feedLinks.add( - OpdsLinkXml( - OpdsConstants.LINK_REL_PREV, - buildUrlWithParams(idPath, actualPageNum - 1), - feedType, - MR.strings.opds_linktitle_previous_page.localized(locale), - ), - ) - } - if (totalResults > actualPageNum * opdsItemsPerPageBounded) { - feedLinks.add( - OpdsLinkXml( - OpdsConstants.LINK_REL_NEXT, - buildUrlWithParams(idPath, actualPageNum + 1), - feedType, - MR.strings.opds_linktitle_next_page.localized(locale), - ), - ) - } - } - - val urnParams = mutableListOf() - urnParams.add(locale.toLanguageTag()) - pageNum?.let { urnParams.add("page$it") } - explicitQueryParams?.let { urnParams.add(it.replace("&", ":").replace("=", "_")) } - currentSort?.let { urnParams.add("sort_$it") } - currentFilter?.let { urnParams.add("filter_$it") } - val urnSuffix = if (urnParams.isNotEmpty()) ":${urnParams.joinToString(":")}" else "" - - val showPaginationFields = pageNum != null && totalResults > 0 - - return OpdsFeedXml( - id = "urn:suwayomi:feed:${idPath.replace('/',':')}$urnSuffix", - title = title, - updated = feedGeneratedAt, - icon = icon, - author = feedAuthor, - links = feedLinks, - entries = entries, - totalResults = totalResults.takeIf { showPaginationFields }, - itemsPerPage = if (showPaginationFields) opdsItemsPerPageBounded else null, - startIndex = if (showPaginationFields) ((actualPageNum - 1) * opdsItemsPerPageBounded + 1) else null, - ) - } - } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/ChapterRepository.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/ChapterRepository.kt index 5743f240..e6a18634 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/ChapterRepository.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/ChapterRepository.kt @@ -6,7 +6,9 @@ import org.jetbrains.exposed.sql.Op import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.andWhere import org.jetbrains.exposed.sql.transactions.transaction import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReady import suwayomi.tachidesk.manga.model.table.ChapterTable @@ -14,6 +16,7 @@ import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.SourceTable import suwayomi.tachidesk.opds.dto.OpdsChapterListAcqEntry import suwayomi.tachidesk.opds.dto.OpdsChapterMetadataAcqEntry +import suwayomi.tachidesk.opds.dto.OpdsHistoryAcqEntry import suwayomi.tachidesk.opds.dto.OpdsLibraryUpdateAcqEntry import suwayomi.tachidesk.server.serverConfig @@ -31,6 +34,7 @@ object ChapterRepository { scanlator = this[ChapterTable.scanlator], read = this[ChapterTable.isRead], lastPageRead = this[ChapterTable.lastPageRead], + lastReadAt = this[ChapterTable.lastReadAt], sourceOrder = this[ChapterTable.sourceOrder], pageCount = this[ChapterTable.pageCount], ) @@ -49,7 +53,6 @@ object ChapterRepository { when (filter) { "unread" -> conditions.add(ChapterTable.isRead eq false) "read" -> conditions.add(ChapterTable.isRead eq true) - // "all" -> no additional condition } if (serverConfig.opdsShowOnlyDownloadedChapters.value) { conditions.add(ChapterTable.isDownloaded eq true) @@ -92,6 +95,7 @@ object ChapterRepository { sourceOrder = chapterDataClass.index, downloaded = chapterDataClass.downloaded, pageCount = chapterDataClass.pageCount, + url = chapterDataClass.realUrl, ) } catch (e: Exception) { null @@ -117,7 +121,7 @@ object ChapterRepository { .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) .map { OpdsLibraryUpdateAcqEntry( - chapter = it.toOpdsChapterListAcqEntry(), // This will work if ChapterTable columns do not collide + chapter = it.toOpdsChapterListAcqEntry(), mangaTitle = it[MangaTable.title], mangaAuthor = it[MangaTable.author], mangaId = it[MangaTable.id].value, @@ -127,4 +131,49 @@ object ChapterRepository { } Pair(items, totalCount) } + + fun getHistory(pageNum: Int): Pair, Long> = + transaction { + val query = + ChapterTable + .join(MangaTable, JoinType.INNER, ChapterTable.manga, MangaTable.id) + .join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id) + .select( + ChapterTable.columns + MangaTable.title + MangaTable.author + MangaTable.thumbnail_url + MangaTable.id + + SourceTable.lang, + ).where { ChapterTable.lastReadAt greater 0L } + + val totalCount = query.count() + + val items = + query + .orderBy(ChapterTable.lastReadAt to SortOrder.DESC) + .limit(opdsItemsPerPageBounded) + .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) + .map { + OpdsHistoryAcqEntry( + chapter = it.toOpdsChapterListAcqEntry(), + mangaTitle = it[MangaTable.title], + mangaAuthor = it[MangaTable.author], + mangaId = it[MangaTable.id].value, + mangaSourceLang = it[SourceTable.lang], + mangaThumbnailUrl = it[MangaTable.thumbnail_url], + ) + } + Pair(items, totalCount) + } + + fun getChapterFilterCounts(mangaId: Int): Map = + transaction { + val baseQuery = ChapterTable.select(ChapterTable.id).where { ChapterTable.manga eq mangaId } + val readCount = baseQuery.copy().andWhere { ChapterTable.isRead eq true }.count() + val unreadCount = baseQuery.copy().andWhere { ChapterTable.isRead eq false }.count() + val allCount = baseQuery.copy().count() + + mapOf( + "read" to readCount, + "unread" to unreadCount, + "all" to allCount, + ) + } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/MangaRepository.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/MangaRepository.kt index 93952b3f..795e703f 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/MangaRepository.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/MangaRepository.kt @@ -1,29 +1,51 @@ package suwayomi.tachidesk.opds.repository +import eu.kanade.tachiyomi.source.model.MangasPage +import org.jetbrains.exposed.sql.Case import org.jetbrains.exposed.sql.JoinType import org.jetbrains.exposed.sql.Op +import org.jetbrains.exposed.sql.Query import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.SqlExpressionBuilder.like +import org.jetbrains.exposed.sql.alias import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.andWhere +import org.jetbrains.exposed.sql.intLiteral import org.jetbrains.exposed.sql.lowerCase +import org.jetbrains.exposed.sql.max import org.jetbrains.exposed.sql.or +import org.jetbrains.exposed.sql.sum import org.jetbrains.exposed.sql.transactions.transaction +import suwayomi.tachidesk.manga.impl.MangaList.insertOrUpdate +import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource import suwayomi.tachidesk.manga.model.dataclass.toGenreList import suwayomi.tachidesk.manga.model.table.CategoryMangaTable +import suwayomi.tachidesk.manga.model.table.CategoryTable import suwayomi.tachidesk.manga.model.table.ChapterTable +import suwayomi.tachidesk.manga.model.table.MangaStatus import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.SourceTable +import suwayomi.tachidesk.opds.dto.OpdsLibraryFeedResult import suwayomi.tachidesk.opds.dto.OpdsMangaAcqEntry import suwayomi.tachidesk.opds.dto.OpdsMangaDetails +import suwayomi.tachidesk.opds.dto.OpdsMangaFilter import suwayomi.tachidesk.opds.dto.OpdsSearchCriteria +import suwayomi.tachidesk.opds.dto.PrimaryFilterType import suwayomi.tachidesk.server.serverConfig +/** + * Repository for fetching manga data tailored for OPDS feeds. + */ object MangaRepository { private val opdsItemsPerPageBounded: Int get() = serverConfig.opdsItemsPerPage.value.coerceIn(10, 5000) + /** + * Maps a database [ResultRow] to an [OpdsMangaAcqEntry] data transfer object. + * @return The mapped [OpdsMangaAcqEntry]. + */ private fun ResultRow.toOpdsMangaAcqEntry(): OpdsMangaAcqEntry = OpdsMangaAcqEntry( id = this[MangaTable.id].value, @@ -32,20 +54,83 @@ object MangaRepository { genres = this[MangaTable.genre].toGenreList(), description = this[MangaTable.description], thumbnailUrl = this[MangaTable.thumbnail_url], - sourceLang = this.getOrNull(SourceTable.lang), + sourceLang = this[SourceTable.lang], inLibrary = this[MangaTable.inLibrary], + status = this[MangaTable.status], + sourceName = this[SourceTable.name], + lastFetchedAt = this[MangaTable.lastFetchedAt], + url = this[MangaTable.realUrl], ) - fun getAllManga(pageNum: Int): Pair, Long> = + /** + * Centralized function to retrieve paginated, sorted, and filtered manga from the library. + * @param pageNum The page number for pagination. + * @param sort The sorting parameter. + * @param filter The filtering parameter. + * @param criteria Additional filtering criteria for categories, sources, etc. + * @return An [OpdsLibraryFeedResult] containing the list of manga, total count, and the specific filter name. + */ + fun getLibraryManga( + pageNum: Int, + sort: String?, + filter: String?, + criteria: OpdsMangaFilter, + ): OpdsLibraryFeedResult = transaction { + val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum() + val unreadCount = unreadCountExpr.alias("unread_count") + + // Base query with necessary joins for filtering and sorting val query = MangaTable - .join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga) .join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id) - .select(MangaTable.columns + SourceTable.lang) + .join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga) + .join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga) + .select(MangaTable.columns + SourceTable.lang + SourceTable.name + unreadCount) .where { MangaTable.inLibrary eq true } - .groupBy(MangaTable.id, SourceTable.lang) - .orderBy(MangaTable.title to SortOrder.ASC) + .groupBy(MangaTable.id, SourceTable.lang, SourceTable.name) + + // Apply specific filters from criteria + criteria.sourceId?.let { query.andWhere { MangaTable.sourceReference eq it } } + criteria.categoryId?.let { query.andWhere { CategoryMangaTable.category eq it } } + criteria.statusId?.let { query.andWhere { MangaTable.status eq it } } + criteria.langCode?.let { query.andWhere { SourceTable.lang eq it } } + criteria.genre?.let { genre -> + val genreTrimmed = genre.trim() + val genreCondition = + (MangaTable.genre like "%, $genreTrimmed, %") or + (MangaTable.genre like "$genreTrimmed, %") or + (MangaTable.genre like "%, $genreTrimmed") or + (MangaTable.genre eq genreTrimmed) + query.andWhere { genreCondition } + } + + // Efficiently get the name of the primary filter item + val specificFilterName = + when (criteria.primaryFilter) { + PrimaryFilterType.SOURCE -> + criteria.sourceId?.let { + SourceTable + .select(SourceTable.name) + .where { SourceTable.id eq it } + .firstOrNull() + ?.get(SourceTable.name) + } + PrimaryFilterType.CATEGORY -> + criteria.categoryId?.let { + CategoryTable + .select(CategoryTable.name) + .where { CategoryTable.id eq it } + .firstOrNull() + ?.get(CategoryTable.name) + } + PrimaryFilterType.GENRE -> criteria.genre + PrimaryFilterType.STATUS -> criteria.statusId.toString() // Controller will map this to a localized string + PrimaryFilterType.LANGUAGE -> criteria.langCode // Controller will map this to a display name + else -> null + } + + applyMangaLibrarySortAndFilter(query, sort, filter) val totalCount = query.count() val mangas = @@ -53,9 +138,48 @@ object MangaRepository { .limit(opdsItemsPerPageBounded) .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) .map { it.toOpdsMangaAcqEntry() } - Pair(mangas, totalCount) + + OpdsLibraryFeedResult(mangas, totalCount, specificFilterName) } + /** + * Fetches a paginated list of manga from a specific source (for exploration). + * @param sourceId The ID of the source. + * @param pageNum The page number for pagination. + * @param sort The sorting parameter ('popular' or 'latest'). + * @return A pair containing the list of [OpdsMangaAcqEntry] and a boolean indicating if there's a next page. + */ + suspend fun getMangaBySource( + sourceId: Long, + pageNum: Int, + sort: String, + ): Pair, Boolean> { + val source = GetCatalogueSource.getCatalogueSourceOrStub(sourceId) + val mangasPage: MangasPage = + if (sort == "latest" && source.supportsLatest) { + source.getLatestUpdates(pageNum) + } else { + source.getPopularManga(pageNum) + } + + val mangaIds = mangasPage.insertOrUpdate(sourceId) + val mangaEntries = + transaction { + MangaTable + .join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id) + .select(MangaTable.columns + SourceTable.name + SourceTable.lang) + .where { MangaTable.id inList mangaIds } + .map { it.toOpdsMangaAcqEntry() } + }.sortedBy { manga -> mangaIds.indexOf(manga.id) } + + return Pair(mangaEntries, mangasPage.hasNextPage) + } + + /** + * Finds manga in the library based on search criteria (query, author, title). + * @param criteria The search criteria. + * @return A pair containing the list of matching [OpdsMangaAcqEntry] and the total count. + */ fun findMangaByCriteria(criteria: OpdsSearchCriteria): Pair, Long> = transaction { val conditions = mutableListOf>() @@ -76,15 +200,14 @@ object MangaRepository { conditions += (MangaTable.title.lowerCase() like "%${title.lowercase()}%") } - val finalCondition = conditions.reduceOrNull { acc, op -> acc and op } ?: Op.TRUE + val finalCondition = conditions.reduce { acc, op -> acc and op } val query = MangaTable - .join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga) .join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id) - .select(MangaTable.columns + SourceTable.lang) + .select(MangaTable.columns + SourceTable.name + SourceTable.lang) .where(finalCondition) - .groupBy(MangaTable.id, SourceTable.lang) + .groupBy(MangaTable.id, SourceTable.name, SourceTable.lang) .orderBy(MangaTable.title to SortOrder.ASC) val totalCount = query.count() @@ -95,6 +218,11 @@ object MangaRepository { Pair(mangas, totalCount) } + /** + * Retrieves basic details for a single manga, used for populating chapter feed metadata. + * @param mangaId The ID of the manga. + * @return An [OpdsMangaDetails] object or null if not found. + */ fun getMangaDetails(mangaId: Int): OpdsMangaDetails? = transaction { MangaTable @@ -111,126 +239,70 @@ object MangaRepository { } } - fun getMangaBySource( - sourceId: Long, - pageNum: Int, - ): Pair, Long> = - transaction { - val query = - MangaTable - .join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga) - .join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id) - .select(MangaTable.columns + SourceTable.lang) - .where { MangaTable.sourceReference eq sourceId } - .groupBy(MangaTable.id, SourceTable.lang) - .orderBy(MangaTable.title to SortOrder.ASC) + /** + * Applies sorting and filtering logic to a manga library query. + * @param query The Exposed SQL query to modify. + * @param sort The sorting parameter. + * @param filter The filtering parameter. + */ + private fun applyMangaLibrarySortAndFilter( + query: Query, + sort: String?, + filter: String?, + ) { + val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum() + val downloadedCountExpr = Case().When(ChapterTable.isDownloaded eq true, intLiteral(1)).Else(intLiteral(0)).sum() + val lastReadAtExpr = ChapterTable.lastReadAt.max() + val latestChapterDateExpr = ChapterTable.date_upload.max() - val totalCount = query.count() - val mangas = - query - .limit(opdsItemsPerPageBounded) - .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) - .map { it.toOpdsMangaAcqEntry() } - Pair(mangas, totalCount) + // 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 } } - fun getMangaByCategory( - categoryId: Int, - pageNum: Int, - ): Pair, Long> = - transaction { - val query = - MangaTable - .join(CategoryMangaTable, JoinType.INNER, MangaTable.id, CategoryMangaTable.manga) - .join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga) - .join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id) - .select(MangaTable.columns + SourceTable.lang) - .where { CategoryMangaTable.category eq categoryId } - .groupBy(MangaTable.id, SourceTable.lang) - .orderBy(MangaTable.title to SortOrder.ASC) - - val totalCount = query.count() - val mangas = - query - .limit(opdsItemsPerPageBounded) - .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) - .map { it.toOpdsMangaAcqEntry() } - Pair(mangas, totalCount) + // Apply sorting + when (sort) { + "alpha_asc" -> query.orderBy(MangaTable.title to SortOrder.ASC) + "alpha_desc" -> query.orderBy(MangaTable.title to SortOrder.DESC) + "last_read_desc" -> query.orderBy(lastReadAtExpr to SortOrder.DESC_NULLS_LAST) + "latest_chapter_desc" -> query.orderBy(latestChapterDateExpr to SortOrder.DESC_NULLS_LAST) + "date_added_desc" -> query.orderBy(MangaTable.inLibraryAt to SortOrder.DESC) + "unread_desc" -> query.orderBy(unreadCountExpr to SortOrder.DESC) + else -> query.orderBy(MangaTable.title to SortOrder.ASC) // Default sort } + } - fun getMangaByGenre( - genre: String, - pageNum: Int, - ): Pair, Long> = + /** + * Calculates the count of manga for various library filter facets. + * @return A map where keys are filter names and values are the counts. + */ + fun getLibraryFilterCounts(): Map = transaction { - val genreTrimmed = genre.trim() - val query = + 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 baseQuery = MangaTable - .join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga) - .join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id) - .select(MangaTable.columns + SourceTable.lang) - .where { - ( - (MangaTable.genre like "%, $genreTrimmed, %") or - (MangaTable.genre like "$genreTrimmed, %") or - (MangaTable.genre like "%, $genreTrimmed") or - (MangaTable.genre eq genreTrimmed) - ) and (MangaTable.inLibrary eq true) - }.groupBy(MangaTable.id, SourceTable.lang) - .orderBy(MangaTable.title to SortOrder.ASC) + .join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga) + .select(MangaTable.id) + .where { MangaTable.inLibrary eq true } + .groupBy(MangaTable.id) - val totalCount = query.count() - val mangas = - query - .limit(opdsItemsPerPageBounded) - .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) - .map { it.toOpdsMangaAcqEntry() } - Pair(mangas, totalCount) - } + val unreadCount = baseQuery.copy().having { unreadCountExpr greater 0 }.count() + val downloadedCount = baseQuery.copy().having { downloadedCountExpr greater 0 }.count() - fun getMangaByStatus( - statusId: Int, - pageNum: Int, - ): Pair, Long> = - transaction { - val query = - MangaTable - .join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga) - .join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id) - .select(MangaTable.columns + SourceTable.lang) - .where { MangaTable.status eq statusId } - .groupBy(MangaTable.id, SourceTable.lang) - .orderBy(MangaTable.title to SortOrder.ASC) + val statusBaseQuery = MangaTable.select(MangaTable.id).where { MangaTable.inLibrary eq true } + 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 totalCount = query.count() - val mangas = - query - .limit(opdsItemsPerPageBounded) - .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) - .map { it.toOpdsMangaAcqEntry() } - Pair(mangas, totalCount) - } - - fun getMangaByContentLanguage( - langCode: String, - pageNum: Int, - ): Pair, Long> = - transaction { - val query = - MangaTable - .join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id) - .join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga) - .select(MangaTable.columns + SourceTable.lang) - .where { SourceTable.lang eq langCode } - .groupBy(MangaTable.id, SourceTable.lang) - .orderBy(MangaTable.title to SortOrder.ASC) - - val totalCount = query.count() - val mangas = - query - .limit(opdsItemsPerPageBounded) - .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) - .map { it.toOpdsMangaAcqEntry() } - Pair(mangas, totalCount) + mapOf( + "unread" to unreadCount, + "downloaded" to downloadedCount, + "ongoing" to ongoingCount, + "completed" to completedCount, + ) } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/NavigationRepository.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/NavigationRepository.kt index 37d781c2..34eaea25 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/NavigationRepository.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/NavigationRepository.kt @@ -3,12 +3,14 @@ package suwayomi.tachidesk.opds.repository import dev.icerock.moko.resources.StringResource import org.jetbrains.exposed.sql.JoinType import org.jetbrains.exposed.sql.SortOrder +import org.jetbrains.exposed.sql.alias +import org.jetbrains.exposed.sql.count +import org.jetbrains.exposed.sql.countDistinct import org.jetbrains.exposed.sql.transactions.transaction import suwayomi.tachidesk.i18n.MR import suwayomi.tachidesk.manga.impl.extension.Extension import suwayomi.tachidesk.manga.model.table.CategoryMangaTable import suwayomi.tachidesk.manga.model.table.CategoryTable -import suwayomi.tachidesk.manga.model.table.ChapterTable import suwayomi.tachidesk.manga.model.table.ExtensionTable import suwayomi.tachidesk.manga.model.table.MangaStatus import suwayomi.tachidesk.manga.model.table.MangaTable @@ -28,20 +30,41 @@ object NavigationRepository { private val opdsItemsPerPageBounded: Int get() = serverConfig.opdsItemsPerPage.value.coerceIn(10, 5000) - // Mapping of section IDs to their StringResources for title and description private val rootSectionDetails: Map> = mapOf( - "mangas" to + "explore" to + Triple( + OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, + MR.strings.opds_feeds_explore_title, + MR.strings.opds_feeds_explore_entry_content, + ), + "library-updates" to Triple( OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, - MR.strings.opds_feeds_all_manga_title, - MR.strings.opds_feeds_all_manga_entry_content, + MR.strings.opds_feeds_library_updates_title, + MR.strings.opds_feeds_library_updates_entry_content, + ), + "history" to + Triple( + OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, + MR.strings.opds_feeds_history_title, + MR.strings.opds_feeds_history_entry_content, + ), + ) + + val librarySectionDetails: Map> = + mapOf( + "series" to + Triple( + OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, + MR.strings.opds_feeds_all_series_in_library_title, + MR.strings.opds_feeds_all_series_in_library_entry_content, ), "sources" to Triple( OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, - MR.strings.opds_feeds_sources_title, - MR.strings.opds_feeds_sources_entry_content, + MR.strings.opds_feeds_library_sources_title, + MR.strings.opds_feeds_library_sources_entry_content, ), "categories" to Triple( @@ -55,7 +78,7 @@ object NavigationRepository { MR.strings.opds_feeds_genres_title, MR.strings.opds_feeds_genres_entry_content, ), - "status" to + "statuses" to Triple( OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, MR.strings.opds_feeds_status_title, @@ -67,16 +90,36 @@ object NavigationRepository { MR.strings.opds_feeds_languages_title, MR.strings.opds_feeds_languages_entry_content, ), - "library-updates" to - Triple( - OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, - MR.strings.opds_feeds_library_updates_title, - MR.strings.opds_feeds_library_updates_entry_content, - ), ) - fun getRootNavigationItems(locale: Locale): List = - rootSectionDetails.map { (id, details) -> + fun getRootNavigationItems(locale: Locale): List { + val libraryItems = + librarySectionDetails.map { (id, details) -> + val (linkType, titleRes, descriptionRes) = details + OpdsRootNavEntry( + id = "library/$id", + title = titleRes.localized(locale), + description = descriptionRes.localized(locale), + linkType = linkType, + ) + } + + val otherRootItems = + rootSectionDetails.map { (id, details) -> + val (linkType, titleRes, descriptionRes) = details + OpdsRootNavEntry( + id = id, + title = titleRes.localized(locale), + description = descriptionRes.localized(locale), + linkType = linkType, + ) + } + + return libraryItems + otherRootItems + } + + fun getLibraryNavigationItems(locale: Locale): List = + librarySectionDetails.map { (id, details) -> val (linkType, titleRes, descriptionRes) = details OpdsRootNavEntry( id = id, @@ -86,14 +129,14 @@ object NavigationRepository { ) } - fun getSources(pageNum: Int): Pair, Long> = + // ... (El resto del archivo permanece sin cambios) + fun getExploreSources(pageNum: Int): Pair, Long> = transaction { val query = SourceTable - .join(MangaTable, JoinType.INNER) { MangaTable.sourceReference eq SourceTable.id } - .join(ChapterTable, JoinType.INNER) { ChapterTable.manga eq MangaTable.id } .join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id) .select(SourceTable.id, SourceTable.name, ExtensionTable.apkName) + .where { ExtensionTable.isInstalled eq true } .groupBy(SourceTable.id, SourceTable.name, ExtensionTable.apkName) .orderBy(SourceTable.name to SortOrder.ASC) @@ -107,6 +150,36 @@ object NavigationRepository { id = it[SourceTable.id].value, name = it[SourceTable.name], iconUrl = it[ExtensionTable.apkName].let { apkName -> Extension.getExtensionIconUrl(apkName) }, + mangaCount = null, + ) + } + Pair(sources, totalCount) + } + + fun getLibrarySources(pageNum: Int): Pair, Long> = + transaction { + val mangaCount = MangaTable.id.countDistinct().alias("manga_count") + + val query = + SourceTable + .join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference) + .join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id) + .select(SourceTable.id, SourceTable.name, ExtensionTable.apkName, mangaCount) + .where { MangaTable.inLibrary eq true } + .groupBy(SourceTable.id, SourceTable.name, ExtensionTable.apkName) + .orderBy(SourceTable.name to SortOrder.ASC) + + val totalCount = query.count() + val sources = + query + .limit(opdsItemsPerPageBounded) + .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) + .map { + OpdsSourceNavEntry( + id = it[SourceTable.id].value, + name = it[SourceTable.name], + iconUrl = it[ExtensionTable.apkName].let { apkName -> Extension.getExtensionIconUrl(apkName) }, + mangaCount = it[mangaCount], ) } Pair(sources, totalCount) @@ -114,12 +187,14 @@ object NavigationRepository { fun getCategories(pageNum: Int): Pair, Long> = transaction { + val mangaCount = MangaTable.id.countDistinct().alias("manga_count") + val query = CategoryTable .join(CategoryMangaTable, JoinType.INNER, CategoryTable.id, CategoryMangaTable.category) .join(MangaTable, JoinType.INNER, CategoryMangaTable.manga, MangaTable.id) - .join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga) - .select(CategoryTable.id, CategoryTable.name) + .select(CategoryTable.id, CategoryTable.name, mangaCount) + .where { MangaTable.inLibrary eq true } .groupBy(CategoryTable.id, CategoryTable.name) .orderBy(CategoryTable.order to SortOrder.ASC) @@ -132,6 +207,7 @@ object NavigationRepository { OpdsCategoryNavEntry( id = it[CategoryTable.id].value, name = it[CategoryTable.name], + mangaCount = it[mangaCount], ) } Pair(categories, totalCount) @@ -142,31 +218,32 @@ object NavigationRepository { locale: Locale, ): Pair, Long> = transaction { - val genres = + val allGenres = MangaTable - .join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga) .select(MangaTable.genre) + .where { MangaTable.inLibrary eq true } .mapNotNull { it[MangaTable.genre] } .flatMap { it.split(",").map(String::trim).filterNot(String::isBlank) } - .distinct() - .sorted() - val totalCount = genres.size.toLong() + val genreCounts = allGenres.groupingBy { it }.eachCount() + val distinctGenres = genreCounts.keys.sorted() + + val totalCount = distinctGenres.size.toLong() val fromIndex = ((pageNum - 1) * opdsItemsPerPageBounded) - val toIndex = minOf(fromIndex + opdsItemsPerPageBounded, genres.size) + val toIndex = minOf(fromIndex + opdsItemsPerPageBounded, distinctGenres.size) val paginatedGenres = - (if (fromIndex < genres.size) genres.subList(fromIndex, toIndex) else emptyList()) + (if (fromIndex < distinctGenres.size) distinctGenres.subList(fromIndex, toIndex) else emptyList()) .map { genreName -> OpdsGenreNavEntry( id = genreName.encodeForOpdsURL(), title = genreName, + mangaCount = genreCounts[genreName]?.toLong() ?: 0L, ) } Pair(paginatedGenres, totalCount) } fun getStatuses(locale: Locale): List { - // Mapping of MangaStatus to its StringResources val statusStringResources: Map = mapOf( MangaStatus.UNKNOWN to MR.strings.manga_status_unknown, @@ -178,32 +255,44 @@ object NavigationRepository { MangaStatus.ON_HIATUS to MR.strings.manga_status_on_hiatus, ) + val statusCounts = + transaction { + MangaTable + .select(MangaTable.status, MangaTable.id.count()) + .where { MangaTable.inLibrary eq true } + .groupBy(MangaTable.status) + .associate { it[MangaTable.status] to it[MangaTable.id.count()] } + } + return MangaStatus.entries .map { mangaStatus -> val titleRes = statusStringResources[mangaStatus] ?: MR.strings.manga_status_unknown OpdsStatusNavEntry( id = mangaStatus.value, title = titleRes.localized(locale), + mangaCount = statusCounts[mangaStatus.value] ?: 0L, ) }.sortedBy { it.id } } fun getContentLanguages(uiLocale: Locale): List = transaction { + val mangaCount = MangaTable.id.countDistinct().alias("manga_count") SourceTable .join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference) - .join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga) - .select(SourceTable.lang) + .select(SourceTable.lang, mangaCount) + .where { MangaTable.inLibrary eq true } .groupBy(SourceTable.lang) - .map { it[SourceTable.lang] } - .sorted() - .map { langCode -> + .orderBy(SourceTable.lang to SortOrder.ASC) + .map { + val langCode = it[SourceTable.lang] OpdsLanguageNavEntry( id = langCode, title = - Locale.forLanguageTag(langCode).getDisplayName(uiLocale).replaceFirstChar { - if (it.isLowerCase()) it.titlecase(uiLocale) else it.toString() + Locale.forLanguageTag(langCode).getDisplayName(uiLocale).replaceFirstChar { char -> + if (char.isLowerCase()) char.titlecase(uiLocale) else char.toString() }, + mangaCount = it[mangaCount], ) } }