From 0a7e6cce871056bc974b96e193990e097c3d16a7 Mon Sep 17 00:00:00 2001 From: FadedSociety Date: Wed, 12 Nov 2025 14:23:34 -0700 Subject: [PATCH] Remote Image Processing (#1684) * Update ServerConfig.kt * Update ConversionUtil.kt * Update Page.kt * Update ServerConfig.kt fixed deletions caused by ide * Update ServerConfig.kt * Update ServerConfig.kt * Cleanup * Post-processing terminology * More comments * Lint * Add known image mimes * Fix weird mime set/get * Implement different downloadConversions and serveConversions * Lint * Improve Post-Processing massivly * Fix thumbnail build * Use Array for headers * Actually fix headers * Actually fix headers 2 * Manually parse DownloadConversion * Cleanup parse * Fix write * Update TypeName * Optimize imports * Remove header type * Fix build --------- Co-authored-by: Syer10 --- .../src/main/java/libcore/net/MimeUtils.java | 6 + docs/Configuring-Suwayomi‐Server.md | 23 +- docs/Getting-Extensions.md | 2 +- docs/Local-Source.md | 4 +- docs/Troubleshooting.md | 2 +- .../tachidesk/graphql/types/SettingTypes.kt | 23 +- .../BackupSettingsDownloadConversionType.kt | 15 +- .../suwayomi/tachidesk/server/ServerConfig.kt | 55 +++- .../server/util/ConfigTypeRegistration.kt | 1 + .../server/util/DownloadConversionType.kt | 73 ++++++ .../manga/controller/MangaController.kt | 2 +- .../suwayomi/tachidesk/manga/impl/Page.kt | 240 +++++++++++++++--- .../fileProvider/ChaptersFilesProvider.kt | 109 +------- .../impl/ThumbnailFileProvider.kt | 5 +- .../manga/impl/util/storage/ImageResponse.kt | 24 +- .../suwayomi/tachidesk/util/ConversionUtil.kt | 131 ++++++++-- 16 files changed, 527 insertions(+), 188 deletions(-) create mode 100644 server/server-config/src/main/kotlin/suwayomi/tachidesk/server/util/DownloadConversionType.kt diff --git a/AndroidCompat/src/main/java/libcore/net/MimeUtils.java b/AndroidCompat/src/main/java/libcore/net/MimeUtils.java index c0d916e8..a47730d7 100644 --- a/AndroidCompat/src/main/java/libcore/net/MimeUtils.java +++ b/AndroidCompat/src/main/java/libcore/net/MimeUtils.java @@ -14,6 +14,8 @@ * limitations under the License. */ package libcore.net; +import android.annotation.Nullable; + import java.util.HashMap; import java.util.Locale; import java.util.Map; @@ -249,10 +251,12 @@ public final class MimeUtils { add("audio/x-scpls", "pls"); add("audio/x-sd2", "sd2"); add("audio/x-wav", "wav"); + add("image/avif", "avif"); // image/bmp isn't IANA, so image/x-ms-bmp should come first. add("image/x-ms-bmp", "bmp"); add("image/bmp", "bmp"); add("image/gif", "gif"); + add("image/heif", "heif"); // image/ico isn't IANA, so image/x-icon should come first. add("image/x-icon", "ico"); add("image/ico", "cur"); @@ -262,6 +266,7 @@ public final class MimeUtils { add("image/jpeg", "jpg"); add("image/jpeg", "jpeg"); add("image/jpeg", "jpe"); + add("image/jxl", "jxl"); add("image/pcx", "pcx"); add("image/png", "png"); add("image/svg+xml", "svg"); @@ -438,6 +443,7 @@ public final class MimeUtils { * @return The extension has been registered for * the given case insensitive MIME type or null if there is none. */ + @Nullable public static String guessExtensionFromMimeType(String mimeType) { if (mimeType == null || mimeType.isEmpty()) { return null; diff --git a/docs/Configuring-Suwayomi‐Server.md b/docs/Configuring-Suwayomi‐Server.md index d567ed2a..d5a1690f 100644 --- a/docs/Configuring-Suwayomi‐Server.md +++ b/docs/Configuring-Suwayomi‐Server.md @@ -23,7 +23,7 @@ The configuration file is written in HOCON. Google is your friend if you want to server.ip = "0.0.0.0" server.port = 4567 ``` -- `server.ip` can be a IP or domain name. +- `server.ip` can be an IP or domain name. ### Socks5 proxy ``` @@ -78,8 +78,8 @@ server.downloadConversions = {} - `server.excludeEntryWithUnreadChapters = true` controls if Suwayomi will download new chapters for titles with unread chapters (requires `server.autoDownloadNewChapters`). - `server.autoDownloadNewChaptersLimit = 0` sets how many chapters should be downloaded at most, `0` to disable the limit; if the limit is reached, new chapters will not be downloaded (requires `server.autoDownloadNewChapters`). - `server.autoDownloadIgnoreReUploads = false` controls if Suwayomi will re-download re-uploads on update (requires `server.autoDownloadNewChapters`). -- `server.downloadConversions = {}` configures optional image conversions for all downloads. This is an [JSON object](https://en.wikipedia.org/wiki/JSON#Syntax), with the source image [mime type](https://en.wikipedia.org/wiki/Media_type) as the key and an object with the target mime type and options as value. - The following options are both valid: +- `server.downloadConversions = {}` configures optional image conversions for all downloads. This is an [JSON object](https://en.wikipedia.org/wiki/JSON#Syntax), with the source image [mime type](https://en.wikipedia.org/wiki/Media_type) as the key and an object with the target mime type or url and options as value. + The following options are all valid: ``` server.downloadConversions = { "image/webp" : { target : "image/jpeg", compressionLevel = 0.8 }} # -- or -- @@ -87,8 +87,25 @@ server.downloadConversions = {} target = "image/jpeg" # image type to convert to compressionLevel = 0.8 # quality in range [0,1], leave away to use default compression } + # -- a url example -- + server.downloadConversions = { "default" : { target : "http://localhost:9999/convert" }} + # -- a url with all parameters example -- + server.downloadConversions = { + "default" : { + target : "http://localhost:9999/convert", + callTimeout : 10m, + connectTimeout : 10s, + headers : { + "authorization" : "MyPassword" + } + } + } ``` A source mime type `default` can be used as fallback to convert all images; a target mime type of `none` can be used to disable conversion for a particular format. + + This is an example curl command for what Suwayomi-Server will send to the conversion url: `curl -X POST "http://localhost:9999/convert" -F "image=@cat.png;type=image/png"` +- `server.serveConversions = {}` configures optional image conversions before serving the image to the client. It follows the same format as `server.downloadConversions`. + ### Updater ``` diff --git a/docs/Getting-Extensions.md b/docs/Getting-Extensions.md index 74ab2e1a..2631cb8a 100644 --- a/docs/Getting-Extensions.md +++ b/docs/Getting-Extensions.md @@ -1,5 +1,5 @@ 1. **Install the latest version(or preview):** https://github.com/Suwayomi/Suwayomi-Server/releases/latest -2. Find an extensions repo, there is now no default extensions and you have to use google to find a Tachiyomi extension repo. +2. Find an extensions repo, there is now no default extensions, and you have to use Google to find a Tachiyomi extension repo. - Note: The repo should look like `https://raw.githubusercontent.com/user/reponame` or `https://www.github.com/user/reponame` 3. Configure it using one of the following: - Suwayomi-Server option 1: With the **new launcher**, go to the `Extensions` tab and add the extensions repo. diff --git a/docs/Local-Source.md b/docs/Local-Source.md index f3969a2d..3dedf51f 100644 --- a/docs/Local-Source.md +++ b/docs/Local-Source.md @@ -5,7 +5,7 @@ Follow the steps below to create local manga. If you add more chapters then you'll have to manually refresh the chapter list. -Supported chapter formats are folder with pictures inside (such as `.jpg`, `.png`, etc), `ZIP`/`CBZ`, `RAR`/`CBR` and `EPUB`. But expect better performance with directories and `ZIP`/`CBZ`. +Supported chapter formats are folder with pictures inside (such as `.jpg`, `.png`, etc.), `ZIP`/`CBZ`, `RAR`/`CBR` and `EPUB`. But expect better performance with directories and `ZIP`/`CBZ`. **Note:** While Suwayomi does support chapters compressed as **RAR** or **CBR**, note that **RAR** or **CBR** files using the **RAR5** format are not supported yet. @@ -111,7 +111,7 @@ Archive files such as `ZIP`/`CBZ` are supported but the folder structure inside It is possible to add details to local manga. Like manga from other catalogs, you add information about the manga such as the author, artist, description, and genre tags. -To import details along with your local manga, you have to create a json file. It can be named anything but it must be placed within the **Manga** folder. A standard file name is `details.json`. This file will contain the extended details about the manga in the `JSON` format. You can see the example below on how to build the file. Once the file is there, the app should load the data when you first open the manga or you can pull down to refresh the details. +To import details along with your local manga, you have to create a json file. It can be named anything but it must be placed within the **Manga** folder. A standard file name is `details.json`. This file will contain the extended details about the manga in the `JSON` format. You can see the example below on how to build the file. Once the file is there, the app should load the data when you first open the manga, or you can pull down to refresh the details. You can copy the following example and edit the details as needed: ``` json diff --git a/docs/Troubleshooting.md b/docs/Troubleshooting.md index de4a5fe3..0e7a6736 100644 --- a/docs/Troubleshooting.md +++ b/docs/Troubleshooting.md @@ -1,5 +1,5 @@ ## General Troubleshooting -This guide will try to fix Suwayomi by reseting it to a clean install state. +This guide will try to fix Suwayomi by reseting it to a clean installation state. - Make sure you have a recent backup of your library or create one in the app (if possible) because we **are going to wipe all Suwayomi data**. - Make sure Suwayomi is not running (right click on tray icon and quit or kill it through the way your Operating System provides) diff --git a/server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingTypes.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingTypes.kt index e9d4945a..8d48260e 100644 --- a/server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingTypes.kt +++ b/server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingTypes.kt @@ -1,21 +1,42 @@ package suwayomi.tachidesk.graphql.types +import kotlin.time.Duration + // These types belong to SettingsType.kt. However, since that file is auto-generated, these types need to be placed in // a "static" file. -data class DownloadConversion( +class DownloadConversion( val target: String, val compressionLevel: Double? = null, + val callTimeout: Duration? = null, + val connectTimeout: Duration? = null, + val headers: Map? = null, ) interface SettingsDownloadConversion { val mimeType: String val target: String val compressionLevel: Double? + val callTimeout: Duration? + val connectTimeout: Duration? + val headers: List? } class SettingsDownloadConversionType( override val mimeType: String, override val target: String, override val compressionLevel: Double?, + override val callTimeout: Duration?, + override val connectTimeout: Duration?, + override val headers: List? ) : SettingsDownloadConversion + +interface SettingsDownloadConversionHeader { + val name: String + val value: String +} + +class SettingsDownloadConversionHeaderType( + override val name: String, + override val value: String, +) : SettingsDownloadConversionHeader diff --git a/server/server-config/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupSettingsDownloadConversionType.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupSettingsDownloadConversionType.kt index 300125fb..9a37df43 100644 --- a/server/server-config/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupSettingsDownloadConversionType.kt +++ b/server/server-config/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupSettingsDownloadConversionType.kt @@ -4,6 +4,8 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.protobuf.ProtoNumber import suwayomi.tachidesk.graphql.types.SettingsDownloadConversion +import suwayomi.tachidesk.graphql.types.SettingsDownloadConversionHeader +import kotlin.time.Duration @OptIn(ExperimentalSerializationApi::class) @Serializable @@ -11,4 +13,15 @@ class BackupSettingsDownloadConversionType( @ProtoNumber(1) override val mimeType: String, @ProtoNumber(2) override val target: String, @ProtoNumber(3) override val compressionLevel: Double?, -) : SettingsDownloadConversion \ No newline at end of file + @ProtoNumber(4) override val callTimeout: Duration?, + @ProtoNumber(5) override val connectTimeout: Duration?, + @ProtoNumber(6) override val headers: List? +) : SettingsDownloadConversion + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +class BackupSettingsDownloadConversionHeaderType( + @ProtoNumber(1) override val name: String, + @ProtoNumber(2) override val value: String + +): SettingsDownloadConversionHeader 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 75e80362..d97e61d6 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 @@ -33,11 +33,13 @@ import suwayomi.tachidesk.graphql.types.DownloadConversion import suwayomi.tachidesk.graphql.types.KoreaderSyncChecksumMethod import suwayomi.tachidesk.graphql.types.KoreaderSyncConflictStrategy import suwayomi.tachidesk.graphql.types.KoreaderSyncLegacyStrategy +import suwayomi.tachidesk.graphql.types.SettingsDownloadConversionHeaderType import suwayomi.tachidesk.graphql.types.SettingsDownloadConversionType import suwayomi.tachidesk.graphql.types.WebUIChannel import suwayomi.tachidesk.graphql.types.WebUIFlavor import suwayomi.tachidesk.graphql.types.WebUIInterface import suwayomi.tachidesk.manga.impl.backup.BackupFlags +import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSettingsDownloadConversionHeaderType import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSettingsDownloadConversionType import suwayomi.tachidesk.manga.impl.extension.repoMatchRegex import suwayomi.tachidesk.server.settings.BooleanSetting @@ -537,8 +539,8 @@ class ServerConfig( excludeFromBackup = true, ) - val downloadConversions: MutableStateFlow> by MapSetting( - protoNumber = 57, + fun createDownloadConversionsMap(protoNumber: Int, key: String) = MapSetting( + protoNumber = protoNumber, defaultValue = emptyMap(), group = SettingGroup.DOWNLOADER, typeInfo = @@ -559,6 +561,14 @@ class ServerConfig( it.key, it.value.target, it.value.compressionLevel, + it.value.callTimeout, + it.value.connectTimeout, + it.value.headers?.map { header -> + SettingsDownloadConversionHeaderType( + header.key, + header.value, + ) + }, ) } }, @@ -571,6 +581,11 @@ class ServerConfig( DownloadConversion( target = it.target, compressionLevel = it.compressionLevel, + callTimeout = it.callTimeout, + connectTimeout = it.connectTimeout, + headers = it.headers?.associate { header -> + header.name to header.value + }, ) } }, @@ -583,6 +598,14 @@ class ServerConfig( it.key, it.value.target, it.value.compressionLevel, + it.value.callTimeout, + it.value.connectTimeout, + it.value.headers?.map { header -> + BackupSettingsDownloadConversionHeaderType( + header.key, + header.value, + ) + }, ) } }, @@ -590,12 +613,16 @@ class ServerConfig( description = """ map input mime type to conversion information, or "default" for others - server.downloadConversions."image/webp" = { - target = "image/jpeg" # image type to convert to + server.$key."image/webp" = { + target = "image/jpeg" # image type to convert to, can also be a url to an external server compressionLevel = 0.8 # quality in range [0,1], leave away to use default compression } """.trimIndent(), ) + val downloadConversions: MutableStateFlow> by createDownloadConversionsMap( + protoNumber = 57, + key = "downloadConversions" + ) val jwtAudience: MutableStateFlow by StringSetting( protoNumber = 58, @@ -669,6 +696,7 @@ class ServerConfig( typeInfo = SettingsRegistry.PartialTypeInfo(imports = listOf("suwayomi.tachidesk.graphql.types.KoreaderSyncChecksumMethod")), ) + @Suppress("DEPRECATION") @Deprecated("Use koreaderSyncStrategyForward and koreaderSyncStrategyBackward instead") val koreaderSyncStrategy: MutableStateFlow by MigratedConfigValue( protoNumber = 64, @@ -707,15 +735,11 @@ class ServerConfig( ), readMigrated = { // This is a best-effort reverse mapping. It's not perfect but covers common cases. - when { - koreaderSyncStrategyForward.value == KoreaderSyncConflictStrategy.PROMPT && - koreaderSyncStrategyBackward.value == KoreaderSyncConflictStrategy.PROMPT -> KoreaderSyncLegacyStrategy.PROMPT - koreaderSyncStrategyForward.value == KoreaderSyncConflictStrategy.KEEP_REMOTE && - koreaderSyncStrategyBackward.value == KoreaderSyncConflictStrategy.KEEP_LOCAL -> KoreaderSyncLegacyStrategy.SILENT - koreaderSyncStrategyForward.value == KoreaderSyncConflictStrategy.KEEP_LOCAL && - koreaderSyncStrategyBackward.value == KoreaderSyncConflictStrategy.KEEP_LOCAL -> KoreaderSyncLegacyStrategy.SEND - koreaderSyncStrategyForward.value == KoreaderSyncConflictStrategy.KEEP_REMOTE && - koreaderSyncStrategyBackward.value == KoreaderSyncConflictStrategy.KEEP_REMOTE -> KoreaderSyncLegacyStrategy.RECEIVE + when (koreaderSyncStrategyForward.value) { + KoreaderSyncConflictStrategy.PROMPT if koreaderSyncStrategyBackward.value == KoreaderSyncConflictStrategy.PROMPT -> KoreaderSyncLegacyStrategy.PROMPT + KoreaderSyncConflictStrategy.KEEP_REMOTE if koreaderSyncStrategyBackward.value == KoreaderSyncConflictStrategy.KEEP_LOCAL -> KoreaderSyncLegacyStrategy.SILENT + KoreaderSyncConflictStrategy.KEEP_LOCAL if koreaderSyncStrategyBackward.value == KoreaderSyncConflictStrategy.KEEP_LOCAL -> KoreaderSyncLegacyStrategy.SEND + KoreaderSyncConflictStrategy.KEEP_REMOTE if koreaderSyncStrategyBackward.value == KoreaderSyncConflictStrategy.KEEP_REMOTE -> KoreaderSyncLegacyStrategy.RECEIVE else -> KoreaderSyncLegacyStrategy.DISABLED } }, @@ -885,6 +909,11 @@ class ServerConfig( 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.", ) + val serveConversions: MutableStateFlow> by createDownloadConversionsMap( + protoNumber = 84, + key = "serveConversions" + ) + /** ****************************************************************** **/ diff --git a/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/util/ConfigTypeRegistration.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/util/ConfigTypeRegistration.kt index a39ca478..776743b2 100644 --- a/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/util/ConfigTypeRegistration.kt +++ b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/util/ConfigTypeRegistration.kt @@ -14,6 +14,7 @@ object ConfigTypeRegistration { registerCustomType(MutableStateFlowType()) registerCustomType(DurationType()) + registerCustomType(DownloadConversionType()) registered = true } diff --git a/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/util/DownloadConversionType.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/util/DownloadConversionType.kt new file mode 100644 index 00000000..b28f2f82 --- /dev/null +++ b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/util/DownloadConversionType.kt @@ -0,0 +1,73 @@ +package suwayomi.tachidesk.server.util + +import com.typesafe.config.Config +import com.typesafe.config.ConfigFactory +import com.typesafe.config.ConfigValue +import io.github.config4k.ClassContainer +import io.github.config4k.CustomType +import io.github.config4k.extract +import io.github.config4k.toConfig +import suwayomi.tachidesk.graphql.types.DownloadConversion +import kotlin.time.Duration + +class DownloadConversionType : CustomType { + override fun parse( + clazz: ClassContainer, + config: Config, + name: String, + ): Any? { + val target = config.extract("$name.target") + val compressionLevel = config.extract("$name.compressionLevel") + val callTimeout = config.extract("$name.callTimeout") + val connectTimeout = config.extract("$name.connectTimeout") + val headers = config.extract?>("$name.headers") + + return DownloadConversion( + target = target, + compressionLevel = compressionLevel, + callTimeout = callTimeout, + connectTimeout = connectTimeout, + headers = headers, + ) + } + + override fun testParse(clazz: ClassContainer): Boolean = + clazz.mapperClass.qualifiedName == "suwayomi.tachidesk.graphql.types.DownloadConversion" + + override fun testToConfig(obj: Any): Boolean = obj is DownloadConversion + + override fun toConfig( + obj: Any, + name: String, + ): Config { + val conversion = obj as DownloadConversion + val builder = ConfigFactory.empty() + + var config = + builder + .withValue("$name.target", conversion.target.asConfigValue()) + .withValueIfPresent("$name.compressionLevel", conversion.compressionLevel) + .withValueIfPresent("$name.callTimeout", conversion.callTimeout?.toString()) + .withValueIfPresent("$name.connectTimeout", conversion.connectTimeout?.toString()) + + if (conversion.headers != null) { + config = + config + .withValue("$name.headers", conversion.headers.asConfigValue()) + } + + return config + } + + private fun Config.withValueIfPresent( + key: String, + value: Any?, + ): Config = + if (value != null) { + withValue(key, value.asConfigValue()) + } else { + this + } + + private fun Any.asConfigValue(): ConfigValue = toConfig("internal").getValue("internal") +} 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 9867477c..f41a7b59 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt @@ -475,7 +475,7 @@ object MangaController { ctx.future { future { - Page.getPageImage( + Page.getPageImageServe( mangaId = mangaId, chapterIndex = chapterIndex, index = index, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Page.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Page.kt index b46792a0..9e0f80f7 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Page.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Page.kt @@ -10,6 +10,7 @@ package suwayomi.tachidesk.manga.impl import eu.kanade.tachiyomi.source.local.LocalSource import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.online.HttpSource +import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.flow.StateFlow import libcore.net.MimeUtils import org.jetbrains.exposed.sql.SortOrder @@ -17,6 +18,7 @@ import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.update +import suwayomi.tachidesk.graphql.types.DownloadConversion import suwayomi.tachidesk.manga.impl.util.getChapterCachePath import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse @@ -24,14 +26,20 @@ import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil import suwayomi.tachidesk.manga.model.table.ChapterTable import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.PageTable +import suwayomi.tachidesk.server.serverConfig import suwayomi.tachidesk.util.ConversionUtil import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File import java.io.InputStream +import javax.imageio.IIOImage import javax.imageio.ImageIO +import javax.imageio.ImageWriteParam +import javax.imageio.ImageWriter object Page { + private val logger = KotlinLogging.logger {} + /** * A page might have a imageUrl ready from the get go, or we might need to * go an extra step and call fetchImageUrl to get it. @@ -51,7 +59,6 @@ object Page { chapterId: Int? = null, chapterIndex: Int? = null, index: Int, - format: String? = null, progressFlow: ((StateFlow) -> Unit)? = null, ): Pair { val mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() } @@ -73,7 +80,7 @@ object Page { try { if (chapterEntry[ChapterTable.isDownloaded]) { - return convertImageResponse(ChapterDownloadHelper.getImage(mangaId, chapterId, index), format) + return ChapterDownloadHelper.getImage(mangaId, chapterId, index) } } catch (_: Exception) { // ignore and fetch again @@ -102,15 +109,12 @@ object Page { // is of archive format if (LocalSource.pageCache.containsKey(chapterEntry[ChapterTable.url])) { val pageStream = LocalSource.pageCache[chapterEntry[ChapterTable.url]]!![index] - return convertImageResponse(pageStream() to (ImageUtil.findImageType { pageStream() }?.mime ?: "image/jpeg"), format) + return pageStream() to (ImageUtil.findImageType { pageStream() }?.mime ?: "image/jpeg") } // is of directory format val imageFile = File(tachiyomiPage.imageUrl!!) - return convertImageResponse( - imageFile.inputStream() to (ImageUtil.findImageType { imageFile.inputStream() }?.mime ?: "image/jpeg"), - format, - ) + return imageFile.inputStream() to (ImageUtil.findImageType { imageFile.inputStream() }?.mime ?: "image/jpeg") } val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference]) @@ -130,38 +134,210 @@ object Page { val cacheSaveDir = getChapterCachePath(mangaId, chapterId) // Note: don't care about invalidating cache because OS cache is not permanent - return convertImageResponse( - getImageResponse(cacheSaveDir, fileName) { - source.getImage(tachiyomiPage) - }, - format, - ) + return getImageResponse(cacheSaveDir, fileName) { + source.getImage(tachiyomiPage) + } + } + + suspend fun getPageImageServe( + mangaId: Int, + chapterIndex: Int, + index: Int, + format: String? = null, + ): Pair { + val (inputStream, mime) = + getPageImage( + mangaId = mangaId, + chapterIndex = chapterIndex, + index = index, + ) + val conversions = serverConfig.serveConversions.value + val defaultConversion = conversions["default"] + val formatConversion = format?.let { DownloadConversion(target = it) } + val conversion = + formatConversion + ?: conversions[mime] + ?: defaultConversion + ?: return inputStream to mime + + val converted = + try { + convertImageResponse( + image = inputStream, + mime = mime, + conversion = conversion, + ) + } catch (e: Exception) { + logger.error(e) { "Error while post-processing image" } + null + } + return converted?.also { inputStream.close() } ?: (inputStream to mime) + } + + suspend fun getPageImageDownload( + mangaId: Int, + chapterId: Int, + index: Int, + downloadCacheFolder: File, + fileName: String, + progressFlow: (StateFlow) -> Unit, + ) { + val (inputStream, mime) = + getPageImage( + mangaId = mangaId, + chapterId = chapterId, + index = index, + progressFlow = progressFlow, + ) + val conversions = serverConfig.downloadConversions.value + if (conversions.isEmpty() || !downloadCacheFolder.exists()) { + inputStream.close() + return + } + val defaultConversion = conversions["default"] + val conversion = + conversions[mime] + ?: defaultConversion + if (conversion == null) { + inputStream.close() + return + } + + try { + val converted = + try { + convertImageResponse( + image = inputStream, + mime = mime, + conversion = conversion, + ) + } catch (e: Exception) { + throw e + } finally { + inputStream.close() + } + + if (converted != null) { + val (convertedStream, convertedMime) = converted + val convertedExtension = + MimeUtils.guessExtensionFromMimeType(convertedMime) + ?: convertedMime.substringAfter('/') + val convertedPage = + File( + downloadCacheFolder, + "$fileName.$convertedExtension", + ) + + convertedPage.outputStream().use { outputStream -> + convertedStream.use { it.copyTo(outputStream) } + } + + val extension = + MimeUtils.guessExtensionFromMimeType(mime) + ?: mime.substringAfter('/') + if (extension != convertedExtension) { + File( + downloadCacheFolder, + "$fileName.$extension", + ).delete() + } + } + } catch (e: Exception) { + logger.warn(e) { "Error while post-processing image" } + } } private suspend fun convertImageResponse( - image: Pair, - format: String? = null, - ): Pair { - val imageExtension = MimeUtils.guessExtensionFromMimeType(image.second) ?: image.second.removePrefix("image/") + image: InputStream, + mime: String, + conversion: DownloadConversion, + ): Pair? { + // Apply HTTP post-process if configured (complementary with format conversion) + if (ConversionUtil.isHttpPostProcess(conversion)) { + try { + val processedStream = + ConversionUtil + .imageHttpPostProcess( + inputStream = image, + mimeType = mime, + conversion = conversion, + )?.buffered() + if (processedStream != null) { + val mime = + ImageUtil.findImageType(processedStream)?.mime + ?: "image/jpeg" - val targetExtension = - (if (format != imageExtension) format else null) - ?: return image + return processedStream to mime + } + } catch (e: Exception) { + // HTTP post-processing failed, continue with original image + logger.warn(e) { "Error while post-processing image" } + } + return null + } else { + if (mime == conversion.target) { + return null + } - val outStream = ByteArrayOutputStream() - val writers = ImageIO.getImageWritersBySuffix(targetExtension) - val writer = writers.next() - ImageIO.createImageOutputStream(outStream).use { o -> - writer.setOutput(o) - - val inImage = - ConversionUtil.readImage(image.first, image.second) - ?: throw NoSuchElementException("No conversion to $targetExtension possible") - writer.write(inImage) + return convertToFormat(image, mime, conversion) + } + } + + private fun convertToFormat( + inputStream: InputStream, + sourceMimeType: String, + target: DownloadConversion, + ): Pair? { + val outStream = ByteArrayOutputStream() + val conversionWriter = + getConversionWriter( + target.target, + target.compressionLevel, + ) + if (conversionWriter == null) { + logger.warn { "Conversion aborted: No reader for target format ${target.target}" } + return inputStream to sourceMimeType + } + + val (writer, writerParams) = conversionWriter + try { + ImageIO.createImageOutputStream(outStream).use { o -> + writer.setOutput(o) + + val inImage = + ConversionUtil.readImage(inputStream, sourceMimeType) + ?: throw NoSuchElementException("No conversion to ${target.target} possible") + writer.write(null, IIOImage(inImage, null, null), writerParams) + } + } catch (e: Exception) { + logger.warn(e) { "Conversion aborted" } + return null + } finally { + writer.dispose() } - writer.dispose() val inStream = ByteArrayInputStream(outStream.toByteArray()) - return Pair(inStream.buffered(), MimeUtils.guessMimeTypeFromExtension(targetExtension) ?: "image/$targetExtension") + return Pair(inStream.buffered(), target.target) + } + + private fun getConversionWriter( + targetMime: String, + compressionLevel: Double?, + ): Pair? { + val writers = ImageIO.getImageWritersByMIMEType(targetMime) + val writer = + try { + writers.next() + } catch (_: NoSuchElementException) { + return null + } + + val writerParams = writer.defaultWriteParam + compressionLevel?.let { + writerParams.compressionMode = ImageWriteParam.MODE_EXPLICIT + writerParams.compressionQuality = it.toFloat() + } + + return writer to writerParams } /** converts 0 to "001" */ diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/ChaptersFilesProvider.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/ChaptersFilesProvider.kt index ba7bd94c..cd0b98f0 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/ChaptersFilesProvider.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/ChaptersFilesProvider.kt @@ -151,10 +151,12 @@ abstract class ChaptersFilesProvider( try { Page - .getPageImage( + .getPageImageDownload( mangaId = download.mangaId, chapterId = download.chapterId, index = pageNum, + downloadCacheFolder, + fileName, ) { flow -> pageProgressJob = flow @@ -167,8 +169,7 @@ abstract class ChaptersFilesProvider( false, ) // don't throw on canceled download here since we can't do anything }.launchIn(scope) - }.first - .close() + } } finally { // always cancel the page progress job even if it throws an exception to avoid memory leaks pageProgressJob?.cancel() @@ -188,8 +189,6 @@ abstract class ChaptersFilesProvider( }, ) - maybeConvertPages(downloadCacheFolder) - handleSuccessfulDownload() // Calculate and save Koreader hash for CBZ files @@ -221,104 +220,4 @@ abstract class ChaptersFilesProvider( abstract fun getAsArchiveStream(): Pair abstract fun getArchiveSize(): Long - - private fun maybeConvertPages(chapterCacheFolder: File) { - val conversions = serverConfig.downloadConversions.value - - if (!chapterCacheFolder.isDirectory || conversions.isEmpty()) { - return - } - - val pages = - chapterCacheFolder - .listFiles() - .orEmpty() - .filter { it.name != COMIC_INFO_FILE } - - val pagesByMimeType = - pages - .groupBy { MimeUtils.guessMimeTypeFromExtension(it.extension) } - .mapValues { it.value.map { it.nameWithoutExtension } } - - logger.debug { "maybeConvertPages: pagesByMimeType= $pagesByMimeType; conversions= $conversions" } - - pages.forEach { page -> - val imageType = MimeUtils.guessMimeTypeFromExtension(page.extension) ?: return@forEach - - val defaultConversion = conversions["default"] - val conversion = conversions[imageType] - val targetConversion = conversion ?: defaultConversion ?: return@forEach - - val (targetMime) = targetConversion - val requiresConversion = imageType != targetMime && targetMime != "none" - if (!requiresConversion) { - return@forEach - } - - convertPage(page, targetConversion) - } - } - - private fun convertPage( - page: File, - conversion: DownloadConversion, - ) { - val (targetMime, compressionLevel) = conversion - - val targetExtension = - MimeUtils.guessExtensionFromMimeType(targetMime) ?: targetMime.removePrefix("image/") - - val convertedPage = File(page.parentFile, page.nameWithoutExtension + "." + targetExtension) - - val conversionWriter = getConversionWriter(targetMime, compressionLevel) - if (conversionWriter == null) { - logger.warn { "Conversion aborted: No reader for target format $targetMime" } - return - } - - val (writer, writerParams) = conversionWriter - - val success = - try { - ImageIO.createImageOutputStream(convertedPage).use { outStream -> - writer.setOutput(outStream) - - val inImage = ConversionUtil.readImage(page) ?: return@use false - writer.write(null, IIOImage(inImage, null, null), writerParams) - - true - } - } catch (e: Exception) { - logger.warn(e) { "Conversion aborted: for image $page" } - false - } - writer.dispose() - - if (success) { - page.delete() - } else { - convertedPage.delete() - } - } - - private fun getConversionWriter( - targetMime: String, - compressionLevel: Double?, - ): Pair? { - val writers = ImageIO.getImageWritersByMIMEType(targetMime) - val writer = - try { - writers.next() - } catch (_: NoSuchElementException) { - return null - } - - val writerParams = writer.defaultWriteParam - compressionLevel?.let { - writerParams.compressionMode = ImageWriteParam.MODE_EXPLICIT - writerParams.compressionQuality = it.toFloat() - } - - return writer to writerParams - } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/impl/ThumbnailFileProvider.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/impl/ThumbnailFileProvider.kt index c16478b0..d024c050 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/impl/ThumbnailFileProvider.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/impl/ThumbnailFileProvider.kt @@ -44,10 +44,11 @@ class ThumbnailFileProvider( return true } - Manga.fetchMangaThumbnail(mangaId).first.use { image -> + val (inputStream, mime) = Manga.fetchMangaThumbnail(mangaId) + inputStream.use { image -> makeSureDownloadDirExists() val filePath = getThumbnailDownloadPath(mangaId) - ImageResponse.saveImage(filePath, image) + ImageResponse.saveImage(filePath, image, mime) } return true diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/storage/ImageResponse.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/storage/ImageResponse.kt index af9c244f..ccb430fa 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/storage/ImageResponse.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/storage/ImageResponse.kt @@ -7,6 +7,7 @@ package suwayomi.tachidesk.manga.impl.util.storage * 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 libcore.net.MimeUtils import okhttp3.Response import okhttp3.internal.closeQuietly import java.io.File @@ -69,7 +70,12 @@ object ImageResponse { try { if (response.code == 200) { - val (actualSavePath, imageType) = saveImage(filePath, response.body.byteStream()) + val (actualSavePath, imageType) = + saveImage( + filePath, + response.body.byteStream(), + response.header("Content-Type"), + ) return pathToInputStream(actualSavePath) to imageType } else { throw Exception("request error! ${response.code}") @@ -87,20 +93,28 @@ object ImageResponse { fun saveImage( filePath: String, image: InputStream, + mimeType: String?, ): Pair { + val mimeType = mimeType?.takeIf { it.startsWith("image/") }?.lowercase() val tmpSavePath = "$filePath.tmp" val tmpSaveFile = File(tmpSavePath) image.use { input -> tmpSaveFile.outputStream().use { output -> input.copyTo(output) } } // find image type val imageType = - ImageUtil.findImageType { tmpSaveFile.inputStream() }?.mime - ?: "image/jpeg" + ImageUtil.findImageType { tmpSaveFile.inputStream() } + ?: ImageUtil.ImageType.entries.find { + it.mime == mimeType + } + val extension = + imageType?.extension ?: mimeType?.let { + MimeUtils.guessExtensionFromMimeType(it) + } ?: "jpg" - val actualSavePath = "$filePath.${imageType.substringAfter("/")}" + val actualSavePath = "$filePath.$extension" tmpSaveFile.renameTo(File(actualSavePath)) - return Pair(actualSavePath, imageType) + return Pair(actualSavePath, imageType?.mime ?: mimeType ?: "image/jpeg") } fun clearCachedImage( diff --git a/server/src/main/kotlin/suwayomi/tachidesk/util/ConversionUtil.kt b/server/src/main/kotlin/suwayomi/tachidesk/util/ConversionUtil.kt index 2279837c..46051ab8 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/util/ConversionUtil.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/util/ConversionUtil.kt @@ -1,35 +1,28 @@ package suwayomi.tachidesk.util +import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.await import io.github.oshai.kotlinlogging.KotlinLogging +import libcore.net.MimeUtils +import okhttp3.Headers +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import suwayomi.tachidesk.graphql.types.DownloadConversion +import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil +import uy.kohesive.injekt.injectLazy import java.awt.image.BufferedImage import java.io.File import java.io.InputStream +import java.nio.file.Files import javax.imageio.ImageIO +import kotlin.getValue object ConversionUtil { val logger = KotlinLogging.logger {} - public fun readImage(image: File): BufferedImage? { - val readers = ImageIO.getImageReadersBySuffix(image.extension) - image.inputStream().use { - ImageIO.createImageInputStream(it).use { inputStream -> - for (reader in readers) { - try { - reader.setInput(inputStream) - return reader.read(0) - } catch (e: Throwable) { - logger.debug(e) { "Reader ${reader.javaClass.name} not suitable" } - } finally { - reader.dispose() - } - } - } - } - logger.info { "No suitable image converter found for ${image.name}" } - return null - } - - public fun readImage( + fun readImage( image: InputStream, mimeType: String, ): BufferedImage? { @@ -49,4 +42,100 @@ object ConversionUtil { logger.info { "No suitable image converter found for $mimeType" } return null } + + private val networkService: NetworkHelper by injectLazy() + + /** + * Send image to external HTTP service for post-processing + * Returns the processed image stream or null if failed + */ + suspend fun imageHttpPostProcess( + imageFile: File, + conversion: DownloadConversion, + mimeType: String, + ): InputStream? = + try { + logger.debug { "Sending ${imageFile.name} to HTTP converter: ${conversion.target}" } + + val requestBody = + MultipartBody + .Builder() + .setType(MultipartBody.FORM) + .addFormDataPart( + "image", + imageFile.name, + imageFile.asRequestBody(mimeType.toMediaType()), + ).build() + + val client = + networkService.client + .newBuilder() + .apply { + if (conversion.callTimeout != null) { + callTimeout(conversion.callTimeout!!) + } + if (conversion.connectTimeout != null) { + connectTimeout(conversion.connectTimeout!!) + } + }.build() + + val response = + client + .newCall( + POST( + conversion.target, + body = requestBody, + headers = + Headers + .Builder() + .apply { + conversion.headers?.forEach { + set(it.key, it.value) + } + }.build(), + ), + ).await() + logger.debug { "HTTP conversion successful for ${imageFile.name}" } + response.body.byteStream() + } catch (e: Exception) { + logger.warn(e) { "HTTP conversion failed for ${imageFile.name}" } + null + } + + /** + * Overload that takes InputStream and mimeType, creates temp file for HTTP upload + */ + suspend fun imageHttpPostProcess( + inputStream: InputStream, + mimeType: String, + conversion: DownloadConversion, + ): InputStream? = + try { + // Create temporary file from input stream + val extension = + MimeUtils.guessExtensionFromMimeType(mimeType) + ?: mimeType.substringAfter('/') + + val tempFile = Files.createTempFile("conversion", ".$extension").toFile() + tempFile.outputStream().use { output -> + inputStream.copyTo(output) + } + + // Convert using file method + val result = imageHttpPostProcess(tempFile, conversion, mimeType) + + // Clean up temp file + tempFile.delete() + + result + } catch (e: Exception) { + logger.warn(e) { "Failed to create temp file for HTTP converter" } + null + } + + /** + * Check if a DownloadConversion target is an HTTP URL + */ + fun isHttpPostProcess(conversion: DownloadConversion): Boolean = + conversion.target.startsWith("http://") || conversion.target.startsWith("https://") }