From 61f429896cfda55d414e73e8f85b64c3fdd6c957 Mon Sep 17 00:00:00 2001 From: Zeedif Date: Mon, 26 May 2025 18:46:14 -0600 Subject: [PATCH] =?UTF-8?q?feat(opds):=20implement=20full=20internationali?= =?UTF-8?q?zation=20and=20refactor=20feed=20gen=E2=80=A6=20(#1405)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(opds): implement full internationalization and refactor feed generation This commit introduces a comprehensive internationalization (i18n) framework and significantly refactors the OPDS v1.2 implementation for improved robustness, spec compliance, and localization. Key changes: Internationalization (`i18n`): - Introduces `LocalizationService` to manage translations: - Loads localized strings from JSON files (e.g., `en.json`, `es.json`) stored in a new `i18n` data directory. - Default `en.json` and `es.json` files are bundled and copied from resources on first run if not present. - Supports template resolution with `$t()` cross-references, locale fallbacks (to "en" by default), and argument interpolation ({{placeholder}}). - `ServerSetup` now initializes the `i18n` directory and `LocalizationService`. OPDS Refactor & Enhancements: - Replaces the previous `Opds.kt` and `OpdsDataClass.kt` with a new `OpdsFeedBuilder.kt` and a set of more granular, spec-aligned XML models (e.g., `OpdsFeedXml`, `OpdsEntryXml`, `OpdsLinkXml`). - Integrates `LocalizationService` throughout all OPDS feeds: - All user-facing text (feed titles, entry titles, summaries, link titles, facet labels for sorting/filtering) is now localized. - Adds a `lang` query parameter to all OPDS endpoints to allow clients to request a specific UI language. - Uses the `Accept-Language` header as a fallback for language detection. - The OpenSearch description (`/search` endpoint) is now localized and its template URL includes the determined language. - Centralizes OPDS constants (namespaces, link relations, media types) in `OpdsConstants.kt`. - Adds utility classes `OpdsDateUtil.kt`, `OpdsStringUtil.kt`, and `OpdsXmlUtil.kt` for common OPDS tasks. - `MangaDataClass` now includes `sourceLang` to provide the content language of the manga in OPDS entries (``). - Updates OpenAPI documentation for OPDS endpoints with more detail and includes the new `lang` parameter. Configuration: - Adds `useBinaryFileSizes` server configuration option. File sizes in OPDS feeds now respect this setting (e.g., MiB vs MB), utilized via `OpdsStringUtil.formatFileSizeForOpds`. This major refactor addresses the request for internationalization originally mentioned in PR #1257 ("it would be great if messages were adapted based on the user's language settings"). It builds upon the foundational OPDS work in #1257 and subsequent enhancements in #1262, #1263, #1278, and #1392, providing a more stable and extensible OPDS implementation. Features like localized facet titles from #1392 are now fully integrated with the i18n system. This resolves long-standing requests for better OPDS support (e.g., issue #769) by making feeds more user-friendly, accessible, and standards-compliant, also improving the robustness of features requested in #1390 (resolved by #1392) and addressing underlying data needs for issues like #1265 (related to #1277, #1278). * fix(opds): revert MIME type to application/xml for browser compatibility * fix(opds): use chapter index for metadata feed and correct link relation - Change `getChapterMetadataFeed` to use `chapterIndexFromPath` (sourceOrder) instead of `chapterIdFromPath` for fetching chapter data, ensuring consistency with how chapters are identified in manga feeds. - Add error handling for cases where manga or chapter by index is not found. - Correct OPDS link relation for chapter detail/fetch link in non-metadata chapter entries from `alternate` to `subsection` as per OPDS spec for navigation to more specific content or views. * Use Moko-Resources * Format * Forgot the Languages.json * refactor(opds)!: restructure OPDS feeds and introduce data repositories This commit significantly refactors the OPDS v1.2 implementation by introducing dedicated repository classes for data fetching and by restructuring the feed generation logic for clarity and maintainability. The `chapterId` path parameter for chapter metadata feeds has been changed to `chapterIndex` (sourceOrder) to align with how chapters are identified in manga feeds. BREAKING CHANGE: The OPDS endpoint for chapter metadata has changed from `/api/opds/v1.2/manga/{mangaId}/chapter/{chapterId}/fetch` to `/api/opds/v1.2/manga/{mangaId}/chapter/{chapterIndex}/fetch`. Clients will need to update to use the chapter's source order (index) instead of its database ID. Key changes: - Introduced `MangaRepository`, `ChapterRepository`, and `NavigationRepository` to encapsulate database queries and data transformation logic for OPDS feeds. - Moved data fetching logic from `OpdsFeedBuilder` to these new repositories. - `OpdsFeedBuilder` now primarily focuses on constructing the XML feed structure using DTOs provided by the repositories. - Renamed `OpdsMangaAcqEntry.thumbnailUrl` to `rawThumbnailUrl` for clarity. - Added various DTOs (e.g., `OpdsRootNavEntry`, `OpdsMangaDetails`, `OpdsChapterListAcqEntry`) to define clear data contracts between repositories and the feed builder. - Simplified `OpdsV1Controller` by reorganizing feed endpoints into logical groups (Main Navigation, Filtered Acquisition, Item-Specific). - Updated `OpdsAPI` to reflect the path parameter change for chapter metadata (`chapterIndex` instead of `chapterId`). - Added `slugify()` utility to `OpdsStringUtil` for creating URL-friendly genre IDs. - Standardized localization keys for root feed entry descriptions to use `*.entryContent` instead of `*.description`. - Added `server.generated.BuildConfig` (likely from build process). * style(opds): apply ktlint fixes * Delete server/bin * refactor(i18n): remove custom LocalizationService initialization * refactor(i18n): remove unused imports from ServerSetup * refactor(model): remove sourceLang from MangaDataClass * refactor(opds): rename OPDS binary file size config property - Rename `useBinaryFileSizes` to `opdsUseBinaryFileSizes` in code and config - Update related condition check in formatFileSizeForOpds BREAKING CHANGE: Existing server configurations using `server.useBinaryFileSizes` need to migrate to `server.opdsUseBinaryFileSizes` * refactor(opds): improve OPDS endpoint structure and documentation - Restructure endpoint paths for better resource hierarchy - Add descriptive comments for each feed type and purpose - Rename `/fetch` endpoint to `/metadata` for clarity - Standardize feed naming conventions in route definitions BREAKING CHANGE: Existing OPDS client integrations using old endpoint paths (`/manga/{mangaId}` and `/chapter/{chapterIndex}/fetch`) require updates to new paths (`/manga/{mangaId}/chapters` and `/chapter/{chapterIndex}/metadata`) * fix(opds): Apply review suggestions for localization and comments * Fix * fix(opds): Update chapter links to include 'chapters' and 'metadata' in URLs --------- Co-authored-by: Syer10 --- build.gradle.kts | 12 +- gradle/libs.versions.toml | 8 + server/build.gradle.kts | 3 + server/i18n/build.gradle.kts | 77 ++ .../moko-resources/files/languages.json | 1 + .../moko-resources/values/base/strings.xml | 83 ++ .../moko-resources/values/es/strings.xml | 83 ++ .../tachidesk/i18n/LocalizationHelper.kt | 45 + .../manga/model/dataclass/OpdsDataClass.kt | 117 -- .../kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt | 34 +- .../tachidesk/opds/constants/OpdsConstants.kt | 40 + .../opds/controller/OpdsV1Controller.kt | 323 +++--- .../opds/dto/OpdsCategoryNavEntry.kt | 6 + .../opds/dto/OpdsChapterListAcqEntry.kt | 14 + .../opds/dto/OpdsChapterMetadataAcqEntry.kt | 15 + .../tachidesk/opds/dto/OpdsGenreNavEntry.kt | 6 + .../opds/dto/OpdsLanguageNavEntry.kt | 6 + .../opds/dto/OpdsLibraryUpdateAcqEntry.kt | 10 + .../tachidesk/opds/dto/OpdsMangaAcqEntry.kt | 12 + .../tachidesk/opds/dto/OpdsMangaDetails.kt | 8 + .../tachidesk/opds/dto/OpdsRootNavEntry.kt | 8 + .../OpdsSearchCriteria.kt} | 4 +- .../tachidesk/opds/dto/OpdsSourceNavEntry.kt | 7 + .../tachidesk/opds/dto/OpdsStatusNavEntry.kt | 6 + .../suwayomi/tachidesk/opds/impl/Opds.kt | 946 --------------- .../tachidesk/opds/impl/OpdsFeedBuilder.kt | 1017 +++++++++++++++++ .../tachidesk/opds/model/OpdsAuthorXml.kt | 14 + .../tachidesk/opds/model/OpdsCategoryXml.kt | 10 + .../tachidesk/opds/model/OpdsContentXml.kt | 11 + .../tachidesk/opds/model/OpdsEntryXml.kt | 50 + .../tachidesk/opds/model/OpdsFeedXml.kt | 56 + .../opds/model/OpdsIndirectAcquisitionXml.kt | 15 + .../tachidesk/opds/model/OpdsLinkXml.kt | 32 + .../tachidesk/opds/model/OpdsSummaryXml.kt | 11 + .../tachidesk/opds/model/OpdsXmlModels.kt | 138 --- .../opds/repository/ChapterRepository.kt | 130 +++ .../opds/repository/MangaRepository.kt | 236 ++++ .../opds/repository/NavigationRepository.kt | 210 ++++ .../tachidesk/opds/util/OpdsDateUtil.kt | 38 + .../tachidesk/opds/util/OpdsStringUtil.kt | 64 ++ .../tachidesk/opds/util/OpdsXmlUtil.kt | 31 + .../suwayomi/tachidesk/server/ServerConfig.kt | 1 + .../suwayomi/tachidesk/server/ServerSetup.kt | 5 + .../src/main/resources/server-reference.conf | 1 + settings.gradle.kts | 1 + 45 files changed, 2586 insertions(+), 1359 deletions(-) create mode 100644 server/i18n/build.gradle.kts create mode 100644 server/i18n/src/commonMain/moko-resources/files/languages.json create mode 100644 server/i18n/src/commonMain/moko-resources/values/base/strings.xml create mode 100644 server/i18n/src/commonMain/moko-resources/values/es/strings.xml create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/i18n/LocalizationHelper.kt delete mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/OpdsDataClass.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/constants/OpdsConstants.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsCategoryNavEntry.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterListAcqEntry.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterMetadataAcqEntry.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsGenreNavEntry.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsLanguageNavEntry.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsLibraryUpdateAcqEntry.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaAcqEntry.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaDetails.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsRootNavEntry.kt rename server/src/main/kotlin/suwayomi/tachidesk/opds/{model/SearchCriteria.kt => dto/OpdsSearchCriteria.kt} (58%) create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsSourceNavEntry.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsStatusNavEntry.kt delete mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/impl/Opds.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsAuthorXml.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsCategoryXml.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsContentXml.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsEntryXml.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsFeedXml.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsIndirectAcquisitionXml.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsLinkXml.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsSummaryXml.kt delete mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsXmlModels.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/repository/ChapterRepository.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/repository/MangaRepository.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/repository/NavigationRepository.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/util/OpdsDateUtil.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/util/OpdsStringUtil.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/opds/util/OpdsXmlUtil.kt diff --git a/build.gradle.kts b/build.gradle.kts index 2aa6fb6e..fabe5f12 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,11 +4,13 @@ import org.jlleitschuh.gradle.ktlint.KtlintExtension import org.jlleitschuh.gradle.ktlint.KtlintPlugin plugins { - alias(libs.plugins.kotlin.jvm) - alias(libs.plugins.kotlin.serialization) - alias(libs.plugins.ktlint) + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.ktlint) apply false alias(libs.plugins.buildconfig) apply false alias(libs.plugins.download) + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.moko) apply false } allprojects { @@ -43,7 +45,9 @@ subprojects { tasks { withType { - dependsOn("ktlintFormat") + if (plugins.hasPlugin(KtlintPlugin::class)) { + dependsOn("ktlintFormat") + } compilerOptions { jvmTarget = JvmTarget.JVM_21 freeCompilerArgs.add("-Xcontext-receivers") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9753f72b..15db22c7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ graphqlkotlin = "8.4.0" xmlserialization = "0.91.0" ktlint = "1.5.0" koin = "4.0.4" +moko = "0.24.5" [libraries] # Kotlin @@ -148,10 +149,14 @@ cronUtils = "com.cronutils:cron-utils:9.2.1" # lint - used for renovate to update ktlint version ktlint = { module = "com.pinterest.ktlint:ktlint-cli", version.ref = "ktlint" } +# moko +moko = { module = "dev.icerock.moko:resources", version.ref = "moko" } + [plugins] # Kotlin kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin"} kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin"} +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin"} # Linter ktlint = { id = "org.jlleitschuh.gradle.ktlint", version = "12.2.0"} @@ -165,6 +170,9 @@ download = { id = "de.undercouch.download", version = "5.6.0"} # ShadowJar shadowjar = { id = "com.github.johnrengelman.shadow", version = "8.1.1"} +# Moko +moko = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "moko" } + [bundles] shared = [ "kotlin-stdlib-jdk8", diff --git a/server/build.gradle.kts b/server/build.gradle.kts index fb734e0d..15b27acf 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -85,6 +85,9 @@ dependencies { implementation(projects.androidCompat) implementation(projects.androidCompat.config) + // i18n + implementation(projects.server.i18n) + // uncomment to test extensions directly // implementation(fileTree("lib/")) implementation(kotlin("script-runtime")) diff --git a/server/i18n/build.gradle.kts b/server/i18n/build.gradle.kts new file mode 100644 index 00000000..1aaae9ab --- /dev/null +++ b/server/i18n/build.gradle.kts @@ -0,0 +1,77 @@ +import com.google.gson.Gson +import com.google.gson.JsonArray +import com.google.gson.JsonObject + +plugins { + id( + libs.plugins.kotlin.multiplatform + .get() + .pluginId, + ) + id( + libs.plugins.moko + .get() + .pluginId, + ) +} + +kotlin { + jvm() + + sourceSets { + getByName("jvmMain") { + dependencies { + api(libs.moko) + } + } + } +} + +multiplatformResources { + resourcesPackage = "suwayomi.tachidesk.i18n" +} + +tasks { + register("generateLocales") { + group = "moko-resources" + doFirst { + val langs = + listOf("en") + + file("src/commonMain/moko-resources/values") + .listFiles() + ?.map { it.name } + ?.minus("base") + ?.map { it.replace("-r", "-") } + ?.sorted() + .orEmpty() + + val langFile = file("src/commonMain/moko-resources/files/languages.json", PathValidation.NONE) + if (langFile.exists()) { + val currentLangs = + langFile.reader().use { + Gson() + .fromJson(it, JsonObject::class.java) + .getAsJsonArray("langs") + .mapNotNull { it.asString } + .toSet() + } + + if (currentLangs == langs.toSet()) return@doFirst + } + langFile.parentFile.mkdirs() + + val json = + JsonObject().apply { + val array = + JsonArray().apply { + langs.forEach(::add) + } + add("langs", array) + } + + langFile.writer().use { + Gson().toJson(json, it) + } + } + } +} diff --git a/server/i18n/src/commonMain/moko-resources/files/languages.json b/server/i18n/src/commonMain/moko-resources/files/languages.json new file mode 100644 index 00000000..ff3cf55d --- /dev/null +++ b/server/i18n/src/commonMain/moko-resources/files/languages.json @@ -0,0 +1 @@ +{"langs":["en","es"]} \ No newline at end of file diff --git a/server/i18n/src/commonMain/moko-resources/values/base/strings.xml b/server/i18n/src/commonMain/moko-resources/values/base/strings.xml new file mode 100644 index 00000000..e5db31fb --- /dev/null +++ b/server/i18n/src/commonMain/moko-resources/values/base/strings.xml @@ -0,0 +1,83 @@ + + + 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 + + Search Results + + Sources + Browse manga by source + + Categories + Browse manga organized by categories + + Genres + Browse manga by genre tags + + Status + Browse manga by publication status + + Languages + Browse manga by content language + + Library Update History + Recently updated chapters from your library + + 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 + + Sort Order + Read Status + + Oldest First + Newest First + Date ascending + Date descending + + All Chapters + Unread Only + Read Only + + View Chapter Details & Get Pages + Download CBZ + View Pages (Streaming) + Chapter Cover + Current Page + Catalog Root + Search Catalog + Previous Page + Next Page + Current Feed + + ⬇️ + + + ⚠️ + + + + %1$s | %2$s + | By %1$s + | Progress: %1$d of %2$d + + Unknown + Ongoing + Completed + Licensed + Publishing Finished + Cancelled + On Hiatus + \ No newline at end of file diff --git a/server/i18n/src/commonMain/moko-resources/values/es/strings.xml b/server/i18n/src/commonMain/moko-resources/values/es/strings.xml new file mode 100644 index 00000000..4ad168cc --- /dev/null +++ b/server/i18n/src/commonMain/moko-resources/values/es/strings.xml @@ -0,0 +1,83 @@ + + + Búsqueda OPDS de Suwayomi + Buscar mangas en el catálogo + + Catálogo OPDS de Suwayomi + Capítulos de %1$s + %1$s | Detalles de %2$s + + Todos los mangas + Explorar todos los mangas en tu biblioteca + + Resultados de búsqueda + + Fuentes + Explorar mangas por fuente + + Categorías + Explorar mangas organizados por categorías + + Géneros + Explorar mangas por etiquetas de género + + Estado + Explorar mangas por estado de publicación + + Idiomas + Explorar mangas por idioma del contenido + + Historial de actualizaciones + Capítulos recientemente actualizados de tu biblioteca + + Categoría: %1$s + Género: %1$s + Estado: %1$s + Idioma: %1$s + Fuente: %1$s + + Ordenar por + Estado de lectura + + Manga con ID %1$d no encontrado + Capítulo con índice %1$d no encontrado + + Más antiguos primero + Más recientes primero + Fecha ascendente + Fecha descendente + + Todos los capítulos + Solo sin leer + Solo leídos + + Ver detalles del capítulo y obtener páginas + Descargar CBZ + Ver páginas (streaming) + Portada del capítulo + Página actual + Raíz del catálogo + Buscar en catálogo + Página anterior + Página siguiente + Feed actual + + ⬇️ + + + ⚠️ + + + + Manga: %s | %s + | Publicado por: %1$s + | Progreso: %1$d de %2$d + + Desconocido + En emisión + Completado + Licenciado + Publicación finalizada + Cancelado + En pausa + \ No newline at end of file diff --git a/server/src/main/kotlin/suwayomi/tachidesk/i18n/LocalizationHelper.kt b/server/src/main/kotlin/suwayomi/tachidesk/i18n/LocalizationHelper.kt new file mode 100644 index 00000000..3a6acf5e --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/i18n/LocalizationHelper.kt @@ -0,0 +1,45 @@ +package suwayomi.tachidesk.i18n + +import io.javalin.http.Context +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.util.Locale + +object LocalizationHelper { + // Supported language codes (lowercase) + private var supportedLocales = emptyList() + + @Serializable + data class Languages( + val langs: List, + ) + + fun initialize() { + val languages = + Json + .decodeFromString( + MR.files.languages_json.readText(), + ).langs + supportedLocales = languages.map { Locale.forLanguageTag(it) } + } + + fun getSupportedLocales(): List = supportedLocales.map { it.displayLanguage } + + fun ctxToLocale( + ctx: Context, + langParam: String? = null, + ): Locale { + langParam?.trim()?.takeIf { it.isNotBlank() }?.lowercase()?.let { + val locale = Locale.forLanguageTag(it).takeIf { it in supportedLocales } + if (locale != null) { + return locale + } + } + val headerLang: String? = ctx.header("Accept-Language") + return if (headerLang == null || headerLang.isEmpty()) { + Locale.getDefault() + } else { + Locale.lookup(Locale.LanguageRange.parse(headerLang), supportedLocales) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/OpdsDataClass.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/OpdsDataClass.kt deleted file mode 100644 index 1e464691..00000000 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/OpdsDataClass.kt +++ /dev/null @@ -1,117 +0,0 @@ -package suwayomi.tachidesk.manga.model.dataclass - -import kotlinx.serialization.Serializable -import nl.adaptivity.xmlutil.serialization.XmlElement -import nl.adaptivity.xmlutil.serialization.XmlSerialName -import nl.adaptivity.xmlutil.serialization.XmlValue - -@Serializable -@XmlSerialName("feed", "", "") -data class OpdsDataClass( - @XmlElement(true) - val id: String, - @XmlElement(true) - val title: String, - @XmlElement(true) - val icon: String? = null, - @XmlElement(true) - val updated: String, // ISO-8601 - @XmlElement(true) - val author: Author? = null, - @XmlElement(true) - val links: List, - @XmlElement(true) - val entries: List, - @XmlSerialName("xmlns", "", "") - val xmlns: String = "http://www.w3.org/2005/Atom", - @XmlSerialName("xmlns:xsd", "", "") - val xmlnsXsd: String = "http://www.w3.org/2001/XMLSchema", - @XmlSerialName("xmlns:xsi", "", "") - val xmlnsXsi: String = "http://www.w3.org/2001/XMLSchema-instance", - @XmlSerialName("xmlns:opds", "", "") - val xmlnsOpds: String = "http://opds-spec.org/2010/catalog", - @XmlSerialName("xmlns:dcterms", "", "") - val xmlnsDublinCore: String = "http://purl.org/dc/terms/", - @XmlSerialName("xmlns:pse", "", "") - val xmlnsPse: String = "http://vaemendis.net/opds-pse/ns", - @XmlElement(true) - @XmlSerialName("totalResults", "http://a9.com/-/spec/opensearch/1.1/", "") - val totalResults: Long? = null, - @XmlElement(true) - @XmlSerialName("itemsPerPage", "http://a9.com/-/spec/opensearch/1.1/", "") - val itemsPerPage: Int? = null, - @XmlElement(true) - @XmlSerialName("startIndex", "http://a9.com/-/spec/opensearch/1.1/", "") - val startIndex: Int? = null, -) { - @Serializable - @XmlSerialName("author", "", "") - data class Author( - @XmlElement(true) - val name: String, - @XmlElement(true) - val uri: String? = null, - @XmlElement(true) - val email: String? = null, - ) - - @Serializable - @XmlSerialName("link", "", "") - data class Link( - val rel: String, - val href: String, - val type: String? = null, - val title: String? = null, - @XmlSerialName("pse:count", "", "") - val pseCount: Int? = null, - ) - - @Serializable - @XmlSerialName("entry", "", "") - data class Entry( - @XmlElement(true) - val id: String, - @XmlElement(true) - val title: String, - @XmlElement(true) - val updated: String, - @XmlElement(true) - val summary: Summary? = null, - @XmlElement(true) - val content: Content? = null, - @XmlElement(true) - val link: List, - @XmlElement(true) - val authors: List? = null, - @XmlElement(true) - val categories: List? = null, - @XmlElement(true) - @XmlSerialName("language", "http://purl.org/dc/terms/", "dc") - val extent: String? = null, - @XmlElement(true) - @XmlSerialName("format", "http://purl.org/dc/terms/format", "dc") - val format: String? = null, - ) - - @Serializable - @XmlSerialName("summary", "", "") - data class Summary( - val type: String = "text", - @XmlValue(true) val value: String = "", - ) - - @Serializable - @XmlSerialName("content", "", "") - data class Content( - val type: String = "text", - @XmlValue(true) val value: String = "", - ) - - @Serializable - @XmlSerialName("category", "", "") - data class Category( - val scheme: String? = null, - val term: String, - val label: String, - ) -} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt index 76c97c02..1673aaed 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt @@ -7,49 +7,71 @@ import suwayomi.tachidesk.opds.controller.OpdsV1Controller object OpdsAPI { fun defineEndpoints() { path("opds/v1.2") { - // Root feed (Navigation Feed) + // OPDS Catalog Root Feed (Navigation) get(OpdsV1Controller.rootFeed) - // Search Description + // OPDS Search Description Feed get("search", OpdsV1Controller.searchFeed) // Complete feed for crawlers // get("complete", OpdsV1Controller.completeFeed) - // Main groupings + // --- Main Navigation & Broad Acquisition Feeds --- + + // 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) + + // Library Updates Acquisition Feed get("library-updates", OpdsV1Controller.libraryUpdatesFeed) - // Faceted feeds (Acquisition Feeds) - path("manga/{mangaId}") { + // --- Filtered & Item-Specific Acquisition Feeds --- + + // Manga Chapters Acquisition Feed + path("manga/{mangaId}/chapters") { get(OpdsV1Controller.mangaFeed) } - path("manga/{mangaId}/chapter/{chapterId}/fetch") { + // Chapter Metadata Acquisition Feed + path("manga/{mangaId}/chapter/{chapterIndex}/metadata") { get(OpdsV1Controller.chapterMetadataFeed) } + // Source-Specific Manga Acquisition Feed path("source/{sourceId}") { get(OpdsV1Controller.sourceFeed) } + // Category-Specific Manga Acquisition Feed path("category/{categoryId}") { get(OpdsV1Controller.categoryFeed) } + // Genre-Specific Manga Acquisition Feed path("genre/{genre}") { get(OpdsV1Controller.genreFeed) } + // Status-Specific Manga Acquisition Feed path("status/{statusId}") { get(OpdsV1Controller.statusMangaFeed) } + // Language-Specific Manga Acquisition Feed 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 new file mode 100644 index 00000000..edbb915b --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/constants/OpdsConstants.kt @@ -0,0 +1,40 @@ +package suwayomi.tachidesk.opds.constants + +/** + * Constants for OPDS namespaces, link relationships, and media types. + */ +object OpdsConstants { + // Namespaces + const val NS_ATOM = "http://www.w3.org/2005/Atom" + const val NS_XML_SCHEMA = "http://www.w3.org/2001/XMLSchema" + const val NS_XML_SCHEMA_INSTANCE = "http://www.w3.org/2001/XMLSchema-instance" + const val NS_OPDS = "http://opds-spec.org/2010/catalog" + const val NS_DUBLIN_CORE = "http://purl.org/dc/terms/" + const val NS_PSE = "http://vaemendis.net/opds-pse/ns" + const val NS_OPENSEARCH = "http://a9.com/-/spec/opensearch/1.1/" + const val NS_THREAD = "http://purl.org/syndication/thread/1.0" + + // Link Relations + const val LINK_REL_ACQUISITION = "http://opds-spec.org/acquisition" + const val LINK_REL_ACQUISITION_OPEN_ACCESS = "http://opds-spec.org/acquisition/open-access" + const val LINK_REL_IMAGE = "http://opds-spec.org/image" + const val LINK_REL_IMAGE_THUMBNAIL = "http://opds-spec.org/image/thumbnail" + const val LINK_REL_SELF = "self" + const val LINK_REL_START = "start" + const val LINK_REL_SUBSECTION = "subsection" + const val LINK_REL_ALTERNATE = "alternate" + const val LINK_REL_FACET = "http://opds-spec.org/facet" + const val LINK_REL_SEARCH = "search" + const val LINK_REL_PREV = "prev" + const val LINK_REL_NEXT = "next" + const val LINK_REL_PSE_STREAM = "http://vaemendis.net/opds-pse/stream" + const val LINK_REL_CRAWLABLE = "http://opds-spec.org/crawlable" + + // Media Types + const val TYPE_ATOM_XML_FEED_NAVIGATION = "application/atom+xml;profile=opds-catalog;kind=navigation" + const val TYPE_ATOM_XML_FEED_ACQUISITION = "application/atom+xml;profile=opds-catalog;kind=acquisition" + const val TYPE_ATOM_XML_ENTRY_PROFILE_OPDS = "application/atom+xml;type=entry;profile=opds-catalog" + const val TYPE_OPENSEARCH_DESCRIPTION = "application/opensearchdescription+xml" + const val TYPE_IMAGE_JPEG = "image/jpeg" + const val TYPE_CBZ = "application/vnd.comicbook+zip" +} 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 9e1082dd..40552913 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt @@ -1,31 +1,37 @@ package suwayomi.tachidesk.opds.controller -import SearchCriteria import io.javalin.http.HttpStatus -import suwayomi.tachidesk.opds.impl.Opds +import suwayomi.tachidesk.i18n.LocalizationHelper +import suwayomi.tachidesk.i18n.MR +import suwayomi.tachidesk.opds.constants.OpdsConstants +import suwayomi.tachidesk.opds.dto.OpdsSearchCriteria +import suwayomi.tachidesk.opds.impl.OpdsFeedBuilder import suwayomi.tachidesk.server.JavalinSetup.future import suwayomi.tachidesk.server.util.handler import suwayomi.tachidesk.server.util.pathParam import suwayomi.tachidesk.server.util.queryParam import suwayomi.tachidesk.server.util.withOperation +import java.util.Locale object OpdsV1Controller { private const val OPDS_MIME = "application/xml;profile=opds-catalog;charset=UTF-8" private const val BASE_URL = "/api/opds/v1.2" - // Root Feed + // OPDS Catalog Root Feed val rootFeed = handler( + queryParam("lang"), documentWith = { withOperation { summary("OPDS Root Feed") - description("") + description("Top-level navigation feed for the OPDS catalog.") } }, - behaviorOf = { ctx -> + behaviorOf = { ctx, lang -> + val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { - Opds.getRootFeed(BASE_URL) + OpdsFeedBuilder.getRootFeed(BASE_URL, locale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } @@ -36,27 +42,30 @@ object OpdsV1Controller { }, ) - // Search Description + // OPDS Search Description Feed val searchFeed = handler( + queryParam("lang"), documentWith = { withOperation { summary("OpenSearch Description") - description("XML description for OPDS searches") + description("XML description for OPDS searches, enabling catalog search integration.") } }, - behaviorOf = { ctx -> + behaviorOf = { ctx, lang -> + val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) + ctx.contentType("application/opensearchdescription+xml").result( """ - Suwayomi OPDS Search - Search manga in the catalog + ${MR.strings.opds_search_shortname.localized(locale)} + ${MR.strings.opds_search_description.localized(locale)} UTF-8 UTF-8 - + template="$BASE_URL/mangas?query={searchTerms}&lang=${locale.toLanguageTag()}"/> """.trimIndent(), ) @@ -66,65 +75,40 @@ object OpdsV1Controller { }, ) - // Complete Feed for Crawlers -// val completeFeed = handler( -// documentWith = { -// withOperation { -// summary("OPDS Complete Acquisition Feed") -// description( -// "Complete Acquisition Feed for Crawling: " + -// "This feed provides a full representation of every unique catalog entry " + -// "to facilitate crawling and aggregation. " + -// "It must be referenced using the relation 'http://opds-spec.org/crawlable' " + -// "and is not paginated unless extremely large." -// ) -// } -// }, -// behaviorOf = { ctx -> -// ctx.future { -// future { -// Opds.getCompleteFeed(BASE_URL) -// }.thenApply { xml -> -// ctx.contentType("application/atom+xml;profile=opds-catalog;kind=acquisition").result(xml) -// } -// } -// }, -// withResults = { -// httpCode(HttpStatus.OK) -// }, -// ) + // --- Main Navigation & Broad Acquisition Feeds --- - // Main Manga Grouping - // Search Feed + // All Mangas / Search Results Feed val mangasFeed = handler( queryParam("pageNumber"), queryParam("query"), queryParam("author"), queryParam("title"), + queryParam("lang"), documentWith = { withOperation { summary("OPDS Mangas Feed") - description("OPDS feed for primary grouping of manga entries") + 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.", + ) } }, - behaviorOf = { ctx, pageNumber, query, author, title -> - if (query != null || author != null || title != null) { - val searchCriteria = SearchCriteria(query, author, title) - ctx.future { - future { - Opds.getMangasFeed(searchCriteria, BASE_URL, 1) - }.thenApply { xml -> - ctx.contentType(OPDS_MIME).result(xml) - } + 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 } - } else { - ctx.future { - future { - Opds.getMangasFeed(null, BASE_URL, pageNumber ?: 1) - }.thenApply { xml -> - ctx.contentType(OPDS_MIME).result(xml) - } + 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) } } }, @@ -133,20 +117,22 @@ object OpdsV1Controller { }, ) - // Main Sources Grouping + // Sources Navigation Feed val sourcesFeed = handler( queryParam("pageNumber"), + queryParam("lang"), documentWith = { withOperation { - summary("OPDS Sources Feed") - description("OPDS feed for primary grouping of manga sources") + summary("OPDS Sources Navigation Feed") + description("Navigation feed listing available manga sources. Each entry links to a feed for a specific source.") } }, - behaviorOf = { ctx, pageNumber -> + behaviorOf = { ctx, pageNumber, lang -> + val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { - Opds.getSourcesFeed(BASE_URL, pageNumber ?: 1) + OpdsFeedBuilder.getSourcesFeed(BASE_URL, pageNumber ?: 1, locale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } @@ -157,20 +143,22 @@ object OpdsV1Controller { }, ) - // Main Categories Grouping + // Categories Navigation Feed val categoriesFeed = handler( queryParam("pageNumber"), + queryParam("lang"), documentWith = { withOperation { - summary("OPDS Categories Feed") - description("OPDS feed for primary grouping of manga categories") + summary("OPDS Categories Navigation Feed") + description("Navigation feed listing available manga categories. Each entry links to a feed for a specific category.") } }, - behaviorOf = { ctx, pageNumber -> + behaviorOf = { ctx, pageNumber, lang -> + val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { - Opds.getCategoriesFeed(BASE_URL, pageNumber ?: 1) + OpdsFeedBuilder.getCategoriesFeed(BASE_URL, pageNumber ?: 1, locale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } @@ -181,20 +169,22 @@ object OpdsV1Controller { }, ) - // Main Genres Grouping + // Genres Navigation Feed val genresFeed = handler( queryParam("pageNumber"), + queryParam("lang"), documentWith = { withOperation { - summary("OPDS Genres Feed") - description("OPDS feed for primary grouping of manga genres") + summary("OPDS Genres Navigation Feed") + description("Navigation feed listing available manga genres. Each entry links to a feed for a specific genre.") } }, - behaviorOf = { ctx, pageNumber -> + behaviorOf = { ctx, pageNumber, lang -> + val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { - Opds.getGenresFeed(BASE_URL, pageNumber ?: 1) + OpdsFeedBuilder.getGenresFeed(BASE_URL, pageNumber ?: 1, locale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } @@ -205,20 +195,25 @@ object OpdsV1Controller { }, ) - // Main Status Grouping + // Status Navigation Feed val statusFeed = handler( queryParam("pageNumber"), + queryParam("lang"), documentWith = { withOperation { - summary("OPDS Status Feed") - description("OPDS feed for primary grouping of manga by status") + summary("OPDS Status Navigation Feed") + description( + "Navigation feed listing manga publication statuses. Each entry links to a feed for manga with a specific status.", + ) } }, - behaviorOf = { ctx, pageNumber -> + behaviorOf = { ctx, pageNumber, lang -> + val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { - Opds.getStatusFeed(BASE_URL, pageNumber ?: 1) + // Ignoramos pageNumber aquí, siempre usamos 1 + OpdsFeedBuilder.getStatusFeed(BASE_URL, 1, locale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } @@ -229,19 +224,24 @@ object OpdsV1Controller { }, ) - // Main Languages Grouping + // Content Languages Navigation Feed val languagesFeed = handler( + queryParam("lang"), documentWith = { withOperation { - summary("OPDS Languages Feed") - description("OPDS feed for primary grouping of available languages") + 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.", + ) } }, - behaviorOf = { ctx -> + behaviorOf = { ctx, lang -> + val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { - Opds.getLanguagesFeed(BASE_URL) + OpdsFeedBuilder.getLanguagesFeed(BASE_URL, locale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } @@ -252,21 +252,22 @@ object OpdsV1Controller { }, ) - // Manga Chapters Feed - val mangaFeed = + // Library Updates Acquisition Feed + val libraryUpdatesFeed = handler( - pathParam("mangaId"), queryParam("pageNumber"), + queryParam("lang"), documentWith = { withOperation { - summary("OPDS Manga Feed") - description("OPDS feed for chapters of a specific manga") + summary("OPDS Library Updates Feed") + description("Acquisition feed listing recent chapter updates for manga in the library. Supports pagination.") } }, - behaviorOf = { ctx, mangaId, pageNumber -> + behaviorOf = { ctx, pageNumber, lang -> + val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { - Opds.getMangaFeed(mangaId, BASE_URL, pageNumber ?: 1) + OpdsFeedBuilder.getLibraryUpdatesFeed(BASE_URL, pageNumber ?: 1, locale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } @@ -274,50 +275,28 @@ object OpdsV1Controller { }, withResults = { httpCode(HttpStatus.OK) - httpCode(HttpStatus.NOT_FOUND) }, ) - var chapterMetadataFeed = - handler( - pathParam("mangaId"), - pathParam("chapterId"), - documentWith = { - withOperation { - summary("OPDS Chapter Details Feed") - description("OPDS feed for a specific undownloaded chapter of a manga") - } - }, - behaviorOf = { ctx, mangaId, chapterId -> - ctx.future { - future { - Opds.getChapterMetadataFeed(mangaId, chapterId, BASE_URL) - }.thenApply { xml -> - ctx.contentType(OPDS_MIME).result(xml) - } - } - }, - withResults = { - httpCode(HttpStatus.OK) - httpCode(HttpStatus.NOT_FOUND) - }, - ) + // --- Filtered Acquisition Feeds --- - // Specific Source Feed + // Source-Specific Manga Acquisition Feed val sourceFeed = handler( pathParam("sourceId"), queryParam("pageNumber"), + queryParam("lang"), documentWith = { withOperation { - summary("OPDS Source Feed") - description("OPDS feed for a specific manga source") + summary("OPDS Source Specific Manga Feed") + description("Acquisition feed listing manga from a specific source. Supports pagination.") } }, - behaviorOf = { ctx, sourceId, pageNumber -> + behaviorOf = { ctx, sourceId, pageNumber, lang -> + val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { - Opds.getSourceFeed(sourceId, BASE_URL, pageNumber ?: 1) + OpdsFeedBuilder.getSourceFeed(sourceId, BASE_URL, pageNumber ?: 1, locale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } @@ -329,21 +308,23 @@ object OpdsV1Controller { }, ) - // Facet Feed: Specific Category + // Category-Specific Manga Acquisition Feed val categoryFeed = handler( pathParam("categoryId"), queryParam("pageNumber"), + queryParam("lang"), documentWith = { withOperation { - summary("OPDS Category Feed") - description("OPDS feed for a specific manga category") + summary("OPDS Category Specific Manga Feed") + description("Acquisition feed listing manga belonging to a specific category. Supports pagination.") } }, - behaviorOf = { ctx, categoryId, pageNumber -> + behaviorOf = { ctx, categoryId, pageNumber, lang -> + val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { - Opds.getCategoryFeed(categoryId, BASE_URL, pageNumber ?: 1) + OpdsFeedBuilder.getCategoryFeed(categoryId, BASE_URL, pageNumber ?: 1, locale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } @@ -355,21 +336,23 @@ object OpdsV1Controller { }, ) - // Facet Feed: Specific Genre + // Genre-Specific Manga Acquisition Feed val genreFeed = handler( pathParam("genre"), queryParam("pageNumber"), + queryParam("lang"), documentWith = { withOperation { - summary("OPDS Genre Feed") - description("OPDS feed for a specific manga genre") + summary("OPDS Genre Specific Manga Feed") + description("Acquisition feed listing manga belonging to a specific genre. Supports pagination.") } }, - behaviorOf = { ctx, genre, pageNumber -> + behaviorOf = { ctx, genre, pageNumber, lang -> + val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { - Opds.getGenreFeed(genre, BASE_URL, pageNumber ?: 1) + OpdsFeedBuilder.getGenreFeed(genre, BASE_URL, pageNumber ?: 1, locale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } @@ -381,21 +364,23 @@ object OpdsV1Controller { }, ) - // Facet Feed: Specific Status + // Status-Specific Manga Acquisition Feed val statusMangaFeed = handler( pathParam("statusId"), queryParam("pageNumber"), + queryParam("lang"), documentWith = { withOperation { - summary("OPDS Status Manga Feed") - description("OPDS feed for manga filtered by status") + summary("OPDS Status Specific Manga Feed") + description("Acquisition feed listing manga with a specific publication status. Supports pagination.") } }, - behaviorOf = { ctx, statusId, pageNumber -> + behaviorOf = { ctx, statusId, pageNumber, lang -> + val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { - Opds.getStatusMangaFeed(statusId, BASE_URL, pageNumber ?: 1) + OpdsFeedBuilder.getStatusMangaFeed(statusId, BASE_URL, pageNumber ?: 1, locale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } @@ -407,21 +392,23 @@ object OpdsV1Controller { }, ) - // Facet Feed: Specific Language + // Language-Specific Manga Acquisition Feed val languageFeed = handler( pathParam("langCode"), queryParam("pageNumber"), + queryParam("lang"), documentWith = { withOperation { - summary("OPDS Language Feed") - description("OPDS feed for manga filtered by language") + summary("OPDS Content Language Specific Manga Feed") + description("Acquisition feed listing manga of a specific content language. Supports pagination.") } }, - behaviorOf = { ctx, langCode, pageNumber -> + behaviorOf = { ctx, contentLangCodePath, pageNumber, uiLangParam -> + val uiLocale: Locale = LocalizationHelper.ctxToLocale(ctx, uiLangParam) ctx.future { future { - Opds.getLanguageFeed(langCode, BASE_URL, pageNumber ?: 1) + OpdsFeedBuilder.getLanguageFeed(contentLangCodePath, BASE_URL, pageNumber ?: 1, uiLocale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } @@ -433,20 +420,30 @@ object OpdsV1Controller { }, ) - // Main Library Updates Feed - val libraryUpdatesFeed = + // --- Item-Specific Acquisition Feeds --- + + // Manga Chapters Acquisition Feed + val mangaFeed = handler( + pathParam("mangaId"), queryParam("pageNumber"), + queryParam("sort"), + queryParam("filter"), + queryParam("lang"), documentWith = { withOperation { - summary("OPDS Library Updates Feed") - description("OPDS feed listing recent manga chapter updates") + 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.", + ) } }, - behaviorOf = { ctx, pageNumber -> + behaviorOf = { ctx, mangaId, pageNumber, sort, filter, lang -> + val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { - Opds.getLibraryUpdatesFeed(BASE_URL, pageNumber ?: 1) + OpdsFeedBuilder.getMangaFeed(mangaId, BASE_URL, pageNumber ?: 1, sort, filter, locale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } @@ -454,6 +451,38 @@ object OpdsV1Controller { }, withResults = { httpCode(HttpStatus.OK) + httpCode(HttpStatus.NOT_FOUND) + }, + ) + + // Chapter Metadata Acquisition Feed + val chapterMetadataFeed = + handler( + pathParam("mangaId"), + 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.", + ) + } + }, + behaviorOf = { ctx, mangaId, chapterIndex, lang -> + val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) + ctx.future { + future { + OpdsFeedBuilder.getChapterMetadataFeed(mangaId, chapterIndex, BASE_URL, locale) + }.thenApply { xml -> + ctx.contentType(OPDS_MIME).result(xml) + } + } + }, + withResults = { + httpCode(HttpStatus.OK) + httpCode(HttpStatus.NOT_FOUND) }, ) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsCategoryNavEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsCategoryNavEntry.kt new file mode 100644 index 00000000..571c233b --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsCategoryNavEntry.kt @@ -0,0 +1,6 @@ +package suwayomi.tachidesk.opds.dto + +data class OpdsCategoryNavEntry( + val id: Int, + val name: String, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterListAcqEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterListAcqEntry.kt new file mode 100644 index 00000000..fe491b22 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterListAcqEntry.kt @@ -0,0 +1,14 @@ +package suwayomi.tachidesk.opds.dto + +data class OpdsChapterListAcqEntry( + val id: Int, + val mangaId: Int, + val name: String, + val uploadDate: Long, + val chapterNumber: Float, + val scanlator: String?, + val read: Boolean, + val lastPageRead: Int, + 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 new file mode 100644 index 00000000..3f160e75 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsChapterMetadataAcqEntry.kt @@ -0,0 +1,15 @@ +package suwayomi.tachidesk.opds.dto + +data class OpdsChapterMetadataAcqEntry( + val id: Int, + val mangaId: Int, + val name: String, + val uploadDate: Long, + val scanlator: String?, + val read: Boolean, + val lastPageRead: Int, + val lastReadAt: Long, + val sourceOrder: Int, + val downloaded: Boolean, + val pageCount: Int, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsGenreNavEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsGenreNavEntry.kt new file mode 100644 index 00000000..fa80153f --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsGenreNavEntry.kt @@ -0,0 +1,6 @@ +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" +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsLanguageNavEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsLanguageNavEntry.kt new file mode 100644 index 00000000..d2273bea --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsLanguageNavEntry.kt @@ -0,0 +1,6 @@ +package suwayomi.tachidesk.opds.dto + +data class OpdsLanguageNavEntry( + val id: String, // langCode (e.g., "en") + val title: String, // Localized (e.g., "English") +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsLibraryUpdateAcqEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsLibraryUpdateAcqEntry.kt new file mode 100644 index 00000000..68e2972b --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsLibraryUpdateAcqEntry.kt @@ -0,0 +1,10 @@ +package suwayomi.tachidesk.opds.dto + +data class OpdsLibraryUpdateAcqEntry( + 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/OpdsMangaAcqEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaAcqEntry.kt new file mode 100644 index 00000000..0480dec9 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaAcqEntry.kt @@ -0,0 +1,12 @@ +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 inLibrary: Boolean, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaDetails.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaDetails.kt new file mode 100644 index 00000000..0d737718 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsMangaDetails.kt @@ -0,0 +1,8 @@ +package suwayomi.tachidesk.opds.dto + +data class OpdsMangaDetails( // Kept name, it's specific enough + val id: Int, + val title: String, + val thumbnailUrl: String?, + val author: String?, // Added for chapter entry authors +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsRootNavEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsRootNavEntry.kt new file mode 100644 index 00000000..5866cd0c --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsRootNavEntry.kt @@ -0,0 +1,8 @@ +package suwayomi.tachidesk.opds.dto + +data class OpdsRootNavEntry( + val id: String, + val title: String, // Localized + val description: String, // Localized + val linkType: String, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/model/SearchCriteria.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsSearchCriteria.kt similarity index 58% rename from server/src/main/kotlin/suwayomi/tachidesk/opds/model/SearchCriteria.kt rename to server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsSearchCriteria.kt index b45bc7df..d82995e7 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/model/SearchCriteria.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsSearchCriteria.kt @@ -1,4 +1,6 @@ -data class SearchCriteria( +package suwayomi.tachidesk.opds.dto + +data class OpdsSearchCriteria( val query: String? = null, val author: String? = null, val title: String? = null, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsSourceNavEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsSourceNavEntry.kt new file mode 100644 index 00000000..f9e49077 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsSourceNavEntry.kt @@ -0,0 +1,7 @@ +package suwayomi.tachidesk.opds.dto + +data class OpdsSourceNavEntry( + val id: Long, + val name: String, // Not localized + val iconUrl: String?, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsStatusNavEntry.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsStatusNavEntry.kt new file mode 100644 index 00000000..e12fe96c --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/dto/OpdsStatusNavEntry.kt @@ -0,0 +1,6 @@ +package suwayomi.tachidesk.opds.dto + +data class OpdsStatusNavEntry( + val id: Int, + val title: String, // Localized +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/Opds.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/Opds.kt deleted file mode 100644 index ff3765f2..00000000 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/Opds.kt +++ /dev/null @@ -1,946 +0,0 @@ -package suwayomi.tachidesk.opds.impl - -import SearchCriteria -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import nl.adaptivity.xmlutil.XmlDeclMode -import nl.adaptivity.xmlutil.core.XmlVersion -import nl.adaptivity.xmlutil.serialization.XML -import org.jetbrains.exposed.sql.JoinType -import org.jetbrains.exposed.sql.Op -import org.jetbrains.exposed.sql.SortOrder -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.lowerCase -import org.jetbrains.exposed.sql.or -import org.jetbrains.exposed.sql.selectAll -import org.jetbrains.exposed.sql.transactions.transaction -import suwayomi.tachidesk.manga.impl.ChapterDownloadHelper.getArchiveStreamWithSize -import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl -import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReady -import suwayomi.tachidesk.manga.impl.extension.Extension.getExtensionIconUrl -import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass -import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass -import suwayomi.tachidesk.manga.model.dataclass.SourceDataClass -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 -import suwayomi.tachidesk.manga.model.table.SourceTable -import suwayomi.tachidesk.manga.model.table.toDataClass -import suwayomi.tachidesk.opds.model.OpdsXmlModels -import suwayomi.tachidesk.server.serverConfig -import java.net.URLEncoder -import java.nio.charset.StandardCharsets -import java.time.Instant -import java.time.ZoneOffset -import java.time.format.DateTimeFormatter - -object Opds { - private val opdsItemsPerPageBounded: Int - get() = serverConfig.opdsItemsPerPage.value.coerceIn(10, 5000) - - fun getRootFeed(baseUrl: String): String { - val rootSection = - listOf( - "mangas" to "All Manga", - "sources" to "Sources", - "categories" to "Categories", - "genres" to "Genres", - "status" to "Status", - "languages" to "Languages", - "library-updates" to "Library Update History", - ) - val builder = - FeedBuilder(baseUrl, 1, "opds", "Suwayomi OPDS Catalog").apply { - totalResults = rootSection.size.toLong() - entries += - rootSection.map { (id, title) -> - OpdsXmlModels.Entry( - id = id, - title = title, - updated = formattedNow, - link = - listOf( - OpdsXmlModels.Link( - rel = "subsection", - href = "$baseUrl/$id", - type = "application/atom+xml;profile=opds-catalog;kind=navigation", - ), - ), - ) - } - } - return serialize(builder.build()) - } - - fun getMangasFeed( - criteria: SearchCriteria?, - baseUrl: String, - pageNum: Int, - ): String { - val (mangas, total) = - transaction { - val query = - MangaTable - .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) - .select(MangaTable.columns) - .where { - val conditions = mutableListOf>() - conditions += (MangaTable.inLibrary eq true) - - criteria?.query?.takeIf { it.isNotBlank() }?.let { q -> - val lowerQ = q.lowercase() - conditions += ( - (MangaTable.title.lowerCase() like "%$lowerQ%") or - (MangaTable.author.lowerCase() like "%$lowerQ%") or - (MangaTable.genre.lowerCase() like "%$lowerQ%") - ) - } - - criteria?.author?.takeIf { it.isNotBlank() }?.let { author -> - conditions += (MangaTable.author.lowerCase() like "%${author.lowercase()}%") - } - - criteria?.title?.takeIf { it.isNotBlank() }?.let { title -> - conditions += (MangaTable.title.lowerCase() like "%${title.lowercase()}%") - } - - conditions.reduce { acc, op -> acc and op } - }.groupBy(MangaTable.id) - .orderBy(MangaTable.title to SortOrder.ASC) - val totalCount = query.count() - val mangas = - query - .limit(opdsItemsPerPageBounded) - .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) - .map { MangaTable.toDataClass(it, includeMangaMeta = false) } - Pair(mangas, totalCount) - } - - val feedId = if (criteria == null) "mangas" else "search" - val feedTitle = if (criteria == null) "All Manga" else "Search results" - val searchQuery = criteria?.query?.takeIf { it.isNotBlank() } - - return FeedBuilder(baseUrl, pageNum, feedId, feedTitle, searchQuery) - .apply { - totalResults = total - entries += mangas.map { mangaEntry(it, baseUrl, formattedNow) } - }.build() - .let(::serialize) - } - - fun getSourcesFeed( - baseUrl: String, - pageNum: Int, - ): String { - val formattedNow = opdsDateFormatter.format(Instant.now()) - val (sourceList, totalCount) = - transaction { - val query = - SourceTable - .join(MangaTable, JoinType.INNER) { - MangaTable.sourceReference eq SourceTable.id - }.join(ChapterTable, JoinType.INNER) { - ChapterTable.manga eq MangaTable.id - }.select(SourceTable.columns) - .groupBy(SourceTable.id) - .orderBy(SourceTable.name to SortOrder.ASC) - - val totalCount = query.count() - val sources = - query - .limit(opdsItemsPerPageBounded) - .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) - .map { - SourceDataClass( - id = it[SourceTable.id].value.toString(), - name = it[SourceTable.name], - lang = it[SourceTable.lang], - iconUrl = "", - supportsLatest = false, - isConfigurable = false, - isNsfw = it[SourceTable.isNsfw], - displayName = "", - ) - } - Pair(sources, totalCount) - } - - return FeedBuilder(baseUrl, pageNum, "sources", "Sources") - .apply { - totalResults = totalCount - entries += - sourceList.map { - OpdsXmlModels.Entry( - updated = formattedNow, - id = it.id, - title = it.name, - link = - listOf( - OpdsXmlModels.Link( - rel = "subsection", - href = "$baseUrl/source/${it.id}", - type = "application/atom+xml;profile=opds-catalog;kind=navigation", - ), - ), - ) - } - }.build() - .let(::serialize) - } - - fun getCategoriesFeed( - baseUrl: String, - pageNum: Int, - ): String { - val formattedNow = opdsDateFormatter.format(Instant.now()) - val (categoryList, total) = - transaction { - val query = - CategoryTable - .join(CategoryMangaTable, JoinType.INNER, onColumn = CategoryTable.id, otherColumn = CategoryMangaTable.category) - .join(MangaTable, JoinType.INNER, onColumn = CategoryMangaTable.manga, otherColumn = MangaTable.id) - .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) - .select(CategoryTable.id, CategoryTable.name) - .groupBy(CategoryTable.id) - .orderBy(CategoryTable.order to SortOrder.ASC) - - val total = query.count() - - val paginated = - query - .limit(opdsItemsPerPageBounded) - .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) - .map { row -> Pair(row[CategoryTable.id].value, row[CategoryTable.name]) } - - Pair(paginated, total) - } - - return FeedBuilder(baseUrl, pageNum, "categories", "Categories") - .apply { - totalResults = total - entries += - categoryList.map { (id, name) -> - OpdsXmlModels.Entry( - id = "category/$id", - title = name, - updated = formattedNow, - link = - listOf( - OpdsXmlModels.Link( - rel = "subsection", - href = "$baseUrl/category/$id?pageNumber=1", - type = "application/atom+xml;profile=opds-catalog;kind=acquisition", - ), - ), - ) - } - }.build() - .let(::serialize) - } - - fun getGenresFeed( - baseUrl: String, - pageNum: Int, - ): String { - val formattedNow = opdsDateFormatter.format(Instant.now()) - val genres = - transaction { - MangaTable - .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) - .select(MangaTable.genre) - .map { it[MangaTable.genre] } - .flatMap { it?.split(", ")?.filterNot { g -> g.isBlank() } ?: emptyList() } - .groupingBy { it } - .eachCount() - .map { (genre, _) -> genre } - .sorted() - } - - val totalCount = genres.size - val fromIndex = (pageNum - 1) * opdsItemsPerPageBounded - val toIndex = minOf(fromIndex + opdsItemsPerPageBounded, totalCount) - val paginatedGenres = if (fromIndex < totalCount) genres.subList(fromIndex, toIndex) else emptyList() - - return serialize( - OpdsXmlModels( - id = "genres", - title = "Genres", - updated = formattedNow, - author = OpdsXmlModels.Author("Suwayomi", "https://suwayomi.org/"), - totalResults = totalCount.toLong(), - itemsPerPage = opdsItemsPerPageBounded, - startIndex = fromIndex + 1, - links = - listOf( - OpdsXmlModels.Link( - rel = "self", - href = "$baseUrl/genres?pageNumber=$pageNum", - type = "application/atom+xml;profile=opds-catalog;kind=navigation", - ), - OpdsXmlModels.Link( - rel = "start", - href = baseUrl, - type = "application/atom+xml;profile=opds-catalog;kind=navigation", - ), - ), - entries = - paginatedGenres.map { genre -> - OpdsXmlModels.Entry( - id = "genre/${genre.encodeURL()}", - title = genre, - updated = formattedNow, - link = - listOf( - OpdsXmlModels.Link( - rel = "subsection", - href = "$baseUrl/genre/${genre.encodeURL()}?pageNumber=1", - type = "application/atom+xml;profile=opds-catalog;kind=acquisition", - ), - ), - ) - }, - ), - ) - } - - fun getStatusFeed( - baseUrl: String, - pageNum: Int, - ): String { - val formattedNow = opdsDateFormatter.format(Instant.now()) - - val statuses = MangaStatus.entries.sortedBy { it.value } - val totalCount = statuses.size - val fromIndex = (pageNum - 1) * opdsItemsPerPageBounded - val toIndex = minOf(fromIndex + opdsItemsPerPageBounded, totalCount) - val paginatedStatuses = if (fromIndex < totalCount) statuses.subList(fromIndex, toIndex) else emptyList() - - return FeedBuilder(baseUrl, pageNum, "status", "Status") - .apply { - totalResults = totalCount.toLong() - entries += - paginatedStatuses.map { status -> - OpdsXmlModels.Entry( - id = "status/${status.value}", - title = - status.name - .lowercase() - .replace('_', ' ') - .replaceFirstChar { it.uppercase() }, - updated = formattedNow, - link = - listOf( - OpdsXmlModels.Link( - rel = "subsection", - href = "$baseUrl/status/${status.value}?pageNumber=1", - type = "application/atom+xml;profile=opds-catalog;kind=acquisition", - ), - ), - ) - } - }.build() - .let(::serialize) - } - - fun getLanguagesFeed(baseUrl: String): String { - val formattedNow = opdsDateFormatter.format(Instant.now()) - val languages = - transaction { - SourceTable - .join(MangaTable, JoinType.INNER, onColumn = SourceTable.id, otherColumn = MangaTable.sourceReference) - .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) - .select(SourceTable.lang) - .groupBy(SourceTable.lang) - .orderBy(SourceTable.lang to SortOrder.ASC) - .map { row -> row[SourceTable.lang] } - } - - return FeedBuilder(baseUrl, 1, "languages", "Languages") - .apply { - totalResults = languages.size.toLong() - entries += - languages.map { lang -> - OpdsXmlModels.Entry( - id = "language/$lang", - title = lang, - updated = formattedNow, - link = - listOf( - OpdsXmlModels.Link( - rel = "subsection", - href = "$baseUrl/language/$lang", - type = "application/atom+xml;profile=opds-catalog;kind=acquisition", - ), - ), - ) - } - }.build() - .let(::serialize) - } - - fun getMangaFeed( - mangaId: Int, - baseUrl: String, - pageNum: Int, - ): String { - val sortOrder = serverConfig.opdsChapterSortOrder.value - - val (manga, chapters, totalCount) = - transaction { - val mangaEntry = - MangaTable - .selectAll() - .where { MangaTable.id eq mangaId } - .first() - val mangaData = MangaTable.toDataClass(mangaEntry, includeMangaMeta = false) - - val chapterConditions = - buildList { - if (serverConfig.opdsShowOnlyUnreadChapters.value) { - add(ChapterTable.isRead eq false) - } - - if (serverConfig.opdsShowOnlyDownloadedChapters.value) { - add(ChapterTable.isDownloaded eq true) - } - add(ChapterTable.manga eq mangaId) - }.reduce { acc, op -> acc and op } - - val chaptersQuery = - ChapterTable - .selectAll() - .where { chapterConditions } - .orderBy(ChapterTable.sourceOrder to sortOrder) - - val total = chaptersQuery.count() - val chaptersData = - chaptersQuery - .limit(opdsItemsPerPageBounded) - .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) - .map { ChapterTable.toDataClass(it, includeChapterCount = false, includeChapterMeta = false) } - Triple(mangaData, chaptersData, total) - } - - return FeedBuilder(baseUrl, pageNum, "manga/$mangaId", manga.title) - .apply { - totalResults = totalCount - icon = manga.thumbnailUrl - manga.thumbnailUrl?.let { url -> - links += - OpdsXmlModels.Link( - rel = "http://opds-spec.org/image", - href = url, - type = "image/jpeg", - ) - links += - OpdsXmlModels.Link( - rel = "http://opds-spec.org/image/thumbnail", - href = url, - type = "image/jpeg", - ) - } - entries += chapters.map { createChapterEntry(it, manga, baseUrl, isMetaDataEntry = false) } - }.build() - .let(::serialize) - } - - suspend fun getChapterMetadataFeed( - mangaId: Int, - chapterIndex: Int, - baseUrl: String, - ): String { - val mangaData = - withContext(Dispatchers.IO) { - transaction { - val mangaEntry = - MangaTable - .selectAll() - .where { MangaTable.id eq mangaId } - .first() - MangaTable.toDataClass(mangaEntry, includeMangaMeta = false) - } - } - - val updatedChapterData = getChapterDownloadReady(chapterIndex = chapterIndex, mangaId = mangaId) - val updatedEntry = createChapterEntry(updatedChapterData, mangaData, baseUrl, isMetaDataEntry = true) - - return FeedBuilder( - baseUrl = baseUrl, - pageNum = 1, - id = "manga/$mangaId/chapter/$chapterIndex", - title = "${mangaData.title} | ${updatedChapterData.name} | Details", - ).apply { - totalResults = 1 - icon = mangaData.thumbnailUrl - mangaData.thumbnailUrl?.let { url -> - links += - OpdsXmlModels.Link( - rel = "http://opds-spec.org/image", - href = url, - type = "image/jpeg", - ) - links += - OpdsXmlModels.Link( - rel = "http://opds-spec.org/image/thumbnail", - href = url, - type = "image/jpeg", - ) - } - entries += listOf(updatedEntry) - }.build() - .let(::serialize) - } - - private fun createChapterEntry( - chapter: ChapterDataClass, - manga: MangaDataClass, - baseUrl: String, - isMetaDataEntry: Boolean, - addMangaTitleInEntry: Boolean = false, - ): OpdsXmlModels.Entry { - val chapterDetails = - buildString { - append("${manga.title} | ${chapter.name} | By ${chapter.scanlator}") - if (isMetaDataEntry) { - append(" | Progress (${chapter.lastPageRead} / ${chapter.pageCount})") - } - } - - val entryTitle = - when { - isMetaDataEntry -> "⬇" - chapter.read -> "✅" - chapter.lastPageRead > 0 -> "⌛" - chapter.pageCount == 0 -> "❌" - else -> "⭕" - } + (if (addMangaTitleInEntry) " ${manga.title} :" else "") + " ${chapter.name}" - - val cbzInputStreamPair = - runCatching { - if (isMetaDataEntry && chapter.downloaded) getArchiveStreamWithSize(manga.id, chapter.id) else null - }.getOrNull() - - val links = - mutableListOf().apply { - if (cbzInputStreamPair != null) { - add( - OpdsXmlModels.Link( - rel = "http://opds-spec.org/acquisition/open-access", - href = - "/api/v1/chapter/${chapter.id}/download" + - "?markAsRead=${serverConfig.opdsMarkAsReadOnDownload.value}", - type = "application/vnd.comicbook+zip", - ), - ) - } - if (isMetaDataEntry) { - add( - OpdsXmlModels.Link( - rel = "http://vaemendis.net/opds-pse/stream", - href = - "/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/{pageNumber}" + - "?updateProgress=${serverConfig.opdsEnablePageReadProgress.value}", - type = "image/jpeg", - pseCount = chapter.pageCount, - pseLastRead = chapter.lastPageRead.takeIf { it != 0 }, - ), - ) - add( - OpdsXmlModels.Link( - rel = "http://opds-spec.org/image", - href = "/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/0", - type = "image/jpeg", - ), - ) - } else { - add( - OpdsXmlModels.Link( - rel = "subsection", - href = "$baseUrl/manga/${manga.id}/chapter/${chapter.index}/fetch", - type = "application/atom+xml;profile=opds-catalog;kind=acquisition", - ), - ) - } - } - - return OpdsXmlModels.Entry( - id = "chapter/${chapter.id}", - title = entryTitle, - updated = opdsDateFormatter.format(Instant.ofEpochMilli(chapter.uploadDate)), - content = OpdsXmlModels.Content(value = chapterDetails), - summary = OpdsXmlModels.Summary(value = chapterDetails), - extent = cbzInputStreamPair?.second?.let { formatFileSize(it) }, - format = cbzInputStreamPair?.second?.let { "CBZ" }, - authors = - listOfNotNull( - manga.author?.let { OpdsXmlModels.Author(name = it) }, - manga.artist?.takeIf { it != manga.author }?.let { OpdsXmlModels.Author(name = it) }, - chapter.scanlator?.let { OpdsXmlModels.Author(name = it) }, - ), - link = links, - ) - } - - fun getSourceFeed( - sourceId: Long, - baseUrl: String, - pageNum: Int, - ): String { - val formattedNow = opdsDateFormatter.format(Instant.now()) - val (mangas, total, sourceRow) = - transaction { - val sourceRow = - SourceTable - .join(ExtensionTable, JoinType.INNER, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id) - .select(SourceTable.name, ExtensionTable.apkName) - .where { SourceTable.id eq sourceId } - .firstOrNull() - - val query = - MangaTable - .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) - .select(MangaTable.columns) - .where { - (MangaTable.sourceReference eq sourceId) - }.groupBy(MangaTable.id) - .orderBy(MangaTable.title to SortOrder.ASC) - - val totalCount = query.count() - val paginatedResults = - query - .limit(opdsItemsPerPageBounded) - .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) - .map { MangaTable.toDataClass(it, includeMangaMeta = false) } - - Triple(paginatedResults, totalCount, sourceRow) - } - - return FeedBuilder(baseUrl, pageNum, "source/$sourceId", sourceRow?.get(SourceTable.name) ?: "Source $sourceId") - .apply { - totalResults = total - icon = sourceRow?.get(ExtensionTable.apkName)?.let { getExtensionIconUrl(it) } - entries += mangas.map { mangaEntry(it, baseUrl, formattedNow) } - }.build() - .let(::serialize) - } - - fun getCategoryFeed( - categoryId: Int, - baseUrl: String, - pageNum: Int, - ): String { - val formattedNow = opdsDateFormatter.format(Instant.now()) - val (mangas, total, categoryName) = - transaction { - val categoryRow = CategoryTable.selectAll().where { CategoryTable.id eq categoryId }.firstOrNull() - if (categoryRow == null) { - return@transaction Triple(emptyList(), 0, "") - } - val categoryName = categoryRow[CategoryTable.name] - val query = - CategoryMangaTable - .join(MangaTable, JoinType.INNER, onColumn = CategoryMangaTable.manga, otherColumn = MangaTable.id) - .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) - .select(MangaTable.columns) - .where { (CategoryMangaTable.category eq categoryId) } - .groupBy(MangaTable.id) - .orderBy(MangaTable.title to SortOrder.ASC) - val totalCount = query.count() - val mangas = - query - .limit(opdsItemsPerPageBounded) - .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) - .map { MangaTable.toDataClass(it, includeMangaMeta = false) } - Triple(mangas, totalCount, categoryName) - } - return FeedBuilder(baseUrl, pageNum, "category/$categoryId", "Category: $categoryName") - .apply { - totalResults = total.toLong() - entries += mangas.map { mangaEntry(it, baseUrl, formattedNow) } - }.build() - .let(::serialize) - } - - fun getGenreFeed( - genre: String, - baseUrl: String, - pageNum: Int, - ): String { - val formattedNow = opdsDateFormatter.format(Instant.now()) - val (mangas, total) = - transaction { - val query = - MangaTable - .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) - .select(MangaTable.columns) - .where { (MangaTable.genre like "%$genre%") } - .groupBy(MangaTable.id) - .orderBy(MangaTable.title to SortOrder.ASC) - val totalCount = query.count() - val mangas = - query - .limit(opdsItemsPerPageBounded) - .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) - .map { MangaTable.toDataClass(it, includeMangaMeta = false) } - Pair(mangas, totalCount) - } - return FeedBuilder(baseUrl, pageNum, "genre/${genre.encodeURL()}", "Genre: $genre") - .apply { - totalResults = total - entries += mangas.map { mangaEntry(it, baseUrl, formattedNow) } - }.build() - .let(::serialize) - } - - fun getStatusMangaFeed( - statusId: Long, - baseUrl: String, - pageNum: Int, - ): String { - val formattedNow = opdsDateFormatter.format(Instant.now()) - val statusName = - MangaStatus - .valueOf(statusId.toInt()) - .name - .lowercase() - .replaceFirstChar { it.uppercase() } - val (mangas, total) = - transaction { - val query = - MangaTable - .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) - .select(MangaTable.columns) - .where { (MangaTable.status eq statusId.toInt()) } - .groupBy(MangaTable.id) - .orderBy(MangaTable.title to SortOrder.ASC) - val totalCount = query.count() - val mangas = - query - .limit(opdsItemsPerPageBounded) - .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) - .map { MangaTable.toDataClass(it, includeMangaMeta = false) } - Pair(mangas, totalCount) - } - return FeedBuilder(baseUrl, pageNum, "status/$statusId", "Status: $statusName") - .apply { - totalResults = total - entries += mangas.map { mangaEntry(it, baseUrl, formattedNow) } - }.build() - .let(::serialize) - } - - fun getLanguageFeed( - langCode: String, - baseUrl: String, - pageNum: Int, - ): String { - val formattedNow = opdsDateFormatter.format(Instant.now()) - val (mangas, total) = - transaction { - val query = - SourceTable - .join(MangaTable, JoinType.INNER, onColumn = SourceTable.id, otherColumn = MangaTable.sourceReference) - .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) - .select(MangaTable.columns) - .where { (SourceTable.lang eq langCode) } - .groupBy(MangaTable.id) - .orderBy(MangaTable.title to SortOrder.ASC) - val totalCount = query.count() - val mangas = - query - .limit(opdsItemsPerPageBounded) - .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) - .map { MangaTable.toDataClass(it, includeMangaMeta = false) } - Pair(mangas, totalCount) - } - return FeedBuilder(baseUrl, pageNum, "language/$langCode", "Language: $langCode") - .apply { - totalResults = total - entries += mangas.map { mangaEntry(it, baseUrl, formattedNow) } - }.build() - .let(::serialize) - } - - fun getLibraryUpdatesFeed( - baseUrl: String, - pageNum: Int, - ): String { - val (chapterToMangaMap, total) = - transaction { - val query = - ChapterTable - .join(MangaTable, JoinType.INNER, onColumn = ChapterTable.manga, otherColumn = MangaTable.id) - .selectAll() - .where { (MangaTable.inLibrary eq true) } - .orderBy(ChapterTable.fetchedAt to SortOrder.DESC, ChapterTable.sourceOrder to SortOrder.DESC) - - val totalCount = query.count() - val chapters = - query - .limit(opdsItemsPerPageBounded) - .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) - .map { - ChapterTable.toDataClass( - it, - includeChapterCount = false, - includeChapterMeta = false, - ) to MangaTable.toDataClass(it, includeMangaMeta = false) - } - - Pair(chapters, totalCount) - } - - return FeedBuilder(baseUrl, pageNum, "library-updates", "Library Updates") - .apply { - totalResults = total - entries += - chapterToMangaMap.map { - createChapterEntry( - it.first, - it.second, - baseUrl, - isMetaDataEntry = false, - addMangaTitleInEntry = true, - ) - } - }.build() - .let(::serialize) - } - - private class FeedBuilder( - val baseUrl: String, - val pageNum: Int, - val id: String, - val title: String, - val searchQuery: String? = null, - ) { - val formattedNow = opdsDateFormatter.format(Instant.now()) - var totalResults: Long = 0 - var icon: String? = null - val links = mutableListOf() - val entries = mutableListOf() - - fun build(): OpdsXmlModels = - OpdsXmlModels( - id = id, - title = title, - updated = formattedNow, - icon = icon, - author = OpdsXmlModels.Author("Suwayomi", "https://suwayomi.org/"), - links = - links + - listOfNotNull( - OpdsXmlModels.Link( - rel = "self", - href = - when { - id == "opds" -> baseUrl - searchQuery != null -> "$baseUrl/$id?query=$searchQuery" - else -> "$baseUrl/$id?pageNumber=$pageNum" - }, - type = "application/atom+xml;profile=opds-catalog;kind=acquisition", - ), - OpdsXmlModels.Link( - rel = "start", - href = baseUrl, - type = "application/atom+xml;profile=opds-catalog;kind=navigation", - ), - OpdsXmlModels.Link( - rel = "search", - type = "application/opensearchdescription+xml", - href = "$baseUrl/search", - ), - pageNum.takeIf { it > 1 }?.let { - OpdsXmlModels.Link( - rel = "prev", - href = "$baseUrl/$id?pageNumber=${it - 1}", - type = "application/atom+xml;profile=opds-catalog;kind=navigation", - ) - }, - (totalResults > pageNum * opdsItemsPerPageBounded).takeIf { it }?.let { - OpdsXmlModels.Link( - rel = "next", - href = "$baseUrl/$id?pageNumber=${pageNum + 1}", - type = "application/atom+xml;profile=opds-catalog;kind=navigation", - ) - }, - ), - entries = entries, - totalResults = totalResults, - itemsPerPage = opdsItemsPerPageBounded, - startIndex = (pageNum - 1) * opdsItemsPerPageBounded + 1, - ) - } - - private fun mangaEntry( - manga: MangaDataClass, - baseUrl: String, - formattedNow: String, - ): OpdsXmlModels.Entry { - val proxyThumb = manga.thumbnailUrl?.let { proxyThumbnailUrl(manga.id) } - - return OpdsXmlModels.Entry( - id = "manga/${manga.id}", - title = manga.title, - updated = formattedNow, - authors = manga.author?.let { listOf(OpdsXmlModels.Author(name = it)) }, - categories = - manga.genre.map { - OpdsXmlModels.Category(term = "", label = it) - }, - summary = manga.description?.let { OpdsXmlModels.Summary(value = it) }, - link = - listOfNotNull( - OpdsXmlModels.Link( - rel = "subsection", - href = "$baseUrl/manga/${manga.id}", - type = "application/atom+xml;profile=opds-catalog;kind=acquisition", - ), - proxyThumb?.let { - OpdsXmlModels.Link( - rel = "http://opds-spec.org/image", - href = it, - type = "image/jpeg", - ) - }, - proxyThumb?.let { - OpdsXmlModels.Link( - rel = "http://opds-spec.org/image/thumbnail", - href = it, - type = "image/jpeg", - ) - }, - ), - ) - } - - private fun String.encodeURL(): String = URLEncoder.encode(this, StandardCharsets.UTF_8.toString()) - - private val opdsDateFormatter = - DateTimeFormatter - .ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") - .withZone(ZoneOffset.UTC) - - private fun formatFileSize(size: Long): String = - when { - size >= 1_000_000 -> "%.2f MB".format(size / 1_000_000.0) - size >= 1_000 -> "%.2f KB".format(size / 1_000.0) - else -> "$size bytes" - } - - private val xmlFormat = - XML { - indent = 2 - xmlVersion = XmlVersion.XML10 - xmlDeclMode = XmlDeclMode.Charset - defaultPolicy { - autoPolymorphic = true - } - } - - private fun serialize(feed: OpdsXmlModels): String = xmlFormat.encodeToString(OpdsXmlModels.serializer(), feed) -} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt new file mode 100644 index 00000000..ae5197d2 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt @@ -0,0 +1,1017 @@ +package suwayomi.tachidesk.opds.impl + +import dev.icerock.moko.resources.StringResource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +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.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.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 + +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 --- + + fun getRootFeed( + baseUrl: String, + locale: Locale, + ): String { + 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), + ) + }, + ) + } + 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( + baseUrl: String, + pageNum: Int, + locale: Locale, + ): String { + val (mangaEntries, total) = MangaRepository.getAllManga(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) }) + } + return OpdsXmlUtil.serializeFeedToString(builder.build()) + } + + private fun getMangaSearchResultsFeed( + criteria: OpdsSearchCriteria, + baseUrl: String, + 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) }) + } + return OpdsXmlUtil.serializeFeedToString(builder.build()) + } + + fun getSourcesFeed( + baseUrl: String, + pageNum: Int, + locale: Locale, + ): String { + val (sourceNavEntries, total) = NavigationRepository.getSources(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 + ) + }, + ) + } + return OpdsXmlUtil.serializeFeedToString(builder.build()) + } + + fun getCategoriesFeed( + baseUrl: String, + pageNum: Int, + locale: Locale, + ): String { + 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, + ), + ), + ) + }, + ) + } + return OpdsXmlUtil.serializeFeedToString(builder.build()) + } + + fun getGenresFeed( + baseUrl: String, + pageNum: Int, + locale: Locale, + ): String { + 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, + ), + ), + ) + }, + ) + } + return OpdsXmlUtil.serializeFeedToString(builder.build()) + } + + // `pageNum` is ignored, always fetches all, and sets pageNum = null in builder. + fun getStatusFeed( + baseUrl: String, + @Suppress("UNUSED_PARAMETER") pageNum: Int, + locale: Locale, + ): String { + 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, + ), + ), + ) + }, + ) + } + return OpdsXmlUtil.serializeFeedToString(builder.build()) + } + + fun getLanguagesFeed( + baseUrl: String, + uiLocale: Locale, + ): String { + 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, + ), + ), + ) + }, + ) + } + return OpdsXmlUtil.serializeFeedToString(builder.build()) + } + + // --- Specific Acquisition Feed Generators --- + + fun getMangaFeed( + mangaId: Int, + baseUrl: String, + pageNum: Int, + sortParam: String?, + filterParam: String?, + locale: Locale, + ): String { + val mangaDetails = + MangaRepository.getMangaDetails(mangaId) + ?: return buildNotFoundFeed( + baseUrl, + "manga/$mangaId", + 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 + "desc", "number_desc" -> ChapterTable.sourceOrder to SortOrder.DESC + "date_asc" -> ChapterTable.date_upload to SortOrder.ASC + "date_desc" -> ChapterTable.date_upload to SortOrder.DESC + else -> ChapterTable.sourceOrder to (serverConfig.opdsChapterSortOrder.value ?: SortOrder.ASC) + } + val currentFilter = filterParam?.lowercase() ?: if (serverConfig.opdsShowOnlyUnreadChapters.value) "unread" else "all" + + val (chapterEntries, totalChapters) = + ChapterRepository.getChaptersForManga( + mangaId, + pageNum, + sortColumn, + currentSortOrder, + currentFilter, + ) + + 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 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, + 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) }) + } + return OpdsXmlUtil.serializeFeedToString(builder.build()) + } + + suspend fun getChapterMetadataFeed( + mangaId: Int, + chapterSourceOrder: Int, + baseUrl: String, + locale: Locale, + ): String { + val mangaDetails = + MangaRepository.getMangaDetails(mangaId) + ?: return buildNotFoundFeed( + baseUrl, + "manga/$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", + 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( + locale, + sourceNameOrId, + ) + + 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), + ), + ) + } + } + + 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, + ) + } + + // --- 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( + baseUrl: String, + idPath: String, + title: String, + locale: Locale, + ): String = + FeedBuilderInternal(baseUrl, idPath, title, locale, feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, pageNum = null) + .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/model/OpdsAuthorXml.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsAuthorXml.kt new file mode 100644 index 00000000..799a86a5 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsAuthorXml.kt @@ -0,0 +1,14 @@ +package suwayomi.tachidesk.opds.model + +import kotlinx.serialization.Serializable +import nl.adaptivity.xmlutil.serialization.XmlElement + +@Serializable +data class OpdsAuthorXml( + @XmlElement(true) + val name: String, + @XmlElement(true) + val uri: String? = null, + @XmlElement(true) + val email: String? = null, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsCategoryXml.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsCategoryXml.kt new file mode 100644 index 00000000..4a6889f0 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsCategoryXml.kt @@ -0,0 +1,10 @@ +package suwayomi.tachidesk.opds.model + +import kotlinx.serialization.Serializable + +@Serializable +data class OpdsCategoryXml( + val scheme: String? = null, + val term: String, + val label: String, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsContentXml.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsContentXml.kt new file mode 100644 index 00000000..5cef7747 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsContentXml.kt @@ -0,0 +1,11 @@ +package suwayomi.tachidesk.opds.model + +import kotlinx.serialization.Serializable +import nl.adaptivity.xmlutil.serialization.XmlValue + +@Serializable +data class OpdsContentXml( + val type: String = "text", + @XmlValue(true) + val value: String = "", +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsEntryXml.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsEntryXml.kt new file mode 100644 index 00000000..cfcca80e --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsEntryXml.kt @@ -0,0 +1,50 @@ +package suwayomi.tachidesk.opds.model + +import kotlinx.serialization.Serializable +import nl.adaptivity.xmlutil.serialization.XmlElement +import nl.adaptivity.xmlutil.serialization.XmlSerialName +import suwayomi.tachidesk.opds.constants.OpdsConstants + +@Serializable +data class OpdsEntryXml( + @XmlElement(true) + val id: String, + @XmlElement(true) + val title: String, + @XmlElement(true) + val updated: String, + @XmlElement(true) + @XmlSerialName("summary", OpdsConstants.NS_ATOM, "") + val summary: OpdsSummaryXml? = null, + @XmlElement(true) + @XmlSerialName("content", OpdsConstants.NS_ATOM, "") + val content: OpdsContentXml? = null, + @XmlElement(true) + @XmlSerialName("link", OpdsConstants.NS_ATOM, "") + val link: List, + @XmlElement(true) + @XmlSerialName("author", OpdsConstants.NS_ATOM, "") + val authors: List? = null, + @XmlElement(true) + @XmlSerialName("category", OpdsConstants.NS_ATOM, "") + val categories: List? = null, + // Dublin Core elements + @XmlElement(true) + @XmlSerialName("extent", OpdsConstants.NS_DUBLIN_CORE, "dc") + val extent: String? = null, // SizeOrDuration - Example: "150 pages" or "02:30:00" + @XmlElement(true) + @XmlSerialName("format", OpdsConstants.NS_DUBLIN_CORE, "dc") + val format: String? = null, // MediaType - Example: "application/pdf" or "image/jpeg" + @XmlElement(true) + @XmlSerialName("language", OpdsConstants.NS_DUBLIN_CORE, "dc") + val language: String? = null, // LinguisticSystem - Example: "en" or "eng" + @XmlElement(true) + @XmlSerialName("publisher", OpdsConstants.NS_DUBLIN_CORE, "dc") + val publisher: String? = null, // Agent - Example: "Random House" or "John Doe" + @XmlElement(true) + @XmlSerialName("issued", OpdsConstants.NS_DUBLIN_CORE, "dc") + val issued: String? = null, // W3CDTF - Example: "2023-05-23" or "2023-05-23T15:30:00Z" + @XmlElement(true) + @XmlSerialName("identifier", OpdsConstants.NS_DUBLIN_CORE, "dc") + val identifier: String? = null, // URI - Example: "urn:isbn:0-486-27557-4" or "https://doi.org/10.1000/182" +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsFeedXml.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsFeedXml.kt new file mode 100644 index 00000000..adee9dc3 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsFeedXml.kt @@ -0,0 +1,56 @@ +package suwayomi.tachidesk.opds.model + +import kotlinx.serialization.Serializable +import nl.adaptivity.xmlutil.serialization.XmlElement +import nl.adaptivity.xmlutil.serialization.XmlSerialName +import suwayomi.tachidesk.opds.constants.OpdsConstants + +@Serializable +@XmlSerialName("feed", OpdsConstants.NS_ATOM, "") // Root element in Atom namespace +data class OpdsFeedXml( + // Namespace declarations + @XmlSerialName("xmlns", "", "") + val xmlns: String = OpdsConstants.NS_ATOM, + @XmlSerialName("xmlns:xsd", "", "") + val xmlnsXsd: String = OpdsConstants.NS_XML_SCHEMA, + @XmlSerialName("xmlns:xsi", "", "") + val xmlnsXsi: String = OpdsConstants.NS_XML_SCHEMA_INSTANCE, + @XmlSerialName("xmlns:opds", "", "") + val xmlnsOpds: String = OpdsConstants.NS_OPDS, + @XmlSerialName("xmlns:dc", "", "") + val xmlnsDublinCore: String = OpdsConstants.NS_DUBLIN_CORE, + @XmlSerialName("xmlns:pse", "", "") + val xmlnsPse: String = OpdsConstants.NS_PSE, + @XmlSerialName("xmlns:opensearch", "", "") + val xmlnsOpenSearch: String = OpdsConstants.NS_OPENSEARCH, + @XmlSerialName("xmlns:thr", "", "") + val xmlnsThread: String = OpdsConstants.NS_THREAD, + // Core elements + @XmlElement(true) + val id: String, + @XmlElement(true) + val title: String, + @XmlElement(true) + val icon: String? = null, + @XmlElement(true) + val updated: String, + @XmlElement(true) + @XmlSerialName("author", OpdsConstants.NS_ATOM, "") + val author: OpdsAuthorXml? = null, + @XmlElement(true) + @XmlSerialName("link", OpdsConstants.NS_ATOM, "") + val links: List, + @XmlElement(true) + @XmlSerialName("entry", OpdsConstants.NS_ATOM, "") + val entries: List, + // OpenSearch elements + @XmlElement(true) + @XmlSerialName("totalResults", OpdsConstants.NS_OPENSEARCH, "opensearch") + val totalResults: Long? = null, + @XmlElement(true) + @XmlSerialName("itemsPerPage", OpdsConstants.NS_OPENSEARCH, "opensearch") + val itemsPerPage: Int? = null, + @XmlElement(true) + @XmlSerialName("startIndex", OpdsConstants.NS_OPENSEARCH, "opensearch") + val startIndex: Int? = null, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsIndirectAcquisitionXml.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsIndirectAcquisitionXml.kt new file mode 100644 index 00000000..15cfa271 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsIndirectAcquisitionXml.kt @@ -0,0 +1,15 @@ +package suwayomi.tachidesk.opds.model + +import kotlinx.serialization.Serializable +import nl.adaptivity.xmlutil.serialization.XmlElement +import nl.adaptivity.xmlutil.serialization.XmlSerialName +import suwayomi.tachidesk.opds.constants.OpdsConstants + +@Serializable +@XmlSerialName("indirectAcquisition", OpdsConstants.NS_OPDS, "opds") +data class OpdsIndirectAcquisitionXml( + val type: String, + @XmlElement(true) + @XmlSerialName("indirectAcquisition", OpdsConstants.NS_OPDS, "opds") + val children: List? = null, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsLinkXml.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsLinkXml.kt new file mode 100644 index 00000000..d22b1462 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsLinkXml.kt @@ -0,0 +1,32 @@ +package suwayomi.tachidesk.opds.model + +import kotlinx.serialization.Serializable +import nl.adaptivity.xmlutil.serialization.XmlElement +import nl.adaptivity.xmlutil.serialization.XmlSerialName +import suwayomi.tachidesk.opds.constants.OpdsConstants + +@Serializable +data class OpdsLinkXml( + val rel: String, + val href: String, + val type: String? = null, + val title: String? = null, + // OPDS Facets + @XmlSerialName("facetGroup", OpdsConstants.NS_OPDS, "opds") + val facetGroup: String? = null, + @XmlSerialName("activeFacet", OpdsConstants.NS_OPDS, "opds") + val activeFacet: Boolean? = null, + // Thread count + @XmlSerialName("count", OpdsConstants.NS_THREAD, "thr") + val thrCount: Int? = null, + // OPDS-PSE attributes + @XmlSerialName("count", OpdsConstants.NS_PSE, "pse") + val pseCount: Int? = null, + @XmlSerialName("lastRead", OpdsConstants.NS_PSE, "pse") + val pseLastRead: Int? = null, + @XmlSerialName("lastReadDate", OpdsConstants.NS_PSE, "pse") + val pseLastReadDate: String? = null, + @XmlElement(true) + @XmlSerialName("indirectAcquisition", OpdsConstants.NS_OPDS, "opds") + val indirectAcquisition: List? = null, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsSummaryXml.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsSummaryXml.kt new file mode 100644 index 00000000..0a8fc2b9 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsSummaryXml.kt @@ -0,0 +1,11 @@ +package suwayomi.tachidesk.opds.model + +import kotlinx.serialization.Serializable +import nl.adaptivity.xmlutil.serialization.XmlValue + +@Serializable +data class OpdsSummaryXml( + val type: String = "text", + @XmlValue(true) + val value: String = "", +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsXmlModels.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsXmlModels.kt deleted file mode 100644 index cb14efb8..00000000 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsXmlModels.kt +++ /dev/null @@ -1,138 +0,0 @@ -package suwayomi.tachidesk.opds.model - -import kotlinx.serialization.Serializable -import nl.adaptivity.xmlutil.serialization.XmlElement -import nl.adaptivity.xmlutil.serialization.XmlSerialName -import nl.adaptivity.xmlutil.serialization.XmlValue - -@Serializable -@XmlSerialName("feed", "", "") -data class OpdsXmlModels( - @XmlElement(true) - val id: String, - @XmlElement(true) - val title: String, - @XmlElement(true) - val icon: String? = null, - @XmlElement(true) - val updated: String, // ISO-8601 - @XmlElement(true) - val author: Author? = null, - @XmlElement(true) - val links: List, - @XmlElement(true) - val entries: List, - @XmlSerialName("xmlns", "", "") - val xmlns: String = "http://www.w3.org/2005/Atom", - @XmlSerialName("xmlns:xsd", "", "") - val xmlnsXsd: String = "http://www.w3.org/2001/XMLSchema", - @XmlSerialName("xmlns:xsi", "", "") - val xmlnsXsi: String = "http://www.w3.org/2001/XMLSchema-instance", - @XmlSerialName("xmlns:opds", "", "") - val xmlnsOpds: String = "http://opds-spec.org/2010/catalog", - @XmlSerialName("xmlns:dcterms", "", "") - val xmlnsDublinCore: String = "http://purl.org/dc/terms/", - @XmlSerialName("xmlns:pse", "", "") - val xmlnsPse: String = "http://vaemendis.net/opds-pse/ns", - @XmlElement(true) - @XmlSerialName("totalResults", "http://a9.com/-/spec/opensearch/1.1/", "") - val totalResults: Long? = null, - @XmlElement(true) - @XmlSerialName("itemsPerPage", "http://a9.com/-/spec/opensearch/1.1/", "") - val itemsPerPage: Int? = null, - @XmlElement(true) - @XmlSerialName("startIndex", "http://a9.com/-/spec/opensearch/1.1/", "") - val startIndex: Int? = null, -) { - @Serializable - @XmlSerialName("author", "", "") - data class Author( - @XmlElement(true) - val name: String, - @XmlElement(true) - val uri: String? = null, - @XmlElement(true) - val email: String? = null, - ) - - @Serializable - @XmlSerialName("link", "", "") - data class Link( - val rel: String, - val href: String, - val type: String? = null, - val title: String? = null, - @XmlSerialName("pse:count", "", "") - val pseCount: Int? = null, - @XmlSerialName("pse:lastRead", "", "") - val pseLastRead: Int? = null, - @XmlSerialName("opds:facetGroup", "", "") - val facetGroup: String? = null, - @XmlSerialName("opds:activeFacet", "", "") - val activeFacet: Boolean? = null, - val indirectAcquisition: List? = null, - ) - - @Serializable - @XmlSerialName("opds:indirectAcquisition", "", "") - data class OpdsIndirectAcquisition( - @XmlSerialName("type") val type: String, - ) - - @Serializable - @XmlSerialName("entry", "", "") - data class Entry( - @XmlElement(true) - val id: String, - @XmlElement(true) - val title: String, - @XmlElement(true) - val updated: String, - @XmlElement(true) - val summary: Summary? = null, - @XmlElement(true) - val content: Content? = null, - @XmlElement(true) - val link: List, - @XmlElement(true) - val authors: List? = null, - @XmlElement(true) - val categories: List? = null, - @XmlElement(true) - @XmlSerialName("extent", "http://purl.org/dc/terms/", "") - val extent: String? = null, - @XmlElement(true) - @XmlSerialName("format", "http://purl.org/dc/terms/format", "") - val format: String? = null, - @XmlSerialName("dc:language") - val language: String? = null, - @XmlSerialName("dc:publisher") - val publisher: String? = null, - @XmlSerialName("dc:issued") - val issued: String? = null, - @XmlSerialName("dc:identifier") - val identifier: String? = null, - ) - - @Serializable - @XmlSerialName("summary", "", "") - data class Summary( - val type: String = "text", - @XmlValue(true) val value: String = "", - ) - - @Serializable - @XmlSerialName("content", "", "") - data class Content( - val type: String = "text", - @XmlValue(true) val value: String = "", - ) - - @Serializable - @XmlSerialName("category", "", "") - data class Category( - val scheme: String? = null, - val term: String, - val label: String, - ) -} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/ChapterRepository.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/ChapterRepository.kt new file mode 100644 index 00000000..5743f240 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/ChapterRepository.kt @@ -0,0 +1,130 @@ +package suwayomi.tachidesk.opds.repository + +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.JoinType +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.and +import org.jetbrains.exposed.sql.transactions.transaction +import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReady +import suwayomi.tachidesk.manga.model.table.ChapterTable +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.OpdsLibraryUpdateAcqEntry +import suwayomi.tachidesk.server.serverConfig + +object ChapterRepository { + private val opdsItemsPerPageBounded: Int + get() = serverConfig.opdsItemsPerPage.value.coerceIn(10, 5000) + + private fun ResultRow.toOpdsChapterListAcqEntry(): OpdsChapterListAcqEntry = + OpdsChapterListAcqEntry( + id = this[ChapterTable.id].value, + mangaId = this[ChapterTable.manga].value, + name = this[ChapterTable.name], + uploadDate = this[ChapterTable.date_upload], + chapterNumber = this[ChapterTable.chapter_number], + scanlator = this[ChapterTable.scanlator], + read = this[ChapterTable.isRead], + lastPageRead = this[ChapterTable.lastPageRead], + sourceOrder = this[ChapterTable.sourceOrder], + pageCount = this[ChapterTable.pageCount], + ) + + fun getChaptersForManga( + mangaId: Int, + pageNum: Int, + sortColumn: Column<*>, + sortOrder: SortOrder, + filter: String, + ): Pair, Long> = + transaction { + val conditions = mutableListOf>() + conditions.add(ChapterTable.manga eq mangaId) + + when (filter) { + "unread" -> conditions.add(ChapterTable.isRead eq false) + "read" -> conditions.add(ChapterTable.isRead eq true) + // "all" -> no additional condition + } + if (serverConfig.opdsShowOnlyDownloadedChapters.value) { + conditions.add(ChapterTable.isDownloaded eq true) + } + + val finalCondition = conditions.reduceOrNull { acc, op -> acc and op } ?: Op.TRUE + + val baseQuery = + ChapterTable + .select(ChapterTable.columns) + .where(finalCondition) + + val totalCount = baseQuery.count() + + val chapters = + baseQuery + .orderBy(sortColumn to sortOrder) + .limit(opdsItemsPerPageBounded) + .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) + .map { it.toOpdsChapterListAcqEntry() } + + Pair(chapters, totalCount) + } + + suspend fun getChapterDetailsForMetadataFeed( + mangaId: Int, + chapterSourceOrder: Int, + ): OpdsChapterMetadataAcqEntry? = + try { + val chapterDataClass = getChapterDownloadReady(chapterIndex = chapterSourceOrder, mangaId = mangaId) + OpdsChapterMetadataAcqEntry( + id = chapterDataClass.id, + mangaId = chapterDataClass.mangaId, + name = chapterDataClass.name, + uploadDate = chapterDataClass.uploadDate, + scanlator = chapterDataClass.scanlator, + read = chapterDataClass.read, + lastPageRead = chapterDataClass.lastPageRead, + lastReadAt = chapterDataClass.lastReadAt, + sourceOrder = chapterDataClass.index, + downloaded = chapterDataClass.downloaded, + pageCount = chapterDataClass.pageCount, + ) + } catch (e: Exception) { + null + } + + fun getLibraryUpdates(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 { MangaTable.inLibrary eq true } + + val totalCount = query.count() + + val items = + query + .orderBy(ChapterTable.fetchedAt to SortOrder.DESC, ChapterTable.sourceOrder to SortOrder.DESC) + .limit(opdsItemsPerPageBounded) + .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) + .map { + OpdsLibraryUpdateAcqEntry( + chapter = it.toOpdsChapterListAcqEntry(), // This will work if ChapterTable columns do not collide + 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) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/MangaRepository.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/MangaRepository.kt new file mode 100644 index 00000000..93952b3f --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/MangaRepository.kt @@ -0,0 +1,236 @@ +package suwayomi.tachidesk.opds.repository + +import org.jetbrains.exposed.sql.JoinType +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.like +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.lowerCase +import org.jetbrains.exposed.sql.or +import org.jetbrains.exposed.sql.transactions.transaction +import suwayomi.tachidesk.manga.model.dataclass.toGenreList +import suwayomi.tachidesk.manga.model.table.CategoryMangaTable +import suwayomi.tachidesk.manga.model.table.ChapterTable +import suwayomi.tachidesk.manga.model.table.MangaTable +import suwayomi.tachidesk.manga.model.table.SourceTable +import suwayomi.tachidesk.opds.dto.OpdsMangaAcqEntry +import suwayomi.tachidesk.opds.dto.OpdsMangaDetails +import suwayomi.tachidesk.opds.dto.OpdsSearchCriteria +import suwayomi.tachidesk.server.serverConfig + +object MangaRepository { + private val opdsItemsPerPageBounded: Int + get() = serverConfig.opdsItemsPerPage.value.coerceIn(10, 5000) + + private fun ResultRow.toOpdsMangaAcqEntry(): OpdsMangaAcqEntry = + OpdsMangaAcqEntry( + id = this[MangaTable.id].value, + title = this[MangaTable.title], + author = this[MangaTable.author], + genres = this[MangaTable.genre].toGenreList(), + description = this[MangaTable.description], + thumbnailUrl = this[MangaTable.thumbnail_url], + sourceLang = this.getOrNull(SourceTable.lang), + inLibrary = this[MangaTable.inLibrary], + ) + + fun getAllManga(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.inLibrary eq true } + .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) + } + + fun findMangaByCriteria(criteria: OpdsSearchCriteria): Pair, Long> = + transaction { + val conditions = mutableListOf>() + conditions += (MangaTable.inLibrary eq true) + + criteria.query?.takeIf { it.isNotBlank() }?.let { q -> + val lowerQ = q.lowercase() + conditions += ( + (MangaTable.title.lowerCase() like "%$lowerQ%") or + (MangaTable.author.lowerCase() like "%$lowerQ%") or + (MangaTable.genre.lowerCase() like "%$lowerQ%") + ) + } + criteria.author?.takeIf { it.isNotBlank() }?.let { author -> + conditions += (MangaTable.author.lowerCase() like "%${author.lowercase()}%") + } + criteria.title?.takeIf { it.isNotBlank() }?.let { title -> + conditions += (MangaTable.title.lowerCase() like "%${title.lowercase()}%") + } + + val finalCondition = conditions.reduceOrNull { acc, op -> acc and op } ?: Op.TRUE + + 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(finalCondition) + .groupBy(MangaTable.id, SourceTable.lang) + .orderBy(MangaTable.title to SortOrder.ASC) + + val totalCount = query.count() + val mangas = + query + .limit(opdsItemsPerPageBounded) + .map { it.toOpdsMangaAcqEntry() } + Pair(mangas, totalCount) + } + + fun getMangaDetails(mangaId: Int): OpdsMangaDetails? = + transaction { + MangaTable + .select(MangaTable.id, MangaTable.title, MangaTable.thumbnail_url, MangaTable.author) + .where { MangaTable.id eq mangaId } + .firstOrNull() + ?.let { + OpdsMangaDetails( + id = it[MangaTable.id].value, + title = it[MangaTable.title], + thumbnailUrl = it[MangaTable.thumbnail_url], + author = it[MangaTable.author], + ) + } + } + + 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) + + val totalCount = query.count() + val mangas = + query + .limit(opdsItemsPerPageBounded) + .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) + .map { it.toOpdsMangaAcqEntry() } + Pair(mangas, totalCount) + } + + 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) + } + + fun getMangaByGenre( + genre: String, + pageNum: Int, + ): Pair, Long> = + transaction { + val genreTrimmed = genre.trim() + 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.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) + + val totalCount = query.count() + val mangas = + query + .limit(opdsItemsPerPageBounded) + .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) + .map { it.toOpdsMangaAcqEntry() } + Pair(mangas, totalCount) + } + + 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 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) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/NavigationRepository.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/NavigationRepository.kt new file mode 100644 index 00000000..37d781c2 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/NavigationRepository.kt @@ -0,0 +1,210 @@ +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.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 +import suwayomi.tachidesk.manga.model.table.SourceTable +import suwayomi.tachidesk.opds.constants.OpdsConstants +import suwayomi.tachidesk.opds.dto.OpdsCategoryNavEntry +import suwayomi.tachidesk.opds.dto.OpdsGenreNavEntry +import suwayomi.tachidesk.opds.dto.OpdsLanguageNavEntry +import suwayomi.tachidesk.opds.dto.OpdsRootNavEntry +import suwayomi.tachidesk.opds.dto.OpdsSourceNavEntry +import suwayomi.tachidesk.opds.dto.OpdsStatusNavEntry +import suwayomi.tachidesk.opds.util.OpdsStringUtil.encodeForOpdsURL +import suwayomi.tachidesk.server.serverConfig +import java.util.Locale + +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 + Triple( + OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, + MR.strings.opds_feeds_all_manga_title, + MR.strings.opds_feeds_all_manga_entry_content, + ), + "sources" to + Triple( + OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, + MR.strings.opds_feeds_sources_title, + MR.strings.opds_feeds_sources_entry_content, + ), + "categories" to + Triple( + OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, + MR.strings.opds_feeds_categories_title, + MR.strings.opds_feeds_categories_entry_content, + ), + "genres" to + Triple( + OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, + MR.strings.opds_feeds_genres_title, + MR.strings.opds_feeds_genres_entry_content, + ), + "status" to + Triple( + OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, + MR.strings.opds_feeds_status_title, + MR.strings.opds_feeds_status_entry_content, + ), + "languages" to + Triple( + OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION, + 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) -> + val (linkType, titleRes, descriptionRes) = details + OpdsRootNavEntry( + id = id, + title = titleRes.localized(locale), + description = descriptionRes.localized(locale), + linkType = linkType, + ) + } + + fun getSources(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) + .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) }, + ) + } + Pair(sources, totalCount) + } + + fun getCategories(pageNum: Int): Pair, Long> = + transaction { + 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) + .groupBy(CategoryTable.id, CategoryTable.name) + .orderBy(CategoryTable.order to SortOrder.ASC) + + val totalCount = query.count() + val categories = + query + .limit(opdsItemsPerPageBounded) + .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong()) + .map { + OpdsCategoryNavEntry( + id = it[CategoryTable.id].value, + name = it[CategoryTable.name], + ) + } + Pair(categories, totalCount) + } + + fun getGenres( + pageNum: Int, + locale: Locale, + ): Pair, Long> = + transaction { + val genres = + MangaTable + .join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga) + .select(MangaTable.genre) + .mapNotNull { it[MangaTable.genre] } + .flatMap { it.split(",").map(String::trim).filterNot(String::isBlank) } + .distinct() + .sorted() + + val totalCount = genres.size.toLong() + val fromIndex = ((pageNum - 1) * opdsItemsPerPageBounded) + val toIndex = minOf(fromIndex + opdsItemsPerPageBounded, genres.size) + val paginatedGenres = + (if (fromIndex < genres.size) genres.subList(fromIndex, toIndex) else emptyList()) + .map { genreName -> + OpdsGenreNavEntry( + id = genreName.encodeForOpdsURL(), + title = genreName, + ) + } + 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, + MangaStatus.ONGOING to MR.strings.manga_status_ongoing, + MangaStatus.COMPLETED to MR.strings.manga_status_completed, + MangaStatus.LICENSED to MR.strings.manga_status_licensed, + MangaStatus.PUBLISHING_FINISHED to MR.strings.manga_status_publishing_finished, + MangaStatus.CANCELLED to MR.strings.manga_status_cancelled, + MangaStatus.ON_HIATUS to MR.strings.manga_status_on_hiatus, + ) + + return MangaStatus.entries + .map { mangaStatus -> + val titleRes = statusStringResources[mangaStatus] ?: MR.strings.manga_status_unknown + OpdsStatusNavEntry( + id = mangaStatus.value, + title = titleRes.localized(locale), + ) + }.sortedBy { it.id } + } + + fun getContentLanguages(uiLocale: Locale): List = + transaction { + SourceTable + .join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference) + .join(ChapterTable, JoinType.INNER, MangaTable.id, ChapterTable.manga) + .select(SourceTable.lang) + .groupBy(SourceTable.lang) + .map { it[SourceTable.lang] } + .sorted() + .map { langCode -> + OpdsLanguageNavEntry( + id = langCode, + title = + Locale.forLanguageTag(langCode).getDisplayName(uiLocale).replaceFirstChar { + if (it.isLowerCase()) it.titlecase(uiLocale) else it.toString() + }, + ) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/util/OpdsDateUtil.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/util/OpdsDateUtil.kt new file mode 100644 index 00000000..f97cc0ed --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/util/OpdsDateUtil.kt @@ -0,0 +1,38 @@ +package suwayomi.tachidesk.opds.util + +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +/** + * Utilities for handling dates in OPDS format. + * The OPDS standard uses RFC 3339 formatted dates. + */ +object OpdsDateUtil { + /** + * Date formatter for OPDS in RFC 3339 format. + * Example: "2023-05-23T15:30:00Z" + */ + val opdsDateFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneOffset.UTC) + + /** + * Formats the current date and time for OPDS. + * @return String with the formatted current date + */ + fun formatCurrentInstantForOpds(): String = opdsDateFormatter.format(Instant.now()) + + /** + * Formats a specific instant for OPDS. + * @param instant The instant to format + * @return String with the formatted date + */ + fun formatInstantForOpds(instant: Instant): String = opdsDateFormatter.format(instant) + + /** + * Formats a timestamp in milliseconds for OPDS. + * @param epochMillis Time in milliseconds since Unix epoch + * @return String with the formatted date + */ + fun formatEpochMillisForOpds(epochMillis: Long): String = opdsDateFormatter.format(Instant.ofEpochMilli(epochMillis)) +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/util/OpdsStringUtil.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/util/OpdsStringUtil.kt new file mode 100644 index 00000000..e05120d2 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/util/OpdsStringUtil.kt @@ -0,0 +1,64 @@ +package suwayomi.tachidesk.opds.util + +import suwayomi.tachidesk.server.serverConfig +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.text.Normalizer + +/** + * Utilities for string handling in the OPDS context. + */ +object OpdsStringUtil { + private val DIACRITICS_REGEX = "\\p{InCombiningDiacriticalMarks}+".toRegex() + + /** + * Encodes a string to be used in OPDS URLs. + * @return The URL-encoded string + */ + fun String.encodeForOpdsURL(): String = URLEncoder.encode(this, StandardCharsets.UTF_8.toString()) + + /** + * Converts a string into a URL-friendly slug. + * e.g., "Virtual Reality" -> "virtual-reality" + * @return The slugified string + */ + fun String.slugify(): String { + val normalized = Normalizer.normalize(this, Normalizer.Form.NFD) + val slug = + DIACRITICS_REGEX + .replace(normalized, "") + .lowercase() + .replace(Regex("[^a-z0-9]+"), "-") // Replace non-alphanumeric with hyphens + .replace(Regex("-+"), "-") // Replace multiple hyphens with single + .trim('-') + return slug + } + + /** + * Formats a size in bytes to a human-readable representation. + * Uses binary (KiB, MiB, GiB, TiB) or decimal (KB, MB, GB, TB) units based on server configuration. + * + * @param size Size in bytes + * @return Human-readable representation of the size + */ + fun formatFileSizeForOpds(size: Long): String = + if (serverConfig.opdsUseBinaryFileSizes.value) { + // Binary notation (base 1024) + when { + size >= 1_125_899_906_842_624 -> "%.2f TiB".format(size / 1_125_899_906_842_624.0) // 1024^4 + size >= 1_073_741_824 -> "%.2f GiB".format(size / 1_073_741_824.0) // 1024^3 + size >= 1_048_576 -> "%.2f MiB".format(size / 1_048_576.0) // 1024^2 + size >= 1024 -> "%.2f KiB".format(size / 1024.0) // 1024 + else -> "$size bytes" + } + } else { + // Decimal notation (base 1000) + when { + size >= 1_000_000_000_000 -> "%.2f TB".format(size / 1_000_000_000_000.0) + size >= 1_000_000_000 -> "%.2f GB".format(size / 1_000_000_000.0) + size >= 1_000_000 -> "%.2f MB".format(size / 1_000_000.0) + size >= 1_000 -> "%.2f KB".format(size / 1_000.0) + else -> "$size bytes" + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/util/OpdsXmlUtil.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/util/OpdsXmlUtil.kt new file mode 100644 index 00000000..b0ca563a --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/util/OpdsXmlUtil.kt @@ -0,0 +1,31 @@ +package suwayomi.tachidesk.opds.util + +import nl.adaptivity.xmlutil.XmlDeclMode +import nl.adaptivity.xmlutil.core.XmlVersion +import nl.adaptivity.xmlutil.serialization.XML +import suwayomi.tachidesk.opds.model.OpdsFeedXml + +/** + * Utilities for XML serialization in the OPDS context. + */ +object OpdsXmlUtil { + /** + * Configuration for the XML serializer for OPDS. + */ + val opdsXmlMapper: XML = + XML { + indent = 2 + xmlVersion = XmlVersion.XML10 + xmlDeclMode = XmlDeclMode.Charset + defaultPolicy { + autoPolymorphic = true + } + } + + /** + * Serializes an OPDS feed to its XML string representation. + * @param feed The OPDS feed to serialize + * @return XML string representation of the feed + */ + fun serializeFeedToString(feed: OpdsFeedXml): String = opdsXmlMapper.encodeToString(OpdsFeedXml.serializer(), feed) +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt index ae0e76d3..6b4cc021 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt @@ -157,6 +157,7 @@ class ServerConfig( val flareSolverrAsResponseFallback: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) // opds settings + val opdsUseBinaryFileSizes: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) val opdsItemsPerPage: MutableStateFlow by OverrideConfigValue(IntConfigAdapter) val opdsEnablePageReadProgress: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) val opdsMarkAsReadOnDownload: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt index e7fc56f7..81a8d0e7 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt @@ -23,6 +23,7 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider import org.koin.core.context.startKoin import org.koin.core.module.Module import org.koin.dsl.module +import suwayomi.tachidesk.i18n.LocalizationHelper import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupExport import suwayomi.tachidesk.manga.impl.download.DownloadManager import suwayomi.tachidesk.manga.impl.update.IUpdater @@ -222,6 +223,10 @@ fun applicationSetup() { // fixes #119 , ref: https://github.com/Suwayomi/Suwayomi-Server/issues/119#issuecomment-894681292 , source Id calculation depends on String.lowercase() Locale.setDefault(Locale.ENGLISH) + // Initialize the localization service + LocalizationHelper.initialize() + logger.debug { "Localization service initialized. Supported languages: ${LocalizationHelper.getSupportedLocales()}" } + databaseUp() LocalSource.register() diff --git a/server/src/main/resources/server-reference.conf b/server/src/main/resources/server-reference.conf index dd6b09f1..b4b0d988 100644 --- a/server/src/main/resources/server-reference.conf +++ b/server/src/main/resources/server-reference.conf @@ -72,6 +72,7 @@ server.flareSolverrSessionTtl = 15 # time in minutes server.flareSolverrAsResponseFallback = false # OPDS +server.opdsUseBinaryFileSizes = false # if the file sizes should be displayed in binary (KiB, MiB, GiB) or decimal (KB, MB, GB) server.opdsItemsPerPage = 50 # Range (10 - 5000) server.opdsEnablePageReadProgress = true server.opdsMarkAsReadOnDownload = false diff --git a/settings.gradle.kts b/settings.gradle.kts index c56ccc71..60ff2bd0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,6 +1,7 @@ rootProject.name = System.getenv("ProductName") ?: "Suwayomi-Server" include("server") +include("server:i18n") include("AndroidCompat") include("AndroidCompat:Config")