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.requireUser 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, pageNum: Int?, criteria: OpdsMangaFilter, isSearch: Boolean, ) { val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang")) ctx.future { future { OpdsFeedBuilder.getLibraryFeed( criteria = criteria, baseUrl = BASE_URL, pageNum = pageNum ?: 1, sort = criteria.sort, filter = criteria.filter, locale = locale, isSearch = isSearch, ) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } } } /** * Serves the root navigation feed for the OPDS catalog. */ val rootFeed = handler( queryParam("lang"), documentWith = { withOperation { summary("OPDS Root Feed") description("Top-level navigation feed for the OPDS catalog.") } }, behaviorOf = { ctx, lang -> ctx.getAttribute(Attribute.TachideskUser).requireUser() 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("pageNumber"), queryParam("lang"), documentWith = { withOperation { summary("OPDS History Feed") description("Acquisition feed listing recently read chapters.") } }, behaviorOf = { ctx, pageNumber, lang -> ctx.getAttribute(Attribute.TachideskUser).requireUser() val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { OpdsFeedBuilder.getHistoryFeed(BASE_URL, pageNumber ?: 1, locale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } } }, withResults = { httpCode(HttpStatus.OK) }, ) /** * Serves the OpenSearch description document for catalog integration. */ val searchFeed = handler( queryParam("lang"), documentWith = { withOperation { summary("OpenSearch Description") description("XML description for OPDS searches, enabling catalog search integration.") } }, behaviorOf = { ctx, lang -> ctx.getAttribute(Attribute.TachideskUser).requireUser() val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.contentType("application/opensearchdescription+xml").result( """ ${MR.strings.opds_search_shortname.localized(locale)} ${MR.strings.opds_search_description.localized(locale)} UTF-8 UTF-8 """.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. */ val seriesFeed = handler( documentWith = { withOperation { summary("OPDS Series in Library Feed") } }, behaviorOf = { ctx -> ctx.getAttribute(Attribute.TachideskUser).requireUser() 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(opdsSearchCriteria, BASE_URL, pageNumber ?: 1, locale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } } } else { val criteria = OpdsMangaFilter( sourceId = ctx.queryParam("source_id")?.toLongOrNull(), categoryId = ctx.queryParam("category_id")?.toIntOrNull(), statusId = ctx.queryParam("status_id")?.toIntOrNull(), genre = ctx.queryParam("genre"), langCode = ctx.queryParam("lang_code"), sort = ctx.queryParam("sort"), filter = ctx.queryParam("filter"), primaryFilter = PrimaryFilterType.NONE, ) getLibraryFeed( ctx, pageNumber, criteria, isSearch = false, ) } }, withResults = { httpCode(HttpStatus.OK) }, ) /** * Serves a navigation feed listing all available manga sources for exploration. */ val exploreSourcesFeed = handler( queryParam("pageNumber"), queryParam("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).requireUser() val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { OpdsFeedBuilder.getExploreSourcesFeed(BASE_URL, pageNumber ?: 1, locale) }.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("pageNumber"), queryParam("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).requireUser() val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { OpdsFeedBuilder.getLibrarySourcesFeed(BASE_URL, pageNumber ?: 1, locale) }.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("pageNumber"), queryParam("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).requireUser() val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { OpdsFeedBuilder.getCategoriesFeed(BASE_URL, pageNumber ?: 1, locale) }.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("pageNumber"), queryParam("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).requireUser() val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { OpdsFeedBuilder.getGenresFeed(BASE_URL, pageNumber ?: 1, locale) }.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("lang"), documentWith = { withOperation { summary("OPDS Statuses Navigation Feed") description("Navigation feed listing series publication statuses for the library.") } }, behaviorOf = { ctx, lang -> ctx.getAttribute(Attribute.TachideskUser).requireUser() val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { OpdsFeedBuilder.getStatusFeed(BASE_URL, 1, locale) }.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("lang"), documentWith = { withOperation { summary("OPDS Content Languages Navigation Feed") description("Navigation feed listing available content languages for series in the library.") } }, behaviorOf = { ctx, lang -> ctx.getAttribute(Attribute.TachideskUser).requireUser() val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { OpdsFeedBuilder.getLanguagesFeed(BASE_URL, locale) }.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("pageNumber"), queryParam("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).requireUser() val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { OpdsFeedBuilder.getLibraryUpdatesFeed(BASE_URL, pageNumber ?: 1, locale) }.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("sourceId"), queryParam("pageNumber"), queryParam("sort"), queryParam("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).requireUser() val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { OpdsFeedBuilder.getExploreSourceFeed(sourceId, BASE_URL, pageNumber ?: 1, sort ?: "popular", locale) }.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("sourceId"), documentWith = { withOperation { summary("OPDS Library Source Specific Series Feed") } }, behaviorOf = { ctx, sourceId -> ctx.getAttribute(Attribute.TachideskUser).requireUser() val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(sourceId = sourceId, primaryFilter = PrimaryFilterType.SOURCE)) getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false) }, withResults = { httpCode(HttpStatus.OK) httpCode(HttpStatus.NOT_FOUND) }, ) /** * Serves an acquisition feed for series in a specific category. */ val categoryFeed = handler( pathParam("categoryId"), documentWith = { withOperation { summary("OPDS Category Specific Series Feed") } }, behaviorOf = { ctx, categoryId -> ctx.getAttribute(Attribute.TachideskUser).requireUser() val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(categoryId = categoryId, primaryFilter = PrimaryFilterType.CATEGORY)) getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false) }, withResults = { httpCode(HttpStatus.OK) httpCode(HttpStatus.NOT_FOUND) }, ) /** * Serves an acquisition feed for series belonging to a specific genre. */ val genreFeed = handler( pathParam("genre"), documentWith = { withOperation { summary("OPDS Genre Specific Series Feed") } }, behaviorOf = { ctx, genre -> ctx.getAttribute(Attribute.TachideskUser).requireUser() val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(genre = genre, primaryFilter = PrimaryFilterType.GENRE)) getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false) }, withResults = { httpCode(HttpStatus.OK) httpCode(HttpStatus.NOT_FOUND) }, ) /** * Serves an acquisition feed for series with a specific publication status. */ val statusMangaFeed = handler( pathParam("statusId"), documentWith = { withOperation { summary("OPDS Status Specific Series Feed") } }, behaviorOf = { ctx, statusId -> ctx.getAttribute(Attribute.TachideskUser).requireUser() val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(statusId = statusId, primaryFilter = PrimaryFilterType.STATUS)) getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false) }, withResults = { httpCode(HttpStatus.OK) httpCode(HttpStatus.NOT_FOUND) }, ) /** * Serves an acquisition feed for series of a specific content language. */ val languageFeed = handler( pathParam("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).requireUser() val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(langCode = langCode, primaryFilter = PrimaryFilterType.LANGUAGE)) getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false) }, withResults = { httpCode(HttpStatus.OK) httpCode(HttpStatus.NOT_FOUND) }, ) /** * Serves an acquisition feed listing chapters for a specific series. */ val seriesChaptersFeed = handler( pathParam("seriesId"), queryParam("pageNumber"), queryParam("sort"), queryParam("filter"), queryParam("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).requireUser() val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { OpdsFeedBuilder.getSeriesChaptersFeed(seriesId, BASE_URL, pageNumber ?: 1, sort, filter, locale) }.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("seriesId"), pathParam("chapterIndex"), queryParam("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).requireUser() val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) ctx.future { future { OpdsFeedBuilder.getChapterMetadataFeed(seriesId, chapterIndex, BASE_URL, locale) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) } } }, withResults = { httpCode(HttpStatus.OK) httpCode(HttpStatus.NOT_FOUND) }, ) }