feat(opds): implement full internationalization and refactor feed gen… (#1405)

* feat(opds): implement full internationalization and refactor feed generation

This commit introduces a comprehensive internationalization (i18n) framework
and significantly refactors the OPDS v1.2 implementation for improved
robustness, spec compliance, and localization.

Key changes:

Internationalization (`i18n`):
- Introduces `LocalizationService` to manage translations:
    - Loads localized strings from JSON files (e.g., `en.json`, `es.json`)
      stored in a new `i18n` data directory.
    - Default `en.json` and `es.json` files are bundled and copied from
      resources on first run if not present.
    - Supports template resolution with `$t()` cross-references, locale
      fallbacks (to "en" by default), and argument interpolation ({{placeholder}}).
- `ServerSetup` now initializes the `i18n` directory and `LocalizationService`.

OPDS Refactor & Enhancements:
- Replaces the previous `Opds.kt` and `OpdsDataClass.kt` with a new
  `OpdsFeedBuilder.kt` and a set of more granular, spec-aligned XML
  models (e.g., `OpdsFeedXml`, `OpdsEntryXml`, `OpdsLinkXml`).
- Integrates `LocalizationService` throughout all OPDS feeds:
    - All user-facing text (feed titles, entry titles, summaries,
      link titles, facet labels for sorting/filtering) is now localized.
    - Adds a `lang` query parameter to all OPDS endpoints to allow
      clients to request a specific UI language.
    - Uses the `Accept-Language` header as a fallback for language detection.
- The OpenSearch description (`/search` endpoint) is now localized and
  its template URL includes the determined language.
- Centralizes OPDS constants (namespaces, link relations, media types)
  in `OpdsConstants.kt`.
- Adds utility classes `OpdsDateUtil.kt`, `OpdsStringUtil.kt`, and
  `OpdsXmlUtil.kt` for common OPDS tasks.
- `MangaDataClass` now includes `sourceLang` to provide the content
  language of the manga in OPDS entries (`<dc:language>`).
- Updates OpenAPI documentation for OPDS endpoints with more detail
  and includes the new `lang` parameter.

Configuration:
- Adds `useBinaryFileSizes` server configuration option. File sizes in
  OPDS feeds now respect this setting (e.g., MiB vs MB), utilized via
  `OpdsStringUtil.formatFileSizeForOpds`.

This major refactor addresses the request for internationalization
originally mentioned in PR #1257 ("it would be great if messages were
adapted based on the user's language settings"). It builds upon the
foundational OPDS work in #1257 and subsequent enhancements in #1262,
#1263, #1278, and #1392, providing a more stable and extensible
OPDS implementation. Features like localized facet titles from #1392
are now fully integrated with the i18n system.

This resolves long-standing requests for better OPDS support (e.g., issue #769)
by making feeds more user-friendly, accessible, and standards-compliant,
also improving the robustness of features requested in #1390 (resolved by #1392)
and addressing underlying data needs for issues like #1265 (related to #1277, #1278).

* fix(opds): revert MIME type to application/xml for browser compatibility

* fix(opds): use chapter index for metadata feed and correct link relation

- Change `getChapterMetadataFeed` to use `chapterIndexFromPath` (sourceOrder)
  instead of `chapterIdFromPath` for fetching chapter data, ensuring
  consistency with how chapters are identified in manga feeds.
- Add error handling for cases where manga or chapter by index is not found.
- Correct OPDS link relation for chapter detail/fetch link in non-metadata
  chapter entries from `alternate` to `subsection` as per OPDS spec
  for navigation to more specific content or views.

* Use Moko-Resources

* Format

* Forgot the Languages.json

* refactor(opds)!: restructure OPDS feeds and introduce data repositories

This commit significantly refactors the OPDS v1.2 implementation by introducing dedicated repository classes for data fetching and by restructuring the feed generation logic for clarity and maintainability. The `chapterId` path parameter for chapter metadata feeds has been changed to `chapterIndex` (sourceOrder) to align with how chapters are identified in manga feeds.

BREAKING CHANGE: The OPDS endpoint for chapter metadata has changed from `/api/opds/v1.2/manga/{mangaId}/chapter/{chapterId}/fetch` to `/api/opds/v1.2/manga/{mangaId}/chapter/{chapterIndex}/fetch`. Clients will need to update to use the chapter's source order (index) instead of its database ID.

Key changes:
- Introduced `MangaRepository`, `ChapterRepository`, and `NavigationRepository` to encapsulate database queries and data transformation logic for OPDS feeds.
- Moved data fetching logic from `OpdsFeedBuilder` to these new repositories.
- `OpdsFeedBuilder` now primarily focuses on constructing the XML feed structure using DTOs provided by the repositories.
- Renamed `OpdsMangaAcqEntry.thumbnailUrl` to `rawThumbnailUrl` for clarity.
- Added various DTOs (e.g., `OpdsRootNavEntry`, `OpdsMangaDetails`, `OpdsChapterListAcqEntry`) to define clear data contracts between repositories and the feed builder.
- Simplified `OpdsV1Controller` by reorganizing feed endpoints into logical groups (Main Navigation, Filtered Acquisition, Item-Specific).
- Updated `OpdsAPI` to reflect the path parameter change for chapter metadata (`chapterIndex` instead of `chapterId`).
- Added `slugify()` utility to `OpdsStringUtil` for creating URL-friendly genre IDs.
- Standardized localization keys for root feed entry descriptions to use `*.entryContent` instead of `*.description`.
- Added `server.generated.BuildConfig` (likely from build process).

* style(opds): apply ktlint fixes

* Delete server/bin

* refactor(i18n): remove custom LocalizationService initialization

* refactor(i18n): remove unused imports from ServerSetup

* refactor(model): remove sourceLang from MangaDataClass

* refactor(opds): rename OPDS binary file size config property

- Rename `useBinaryFileSizes` to `opdsUseBinaryFileSizes` in code and config
- Update related condition check in formatFileSizeForOpds

BREAKING CHANGE: Existing server configurations using `server.useBinaryFileSizes` need to migrate to `server.opdsUseBinaryFileSizes`

* refactor(opds): improve OPDS endpoint structure and documentation

- Restructure endpoint paths for better resource hierarchy
- Add descriptive comments for each feed type and purpose
- Rename `/fetch` endpoint to `/metadata` for clarity
- Standardize feed naming conventions in route definitions

BREAKING CHANGE: Existing OPDS client integrations using old endpoint paths (`/manga/{mangaId}` and `/chapter/{chapterIndex}/fetch`) require updates to new paths (`/manga/{mangaId}/chapters` and `/chapter/{chapterIndex}/metadata`)

* fix(opds): Apply review suggestions for localization and comments

* Fix

* fix(opds): Update chapter links to include 'chapters' and 'metadata' in URLs

---------

Co-authored-by: Syer10 <syer10@users.noreply.github.com>
This commit is contained in:
Zeedif
2025-05-26 18:46:14 -06:00
committed by GitHub
parent a9e03837a3
commit 61f429896c
45 changed files with 2586 additions and 1359 deletions

View File

@@ -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<KotlinJvmCompile> {
dependsOn("ktlintFormat")
if (plugins.hasPlugin(KtlintPlugin::class)) {
dependsOn("ktlintFormat")
}
compilerOptions {
jvmTarget = JvmTarget.JVM_21
freeCompilerArgs.add("-Xcontext-receivers")

View File

@@ -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",

View File

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

View File

@@ -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)
}
}
}
}

