Refactoring OPDS API for a more versatile root, allowing selection of manga listing by: all, source, genre, category, language, status. (#1262)

* Añadiendo algunos cambios iniciales para probar OPDS

* Add suport to OPDS v1.2

* Added support for OPDS-PSE and reorganized controllers

* Rename chapterIndex to chapterId in the API and controller, and update descriptions in OPDS

* Refactor OPDS to use formatted timestamps and proxy thumbnail URLs

* Refactor OPDS to use formatted timestamps and proxy thumbnail URLs

* Update Manga API to download chapters cbz using only chapterId and improve chapter download query

* Optimize OPDS queries

* Update Manga API to download chapters cbz using only chapterId and improve chapter download query

* Optimize OPDS queries

* Use SourceDataClass to map sources and optimize thumbnail URL retrieval

* Kotlin lint errors in ChapterDownloadHelper and Opds

* Kotlin lint errors in ChapterDownloadHelper and Opds

* Refactor OPDS API endpoints and rename OpdsController to OpdsV1Controller

* Translate OpdsV1Controller comments to English and remove unused imports

* Translate comments in OpdsAPI.kt to English

* Add SearchCriteria class and update OpdsV1Controller

* Remove spanish comments

* Refactor search handling in OpdsV1Controller and update search feed endpoint

* Fix search
This commit is contained in:
Zeedif
2025-02-09 15:03:18 -06:00
committed by GitHub
parent 01c37cb0ba
commit c2f7cdd72e
6 changed files with 1248 additions and 221 deletions
@@ -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)
}
}
}
}
@@ -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<Long>("sourceId"),
queryParam<Int?>("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<Int>("mangaId"),
queryParam<Int?>("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)
},
)
}
@@ -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(
"""
<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>
<InputEncoding>UTF-8</InputEncoding>
<OutputEncoding>UTF-8</OutputEncoding>
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition"
rel="results"
template="$BASE_URL/mangas?query={searchTerms}"/>
</OpenSearchDescription>
""".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<Int?>("pageNumber"),
queryParam<String?>("query"),
queryParam<String?>("author"),
queryParam<String?>("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<Int?>("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<Int?>("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<Int?>("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<Int?>("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<Int>("mangaId"),
queryParam<Int?>("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<Long>("sourceId"),
queryParam<Int?>("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<Int>("categoryId"),
queryParam<Int?>("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<String>("genre"),
queryParam<Int?>("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<Long>("statusId"),
queryParam<Int?>("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<String>("langCode"),
queryParam<Int?>("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)
},
)
}
@@ -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<Op<Boolean>>()
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<MangaDataClass>(), 0, "")
}
val categoryName = categoryRow[CategoryTable.name]
val query =
CategoryMangaTable
.join(MangaTable, JoinType.INNER, onColumn = CategoryMangaTable.manga, otherColumn = MangaTable.id)
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
.select(MangaTable.columns)
.where { (CategoryMangaTable.category eq categoryId) 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<OpdsXmlModels.Link>()
val entries = mutableListOf<OpdsXmlModels.Entry>()
fun build(): OpdsXmlModels =
OpdsXmlModels(
id = id,
title = title,
updated = formattedNow,
icon = icon,
author = OpdsXmlModels.Author("Suwayomi", "https://suwayomi.org/"),
links =
links +
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)
}
@@ -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<Link>,
@XmlElement(true)
val entries: List<Entry>,
@XmlSerialName("xmlns", "", "")
val xmlns: String = "http://www.w3.org/2005/Atom",
@XmlSerialName("xmlns:xsd", "", "")
val xmlnsXsd: String = "http://www.w3.org/2001/XMLSchema",
@XmlSerialName("xmlns:xsi", "", "")
val xmlnsXsi: String = "http://www.w3.org/2001/XMLSchema-instance",
@XmlSerialName("xmlns:opds", "", "")
val xmlnsOpds: String = "http://opds-spec.org/2010/catalog",
@XmlSerialName("xmlns:dcterms", "", "")
val xmlnsDublinCore: String = "http://purl.org/dc/terms/",
@XmlSerialName("xmlns:pse", "", "")
val xmlnsPse: String = "http://vaemendis.net/opds-pse/ns",
@XmlElement(true)
@XmlSerialName("totalResults", "http://a9.com/-/spec/opensearch/1.1/", "")
val totalResults: Long? = null,
@XmlElement(true)
@XmlSerialName("itemsPerPage", "http://a9.com/-/spec/opensearch/1.1/", "")
val itemsPerPage: Int? = null,
@XmlElement(true)
@XmlSerialName("startIndex", "http://a9.com/-/spec/opensearch/1.1/", "")
val startIndex: Int? = null,
) {
@Serializable
@XmlSerialName("author", "", "")
data class Author(
@XmlElement(true)
val name: String,
@XmlElement(true)
val uri: String? = null,
@XmlElement(true)
val email: String? = null,
)
@Serializable
@XmlSerialName("link", "", "")
data class Link(
val rel: String,
val href: String,
val type: String? = null,
val title: String? = null,
@XmlSerialName("pse:count", "", "")
val pseCount: Int? = null,
@XmlSerialName("opds:facetGroup", "", "")
val facetGroup: String? = null,
@XmlSerialName("opds:activeFacet", "", "")
val activeFacet: Boolean? = null,
val indirectAcquisition: List<OpdsIndirectAcquisition>? = null,
)
@Serializable
@XmlSerialName("opds:indirectAcquisition", "", "")
data class OpdsIndirectAcquisition(
@XmlSerialName("type") val type: String,
)
@Serializable
@XmlSerialName("entry", "", "")
data class Entry(
@XmlElement(true)
val id: String,
@XmlElement(true)
val title: String,
@XmlElement(true)
val updated: String,
@XmlElement(true)
val summary: Summary? = null,
@XmlElement(true)
val content: Content? = null,
@XmlElement(true)
val link: List<Link>,
@XmlElement(true)
val authors: List<Author>? = null,
@XmlElement(true)
val categories: List<Category>? = null,
@XmlElement(true)
@XmlSerialName("extent", "http://purl.org/dc/terms/", "")
val extent: String? = null,
@XmlElement(true)
@XmlSerialName("format", "http://purl.org/dc/terms/format", "")
val format: String? = null,
@XmlSerialName("dc:language")
val language: String? = null,
@XmlSerialName("dc:publisher")
val publisher: String? = null,
@XmlSerialName("dc:issued")
val issued: String? = null,
@XmlSerialName("dc:identifier")
val identifier: String? = null,
)
@Serializable
@XmlSerialName("summary", "", "")
data class Summary(
val type: String = "text",
@XmlValue(true) val value: String = "",
)
@Serializable
@XmlSerialName("content", "", "")
data class Content(
val type: String = "text",
@XmlValue(true) val value: String = "",
)
@Serializable
@XmlSerialName("category", "", "")
data class Category(
val scheme: String? = null,
val term: String,
val label: String,
)
}
@@ -0,0 +1,5 @@
data class SearchCriteria(
val query: String? = null,
val author: String? = null,
val title: String? = null,
)