From bc6e28cabe0e8d05bb0be70ccdb4aae55412e870 Mon Sep 17 00:00:00 2001 From: Constantin Piber <59023762+cpiber@users.noreply.github.com> Date: Sat, 25 Oct 2025 00:36:59 +0200 Subject: [PATCH] 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 --------- Co-authored-by: Mitchell Syer --- docs/Configuring-Suwayomi‐Server.md | 2 ++ .../tachidesk/graphql/types/OpdsTypes.kt | 14 +++++++++++++ .../suwayomi/tachidesk/server/ServerConfig.kt | 12 +++++++++++ .../manga/controller/MangaController.kt | 7 +++++-- .../manga/impl/ChapterDownloadHelper.kt | 5 ++--- .../tachidesk/opds/constants/OpdsConstants.kt | 1 - .../tachidesk/opds/impl/OpdsEntryBuilder.kt | 20 +++++++++---------- .../tachidesk/opds/model/OpdsLinkXml.kt | 2 ++ 8 files changed, 47 insertions(+), 16 deletions(-) create mode 100644 server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/OpdsTypes.kt diff --git a/docs/Configuring-Suwayomi‐Server.md b/docs/Configuring-Suwayomi‐Server.md index 2ed0fe33b..63bff81ae 100644 --- a/docs/Configuring-Suwayomi‐Server.md +++ b/docs/Configuring-Suwayomi‐Server.md @@ -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 ``` diff --git a/server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/OpdsTypes.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/OpdsTypes.kt new file mode 100644 index 000000000..6a90dbc8a --- /dev/null +++ b/server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/OpdsTypes.kt @@ -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 + } +} diff --git a/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt index 5ccfc9305..1f80e1732 100644 --- a/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt +++ b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt @@ -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 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.", + ) + + /** ****************************************************************** **/ /** **/ diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt index b1e0cbcad..2cd2945a6 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt @@ -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) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/ChapterDownloadHelper.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/ChapterDownloadHelper.kt index c128f1cae..5898534b7 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/ChapterDownloadHelper.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/ChapterDownloadHelper.kt @@ -100,12 +100,11 @@ object ChapterDownloadHelper { return Triple(cbzFile.first, fileName, cbzFile.second) } - fun getCbzMetadataForDownload(chapterId: Int): Triple { // fileName, fileSize, contentType + fun getCbzMetadataForDownload(chapterId: Int): Pair { // 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) } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/constants/OpdsConstants.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/constants/OpdsConstants.kt index 28e4e947b..83a55898d 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/constants/OpdsConstants.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/constants/OpdsConstants.kt @@ -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" } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsEntryBuilder.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsEntryBuilder.kt index ed8a2584c..9a302b565 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsEntryBuilder.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsEntryBuilder.kt @@ -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() 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, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsLinkXml.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsLinkXml.kt index d22b14621..a1b65b827 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsLinkXml.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsLinkXml.kt @@ -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,