OPDS: Offer CBZ in older mimetype (#1731)

* OPDS: Offer CBZ in older mimetype

* OPDS: Include length when offering CBZ download

* Disable compression on CBZ endpoint

Zipping a zip

* CBZ download match content type of OPDS

* Move compression disable

* Introduce setting for configuring CBZ mimetype

* Document new option

[no-ci]

* Update server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsEntryBuilder.kt

Co-authored-by: Mitchell Syer <Syer10@users.noreply.github.com>

---------

Co-authored-by: Mitchell Syer <Syer10@users.noreply.github.com>
This commit is contained in:
Constantin Piber
2025-10-25 00:36:59 +02:00
committed by GitHub
parent 68492bf591
commit bc6e28cabe
8 changed files with 47 additions and 16 deletions

View File

@@ -191,6 +191,7 @@ server.opdsMarkAsReadOnDownload = false
server.opdsShowOnlyUnreadChapters = false
server.opdsShowOnlyDownloadedChapters = false
server.opdsChapterSortOrder = "DESC"
server.opdsCbzMimetype = "MODERN"
```
- `server.opdsUseBinaryFileSizes = false` controls if Suwayomi should display file sizes in binary units (KiB, MiB, GiB) or decimal (KB, MB, GB) in OPDS listings.
- `server.opdsItemsPerPage = 50` sets the number of items per page in OPDS listings. Range: 10 <= n <= 5000.
@@ -199,6 +200,7 @@ server.opdsChapterSortOrder = "DESC"
- `server.opdsShowOnlyUnreadChapters = false` controls if OPDS listings should only include unread chapters.
- `server.opdsShowOnlyDownloadedChapters = false` controls if OPDS listings should only include downloaded chapters.
- `server.opdsChapterSortOrder = "DESC"` sets the default chapter sort order in OPDS listings, either `"ASC"` or `"DESC"`
- `server.opdsCbzMimetype = "MODERN"` controls which mimetype to use for CBZ downloads. This affects the offered link in OPDS, as well as the content type of the CBZ download. Allowed is MODERN (current IANA standard), LEGACY (deprecated mimetype for .cbz) and COMPATIBLE (deprecated mimetype for all comic archives). Use LEGACY or COMPATIBLE if older clients don't offer the chapter download (note that the chapter needs to first be downloaded in Suwayomi, before it is available in OPDS).
### KOReader Sync
```

View File

@@ -0,0 +1,14 @@
package suwayomi.tachidesk.graphql.types
enum class CbzMediaType(
val mediaType: String,
) {
MODERN("application/vnd.comicbook+zip"),
LEGACY("application/x-cbz"),
COMPATIBLE("application/x-cbr"),
;
companion object {
fun from(channel: String): CbzMediaType = entries.find { it.name.lowercase() == channel.lowercase() } ?: MODERN
}
}

View File

@@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import org.jetbrains.exposed.sql.SortOrder
import suwayomi.tachidesk.graphql.types.AuthMode
import suwayomi.tachidesk.graphql.types.CbzMediaType
import suwayomi.tachidesk.graphql.types.DatabaseType
import suwayomi.tachidesk.graphql.types.DownloadConversion
import suwayomi.tachidesk.graphql.types.KoreaderSyncChecksumMethod
@@ -836,6 +837,17 @@ class ServerConfig(
defaultValue = BackupFlags.DEFAULT.includeServerSettings,
)
val opdsCbzMimetype: MutableStateFlow<CbzMediaType> by EnumSetting(
protoNumber = 83,
group = SettingGroup.OPDS,
defaultValue = CbzMediaType.MODERN,
enumClass = CbzMediaType::class,
typeInfo = SettingsRegistry.PartialTypeInfo(imports = listOf("suwayomi.tachidesk.graphql.types.CbzMediaType")),
excludeFromBackup = true,
description = "Controls the MimeType that Suwayomi sends in OPDS entries for CBZ archives. Also affects global CBZ download. Modern follows recent IANA standard (2017), while LEGACY (deprecated mimetype for .cbz) and COMPATIBLE (deprecated mimetype for all comic archives) might be more compatible with older clients.",
)
/** ****************************************************************** **/
/** **/

View File

@@ -29,6 +29,7 @@ import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.server.JavalinSetup.Attribute
import suwayomi.tachidesk.server.JavalinSetup.future
import suwayomi.tachidesk.server.JavalinSetup.getAttribute
import suwayomi.tachidesk.server.serverConfig
import suwayomi.tachidesk.server.user.requireUser
import suwayomi.tachidesk.server.util.formParam
import suwayomi.tachidesk.server.util.handler
@@ -507,10 +508,12 @@ object MangaController {
},
behaviorOf = { ctx, chapterId, markAsRead ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.disableCompression()
val contentType = serverConfig.opdsCbzMimetype.value.mediaType
if (ctx.method() == HandlerType.HEAD) {
ctx.future {
future { ChapterDownloadHelper.getCbzMetadataForDownload(chapterId) }
.thenApply { (fileName, fileSize, contentType) ->
.thenApply { (fileName, fileSize) ->
ctx.header("Content-Type", contentType)
ctx.header("Content-Disposition", "attachment; filename=\"$fileName\"")
ctx.header("Content-Length", fileSize.toString())
@@ -522,7 +525,7 @@ object MangaController {
ctx.future {
future { ChapterDownloadHelper.getCbzForDownload(chapterId, shouldMarkAsRead) }
.thenApply { (inputStream, fileName, fileSize) ->
ctx.header("Content-Type", "application/vnd.comicbook+zip")
ctx.header("Content-Type", contentType)
ctx.header("Content-Disposition", "attachment; filename=\"$fileName\"")
ctx.header("Content-Length", fileSize.toString())
ctx.result(inputStream)

View File

@@ -100,12 +100,11 @@ object ChapterDownloadHelper {
return Triple(cbzFile.first, fileName, cbzFile.second)
}
fun getCbzMetadataForDownload(chapterId: Int): Triple<String, Long, String> { // fileName, fileSize, contentType
fun getCbzMetadataForDownload(chapterId: Int): Pair<String, Long> { // fileName, fileSize
val (chapterData, fileName) = getChapterWithCbzFileName(chapterId)
val fileSize = provider(chapterData.mangaId, chapterData.id).getArchiveSize()
val contentType = "application/vnd.comicbook+zip"
return Triple(fileName, fileSize, contentType)
return Pair(fileName, fileSize)
}
}

View File

@@ -40,5 +40,4 @@ object OpdsConstants {
const val TYPE_ATOM_XML_ENTRY_PROFILE_OPDS = "application/atom+xml;type=entry;profile=opds-catalog"
const val TYPE_OPENSEARCH_DESCRIPTION = "application/opensearchdescription+xml"
const val TYPE_IMAGE_JPEG = "image/jpeg"
const val TYPE_CBZ = "application/vnd.comicbook+zip"
}

View File

@@ -336,6 +336,14 @@ object OpdsEntryBuilder {
}
val entryTitle = "$titlePrefix ${chapter.name}"
val cbzFileSize =
if (chapter.downloaded) {
withContext(Dispatchers.IO) {
runCatching { ChapterDownloadHelper.getArchiveStreamWithSize(manga.id, chapter.id).second }.getOrNull()
}
} else {
null
}
val links = mutableListOf<OpdsLinkXml>()
chapter.url?.let {
@@ -348,8 +356,9 @@ object OpdsEntryBuilder {
OpdsLinkXml(
OpdsConstants.LINK_REL_ACQUISITION_OPEN_ACCESS,
"/api/v1/chapter/${chapter.id}/download?markAsRead=${serverConfig.opdsMarkAsReadOnDownload.value}",
OpdsConstants.TYPE_CBZ,
serverConfig.opdsCbzMimetype.value.mediaType,
MR.strings.opds_linktitle_download_cbz.localized(locale),
length = cbzFileSize,
),
)
}
@@ -411,15 +420,6 @@ object OpdsEntryBuilder {
)
}
val cbzFileSize =
if (chapter.downloaded) {
withContext(Dispatchers.IO) {
runCatching { ChapterDownloadHelper.getArchiveStreamWithSize(manga.id, chapter.id).second }.getOrNull()
}
} else {
null
}
return OpdsEntryXml(
id = "urn:suwayomi:chapter:${chapter.id}:metadata$idSuffix",
title = entryTitle,

View File

@@ -19,6 +19,8 @@ data class OpdsLinkXml(
// Thread count
@XmlSerialName("count", OpdsConstants.NS_THREAD, "thr")
val thrCount: Int? = null,
// link download size in bytes
val length: Long? = null,
// OPDS-PSE attributes
@XmlSerialName("count", OpdsConstants.NS_PSE, "pse")
val pseCount: Int? = null,