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
@@ -82,6 +82,7 @@ object MangaAPI {
path("chapter") {
post("batch", MangaController.anyChapterBatch)
get("{chapterId}/download", MangaController.downloadChapter)
}
path("category") {
@@ -11,6 +11,7 @@ import io.javalin.http.HttpStatus
import kotlinx.serialization.json.Json
import suwayomi.tachidesk.manga.impl.CategoryManga
import suwayomi.tachidesk.manga.impl.Chapter
import suwayomi.tachidesk.manga.impl.ChapterDownloadHelper
import suwayomi.tachidesk.manga.impl.Library
import suwayomi.tachidesk.manga.impl.Manga
import suwayomi.tachidesk.manga.impl.Page
@@ -424,4 +425,29 @@ object MangaController {
httpCode(HttpStatus.NOT_FOUND)
},
)
val downloadChapter =
handler(
pathParam<Int>("chapterId"),
documentWith = {
withOperation {
summary("Download chapter as CBZ")
description("Get the CBZ file of the specified chapter")
}
},
behaviorOf = { ctx, chapterId ->
ctx.future {
future { ChapterDownloadHelper.getCbzDownload(chapterId) }
.thenApply { (inputStream, contentType, fileName) ->
ctx.header("Content-Type", contentType)
ctx.header("Content-Disposition", "attachment; filename=\"$fileName\"")
ctx.result(inputStream)
}
}
},
withResults = {
httpCode(HttpStatus.OK)
httpCode(HttpStatus.NOT_FOUND)
},
)
}
@@ -1,14 +1,19 @@
package suwayomi.tachidesk.manga.impl
import kotlinx.coroutines.CoroutineScope
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.manga.impl.download.fileProvider.ChaptersFilesProvider
import suwayomi.tachidesk.manga.impl.download.fileProvider.impl.ArchiveProvider
import suwayomi.tachidesk.manga.impl.download.fileProvider.impl.FolderProvider
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
import suwayomi.tachidesk.manga.impl.util.getChapterCbzPath
import suwayomi.tachidesk.manga.impl.util.getChapterDownloadPath
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.toDataClass
import suwayomi.tachidesk.server.serverConfig
import java.io.File
import java.io.IOException
import java.io.InputStream
object ChapterDownloadHelper {
@@ -42,4 +47,27 @@ object ChapterDownloadHelper {
if (!chapterFolder.exists() && serverConfig.downloadAsCbz.value) return ArchiveProvider(mangaId, chapterId)
return FolderProvider(mangaId, chapterId)
}
suspend fun getCbzDownload(chapterId: Int): Triple<InputStream, String, String> {
val (chapterData, mangaTitle) =
transaction {
val row =
(ChapterTable innerJoin MangaTable)
.select(ChapterTable.columns + MangaTable.columns)
.where { ChapterTable.id eq chapterId }
.firstOrNull() ?: throw Exception("Chapter not found")
val chapter = ChapterTable.toDataClass(row)
val title = row[MangaTable.title]
Pair(chapter, title)
}
val provider = provider(chapterData.mangaId, chapterData.id)
return if (provider is ArchiveProvider) {
val cbzFile = File(getChapterCbzPath(chapterData.mangaId, chapterData.id))
val fileName = "$mangaTitle - [${chapterData.scanlator}] ${chapterData.name}.cbz"
Triple(cbzFile.inputStream(), "application/vnd.comicbook+zip", fileName)
} else {
throw IOException("Chapter not available as CBZ")
}
}
}
@@ -0,0 +1,117 @@
package suwayomi.tachidesk.manga.model.dataclass
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 OpdsDataClass(
@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,
)
@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("language", "http://purl.org/dc/terms/", "dc")
val extent: String? = null,
@XmlElement(true)
@XmlSerialName("format", "http://purl.org/dc/terms/format", "dc")
val format: 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,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)
}
}
}
@@ -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)
},
)
}
@@ -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)
}
@@ -22,6 +22,7 @@ import org.eclipse.jetty.server.ServerConnector
import suwayomi.tachidesk.global.GlobalAPI
import suwayomi.tachidesk.graphql.GraphQL
import suwayomi.tachidesk.manga.MangaAPI
import suwayomi.tachidesk.opds.OpdsAPI
import suwayomi.tachidesk.server.util.Browser
import suwayomi.tachidesk.server.util.WebInterfaceManager
import uy.kohesive.injekt.injectLazy
@@ -102,6 +103,8 @@ object JavalinSetup {
GlobalAPI.defineEndpoints()
MangaAPI.defineEndpoints()
}
OpdsAPI.defineEndpoints()
GraphQL.defineEndpoints()
}
}