Add support for OPDS v1.2 to browse stored CBZ files (#1257)

* 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
This commit is contained in:
Zeedif
2025-02-08 10:53:06 -06:00
committed by GitHub
parent 9669cdfb76
commit 26aa684300
8 changed files with 633 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
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
object OpdsAPI {
fun defineEndpoints() {
path("opds/v1.2") {
get(OpdsController.rootFeed)
get("source/{sourceId}", OpdsController.sourceFeed)
get("manga/{mangaId}", OpdsController.mangaFeed)
}
}
}

View File

@@ -0,0 +1,86 @@
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,350 @@
package suwayomi.tachidesk.opds.impl
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.SortOrder
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
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.ChapterTable
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.SourceTable
import suwayomi.tachidesk.manga.model.table.toDataClass
import java.io.File
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
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 = "",
)
}
}
return serialize(
OpdsDataClass(
id = "opds",
title = "Suwayomi OPDS Catalog",
icon = "/favicon",
updated = formattedNow,
author = OpdsDataClass.Author("Suwayomi", "https://suwayomi.org/"),
links =
listOf(
OpdsDataClass.Link(
rel = "self",
href = baseUrl,
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
),
OpdsDataClass.Link(
rel = "start",
href = baseUrl,
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
),
),
entries =
sources.map {
OpdsDataClass.Entry(
updated = formattedNow,
id = it.id,
title = it.name,
link =
listOf(
OpdsDataClass.Link(
rel = "subsection",
href = "$baseUrl/source/${it.id}",
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
),
),
)
},
),
)
}
fun getSourceFeed(
sourceId: Long,
baseUrl: String,
pageNum: Int = 1,
): String {
val formattedNow = opdsDateFormatter.format(Instant.now())
val (mangas, totalCount, 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)
}
val sourceName = sourceRow?.get(SourceTable.name) ?: sourceId.toString()
val iconUrl = sourceRow?.get(ExtensionTable.apkName)?.let { getExtensionIconUrl(it) }
return serialize(
OpdsDataClass(
id = "source/$sourceId",
title = sourceName,
updated = formattedNow,
totalResults = totalCount,
itemsPerPage = ITEMS_PER_PAGE,
startIndex = (pageNum - 1) * ITEMS_PER_PAGE + 1,
icon = iconUrl,
author = OpdsDataClass.Author("Suwayomi", "https://suwayomi.org/"),
links =
listOfNotNull(
OpdsDataClass.Link(
rel = "self",
href = "$baseUrl/source/$sourceId?pageNumber=$pageNum",
type = "application/atom+xml;profile=opds-catalog;kind=acquisition",
),
OpdsDataClass.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,
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(
rel = "subsection",
href = "$baseUrl/manga/${manga.id}",
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,
),
)
},
),
)
}
suspend fun getMangaFeed(
mangaId: Int,
baseUrl: String,
pageNum: Int = 1,
): String {
val formattedNow = opdsDateFormatter.format(Instant.now())
val (manga, chapters, totalCount) =
transaction {
val mangaEntry =
MangaTable
.selectAll()
.where { MangaTable.id eq mangaId }
.first()
val mangaData = MangaTable.toDataClass(mangaEntry)
val chaptersQuery =
ChapterTable
.selectAll()
.where {
(ChapterTable.manga eq mangaId) and
(ChapterTable.isDownloaded eq true) and
(ChapterTable.pageCount greater 0)
}.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
val total = chaptersQuery.count()
val chaptersData =
chaptersQuery
.limit(ITEMS_PER_PAGE)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
.map { ChapterTable.toDataClass(it) }
Triple(mangaData, chaptersData, total)
}
return serialize(
OpdsDataClass(
id = "manga/$mangaId",
title = manga.title,
updated = formattedNow,
icon = manga.thumbnailUrl,
author =
OpdsDataClass.Author(
name = "Suwayomi",
uri = "https://suwayomi.org/",
),
totalResults = totalCount,
itemsPerPage = ITEMS_PER_PAGE,
startIndex = (pageNum - 1) * ITEMS_PER_PAGE + 1,
links =
listOfNotNull(
OpdsDataClass.Link(
rel = "self",
href = "$baseUrl/manga/$mangaId?pageNumber=$pageNum",
type = "application/atom+xml;profile=opds-catalog;kind=acquisition",
),
OpdsDataClass.Link(
rel = "start",
href = baseUrl,
type = "application/atom+xml;profile=opds-catalog;kind=navigation",
),
manga.thumbnailUrl?.let { url ->
OpdsDataClass.Link(
rel = "http://opds-spec.org/image",
href = url,
type = "image/jpeg",
)
},
manga.thumbnailUrl?.let { url ->
OpdsDataClass.Link(
rel = "http://opds-spec.org/image/thumbnail",
href = url,
type = "image/jpeg",
)
},
// OpdsDataClass.Link(
// rel = "search",
// type = "application/opensearchdescription+xml",
// href = "$baseUrl/search"
// ),
),
entries =
chapters.map { chapter ->
createChapterEntry(chapter, manga)
},
),
)
}
private fun createChapterEntry(
chapter: ChapterDataClass,
manga: MangaDataClass,
): OpdsDataClass.Entry {
val cbzFile = File(getChapterCbzPath(manga.id, chapter.id))
val isCbzAvailable = cbzFile.exists()
return OpdsDataClass.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) },
extent =
cbzFile.takeIf { it.exists() }?.let {
formatFileSize(it.length())
},
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) },
),
link =
listOfNotNull(
if (isCbzAvailable) {
OpdsDataClass.Link(
rel = "http://opds-spec.org/acquisition/open-access",
href = "/api/v1/chapter/${chapter.id}/download",
type = "application/vnd.comicbook+zip",
)
} else {
OpdsDataClass.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(
rel = "http://opds-spec.org/image",
href = "/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/0",
type = "image/jpeg",
),
),
)
}
private val opdsDateFormatter =
DateTimeFormatter
.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
.withZone(ZoneOffset.UTC)
private fun formatFileSize(size: Long): String =
when {
size >= 1_000_000 -> "%.2f MB".format(size / 1_000_000.0)
size >= 1_000 -> "%.2f KB".format(size / 1_000.0)
else -> "$size bytes"
}
private val xmlFormat =
XML {
indent = 2
xmlVersion = XmlVersion.XML10
xmlDeclMode = XmlDeclMode.Charset
defaultPolicy {
autoPolymorphic = true
}
}
private fun serialize(feed: OpdsDataClass): String = xmlFormat.encodeToString(OpdsDataClass.serializer(), feed)
}