View File

@@ -0,0 +1 @@
{"langs":["en","es"]}

View File

@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8" ?>
<resources>
<string name="opds_search_shortname">Suwayomi OPDS Search</string>
<string name="opds_search_description">Search manga in the catalog</string>
<string name="opds_feeds_root">Suwayomi OPDS Catalog</string>
<string name="opds_feeds_manga_chapters">%1$s Chapters</string>
<string name="opds_feeds_chapter_details">%1$s | %2$s | Details</string>
<string name="opds_feeds_all_manga_title">All Manga</string>
<string name="opds_feeds_all_manga_entry_content">Browse all manga in your library</string>
<string name="opds_feeds_search_results">Search Results</string>
<string name="opds_feeds_sources_title">Sources</string>
<string name="opds_feeds_sources_entry_content">Browse manga by source</string>
<string name="opds_feeds_categories_title">Categories</string>
<string name="opds_feeds_categories_entry_content">Browse manga organized by categories</string>
<string name="opds_feeds_genres_title">Genres</string>
<string name="opds_feeds_genres_entry_content">Browse manga by genre tags</string>
<string name="opds_feeds_status_title">Status</string>
<string name="opds_feeds_status_entry_content">Browse manga by publication status</string>
<string name="opds_feeds_languages_title">Languages</string>
<string name="opds_feeds_languages_entry_content">Browse manga by content language</string>
<string name="opds_feeds_library_updates_title">Library Update History</string>
<string name="opds_feeds_library_updates_entry_content">Recently updated chapters from your library</string>
<string name="opds_feeds_category_specific_title">Category: %1$s</string>
<string name="opds_feeds_genre_specific_title">Genre: %1$s</string>
<string name="opds_feeds_status_specific_title">Status: %1$s</string>
<string name="opds_feeds_language_specific_title">Language: %1$s</string>
<string name="opds_feeds_source_specific_title">Source: %1$s</string>
<string name="opds_error_manga_not_found">Manga with ID %1$d not found</string>
<string name="opds_error_chapter_not_found">Chapter with index %1$d not found</string>
<string name="opds_facetgroup_sort_order">Sort Order</string>
<string name="opds_facetgroup_read_status">Read Status</string>
<string name="opds_facet_sort_oldest_first">Oldest First</string>
<string name="opds_facet_sort_newest_first">Newest First</string>
<string name="opds_facet_sort_date_asc">Date ascending</string>
<string name="opds_facet_sort_date_desc">Date descending</string>
<string name="opds_facet_filter_all_chapters">All Chapters</string>
<string name="opds_facet_filter_unread_only">Unread Only</string>
<string name="opds_facet_filter_read_only">Read Only</string>
<string name="opds_linktitle_view_chapter_details">View Chapter Details &amp; Get Pages</string>
<string name="opds_linktitle_download_cbz">Download CBZ</string>
<string name="opds_linktitle_stream_pages">View Pages (Streaming)</string>
<string name="opds_linktitle_chapter_cover">Chapter Cover</string>
<string name="opds_linktitle_current_page">Current Page</string>
<string name="opds_linktitle_catalog_root">Catalog Root</string>
<string name="opds_linktitle_search_catalog">Search Catalog</string>
<string name="opds_linktitle_previous_page">Previous Page</string>
<string name="opds_linktitle_next_page">Next Page</string>
<string name="opds_linktitle_self_feed">Current Feed</string>
<string name="opds_chapter_status_downloaded">⬇️ </string>
<string name="opds_chapter_status_read"></string>
<string name="opds_chapter_status_in_progress"></string>
<string name="opds_chapter_status_error">⚠️ </string>
<string name="opds_chapter_status_unknown"></string>
<string name="opds_chapter_status_unread"></string>
<string name="opds_chapter_details_base">%1$s | %2$s</string>
<string name="opds_chapter_details_scanlator"> | By %1$s</string>
<string name="opds_chapter_details_progress"> | Progress: %1$d of %2$d</string>
<string name="manga_status_unknown">Unknown</string>
<string name="manga_status_ongoing">Ongoing</string>
<string name="manga_status_completed">Completed</string>
<string name="manga_status_licensed">Licensed</string>
<string name="manga_status_publishing_finished">Publishing Finished</string>
<string name="manga_status_cancelled">Cancelled</string>
<string name="manga_status_on_hiatus">On Hiatus</string>
</resources>

