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:
+8
-4
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"langs":["en","es"]}
|
||||
@@ -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 & 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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package suwayomi.tachidesk.opds.dto
|
||||
|
||||
data class OpdsCategoryNavEntry(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
+3
-1
@@ -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,
|
||||
@@ -0,0 +1,7 @@
|
||||
package suwayomi.tachidesk.opds.dto
|
||||
|
||||
data class OpdsSourceNavEntry(
|
||||
val id: Long,
|
||||
val name: String, // Not localized
|
||||
val iconUrl: String?,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package suwayomi.tachidesk.opds.dto
|
||||
|
||||
data class OpdsStatusNavEntry(
|
||||
val id: Int,
|
||||
val title: String, // Localized
|
||||
)
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 = "",
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 = "",
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
rootProject.name = System.getenv("ProductName") ?: "Suwayomi-Server"
|
||||
|
||||
include("server")
|
||||
include("server:i18n")
|
||||
|
||||
include("AndroidCompat")
|
||||
include("AndroidCompat:Config")
|
||||
|
||||
Reference in New Issue
Block a user