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

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

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

Key changes:

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

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

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

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

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

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

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

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

* Use Moko-Resources

* Format

* Forgot the Languages.json

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

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

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

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

* style(opds): apply ktlint fixes

* Delete server/bin

* refactor(i18n): remove custom LocalizationService initialization

* refactor(i18n): remove unused imports from ServerSetup

* refactor(model): remove sourceLang from MangaDataClass

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

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

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

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

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

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

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

* Fix

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

---------

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

View File

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