View File

@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8" ?>
<resources>
<string name="opds_search_shortname">Búsqueda OPDS de Suwayomi</string>
<string name="opds_search_description">Buscar mangas en el catálogo</string>
<string name="opds_feeds_root">Catálogo OPDS de Suwayomi</string>
<string name="opds_feeds_manga_chapters">Capítulos de %1$s</string>
<string name="opds_feeds_chapter_details">%1$s | Detalles de %2$s</string>
<string name="opds_feeds_all_manga_title">Todos los mangas</string>
<string name="opds_feeds_all_manga_entry_content">Explorar todos los mangas en tu biblioteca</string>
<string name="opds_feeds_search_results">Resultados de búsqueda</string>
<string name="opds_feeds_sources_title">Fuentes</string>
<string name="opds_feeds_sources_entry_content">Explorar mangas por fuente</string>
<string name="opds_feeds_categories_title">Categorías</string>
<string name="opds_feeds_categories_entry_content">Explorar mangas organizados por categorías</string>
<string name="opds_feeds_genres_title">Géneros</string>
<string name="opds_feeds_genres_entry_content">Explorar mangas por etiquetas de género</string>
<string name="opds_feeds_status_title">Estado</string>
<string name="opds_feeds_status_entry_content">Explorar mangas por estado de publicación</string>
<string name="opds_feeds_languages_title">Idiomas</string>
<string name="opds_feeds_languages_entry_content">Explorar mangas por idioma del contenido</string>
<string name="opds_feeds_library_updates_title">Historial de actualizaciones</string>
<string name="opds_feeds_library_updates_entry_content">Capítulos recientemente actualizados de tu biblioteca</string>
<string name="opds_feeds_category_specific_title">Categoría: %1$s</string>
<string name="opds_feeds_genre_specific_title">Género: %1$s</string>
<string name="opds_feeds_status_specific_title">Estado: %1$s</string>
<string name="opds_feeds_language_specific_title">Idioma: %1$s</string>
<string name="opds_feeds_source_specific_title">Fuente: %1$s</string>
<string name="opds_facetgroup_sort_order">Ordenar por</string>
<string name="opds_facetgroup_read_status">Estado de lectura</string>
<string name="opds_error_manga_not_found">Manga con ID %1$d no encontrado</string>
<string name="opds_error_chapter_not_found">Capítulo con índice %1$d no encontrado</string>
<string name="opds_facet_sort_oldest_first">Más antiguos primero</string>
<string name="opds_facet_sort_newest_first">Más recientes primero</string>
<string name="opds_facet_sort_date_asc">Fecha ascendente</string>
<string name="opds_facet_sort_date_desc">Fecha descendente</string>
<string name="opds_facet_filter_all_chapters">Todos los capítulos</string>
<string name="opds_facet_filter_unread_only">Solo sin leer</string>
<string name="opds_facet_filter_read_only">Solo leídos</string>
<string name="opds_linktitle_view_chapter_details">Ver detalles del capítulo y obtener páginas</string>
<string name="opds_linktitle_download_cbz">Descargar CBZ</string>
<string name="opds_linktitle_stream_pages">Ver páginas (streaming)</string>
<string name="opds_linktitle_chapter_cover">Portada del capítulo</string>
<string name="opds_linktitle_current_page">Página actual</string>
<string name="opds_linktitle_catalog_root">Raíz del catálogo</string>
<string name="opds_linktitle_search_catalog">Buscar en catálogo</string>
<string name="opds_linktitle_previous_page">Página anterior</string>
<string name="opds_linktitle_next_page">Página siguiente</string>
<string name="opds_linktitle_self_feed">Feed actual</string>
<string name="opds_chapter_status_downloaded">⬇️ </string>
<string name="opds_chapter_status_read"></string>
<string name="opds_chapter_status_in_progress"></string>
<string name="opds_chapter_status_error">⚠️ </string>
<string name="opds_chapter_status_unknown"></string>
<string name="opds_chapter_status_unread"></string>
<string name="opds_chapter_details_base">Manga: %s | %s</string>
<string name="opds_chapter_details_scanlator"> | Publicado por: %1$s</string>
<string name="opds_chapter_details_progress"> | Progreso: %1$d de %2$d</string>
<string name="manga_status_unknown">Desconocido</string>
<string name="manga_status_ongoing">En emisión</string>
<string name="manga_status_completed">Completado</string>
<string name="manga_status_licensed">Licenciado</string>
<string name="manga_status_publishing_finished">Publicación finalizada</string>
<string name="manga_status_cancelled">Cancelado</string>
<string name="manga_status_on_hiatus">En pausa</string>
</resources>

