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")