diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt index cd3943f1..b02b04e4 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt @@ -1,22 +1,53 @@ package suwayomi.tachidesk.opds -/* - * Copyright (C) Contributors to the Suwayomi project - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ - import io.javalin.apibuilder.ApiBuilder.get import io.javalin.apibuilder.ApiBuilder.path -import suwayomi.tachidesk.opds.controller.OpdsController +import suwayomi.tachidesk.opds.controller.OpdsV1Controller object OpdsAPI { fun defineEndpoints() { path("opds/v1.2") { - get(OpdsController.rootFeed) - get("source/{sourceId}", OpdsController.sourceFeed) - get("manga/{mangaId}", OpdsController.mangaFeed) + // Root feed (Navigation Feed) + get(OpdsV1Controller.rootFeed) + + // Search Description + get("search", OpdsV1Controller.searchFeed) + + // Complete feed for crawlers + // get("complete", OpdsV1Controller.completeFeed) + + // Main groupings + get("mangas", OpdsV1Controller.mangasFeed) + get("sources", OpdsV1Controller.sourcesFeed) + get("categories", OpdsV1Controller.categoriesFeed) + get("genres", OpdsV1Controller.genresFeed) + get("status", OpdsV1Controller.statusFeed) + get("languages", OpdsV1Controller.languagesFeed) + + // Faceted feeds (Acquisition Feeds) + path("manga/{mangaId}") { + get(OpdsV1Controller.mangaFeed) + } + + path("source/{sourceId}") { + get(OpdsV1Controller.sourceFeed) + } + + path("category/{categoryId}") { + get(OpdsV1Controller.categoryFeed) + } + + path("genre/{genre}") { + get(OpdsV1Controller.genreFeed) + } + + path("status/{statusId}") { + get(OpdsV1Controller.statusMangaFeed) + } + + path("language/{langCode}") { + get(OpdsV1Controller.languageFeed) + } } } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsController.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsController.kt deleted file mode 100644 index 7f095df4..00000000 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsController.kt +++ /dev/null @@ -1,86 +0,0 @@ -package suwayomi.tachidesk.opds.controller - -import io.javalin.http.HttpStatus -import suwayomi.tachidesk.opds.impl.Opds -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 - -object OpdsController { - private const val OPDS_MIME = "application/xml;profile=opds-catalog;charset=UTF-8" - private const val BASE_URL = "/api/opds/v1.2" - - val rootFeed = - handler( - documentWith = { - withOperation { - summary("OPDS Root Feed") - description("OPDS feed for the list of available manga sources") - } - }, - behaviorOf = { ctx -> - ctx.future { - future { - Opds.getRootFeed(BASE_URL) - }.thenApply { xml -> - ctx.contentType(OPDS_MIME).result(xml) - } - } - }, - withResults = { - httpCode(HttpStatus.OK) - }, - ) - - val sourceFeed = - handler( - pathParam("sourceId"), - queryParam("pageNumber"), - documentWith = { - withOperation { - summary("OPDS Source Feed") - description("OPDS feed for a specific manga source") - } - }, - behaviorOf = { ctx, sourceId, pageNumber -> - ctx.future { - future { - Opds.getSourceFeed(sourceId, BASE_URL, pageNumber ?: 1) - }.thenApply { xml -> - ctx.contentType(OPDS_MIME).result(xml) - } - } - }, - withResults = { - httpCode(HttpStatus.OK) - httpCode(HttpStatus.NOT_FOUND) - }, - ) - - val mangaFeed = - handler( - pathParam("mangaId"), - queryParam("pageNumber"), - documentWith = { - withOperation { - summary("OPDS Manga Feed") - description("OPDS feed for chapters of a specific manga") - } - }, - behaviorOf = { ctx, mangaId, pageNumber -> - ctx.future { - future { - Opds.getMangaFeed(mangaId, BASE_URL, pageNumber ?: 1) - }.thenApply { xml -> - ctx.contentType(OPDS_MIME).result(xml) - } - } - }, - withResults = { - httpCode(HttpStatus.OK) - httpCode(HttpStatus.NOT_FOUND) - }, - ) -} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt new file mode 100644 index 00000000..101e326b --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt @@ -0,0 +1,410 @@ +package suwayomi.tachidesk.opds.controller + +import SearchCriteria +import io.javalin.http.HttpStatus +import suwayomi.tachidesk.opds.impl.Opds +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 + +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 + val rootFeed = + handler( + documentWith = { + withOperation { + summary("OPDS Root Feed") + description("") + } + }, + behaviorOf = { ctx -> + ctx.future { + future { + Opds.getRootFeed(BASE_URL) + }.thenApply { xml -> + ctx.contentType(OPDS_MIME).result(xml) + } + } + }, + withResults = { + httpCode(HttpStatus.OK) + }, + ) + + // Search Description + val searchFeed = + handler( + documentWith = { + withOperation { + summary("OpenSearch Description") + description("XML description for OPDS searches") + } + }, + behaviorOf = { ctx -> + ctx.contentType("application/opensearchdescription+xml").result( + """ + + Suwayomi OPDS Search + Search manga in the catalog + UTF-8 + UTF-8 + + + """.trimIndent(), + ) + }, + withResults = { + httpCode(HttpStatus.OK) + }, + ) + + // 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 Manga Grouping + // Search Feed + val mangasFeed = + handler( + queryParam("pageNumber"), + queryParam("query"), + queryParam("author"), + queryParam("title"), + documentWith = { + withOperation { + summary("OPDS Mangas Feed") + description("OPDS feed for primary grouping of manga entries") + } + }, + 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) + } + } + } else { + ctx.future { + future { + Opds.getMangasFeed(null, BASE_URL, pageNumber ?: 1) + }.thenApply { xml -> + ctx.contentType(OPDS_MIME).result(xml) + } + } + } + }, + withResults = { + httpCode(HttpStatus.OK) + }, + ) + + // Main Sources Grouping + val sourcesFeed = + handler( + queryParam("pageNumber"), + documentWith = { + withOperation { + summary("OPDS Sources Feed") + description("OPDS feed for primary grouping of manga sources") + } + }, + behaviorOf = { ctx, pageNumber -> + ctx.future { + future { + Opds.getSourcesFeed(BASE_URL, pageNumber ?: 1) + }.thenApply { xml -> + ctx.contentType(OPDS_MIME).result(xml) + } + } + }, + withResults = { + httpCode(HttpStatus.OK) + }, + ) + + // Main Categories Grouping + val categoriesFeed = + handler( + queryParam("pageNumber"), + documentWith = { + withOperation { + summary("OPDS Categories Feed") + description("OPDS feed for primary grouping of manga categories") + } + }, + behaviorOf = { ctx, pageNumber -> + ctx.future { + future { + Opds.getCategoriesFeed(BASE_URL, pageNumber ?: 1) + }.thenApply { xml -> + ctx.contentType(OPDS_MIME).result(xml) + } + } + }, + withResults = { + httpCode(HttpStatus.OK) + }, + ) + + // Main Genres Grouping + val genresFeed = + handler( + queryParam("pageNumber"), + documentWith = { + withOperation { + summary("OPDS Genres Feed") + description("OPDS feed for primary grouping of manga genres") + } + }, + behaviorOf = { ctx, pageNumber -> + ctx.future { + future { + Opds.getGenresFeed(BASE_URL, pageNumber ?: 1) + }.thenApply { xml -> + ctx.contentType(OPDS_MIME).result(xml) + } + } + }, + withResults = { + httpCode(HttpStatus.OK) + }, + ) + + // Main Status Grouping + val statusFeed = + handler( + queryParam("pageNumber"), + documentWith = { + withOperation { + summary("OPDS Status Feed") + description("OPDS feed for primary grouping of manga by status") + } + }, + behaviorOf = { ctx, pageNumber -> + ctx.future { + future { + Opds.getStatusFeed(BASE_URL, pageNumber ?: 1) + }.thenApply { xml -> + ctx.contentType(OPDS_MIME).result(xml) + } + } + }, + withResults = { + httpCode(HttpStatus.OK) + }, + ) + + // Main Languages Grouping + val languagesFeed = + handler( + documentWith = { + withOperation { + summary("OPDS Languages Feed") + description("OPDS feed for primary grouping of available languages") + } + }, + behaviorOf = { ctx -> + ctx.future { + future { + Opds.getLanguagesFeed(BASE_URL) + }.thenApply { xml -> + ctx.contentType(OPDS_MIME).result(xml) + } + } + }, + withResults = { + httpCode(HttpStatus.OK) + }, + ) + + // Manga Chapters Feed + val mangaFeed = + handler( + pathParam("mangaId"), + queryParam("pageNumber"), + documentWith = { + withOperation { + summary("OPDS Manga Feed") + description("OPDS feed for chapters of a specific manga") + } + }, + behaviorOf = { ctx, mangaId, pageNumber -> + ctx.future { + future { + Opds.getMangaFeed(mangaId, BASE_URL, pageNumber ?: 1) + }.thenApply { xml -> + ctx.contentType(OPDS_MIME).result(xml) + } + } + }, + withResults = { + httpCode(HttpStatus.OK) + httpCode(HttpStatus.NOT_FOUND) + }, + ) + + // Specific Source Feed + val sourceFeed = + handler( + pathParam("sourceId"), + queryParam("pageNumber"), + documentWith = { + withOperation { + summary("OPDS Source Feed") + description("OPDS feed for a specific manga source") + } + }, + behaviorOf = { ctx, sourceId, pageNumber -> + ctx.future { + future { + Opds.getSourceFeed(sourceId, BASE_URL, pageNumber ?: 1) + }.thenApply { xml -> + ctx.contentType(OPDS_MIME).result(xml) + } + } + }, + withResults = { + httpCode(HttpStatus.OK) + httpCode(HttpStatus.NOT_FOUND) + }, + ) + + // Facet Feed: Specific Category + val categoryFeed = + handler( + pathParam("categoryId"), + queryParam("pageNumber"), + documentWith = { + withOperation { + summary("OPDS Category Feed") + description("OPDS feed for a specific manga category") + } + }, + behaviorOf = { ctx, categoryId, pageNumber -> + ctx.future { + future { + Opds.getCategoryFeed(categoryId, BASE_URL, pageNumber ?: 1) + }.thenApply { xml -> + ctx.contentType(OPDS_MIME).result(xml) + } + } + }, + withResults = { + httpCode(HttpStatus.OK) + httpCode(HttpStatus.NOT_FOUND) + }, + ) + + // Facet Feed: Specific Genre + val genreFeed = + handler( + pathParam("genre"), + queryParam("pageNumber"), + documentWith = { + withOperation { + summary("OPDS Genre Feed") + description("OPDS feed for a specific manga genre") + } + }, + behaviorOf = { ctx, genre, pageNumber -> + ctx.future { + future { + Opds.getGenreFeed(genre, BASE_URL, pageNumber ?: 1) + }.thenApply { xml -> + ctx.contentType(OPDS_MIME).result(xml) + } + } + }, + withResults = { + httpCode(HttpStatus.OK) + httpCode(HttpStatus.NOT_FOUND) + }, + ) + + // Facet Feed: Specific Status + val statusMangaFeed = + handler( + pathParam("statusId"), + queryParam("pageNumber"), + documentWith = { + withOperation { + summary("OPDS Status Manga Feed") + description("OPDS feed for manga filtered by status") + } + }, + behaviorOf = { ctx, statusId, pageNumber -> + ctx.future { + future { + Opds.getStatusMangaFeed(statusId, BASE_URL, pageNumber ?: 1) + }.thenApply { xml -> + ctx.contentType(OPDS_MIME).result(xml) + } + } + }, + withResults = { + httpCode(HttpStatus.OK) + httpCode(HttpStatus.NOT_FOUND) + }, + ) + + // Facet Feed: Specific Language + val languageFeed = + handler( + pathParam("langCode"), + queryParam("pageNumber"), + documentWith = { + withOperation { + summary("OPDS Language Feed") + description("OPDS feed for manga filtered by language") + } + }, + behaviorOf = { ctx, langCode, pageNumber -> + ctx.future { + future { + Opds.getLanguageFeed(langCode, BASE_URL, pageNumber ?: 1) + }.thenApply { xml -> + ctx.contentType(OPDS_MIME).result(xml) + } + } + }, + withResults = { + httpCode(HttpStatus.OK) + httpCode(HttpStatus.NOT_FOUND) + }, + ) +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/Opds.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/Opds.kt index a056b628..8febcd2e 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/Opds.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/Opds.kt @@ -1,11 +1,14 @@ package suwayomi.tachidesk.opds.impl +import SearchCriteria 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.and +import org.jetbrains.exposed.sql.or import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl @@ -13,14 +16,19 @@ import suwayomi.tachidesk.manga.impl.extension.Extension.getExtensionIconUrl import suwayomi.tachidesk.manga.impl.util.getChapterCbzPath import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass -import suwayomi.tachidesk.manga.model.dataclass.OpdsDataClass 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 java.io.File +import java.net.URLEncoder +import java.nio.charset.StandardCharsets import java.time.Instant import java.time.ZoneOffset import java.time.format.DateTimeFormatter @@ -29,61 +37,160 @@ object Opds { private const val ITEMS_PER_PAGE = 20 fun getRootFeed(baseUrl: String): String { - val formattedNow = opdsDateFormatter.format(Instant.now()) - val sources = - transaction { - SourceTable - .join(MangaTable, JoinType.INNER) { - MangaTable.sourceReference eq SourceTable.id - }.join(ChapterTable, JoinType.INNER) { - ChapterTable.manga eq MangaTable.id - }.selectAll() - .where { ChapterTable.isDownloaded eq true } - .orderBy(SourceTable.name to SortOrder.ASC) - .distinct() - .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 = "", + val builder = + FeedBuilder(baseUrl, 1, "opds", "Suwayomi OPDS Catalog").apply { + totalResults = 6 + entries += + listOf( + "mangas" to "All Manga", + "sources" to "Sources", + "categories" to "Categories", + "genres" to "Genres", + "status" to "Status", + "languages" to "Languages", + ).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 baseCondition = ChapterTable.isDownloaded eq true + if (criteria == null) { + baseCondition + } else { + val conditions = mutableListOf>() + criteria.query?.takeIf { it.isNotBlank() }?.let { q -> + conditions += ( + (MangaTable.title like "%$q%") or + (MangaTable.author like "%$q%") or + (MangaTable.genre like "%$q%") + ) + } + criteria.author?.takeIf { it.isNotBlank() }?.let { author -> + conditions += (MangaTable.author like "%$author%") + } + criteria.title?.takeIf { it.isNotBlank() }?.let { title -> + conditions += (MangaTable.title like "%$title%") + } + baseCondition and (if (conditions.isEmpty()) Op.TRUE else conditions.reduce { acc, op -> acc and op }) + } + }.groupBy(MangaTable.id) + .orderBy(MangaTable.title to SortOrder.ASC) + val totalCount = query.count() + val mangas = + query + .limit(ITEMS_PER_PAGE) + .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) + .map { MangaTable.toDataClass(it) } + 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) + .where { ChapterTable.isDownloaded eq true } + .groupBy(SourceTable.id) + .orderBy(SourceTable.name to SortOrder.ASC) + + val totalCount = query.count() + val sources = + query + .limit(ITEMS_PER_PAGE) + .offset(((pageNum - 1) * ITEMS_PER_PAGE).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 serialize( - OpdsDataClass( - id = "opds", - title = "Suwayomi OPDS Catalog", - icon = "/favicon", + OpdsXmlModels( + id = "sources", + title = "Sources", updated = formattedNow, - author = OpdsDataClass.Author("Suwayomi", "https://suwayomi.org/"), + totalResults = totalCount, + itemsPerPage = ITEMS_PER_PAGE, + startIndex = (pageNum - 1) * ITEMS_PER_PAGE + 1, + author = OpdsXmlModels.Author("Suwayomi", "https://suwayomi.org/"), links = - listOf( - OpdsDataClass.Link( + listOfNotNull( + OpdsXmlModels.Link( rel = "self", - href = baseUrl, + href = "$baseUrl/sources?pageNumber=$pageNum", type = "application/atom+xml;profile=opds-catalog;kind=navigation", ), - OpdsDataClass.Link( + OpdsXmlModels.Link( rel = "start", href = baseUrl, type = "application/atom+xml;profile=opds-catalog;kind=navigation", ), ), entries = - sources.map { - OpdsDataClass.Entry( + sourceList.map { + OpdsXmlModels.Entry( updated = formattedNow, id = it.id, title = it.name, link = listOf( - OpdsDataClass.Link( + OpdsXmlModels.Link( rel = "subsection", href = "$baseUrl/source/${it.id}", type = "application/atom+xml;profile=opds-catalog;kind=navigation", @@ -95,95 +202,65 @@ object Opds { ) } - fun getSourceFeed( - sourceId: Long, + fun getCategoriesFeed( baseUrl: String, - pageNum: Int = 1, + pageNum: Int, ): String { val formattedNow = opdsDateFormatter.format(Instant.now()) - val (mangas, totalCount, sourceRow) = + val categoryList = 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) and (ChapterTable.isDownloaded eq true) - }.groupBy(MangaTable.id) - .orderBy(MangaTable.title to SortOrder.ASC) - - val totalCount = query.count() - val paginatedResults = - query - .limit(ITEMS_PER_PAGE) - .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) - .map { MangaTable.toDataClass(it) } - - Triple(paginatedResults, totalCount, sourceRow) + 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) + .where { ChapterTable.isDownloaded eq true } + .groupBy(CategoryTable.id) + .orderBy(CategoryTable.order to SortOrder.ASC) + .map { row -> + Pair(row[CategoryTable.id].value, row[CategoryTable.name]) + } } - - val sourceName = sourceRow?.get(SourceTable.name) ?: sourceId.toString() - val iconUrl = sourceRow?.get(ExtensionTable.apkName)?.let { getExtensionIconUrl(it) } + val totalCount = categoryList.size + val fromIndex = (pageNum - 1) * ITEMS_PER_PAGE + val toIndex = minOf(fromIndex + ITEMS_PER_PAGE, totalCount) + val paginatedCategories = if (fromIndex < totalCount) categoryList.subList(fromIndex, toIndex) else emptyList() return serialize( - OpdsDataClass( - id = "source/$sourceId", - title = sourceName, + OpdsXmlModels( + id = "categories", + title = "Categories", updated = formattedNow, - totalResults = totalCount, + author = OpdsXmlModels.Author("Suwayomi", "https://suwayomi.org/"), + totalResults = totalCount.toLong(), itemsPerPage = ITEMS_PER_PAGE, - startIndex = (pageNum - 1) * ITEMS_PER_PAGE + 1, - icon = iconUrl, - author = OpdsDataClass.Author("Suwayomi", "https://suwayomi.org/"), + startIndex = fromIndex + 1, links = - listOfNotNull( - OpdsDataClass.Link( + listOf( + OpdsXmlModels.Link( rel = "self", - href = "$baseUrl/source/$sourceId?pageNumber=$pageNum", - type = "application/atom+xml;profile=opds-catalog;kind=acquisition", + href = "$baseUrl/categories?pageNumber=$pageNum", + type = "application/atom+xml;profile=opds-catalog;kind=navigation", ), - OpdsDataClass.Link( + OpdsXmlModels.Link( rel = "start", href = baseUrl, type = "application/atom+xml;profile=opds-catalog;kind=navigation", ), ), entries = - mangas.map { manga -> - OpdsDataClass.Entry( - id = "manga/${manga.id}", - title = manga.title, + paginatedCategories.map { (id, name) -> + OpdsXmlModels.Entry( + id = "category/$id", + title = name, updated = formattedNow, - authors = manga.author?.let { listOf(OpdsDataClass.Author(name = it)) } ?: emptyList(), - categories = - manga.genre.map { genre -> - OpdsDataClass.Category(term = "", label = genre) - }, - summary = manga.description?.let { OpdsDataClass.Summary(value = it) }, link = - listOfNotNull( - OpdsDataClass.Link( + listOf( + OpdsXmlModels.Link( rel = "subsection", - href = "$baseUrl/manga/${manga.id}", + href = "$baseUrl/category/$id?pageNumber=1", type = "application/atom+xml;profile=opds-catalog;kind=acquisition", ), - OpdsDataClass.Link( - rel = "http://opds-spec.org/image", - href = proxyThumbnailUrl(manga.id), - type = "image/jpeg", - ), - ), - content = - OpdsDataClass.Content( - type = "text", - value = manga.status, ), ) }, @@ -191,10 +268,134 @@ object Opds { ) } - suspend fun getMangaFeed( + 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) + .where { ChapterTable.isDownloaded eq true } + .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) * ITEMS_PER_PAGE + val toIndex = minOf(fromIndex + ITEMS_PER_PAGE, 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 = ITEMS_PER_PAGE, + 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) * ITEMS_PER_PAGE + val toIndex = minOf(fromIndex + ITEMS_PER_PAGE, totalCount) + val paginatedStatuses = if (fromIndex < totalCount) statuses.subList(fromIndex, toIndex) else emptyList() + + return serialize( + OpdsXmlModels( + id = "status", + title = "Status", + updated = formattedNow, + author = OpdsXmlModels.Author("Suwayomi", "https://suwayomi.org/"), + totalResults = totalCount.toLong(), + itemsPerPage = ITEMS_PER_PAGE, + startIndex = fromIndex + 1, + links = + listOf( + OpdsXmlModels.Link( + rel = "self", + href = "$baseUrl/status?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 = + 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", + ), + ), + ) + }, + ), + ) + } + + fun getMangaFeed( mangaId: Int, baseUrl: String, - pageNum: Int = 1, + pageNum: Int, ): String { val formattedNow = opdsDateFormatter.format(Instant.now()) val (manga, chapters, totalCount) = @@ -223,13 +424,13 @@ object Opds { } return serialize( - OpdsDataClass( + OpdsXmlModels( id = "manga/$mangaId", title = manga.title, updated = formattedNow, icon = manga.thumbnailUrl, author = - OpdsDataClass.Author( + OpdsXmlModels.Author( name = "Suwayomi", uri = "https://suwayomi.org/", ), @@ -238,35 +439,35 @@ object Opds { startIndex = (pageNum - 1) * ITEMS_PER_PAGE + 1, links = listOfNotNull( - OpdsDataClass.Link( + OpdsXmlModels.Link( rel = "self", href = "$baseUrl/manga/$mangaId?pageNumber=$pageNum", type = "application/atom+xml;profile=opds-catalog;kind=acquisition", ), - OpdsDataClass.Link( + OpdsXmlModels.Link( rel = "start", href = baseUrl, type = "application/atom+xml;profile=opds-catalog;kind=navigation", ), manga.thumbnailUrl?.let { url -> - OpdsDataClass.Link( + OpdsXmlModels.Link( rel = "http://opds-spec.org/image", href = url, type = "image/jpeg", ) }, manga.thumbnailUrl?.let { url -> - OpdsDataClass.Link( + OpdsXmlModels.Link( rel = "http://opds-spec.org/image/thumbnail", href = url, type = "image/jpeg", ) }, - // OpdsDataClass.Link( - // rel = "search", - // type = "application/opensearchdescription+xml", - // href = "$baseUrl/search" - // ), + OpdsXmlModels.Link( + rel = "search", + type = "application/opensearchdescription+xml", + href = "$baseUrl/search", + ), ), entries = chapters.map { chapter -> @@ -276,19 +477,72 @@ object Opds { ) } + 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) + .where { ChapterTable.isDownloaded eq true } + .groupBy(SourceTable.lang) + .orderBy(SourceTable.lang to SortOrder.ASC) + .map { row -> row[SourceTable.lang] } + } + + return serialize( + OpdsXmlModels( + id = "languages", + title = "Languages", + updated = formattedNow, + author = OpdsXmlModels.Author("Suwayomi", "https://suwayomi.org/"), + links = + listOf( + OpdsXmlModels.Link( + rel = "self", + href = "$baseUrl/languages", + 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 = + 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", + ), + ), + ) + }, + ), + ) + } + private fun createChapterEntry( chapter: ChapterDataClass, manga: MangaDataClass, - ): OpdsDataClass.Entry { + ): OpdsXmlModels.Entry { val cbzFile = File(getChapterCbzPath(manga.id, chapter.id)) val isCbzAvailable = cbzFile.exists() - return OpdsDataClass.Entry( + return OpdsXmlModels.Entry( id = "chapter/${chapter.id}", title = chapter.name, updated = opdsDateFormatter.format(Instant.ofEpochMilli(chapter.uploadDate)), - content = OpdsDataClass.Content(value = "${chapter.scanlator}"), - summary = manga.description?.let { OpdsDataClass.Summary(value = it) }, + content = OpdsXmlModels.Content(value = "${chapter.scanlator}"), + summary = manga.description?.let { OpdsXmlModels.Summary(value = it) }, extent = cbzFile.takeIf { it.exists() }?.let { formatFileSize(it.length()) @@ -296,26 +550,26 @@ object Opds { format = cbzFile.takeIf { it.exists() }?.let { "CBZ" }, authors = listOfNotNull( - manga.author?.let { OpdsDataClass.Author(name = it) }, - manga.artist?.takeIf { it != manga.author }?.let { OpdsDataClass.Author(name = it) }, + manga.author?.let { OpdsXmlModels.Author(name = it) }, + manga.artist?.takeIf { it != manga.author }?.let { OpdsXmlModels.Author(name = it) }, ), link = listOfNotNull( if (isCbzAvailable) { - OpdsDataClass.Link( + OpdsXmlModels.Link( rel = "http://opds-spec.org/acquisition/open-access", href = "/api/v1/chapter/${chapter.id}/download", type = "application/vnd.comicbook+zip", ) } else { - OpdsDataClass.Link( + OpdsXmlModels.Link( rel = "http://vaemendis.net/opds-pse/stream", href = "/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/{pageNumber}", type = "image/jpeg", pseCount = chapter.pageCount, ) }, - OpdsDataClass.Link( + OpdsXmlModels.Link( rel = "http://opds-spec.org/image", href = "/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/0", type = "image/jpeg", @@ -324,6 +578,283 @@ object Opds { ) } + 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) and (ChapterTable.isDownloaded eq true) + }.groupBy(MangaTable.id) + .orderBy(MangaTable.title to SortOrder.ASC) + + val totalCount = query.count() + val paginatedResults = + query + .limit(ITEMS_PER_PAGE) + .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) + .map { MangaTable.toDataClass(it) } + + 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(), 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) and (ChapterTable.isDownloaded eq true) } + .groupBy(MangaTable.id) + .orderBy(MangaTable.title to SortOrder.ASC) + val totalCount = query.count() + val mangas = + query + .limit(ITEMS_PER_PAGE) + .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) + .map { MangaTable.toDataClass(it) } + 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%") and (ChapterTable.isDownloaded eq true) } + .groupBy(MangaTable.id) + .orderBy(MangaTable.title to SortOrder.ASC) + val totalCount = query.count() + val mangas = + query + .limit(ITEMS_PER_PAGE) + .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) + .map { MangaTable.toDataClass(it) } + 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()) and (ChapterTable.isDownloaded eq true) } + .groupBy(MangaTable.id) + .orderBy(MangaTable.title to SortOrder.ASC) + val totalCount = query.count() + val mangas = + query + .limit(ITEMS_PER_PAGE) + .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) + .map { MangaTable.toDataClass(it) } + 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) and (ChapterTable.isDownloaded eq true) } + .groupBy(MangaTable.id) + .orderBy(MangaTable.title to SortOrder.ASC) + val totalCount = query.count() + val mangas = + query + .limit(ITEMS_PER_PAGE) + .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) + .map { MangaTable.toDataClass(it) } + Pair(mangas, totalCount) + } + return FeedBuilder(baseUrl, pageNum, "language/$langCode", "Language: $langCode") + .apply { + totalResults = total + entries += mangas.map { mangaEntry(it, baseUrl, formattedNow) } + }.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() + val entries = mutableListOf() + + fun build(): OpdsXmlModels = + OpdsXmlModels( + id = id, + title = title, + updated = formattedNow, + icon = icon, + author = OpdsXmlModels.Author("Suwayomi", "https://suwayomi.org/"), + links = + links + + listOf( + OpdsXmlModels.Link( + rel = "self", + href = + if (id == "opds") { + baseUrl + } else if (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", + ), + ), + entries = entries, + totalResults = totalResults, + itemsPerPage = ITEMS_PER_PAGE, + startIndex = (pageNum - 1) * ITEMS_PER_PAGE + 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'") @@ -346,5 +877,5 @@ object Opds { } } - private fun serialize(feed: OpdsDataClass): String = xmlFormat.encodeToString(OpdsDataClass.serializer(), feed) + private fun serialize(feed: OpdsXmlModels): String = xmlFormat.encodeToString(OpdsXmlModels.serializer(), feed) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsXmlModels.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsXmlModels.kt new file mode 100644 index 00000000..9e33b1b4 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsXmlModels.kt @@ -0,0 +1,136 @@ +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, + @XmlElement(true) + val entries: List, + @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("opds:facetGroup", "", "") + val facetGroup: String? = null, + @XmlSerialName("opds:activeFacet", "", "") + val activeFacet: Boolean? = null, + val indirectAcquisition: List? = 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, + @XmlElement(true) + val authors: List? = null, + @XmlElement(true) + val categories: List? = 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, + ) +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/model/SearchCriteria.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/SearchCriteria.kt new file mode 100644 index 00000000..b45bc7df --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/SearchCriteria.kt @@ -0,0 +1,5 @@ +data class SearchCriteria( + val query: String? = null, + val author: String? = null, + val title: String? = null, +)