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

@@ -82,6 +82,7 @@ object MangaAPI {
path("chapter") {
post("batch", MangaController.anyChapterBatch)
get("{chapterId}/download", MangaController.downloadChapter)
}
path("category") {

View File

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

View File

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

View File

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