View File

@@ -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<Locale>()
@Serializable
data class Languages(
val langs: List<String>,
)
fun initialize() {
val languages =
Json
.decodeFromString<Languages>(
MR.files.languages_json.readText(),
).langs
supportedLocales = languages.map { Locale.forLanguageTag(it) }
}
fun getSupportedLocales(): List<String> = 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)
}
}
}

View File

@@ -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<Link>,
@XmlElement(true)
val entries: List<Entry>,
@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<Link>,
@XmlElement(true)
val authors: List<Author>? = null,
@XmlElement(true)
val categories: List<Category>? = 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,
)
}

View File

@@ -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)
}

View File

@@ -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"
}

View File

@@ -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<String?>("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<String?>("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(
"""
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"
xmlns:atom="http://www.w3.org/2005/Atom">
<ShortName>Suwayomi OPDS Search</ShortName>
<Description>Search manga in the catalog</Description>
<ShortName>${MR.strings.opds_search_shortname.localized(locale)}</ShortName>
<Description>${MR.strings.opds_search_description.localized(locale)}</Description>
<InputEncoding>UTF-8</InputEncoding>
<OutputEncoding>UTF-8</OutputEncoding>
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition"
<Url type="${OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION}"
rel="results"
template="$BASE_URL/mangas?query={searchTerms}"/>
template="$BASE_URL/mangas?query={searchTerms}&lang=${locale.toLanguageTag()}"/>
</OpenSearchDescription>
""".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<Int?>("pageNumber"),
queryParam<String?>("query"),
queryParam<String?>("author"),
queryParam<String?>("title"),
queryParam<String?>("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<Int?>("pageNumber"),
queryParam<String?>("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<Int?>("pageNumber"),
queryParam<String?>("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<Int?>("pageNumber"),
queryParam<String?>("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<Int?>("pageNumber"),
queryParam<String?>("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<String?>("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<Int>("mangaId"),
queryParam<Int?>("pageNumber"),
queryParam<String?>("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<Int>("mangaId"),
pathParam<Int>("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<Long>("sourceId"),
queryParam<Int?>("pageNumber"),
queryParam<String?>("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<Int>("categoryId"),
queryParam<Int?>("pageNumber"),
queryParam<String?>("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<String>("genre"),
queryParam<Int?>("pageNumber"),
queryParam<String?>("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<Long>("statusId"),
queryParam<Int?>("pageNumber"),
queryParam<String?>("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<String>("langCode"),
queryParam<Int?>("pageNumber"),
queryParam<String?>("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<Int>("mangaId"),
queryParam<Int?>("pageNumber"),
queryParam<String?>("sort"),
queryParam<String?>("filter"),
queryParam<String?>("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<Int>("mangaId"),
pathParam<Int>("chapterIndex"),
queryParam<String?>("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)
},
)
}

View File

@@ -0,0 +1,6 @@
package suwayomi.tachidesk.opds.dto
data class OpdsCategoryNavEntry(
val id: Int,
val name: String,
)

View File

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

View File

@@ -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,
)

View File

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

View File

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

View File

@@ -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?,
)

View File

@@ -0,0 +1,12 @@
package suwayomi.tachidesk.opds.dto
data class OpdsMangaAcqEntry(
val id: Int,
val title: String,
val author: String?,
val genres: List<String>, // Raw genres, will be processed in builder
val description: String?,
val thumbnailUrl: String?, // Raw thumbnail URL from DB
val sourceLang: String?,
val inLibrary: Boolean,
)

View File

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

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -0,0 +1,7 @@
package suwayomi.tachidesk.opds.dto
data class OpdsSourceNavEntry(
val id: Long,
val name: String, // Not localized
val iconUrl: String?,
)

View File

@@ -0,0 +1,6 @@
package suwayomi.tachidesk.opds.dto
data class OpdsStatusNavEntry(
val id: Int,
val title: String, // Localized
)

View File

@@ -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<Op<Boolean>>()
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<OpdsXmlModels.Link>().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<MangaDataClass>(), 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<OpdsXmlModels.Link>()
val entries = mutableListOf<OpdsXmlModels.Entry>()
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)
}

File diff suppressed because it is too large Load Diff

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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 = "",
)

View File

@@ -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<OpdsLinkXml>,
@XmlElement(true)
@XmlSerialName("author", OpdsConstants.NS_ATOM, "")
val authors: List<OpdsAuthorXml>? = null,
@XmlElement(true)
@XmlSerialName("category", OpdsConstants.NS_ATOM, "")
val categories: List<OpdsCategoryXml>? = 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"
)

View File

@@ -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 <feed> 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<OpdsLinkXml>,
@XmlElement(true)
@XmlSerialName("entry", OpdsConstants.NS_ATOM, "")
val entries: List<OpdsEntryXml>,
// 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,
)

View File

@@ -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<OpdsIndirectAcquisitionXml>? = null,
)

View File

@@ -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<OpdsIndirectAcquisitionXml>? = null,
)

View File

@@ -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 = "",
)

View File

@@ -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<Link>,
@XmlElement(true)
val entries: List<Entry>,
@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<OpdsIndirectAcquisition>? = 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<Link>,
@XmlElement(true)
val authors: List<Author>? = null,
@XmlElement(true)
val categories: List<Category>? = 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,
)
}

View File

@@ -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<List<OpdsChapterListAcqEntry>, Long> =
transaction {
val conditions = mutableListOf<Op<Boolean>>()
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<List<OpdsLibraryUpdateAcqEntry>, 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)
}
}

