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], ) } }