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

View File

@@ -1,22 +1,53 @@
package suwayomi.tachidesk.opds 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.get
import io.javalin.apibuilder.ApiBuilder.path import io.javalin.apibuilder.ApiBuilder.path
import suwayomi.tachidesk.opds.controller.OpdsController import suwayomi.tachidesk.opds.controller.OpdsV1Controller
object OpdsAPI { object OpdsAPI {
fun defineEndpoints() { fun defineEndpoints() {
path("opds/v1.2") { path("opds/v1.2") {
get(OpdsController.rootFeed) // Root feed (Navigation Feed)
get("source/{sourceId}", OpdsController.sourceFeed) get(OpdsV1Controller.rootFeed)
get("manga/{mangaId}", OpdsController.mangaFeed)
// 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)
}
} }
} }
} }

View File

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

View File

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

View File

@@ -1,11 +1,14 @@
package suwayomi.tachidesk.opds.impl package suwayomi.tachidesk.opds.impl
import SearchCriteria
import nl.adaptivity.xmlutil.XmlDeclMode import nl.adaptivity.xmlutil.XmlDeclMode
import nl.adaptivity.xmlutil.core.XmlVersion import nl.adaptivity.xmlutil.core.XmlVersion
import nl.adaptivity.xmlutil.serialization.XML import nl.adaptivity.xmlutil.serialization.XML
import org.jetbrains.exposed.sql.JoinType import org.jetbrains.exposed.sql.JoinType
import org.jetbrains.exposed.sql.Op
import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.or
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl 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.impl.util.getChapterCbzPath
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass 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.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.ChapterTable
import suwayomi.tachidesk.manga.model.table.ExtensionTable 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.MangaTable
import suwayomi.tachidesk.manga.model.table.SourceTable import suwayomi.tachidesk.manga.model.table.SourceTable
import suwayomi.tachidesk.manga.model.table.toDataClass import suwayomi.tachidesk.manga.model.table.toDataClass
import suwayomi.tachidesk.opds.model.OpdsXmlModels
import java.io.File import java.io.File
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.time.Instant import java.time.Instant
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -29,61 +37,160 @@ object Opds {
private const val ITEMS_PER_PAGE = 20 private const val ITEMS_PER_PAGE = 20
fun getRootFeed(baseUrl: String): String { fun getRootFeed(baseUrl: String): String {
val formattedNow = opdsDateFormatter.format(Instant.now()) val builder =
val sources = FeedBuilder(baseUrl, 1, "opds", "Suwayomi OPDS Catalog").apply {
transaction { totalResults = 6
SourceTable entries +=
.join(MangaTable, JoinType.INNER) { listOf(
MangaTable.sourceReference eq SourceTable.id "mangas" to "All Manga",
}.join(ChapterTable, JoinType.INNER) { "sources" to "Sources",
ChapterTable.manga eq MangaTable.id "categories" to "Categories",
}.selectAll() "genres" to "Genres",
.where { ChapterTable.isDownloaded eq true } "status" to "Status",
.orderBy(SourceTable.name to SortOrder.ASC) "languages" to "Languages",
.distinct() ).map { (id, title) ->
.map { OpdsXmlModels.Entry(
SourceDataClass( id = id,
id = it[SourceTable.id].value.toString(), title = title,
name = it[SourceTable.name], updated = formattedNow,
lang = it[SourceTable.lang], link =
iconUrl = "", listOf(
supportsLatest = false, OpdsXmlModels.Link(
isConfigurable = false, rel = "subsection",
isNsfw = it[SourceTable.isNsfw], href = "$baseUrl/$id",
displayName = "", 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( return serialize(
OpdsDataClass( OpdsXmlModels(
id = "opds", id = "sources",
title = "Suwayomi OPDS Catalog", title = "Sources",
icon = "/favicon",
updated = formattedNow, 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 = links =
listOf( listOfNotNull(
OpdsDataClass.Link( OpdsXmlModels.Link(
rel = "self", rel = "self",
href = baseUrl, href = "$baseUrl/sources?pageNumber=$pageNum",
type = "application/atom+xml;profile=opds-catalog;kind=navigation", type = "application/atom+xml;profile=opds-catalog;kind=navigation",
), ),
OpdsDataClass.Link( OpdsXmlModels.Link(
rel = "start", rel = "start",
href = baseUrl, href = baseUrl,
type = "application/atom+xml;profile=opds-catalog;kind=navigation", type = "application/atom+xml;profile=opds-catalog;kind=navigation",
), ),
), ),
entries = entries =
sources.map { sourceList.map {
OpdsDataClass.Entry( OpdsXmlModels.Entry(
updated = formattedNow, updated = formattedNow,
id = it.id, id = it.id,
title = it.name, title = it.name,
link = link =
listOf( listOf(
OpdsDataClass.Link( OpdsXmlModels.Link(
rel = "subsection", rel = "subsection",
href = "$baseUrl/source/${it.id}", href = "$baseUrl/source/${it.id}",
type = "application/atom+xml;profile=opds-catalog;kind=navigation", type = "application/atom+xml;profile=opds-catalog;kind=navigation",
@@ -95,95 +202,65 @@ object Opds {
) )
} }
fun getSourceFeed( fun getCategoriesFeed(
sourceId: Long,
baseUrl: String, baseUrl: String,
pageNum: Int = 1, pageNum: Int,
): String { ): String {
val formattedNow = opdsDateFormatter.format(Instant.now()) val formattedNow = opdsDateFormatter.format(Instant.now())
val (mangas, totalCount, sourceRow) = val categoryList =
transaction { transaction {
val sourceRow = CategoryTable
SourceTable .join(CategoryMangaTable, JoinType.INNER, onColumn = CategoryTable.id, otherColumn = CategoryMangaTable.category)
.join(ExtensionTable, JoinType.INNER, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id) .join(MangaTable, JoinType.INNER, onColumn = CategoryMangaTable.manga, otherColumn = MangaTable.id)
.select(SourceTable.name, ExtensionTable.apkName) .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
.where { SourceTable.id eq sourceId } .select(CategoryTable.id, CategoryTable.name)
.firstOrNull() .where { ChapterTable.isDownloaded eq true }
.groupBy(CategoryTable.id)
val query = .orderBy(CategoryTable.order to SortOrder.ASC)
MangaTable .map { row ->
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) Pair(row[CategoryTable.id].value, row[CategoryTable.name])
.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)
} }
val totalCount = categoryList.size
val sourceName = sourceRow?.get(SourceTable.name) ?: sourceId.toString() val fromIndex = (pageNum - 1) * ITEMS_PER_PAGE
val iconUrl = sourceRow?.get(ExtensionTable.apkName)?.let { getExtensionIconUrl(it) } val toIndex = minOf(fromIndex + ITEMS_PER_PAGE, totalCount)
val paginatedCategories = if (fromIndex < totalCount) categoryList.subList(fromIndex, toIndex) else emptyList()
return serialize( return serialize(
OpdsDataClass( OpdsXmlModels(
id = "source/$sourceId", id = "categories",
title = sourceName, title = "Categories",
updated = formattedNow, updated = formattedNow,
totalResults = totalCount, author = OpdsXmlModels.Author("Suwayomi", "https://suwayomi.org/"),
totalResults = totalCount.toLong(),
itemsPerPage = ITEMS_PER_PAGE, itemsPerPage = ITEMS_PER_PAGE,
startIndex = (pageNum - 1) * ITEMS_PER_PAGE + 1, startIndex = fromIndex + 1,
icon = iconUrl,
author = OpdsDataClass.Author("Suwayomi", "https://suwayomi.org/"),
links = links =
listOfNotNull( listOf(
OpdsDataClass.Link( OpdsXmlModels.Link(
rel = "self", rel = "self",
href = "$baseUrl/source/$sourceId?pageNumber=$pageNum", href = "$baseUrl/categories?pageNumber=$pageNum",
type = "application/atom+xml;profile=opds-catalog;kind=acquisition", type = "application/atom+xml;profile=opds-catalog;kind=navigation",
), ),
OpdsDataClass.Link( OpdsXmlModels.Link(
rel = "start", rel = "start",
href = baseUrl, href = baseUrl,
type = "application/atom+xml;profile=opds-catalog;kind=navigation", type = "application/atom+xml;profile=opds-catalog;kind=navigation",
), ),
), ),
entries = entries =
mangas.map { manga -> paginatedCategories.map { (id, name) ->
OpdsDataClass.Entry( OpdsXmlModels.Entry(
id = "manga/${manga.id}", id = "category/$id",
title = manga.title, title = name,
updated = formattedNow, 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 = link =
listOfNotNull( listOf(
OpdsDataClass.Link( OpdsXmlModels.Link(
rel = "subsection", rel = "subsection",
href = "$baseUrl/manga/${manga.id}", href = "$baseUrl/category/$id?pageNumber=1",
type = "application/atom+xml;profile=opds-catalog;kind=acquisition", 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, mangaId: Int,
baseUrl: String, baseUrl: String,
pageNum: Int = 1, pageNum: Int,
): String { ): String {
val formattedNow = opdsDateFormatter.format(Instant.now()) val formattedNow = opdsDateFormatter.format(Instant.now())
val (manga, chapters, totalCount) = val (manga, chapters, totalCount) =
@@ -223,13 +424,13 @@ object Opds {
} }
return serialize( return serialize(
OpdsDataClass( OpdsXmlModels(
id = "manga/$mangaId", id = "manga/$mangaId",
title = manga.title, title = manga.title,
updated = formattedNow, updated = formattedNow,
icon = manga.thumbnailUrl, icon = manga.thumbnailUrl,
author = author =
OpdsDataClass.Author( OpdsXmlModels.Author(
name = "Suwayomi", name = "Suwayomi",
uri = "https://suwayomi.org/", uri = "https://suwayomi.org/",
), ),
@@ -238,35 +439,35 @@ object Opds {
startIndex = (pageNum - 1) * ITEMS_PER_PAGE + 1, startIndex = (pageNum - 1) * ITEMS_PER_PAGE + 1,
links = links =
listOfNotNull( listOfNotNull(
OpdsDataClass.Link( OpdsXmlModels.Link(
rel = "self", rel = "self",
href = "$baseUrl/manga/$mangaId?pageNumber=$pageNum", href = "$baseUrl/manga/$mangaId?pageNumber=$pageNum",
type = "application/atom+xml;profile=opds-catalog;kind=acquisition", type = "application/atom+xml;profile=opds-catalog;kind=acquisition",
), ),
OpdsDataClass.Link( OpdsXmlModels.Link(
rel = "start", rel = "start",
href = baseUrl, href = baseUrl,
type = "application/atom+xml;profile=opds-catalog;kind=navigation", type = "application/atom+xml;profile=opds-catalog;kind=navigation",
), ),
manga.thumbnailUrl?.let { url -> manga.thumbnailUrl?.let { url ->
OpdsDataClass.Link( OpdsXmlModels.Link(
rel = "http://opds-spec.org/image", rel = "http://opds-spec.org/image",
href = url, href = url,
type = "image/jpeg", type = "image/jpeg",
) )
}, },
manga.thumbnailUrl?.let { url -> manga.thumbnailUrl?.let { url ->
OpdsDataClass.Link( OpdsXmlModels.Link(
rel = "http://opds-spec.org/image/thumbnail", rel = "http://opds-spec.org/image/thumbnail",
href = url, href = url,
type = "image/jpeg", type = "image/jpeg",
) )
}, },
// OpdsDataClass.Link( OpdsXmlModels.Link(
// rel = "search", rel = "search",
// type = "application/opensearchdescription+xml", type = "application/opensearchdescription+xml",
// href = "$baseUrl/search" href = "$baseUrl/search",
// ), ),
), ),
entries = entries =
chapters.map { chapter -> 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( private fun createChapterEntry(
chapter: ChapterDataClass, chapter: ChapterDataClass,
manga: MangaDataClass, manga: MangaDataClass,
): OpdsDataClass.Entry { ): OpdsXmlModels.Entry {
val cbzFile = File(getChapterCbzPath(manga.id, chapter.id)) val cbzFile = File(getChapterCbzPath(manga.id, chapter.id))
val isCbzAvailable = cbzFile.exists() val isCbzAvailable = cbzFile.exists()
return OpdsDataClass.Entry( return OpdsXmlModels.Entry(
id = "chapter/${chapter.id}", id = "chapter/${chapter.id}",
title = chapter.name, title = chapter.name,
updated = opdsDateFormatter.format(Instant.ofEpochMilli(chapter.uploadDate)), updated = opdsDateFormatter.format(Instant.ofEpochMilli(chapter.uploadDate)),
content = OpdsDataClass.Content(value = "${chapter.scanlator}"), content = OpdsXmlModels.Content(value = "${chapter.scanlator}"),
summary = manga.description?.let { OpdsDataClass.Summary(value = it) }, summary = manga.description?.let { OpdsXmlModels.Summary(value = it) },
extent = extent =
cbzFile.takeIf { it.exists() }?.let { cbzFile.takeIf { it.exists() }?.let {
formatFileSize(it.length()) formatFileSize(it.length())
@@ -296,26 +550,26 @@ object Opds {
format = cbzFile.takeIf { it.exists() }?.let { "CBZ" }, format = cbzFile.takeIf { it.exists() }?.let { "CBZ" },
authors = authors =
listOfNotNull( listOfNotNull(
manga.author?.let { OpdsDataClass.Author(name = it) }, manga.author?.let { OpdsXmlModels.Author(name = it) },
manga.artist?.takeIf { it != manga.author }?.let { OpdsDataClass.Author(name = it) }, manga.artist?.takeIf { it != manga.author }?.let { OpdsXmlModels.Author(name = it) },
), ),
link = link =
listOfNotNull( listOfNotNull(
if (isCbzAvailable) { if (isCbzAvailable) {
OpdsDataClass.Link( OpdsXmlModels.Link(
rel = "http://opds-spec.org/acquisition/open-access", rel = "http://opds-spec.org/acquisition/open-access",
href = "/api/v1/chapter/${chapter.id}/download", href = "/api/v1/chapter/${chapter.id}/download",
type = "application/vnd.comicbook+zip", type = "application/vnd.comicbook+zip",
) )
} else { } else {
OpdsDataClass.Link( OpdsXmlModels.Link(
rel = "http://vaemendis.net/opds-pse/stream", rel = "http://vaemendis.net/opds-pse/stream",
href = "/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/{pageNumber}", href = "/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/{pageNumber}",
type = "image/jpeg", type = "image/jpeg",
pseCount = chapter.pageCount, pseCount = chapter.pageCount,
) )
}, },
OpdsDataClass.Link( OpdsXmlModels.Link(
rel = "http://opds-spec.org/image", rel = "http://opds-spec.org/image",
href = "/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/0", href = "/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/0",
type = "image/jpeg", 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 = private val opdsDateFormatter =
DateTimeFormatter DateTimeFormatter
.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") .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)
} }

View File

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

View File

@@ -0,0 +1,5 @@
data class SearchCriteria(
val query: String? = null,
val author: String? = null,
val title: String? = null,
)