mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-06-30 17:34:39 -05:00
* feat(opds): add option to skip chapter metadata feed Introduces a new server configuration `server.opdsSkipChapterMetadataFeed` (default: false). When enabled, the OPDS chapter feed generates direct acquisition (CBZ download) and streaming (OPDS-PSE) links within the chapter list entries, bypassing the intermediate metadata subsection. This streamlines the user experience and improves compatibility with OPDS clients like KOReader that rely on direct links for automated downloading features. * fix: lint * fix(opds): enrich chapter data and refine sync logic for skip-metadata mode Refines the `opdsSkipChapterMetadataFeed` implementation to ensure necessary data is available for direct links and handles synchronization logic appropriate for a list view. - **Refactor ChapterForDownload:** Extract `refreshChapterPageList` and `updateChapterPersistence` to allow reusing page count verification logic outside the download flow. - **Enrich Chapter Repository:** When skipping metadata, asynchronously verify page counts and calculate CBZ file sizes for chapters in the list. This ensures direct stream/download links are valid even if the chapter wasn't previously fully indexed. - **KoSync Logic:** Implement synchronization logic in `OpdsEntryBuilder`. Since the user cannot be prompted in the chapter list view, `PROMPT` conflicts are explicitly ignored (prioritizing local progress), while updates are applied if non-conflicting. - **OPDS Attributes:** Add `length` (file size) to acquisition links and ensure download links only appear for actually downloaded chapters. - **Documentation:** Update `server.conf` description to clarify KoSync behavior in this mode. * feat(download): improve chapter download filenames * feat(opds): append language to source names * feat(opds): handle empty chapter titles * fix import org.jetbrains.exposed.v1.core.inList * refactor(opds): reorganize API routes and update facet count calculations based on active filters - **API Routing & Controllers**: Reorganize OPDS v1.2 route paths into logical groups in `OpdsAPI`. Centralize request filter extraction into `OpdsMangaFilter.fromContext`. - **Facet Counting**: Extract `Query.applyOpdsMangaFilter` to apply active filters to facet and navigation queries. Pass the active filters to `NavigationRepository` and `MangaRepository` count queries (using `excludeField` to calculate sibling counts). This ensures category, source, language, status, and genre counts (`thr:count`) are accurately computed based on active selections. - **Pagination**: Add pagination support to computed navigation feeds in `NavigationRepository` ( statuses and content languages). - **Builders**: Standardize parameter ordering in `FeedBuilderInternal` and `OpdsEntryBuilder` constructors. Simplify pagination and facet link URL generation. * fix(opds): remove redundant filter logic to avoid duplicate HAVING clauses Resolve IllegalStateException crash caused by applying content filters twice in MangaRepository. Filtering is now handled exclusively by `applyOpdsMangaFilter`, allowing `applyMangaLibrarySort` to focus solely on ordering operations. * revert(download): restore original CBZ filename scheme * refactor(opds): simplify persistence updates and clean up chapter mapping - Simplify page count and download checks in ChapterForDownload - Clean up enriched chapter mapping in ChapterRepository to improve readability * fix(opds): retrieve chapter archive size without leaving stream open * perf(opds): avoid redundant DB query when refreshing chapter page list
573 lines
23 KiB
Kotlin
573 lines
23 KiB
Kotlin
package suwayomi.tachidesk.opds.controller
|
|
|
|
import io.javalin.http.Context
|
|
import io.javalin.http.HttpStatus
|
|
import suwayomi.tachidesk.i18n.LocalizationHelper
|
|
import suwayomi.tachidesk.i18n.MR
|
|
import suwayomi.tachidesk.opds.constants.OpdsConstants
|
|
import suwayomi.tachidesk.opds.dto.OpdsMangaFilter
|
|
import suwayomi.tachidesk.opds.dto.OpdsSearchCriteria
|
|
import suwayomi.tachidesk.opds.dto.PrimaryFilterType
|
|
import suwayomi.tachidesk.opds.impl.OpdsFeedBuilder
|
|
import suwayomi.tachidesk.server.JavalinSetup.Attribute
|
|
import suwayomi.tachidesk.server.JavalinSetup.future
|
|
import suwayomi.tachidesk.server.JavalinSetup.getAttribute
|
|
import suwayomi.tachidesk.server.user.requireUserWithBasicFallback
|
|
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
|
|
|
|
/**
|
|
* Controller for handling OPDS v1.2 feed requests.
|
|
*/
|
|
object OpdsV1Controller {
|
|
private const val OPDS_MIME = "application/xml;profile=opds-catalog;charset=UTF-8"
|
|
private const val BASE_URL = "/api/opds/v1.2"
|
|
|
|
/**
|
|
* Helper function to generate and send a library feed response.
|
|
* It asynchronously builds the feed and sets the response content type.
|
|
*/
|
|
private fun getLibraryFeed(
|
|
ctx: Context,
|
|
locale: Locale,
|
|
criteria: OpdsMangaFilter,
|
|
isSearch: Boolean,
|
|
pageNum: Int,
|
|
) {
|
|
ctx.future {
|
|
future {
|
|
OpdsFeedBuilder.getLibraryFeed(
|
|
BASE_URL,
|
|
locale,
|
|
criteria,
|
|
isSearch,
|
|
pageNum,
|
|
criteria.sort,
|
|
criteria.filter,
|
|
)
|
|
}.thenApply { xml ->
|
|
ctx.contentType(OPDS_MIME).result(xml)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Serves the root navigation feed for the OPDS catalog.
|
|
*/
|
|
val rootFeed =
|
|
handler(
|
|
queryParam<String?>("lang"),
|
|
documentWith = {
|
|
withOperation {
|
|
summary("OPDS Root Feed")
|
|
description("Top-level navigation feed for the OPDS catalog.")
|
|
}
|
|
},
|
|
behaviorOf = { ctx, lang ->
|
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
|
ctx.contentType(OPDS_MIME).result(OpdsFeedBuilder.getRootFeed(BASE_URL, locale))
|
|
},
|
|
withResults = { httpCode(HttpStatus.OK) },
|
|
)
|
|
|
|
// --- Main Navigation Feeds ---
|
|
|
|
/**
|
|
* Serves an acquisition feed listing recently read chapters.
|
|
*/
|
|
val historyFeed =
|
|
handler(
|
|
queryParam<Int?>("pageNumber"),
|
|
queryParam<String?>("lang"),
|
|
documentWith = {
|
|
withOperation {
|
|
summary("OPDS History Feed")
|
|
description("Acquisition feed listing recently read chapters.")
|
|
}
|
|
},
|
|
behaviorOf = { ctx, pageNumber, lang ->
|
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
|
ctx.future {
|
|
future {
|
|
OpdsFeedBuilder.getHistoryFeed(BASE_URL, locale, pageNumber ?: 1)
|
|
}.thenApply { xml ->
|
|
ctx.contentType(OPDS_MIME).result(xml)
|
|
}
|
|
}
|
|
},
|
|
withResults = { httpCode(HttpStatus.OK) },
|
|
)
|
|
|
|
/**
|
|
* Serves the OpenSearch description document for catalog integration.
|
|
*/
|
|
val searchFeed =
|
|
handler(
|
|
queryParam<String?>("lang"),
|
|
documentWith = {
|
|
withOperation {
|
|
summary("OpenSearch Description")
|
|
description("XML description for OPDS searches, enabling catalog search integration.")
|
|
}
|
|
},
|
|
behaviorOf = { ctx, lang ->
|
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
|
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>${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="${OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION}"
|
|
rel="results"
|
|
template="$BASE_URL/library/series?query={searchTerms}&lang=${locale.toLanguageTag()}"/>
|
|
</OpenSearchDescription>
|
|
""".trimIndent(),
|
|
)
|
|
},
|
|
withResults = { httpCode(HttpStatus.OK) },
|
|
)
|
|
|
|
/**
|
|
* Serves an acquisition feed for all series in the library or search results.
|
|
* This endpoint handles both general library browsing and specific search queries.
|
|
* This is the ONLY feed that extracts all the cross-filters from the context.
|
|
*/
|
|
val seriesFeed =
|
|
handler(
|
|
documentWith = { withOperation { summary("OPDS Series in Library Feed") } },
|
|
behaviorOf = { ctx ->
|
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
|
val pageNumber = ctx.queryParam("pageNumber")?.toIntOrNull()
|
|
val query = ctx.queryParam("query")
|
|
val author = ctx.queryParam("author")
|
|
val title = ctx.queryParam("title")
|
|
val lang = ctx.queryParam("lang")
|
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
|
val isSearch = query != null || author != null || title != null
|
|
|
|
if (isSearch) {
|
|
val opdsSearchCriteria = OpdsSearchCriteria(query, author, title)
|
|
ctx.future {
|
|
future {
|
|
OpdsFeedBuilder.getSearchFeed(BASE_URL, locale, opdsSearchCriteria, pageNumber ?: 1)
|
|
}.thenApply { xml ->
|
|
ctx.contentType(OPDS_MIME).result(xml)
|
|
}
|
|
}
|
|
} else {
|
|
val criteria = OpdsMangaFilter.fromContext(ctx, PrimaryFilterType.NONE)
|
|
getLibraryFeed(ctx, locale, criteria, false, pageNumber ?: 1)
|
|
}
|
|
},
|
|
withResults = { httpCode(HttpStatus.OK) },
|
|
)
|
|
|
|
/**
|
|
* Serves a navigation feed listing all available manga sources for exploration.
|
|
*/
|
|
val exploreSourcesFeed =
|
|
handler(
|
|
queryParam<Int?>("pageNumber"),
|
|
queryParam<String?>("lang"),
|
|
documentWith = {
|
|
withOperation {
|
|
summary("OPDS All Sources Navigation Feed")
|
|
description("Navigation feed listing all available manga sources.")
|
|
}
|
|
},
|
|
behaviorOf = { ctx, pageNumber, lang ->
|
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
|
ctx.future {
|
|
future {
|
|
OpdsFeedBuilder.getExploreSourcesFeed(BASE_URL, locale, pageNumber ?: 1)
|
|
}.thenApply { xml ->
|
|
ctx.contentType(OPDS_MIME).result(xml)
|
|
}
|
|
}
|
|
},
|
|
withResults = { httpCode(HttpStatus.OK) },
|
|
)
|
|
|
|
/**
|
|
* Serves a navigation feed listing only the sources for series present in the library.
|
|
*/
|
|
val librarySourcesFeed =
|
|
handler(
|
|
queryParam<Int?>("pageNumber"),
|
|
queryParam<String?>("lang"),
|
|
documentWith = {
|
|
withOperation {
|
|
summary("OPDS Library Sources Navigation Feed")
|
|
description("Navigation feed listing sources for series currently in the library.")
|
|
}
|
|
},
|
|
behaviorOf = { ctx, pageNumber, lang ->
|
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
|
ctx.future {
|
|
future {
|
|
OpdsFeedBuilder.getLibrarySourcesFeed(BASE_URL, locale, pageNumber ?: 1)
|
|
}.thenApply { xml ->
|
|
ctx.contentType(OPDS_MIME).result(xml)
|
|
}
|
|
}
|
|
},
|
|
withResults = { httpCode(HttpStatus.OK) },
|
|
)
|
|
|
|
/**
|
|
* Serves a navigation feed for browsing manga categories within the library.
|
|
*/
|
|
val categoriesFeed =
|
|
handler(
|
|
queryParam<Int?>("pageNumber"),
|
|
queryParam<String?>("lang"),
|
|
documentWith = {
|
|
withOperation {
|
|
summary("OPDS Categories Navigation Feed")
|
|
description("Navigation feed listing available manga categories for the library.")
|
|
}
|
|
},
|
|
behaviorOf = { ctx, pageNumber, lang ->
|
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
|
ctx.future {
|
|
future {
|
|
OpdsFeedBuilder.getCategoriesFeed(BASE_URL, locale, pageNumber ?: 1)
|
|
}.thenApply { xml ->
|
|
ctx.contentType(OPDS_MIME).result(xml)
|
|
}
|
|
}
|
|
},
|
|
withResults = { httpCode(HttpStatus.OK) },
|
|
)
|
|
|
|
/**
|
|
* Serves a navigation feed for browsing manga genres within the library.
|
|
*/
|
|
val genresFeed =
|
|
handler(
|
|
queryParam<Int?>("pageNumber"),
|
|
queryParam<String?>("lang"),
|
|
documentWith = {
|
|
withOperation {
|
|
summary("OPDS Genres Navigation Feed")
|
|
description("Navigation feed listing available manga genres in the library.")
|
|
}
|
|
},
|
|
behaviorOf = { ctx, pageNumber, lang ->
|
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
|
ctx.future {
|
|
future {
|
|
OpdsFeedBuilder.getGenresFeed(BASE_URL, locale, pageNumber ?: 1)
|
|
}.thenApply { xml ->
|
|
ctx.contentType(OPDS_MIME).result(xml)
|
|
}
|
|
}
|
|
},
|
|
withResults = { httpCode(HttpStatus.OK) },
|
|
)
|
|
|
|
/**
|
|
* Serves a navigation feed for browsing series by their publication status.
|
|
*/
|
|
val statusesFeed =
|
|
handler(
|
|
queryParam<Int?>("pageNumber"),
|
|
queryParam<String?>("lang"),
|
|
documentWith = {
|
|
withOperation {
|
|
summary("OPDS Statuses Navigation Feed")
|
|
description("Navigation feed listing series publication statuses for the library.")
|
|
}
|
|
},
|
|
behaviorOf = { ctx, pageNumber, lang ->
|
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
|
ctx.future {
|
|
future {
|
|
OpdsFeedBuilder.getStatusFeed(BASE_URL, locale, pageNumber ?: 1)
|
|
}.thenApply { xml ->
|
|
ctx.contentType(OPDS_MIME).result(xml)
|
|
}
|
|
}
|
|
},
|
|
withResults = { httpCode(HttpStatus.OK) },
|
|
)
|
|
|
|
/**
|
|
* Serves a navigation feed for browsing series by their content language.
|
|
*/
|
|
val languagesFeed =
|
|
handler(
|
|
queryParam<Int?>("pageNumber"),
|
|
queryParam<String?>("lang"),
|
|
documentWith = {
|
|
withOperation {
|
|
summary("OPDS Content Languages Navigation Feed")
|
|
description("Navigation feed listing available content languages for series in the library.")
|
|
}
|
|
},
|
|
behaviorOf = { ctx, pageNumber, lang ->
|
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
|
ctx.future {
|
|
future {
|
|
OpdsFeedBuilder.getLanguagesFeed(BASE_URL, locale, pageNumber ?: 1)
|
|
}.thenApply { xml ->
|
|
ctx.contentType(OPDS_MIME).result(xml)
|
|
}
|
|
}
|
|
},
|
|
withResults = { httpCode(HttpStatus.OK) },
|
|
)
|
|
|
|
/**
|
|
* Serves an acquisition feed of recent chapter updates for series in the library.
|
|
*/
|
|
val libraryUpdatesFeed =
|
|
handler(
|
|
queryParam<Int?>("pageNumber"),
|
|
queryParam<String?>("lang"),
|
|
documentWith = {
|
|
withOperation {
|
|
summary("OPDS Library Updates Feed")
|
|
description("Acquisition feed listing recent chapter updates for series in the library.")
|
|
}
|
|
},
|
|
behaviorOf = { ctx, pageNumber, lang ->
|
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
|
ctx.future {
|
|
future {
|
|
OpdsFeedBuilder.getLibraryUpdatesFeed(BASE_URL, locale, pageNumber ?: 1)
|
|
}.thenApply { xml ->
|
|
ctx.contentType(OPDS_MIME).result(xml)
|
|
}
|
|
}
|
|
},
|
|
withResults = { httpCode(HttpStatus.OK) },
|
|
)
|
|
|
|
/**
|
|
* Serves an acquisition feed for all series from a specific source.
|
|
*/
|
|
val exploreSourceFeed =
|
|
handler(
|
|
pathParam<Long>("sourceId"),
|
|
queryParam<Int?>("pageNumber"),
|
|
queryParam<String?>("sort"),
|
|
queryParam<String?>("lang"),
|
|
documentWith = {
|
|
withOperation {
|
|
summary("OPDS Source Specific Series Feed (Explore)")
|
|
description("Acquisition feed listing all series from a specific source.")
|
|
}
|
|
},
|
|
behaviorOf = { ctx, sourceId, pageNumber, sort, lang ->
|
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
|
ctx.future {
|
|
future {
|
|
OpdsFeedBuilder.getExploreSourceFeed(BASE_URL, locale, sourceId, pageNumber ?: 1, sort ?: "popular")
|
|
}.thenApply { xml ->
|
|
ctx.contentType(OPDS_MIME).result(xml)
|
|
}
|
|
}
|
|
},
|
|
withResults = {
|
|
httpCode(HttpStatus.OK)
|
|
httpCode(HttpStatus.NOT_FOUND)
|
|
},
|
|
)
|
|
|
|
/**
|
|
* Builds an [OpdsMangaFilter] from the current request context, inheriting existing filters.
|
|
*/
|
|
private fun buildCriteriaFromContext(
|
|
ctx: Context,
|
|
initialCriteria: OpdsMangaFilter,
|
|
): OpdsMangaFilter =
|
|
initialCriteria.copy(
|
|
sort = ctx.queryParam("sort"),
|
|
filter = ctx.queryParam("filter"),
|
|
)
|
|
|
|
/**
|
|
* Serves an acquisition feed for series in the library from a specific source.
|
|
*/
|
|
val librarySourceFeed =
|
|
handler(
|
|
pathParam<Long>("sourceId"),
|
|
documentWith = { withOperation { summary("OPDS Library Source Specific Series Feed") } },
|
|
behaviorOf = { ctx, sourceId ->
|
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
|
|
val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(sourceId = sourceId, primaryFilter = PrimaryFilterType.SOURCE))
|
|
getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
|
|
},
|
|
withResults = {
|
|
httpCode(HttpStatus.OK)
|
|
httpCode(HttpStatus.NOT_FOUND)
|
|
},
|
|
)
|
|
|
|
/**
|
|
* Serves an acquisition feed for series in a specific category.
|
|
*/
|
|
val categoryFeed =
|
|
handler(
|
|
pathParam<Int>("categoryId"),
|
|
documentWith = { withOperation { summary("OPDS Category Specific Series Feed") } },
|
|
behaviorOf = { ctx, categoryId ->
|
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
|
|
val criteria =
|
|
buildCriteriaFromContext(ctx, OpdsMangaFilter(categoryId = categoryId, primaryFilter = PrimaryFilterType.CATEGORY))
|
|
getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
|
|
},
|
|
withResults = {
|
|
httpCode(HttpStatus.OK)
|
|
httpCode(HttpStatus.NOT_FOUND)
|
|
},
|
|
)
|
|
|
|
/**
|
|
* Serves an acquisition feed for series belonging to a specific genre.
|
|
*/
|
|
val genreFeed =
|
|
handler(
|
|
pathParam<String>("genre"),
|
|
documentWith = { withOperation { summary("OPDS Genre Specific Series Feed") } },
|
|
behaviorOf = { ctx, genre ->
|
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
|
|
val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(genre = genre, primaryFilter = PrimaryFilterType.GENRE))
|
|
getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
|
|
},
|
|
withResults = {
|
|
httpCode(HttpStatus.OK)
|
|
httpCode(HttpStatus.NOT_FOUND)
|
|
},
|
|
)
|
|
|
|
/**
|
|
* Serves an acquisition feed for series with a specific publication status.
|
|
*/
|
|
val statusMangaFeed =
|
|
handler(
|
|
pathParam<Int>("statusId"),
|
|
documentWith = { withOperation { summary("OPDS Status Specific Series Feed") } },
|
|
behaviorOf = { ctx, statusId ->
|
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
|
|
val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(statusId = statusId, primaryFilter = PrimaryFilterType.STATUS))
|
|
getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
|
|
},
|
|
withResults = {
|
|
httpCode(HttpStatus.OK)
|
|
httpCode(HttpStatus.NOT_FOUND)
|
|
},
|
|
)
|
|
|
|
/**
|
|
* Serves an acquisition feed for series of a specific content language.
|
|
*/
|
|
val languageFeed =
|
|
handler(
|
|
pathParam<String>("langCode"),
|
|
documentWith = {
|
|
withOperation {
|
|
summary("OPDS Content Language Specific Series Feed")
|
|
description("Acquisition feed listing series of a specific content language.")
|
|
}
|
|
},
|
|
behaviorOf = { ctx, langCode ->
|
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
|
|
val criteria =
|
|
buildCriteriaFromContext(ctx, OpdsMangaFilter(langCode = langCode, primaryFilter = PrimaryFilterType.LANGUAGE))
|
|
getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
|
|
},
|
|
withResults = {
|
|
httpCode(HttpStatus.OK)
|
|
httpCode(HttpStatus.NOT_FOUND)
|
|
},
|
|
)
|
|
|
|
/**
|
|
* Serves an acquisition feed listing chapters for a specific series.
|
|
*/
|
|
val seriesChaptersFeed =
|
|
handler(
|
|
pathParam<Int>("seriesId"),
|
|
queryParam<Int?>("pageNumber"),
|
|
queryParam<String?>("sort"),
|
|
queryParam<String?>("filter"),
|
|
queryParam<String?>("lang"),
|
|
documentWith = {
|
|
withOperation {
|
|
summary("OPDS Series Chapters Feed")
|
|
description("Acquisition feed listing chapters for a specific series. Supports pagination, sorting, and filtering.")
|
|
}
|
|
},
|
|
behaviorOf = { ctx, seriesId, pageNumber, sort, filter, lang ->
|
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
|
ctx.future {
|
|
future {
|
|
OpdsFeedBuilder.getSeriesChaptersFeed(BASE_URL, locale, seriesId, pageNumber ?: 1, sort, filter)
|
|
}.thenApply { xml ->
|
|
ctx.contentType(OPDS_MIME).result(xml)
|
|
}
|
|
}
|
|
},
|
|
withResults = {
|
|
httpCode(HttpStatus.OK)
|
|
httpCode(HttpStatus.NOT_FOUND)
|
|
},
|
|
)
|
|
|
|
/**
|
|
* Serves an acquisition feed with detailed metadata for a single chapter.
|
|
*/
|
|
val chapterMetadataFeed =
|
|
handler(
|
|
pathParam<Int>("seriesId"),
|
|
pathParam<Int>("chapterIndex"),
|
|
queryParam<String?>("lang"),
|
|
documentWith = {
|
|
withOperation {
|
|
summary("OPDS Chapter Details Feed")
|
|
description("Acquisition feed providing detailed metadata for a specific chapter.")
|
|
}
|
|
},
|
|
behaviorOf = { ctx, seriesId, chapterIndex, lang ->
|
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
|
ctx.future {
|
|
future {
|
|
OpdsFeedBuilder.getChapterMetadataFeed(BASE_URL, locale, seriesId, chapterIndex)
|
|
}.thenApply { xml ->
|
|
ctx.contentType(OPDS_MIME).result(xml)
|
|
}
|
|
}
|
|
},
|
|
withResults = {
|
|
httpCode(HttpStatus.OK)
|
|
httpCode(HttpStatus.NOT_FOUND)
|
|
},
|
|
)
|
|
}
|