View File

@@ -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<List<OpdsMangaAcqEntry>, 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<List<OpdsMangaAcqEntry>, Long> =
transaction {
val conditions = mutableListOf<Op<Boolean>>()
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<List<OpdsMangaAcqEntry>, 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<List<OpdsMangaAcqEntry>, 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<List<OpdsMangaAcqEntry>, 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<List<OpdsMangaAcqEntry>, 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<List<OpdsMangaAcqEntry>, 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)
}
}

View File

@@ -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<String, Triple<String, StringResource, StringResource>> =
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<OpdsRootNavEntry> =
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<List<OpdsSourceNavEntry>, 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<List<OpdsCategoryNavEntry>, 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<List<OpdsGenreNavEntry>, 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<OpdsStatusNavEntry> {
// Mapping of MangaStatus to its StringResources
val statusStringResources: Map<MangaStatus, StringResource> =
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<OpdsLanguageNavEntry> =
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()
},
)
}
}
}

View File

@@ -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))
}

View File

@@ -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"
}
}
}

View File

@@ -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)
}

View File

@@ -157,6 +157,7 @@ class ServerConfig(
val flareSolverrAsResponseFallback: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
// opds settings
val opdsUseBinaryFileSizes: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val opdsItemsPerPage: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
val opdsEnablePageReadProgress: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val opdsMarkAsReadOnDownload: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)

View File

@@ -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()

View File

@@ -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

View File

@@ -1,6 +1,7 @@
rootProject.name = System.getenv("ProductName") ?: "Suwayomi-Server"
include("server")
include("server:i18n")
include("AndroidCompat")
include("AndroidCompat:Config")