mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-04 11:24:35 -05:00
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:
@@ -82,6 +82,7 @@ object MangaAPI {
|
|||||||
|
|
||||||
path("chapter") {
|
path("chapter") {
|
||||||
post("batch", MangaController.anyChapterBatch)
|
post("batch", MangaController.anyChapterBatch)
|
||||||
|
get("{chapterId}/download", MangaController.downloadChapter)
|
||||||
}
|
}
|
||||||
|
|
||||||
path("category") {
|
path("category") {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import io.javalin.http.HttpStatus
|
|||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import suwayomi.tachidesk.manga.impl.CategoryManga
|
import suwayomi.tachidesk.manga.impl.CategoryManga
|
||||||
import suwayomi.tachidesk.manga.impl.Chapter
|
import suwayomi.tachidesk.manga.impl.Chapter
|
||||||
|
import suwayomi.tachidesk.manga.impl.ChapterDownloadHelper
|
||||||
import suwayomi.tachidesk.manga.impl.Library
|
import suwayomi.tachidesk.manga.impl.Library
|
||||||
import suwayomi.tachidesk.manga.impl.Manga
|
import suwayomi.tachidesk.manga.impl.Manga
|
||||||
import suwayomi.tachidesk.manga.impl.Page
|
import suwayomi.tachidesk.manga.impl.Page
|
||||||
@@ -424,4 +425,29 @@ object MangaController {
|
|||||||
httpCode(HttpStatus.NOT_FOUND)
|
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
|
package suwayomi.tachidesk.manga.impl
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
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.ChaptersFilesProvider
|
||||||
import suwayomi.tachidesk.manga.impl.download.fileProvider.impl.ArchiveProvider
|
import suwayomi.tachidesk.manga.impl.download.fileProvider.impl.ArchiveProvider
|
||||||
import suwayomi.tachidesk.manga.impl.download.fileProvider.impl.FolderProvider
|
import suwayomi.tachidesk.manga.impl.download.fileProvider.impl.FolderProvider
|
||||||
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
|
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
|
||||||
import suwayomi.tachidesk.manga.impl.util.getChapterCbzPath
|
import suwayomi.tachidesk.manga.impl.util.getChapterCbzPath
|
||||||
import suwayomi.tachidesk.manga.impl.util.getChapterDownloadPath
|
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 suwayomi.tachidesk.server.serverConfig
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
object ChapterDownloadHelper {
|
object ChapterDownloadHelper {
|
||||||
@@ -42,4 +47,27 @@ object ChapterDownloadHelper {
|
|||||||
if (!chapterFolder.exists() && serverConfig.downloadAsCbz.value) return ArchiveProvider(mangaId, chapterId)
|
if (!chapterFolder.exists() && serverConfig.downloadAsCbz.value) return ArchiveProvider(mangaId, chapterId)
|
||||||
return FolderProvider(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,
|
||||||
|
)
|
||||||
|
}
|
||||||
22
server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt
Normal file
22
server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
350
server/src/main/kotlin/suwayomi/tachidesk/opds/impl/Opds.kt
Normal file
350
server/src/main/kotlin/suwayomi/tachidesk/opds/impl/Opds.kt
Normal 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)
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import org.eclipse.jetty.server.ServerConnector
|
|||||||
import suwayomi.tachidesk.global.GlobalAPI
|
import suwayomi.tachidesk.global.GlobalAPI
|
||||||
import suwayomi.tachidesk.graphql.GraphQL
|
import suwayomi.tachidesk.graphql.GraphQL
|
||||||
import suwayomi.tachidesk.manga.MangaAPI
|
import suwayomi.tachidesk.manga.MangaAPI
|
||||||
|
import suwayomi.tachidesk.opds.OpdsAPI
|
||||||
import suwayomi.tachidesk.server.util.Browser
|
import suwayomi.tachidesk.server.util.Browser
|
||||||
import suwayomi.tachidesk.server.util.WebInterfaceManager
|
import suwayomi.tachidesk.server.util.WebInterfaceManager
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
@@ -102,6 +103,8 @@ object JavalinSetup {
|
|||||||
GlobalAPI.defineEndpoints()
|
GlobalAPI.defineEndpoints()
|
||||||
MangaAPI.defineEndpoints()
|
MangaAPI.defineEndpoints()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OpdsAPI.defineEndpoints()
|
||||||
GraphQL.defineEndpoints()
|
GraphQL.defineEndpoints()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user