[#1496] Image conversion (#1505)

* [#1496] First conversion attempt

* [#1496] Configurable conversion

* Fix: allow nested configs (map)

* [#1496] Support explicit `none` conversion

* Use MimeUtils for provided download

* [1496] Support image conversion on load for downloaded images

* Lint

* [#1496] Support conversion on fresh download as well

Previous commit was only for already downloaded images, now also for
fresh and cached

* [#1496] Refactor: Move where conversion for download happens

* Rewrite config handling, improve custom types

* Lint

* Add format to pages mutation

* Lint

* Standardize url encode

* Lint

* Config: Allow additional conversion parameters

* Implement conversion quality parameter

* Lint

* Implement a conversion util to allow fallback readers

* Add downloadConversions to api and backup, fix updateValue issues

* Lint

* Minor cleanup

* Update libs.versions.toml

---------

Co-authored-by: Syer10 <syer10@users.noreply.github.com>
This commit is contained in:
Constantin Piber
2025-07-14 23:51:18 +02:00
committed by GitHub
parent 09c950a890
commit df0078b725
24 changed files with 464 additions and 167 deletions

View File

@@ -403,6 +403,7 @@ object MangaController {
pathParam<Int>("chapterIndex"),
pathParam<Int>("index"),
queryParam<Boolean?>("updateProgress"),
queryParam<String?>("format"),
documentWith = {
withOperation {
summary("Get a chapter page")
@@ -411,9 +412,9 @@ object MangaController {
)
}
},
behaviorOf = { ctx, mangaId, chapterIndex, index, updateProgress ->
behaviorOf = { ctx, mangaId, chapterIndex, index, updateProgress, format ->
ctx.future {
future { Page.getPageImage(mangaId, chapterIndex, index, null) }
future { Page.getPageImage(mangaId, chapterIndex, index, format, null) }
.thenApply {
ctx.header("content-type", it.second)
val httpCacheSeconds = 1.days.inWholeSeconds

View File

@@ -11,6 +11,7 @@ import eu.kanade.tachiyomi.source.local.LocalSource
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.coroutines.flow.StateFlow
import libcore.net.MimeUtils
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.selectAll
@@ -23,8 +24,12 @@ 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.util.ConversionUtil
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStream
import javax.imageio.ImageIO
object Page {
/**
@@ -45,6 +50,7 @@ object Page {
mangaId: Int,
chapterIndex: Int,
index: Int,
format: String? = null,
progressFlow: ((StateFlow<Int>) -> Unit)? = null,
): Pair<InputStream, String> {
val mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
@@ -61,7 +67,7 @@ object Page {
try {
if (chapterEntry[ChapterTable.isDownloaded]) {
return ChapterDownloadHelper.getImage(mangaId, chapterId, index)
return convertImageResponse(ChapterDownloadHelper.getImage(mangaId, chapterId, index), format)
}
} catch (_: Exception) {
// ignore and fetch again
@@ -90,12 +96,15 @@ object Page {
// is of archive format
if (LocalSource.pageCache.containsKey(chapterEntry[ChapterTable.url])) {
val pageStream = LocalSource.pageCache[chapterEntry[ChapterTable.url]]!![index]
return pageStream() to (ImageUtil.findImageType { pageStream() }?.mime ?: "image/jpeg")
return convertImageResponse(pageStream() to (ImageUtil.findImageType { pageStream() }?.mime ?: "image/jpeg"), format)
}
// is of directory format
val imageFile = File(tachiyomiPage.imageUrl!!)
return imageFile.inputStream() to (ImageUtil.findImageType { imageFile.inputStream() }?.mime ?: "image/jpeg")
return convertImageResponse(
imageFile.inputStream() to (ImageUtil.findImageType { imageFile.inputStream() }?.mime ?: "image/jpeg"),
format,
)
}
val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
@@ -115,9 +124,38 @@ object Page {
val cacheSaveDir = getChapterCachePath(mangaId, chapterId)
// Note: don't care about invalidating cache because OS cache is not permanent
return getImageResponse(cacheSaveDir, fileName) {
source.getImage(tachiyomiPage)
return convertImageResponse(
getImageResponse(cacheSaveDir, fileName) {
source.getImage(tachiyomiPage)
},
format,
)
}
private suspend fun convertImageResponse(
image: Pair<InputStream, String>,
format: String? = null,
): Pair<InputStream, String> {
val imageExtension = MimeUtils.guessExtensionFromMimeType(image.second) ?: image.second.removePrefix("image/")
val targetExtension =
(if (format != imageExtension) format else null)
?: return image
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)
}
writer.dispose()
val inStream = ByteArrayInputStream(outStream.toByteArray())
return Pair(inStream.buffered(), MimeUtils.guessMimeTypeFromExtension(targetExtension) ?: "image/$targetExtension")
}
/** converts 0 to "001" */

View File

@@ -37,6 +37,7 @@ import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupChapter
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupHistory
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupManga
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupServerSettings
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupServerSettings.BackupSettingsDownloadConversionType
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSource
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupTracking
import suwayomi.tachidesk.manga.impl.track.Track
@@ -408,6 +409,14 @@ object ProtoBackupExport : ProtoBackupBase() {
autoDownloadAheadLimit = 0, // deprecated
autoDownloadNewChaptersLimit = serverConfig.autoDownloadNewChaptersLimit.value,
autoDownloadIgnoreReUploads = serverConfig.autoDownloadIgnoreReUploads.value,
downloadConversions =
serverConfig.downloadConversions.value.map {
BackupSettingsDownloadConversionType(
it.key,
it.value.target,
it.value.compressionLevel,
)
},
// extension
extensionRepos = serverConfig.extensionRepos.value,
// requests

View File

@@ -5,6 +5,7 @@ import kotlinx.serialization.protobuf.ProtoNumber
import org.jetbrains.exposed.sql.SortOrder
import suwayomi.tachidesk.graphql.types.AuthMode
import suwayomi.tachidesk.graphql.types.Settings
import suwayomi.tachidesk.graphql.types.SettingsDownloadConversion
import suwayomi.tachidesk.graphql.types.WebUIChannel
import suwayomi.tachidesk.graphql.types.WebUIFlavor
import suwayomi.tachidesk.graphql.types.WebUIInterface
@@ -35,6 +36,7 @@ data class BackupServerSettings(
@ProtoNumber(19) override var autoDownloadAheadLimit: Int,
@ProtoNumber(20) override var autoDownloadNewChaptersLimit: Int,
@ProtoNumber(21) override var autoDownloadIgnoreReUploads: Boolean,
@ProtoNumber(57) override val downloadConversions: List<BackupSettingsDownloadConversionType>?,
// extension
@ProtoNumber(22) override var extensionRepos: List<String>,
// requests
@@ -82,4 +84,11 @@ data class BackupServerSettings(
@ProtoNumber(53) override var opdsShowOnlyUnreadChapters: Boolean,
@ProtoNumber(54) override var opdsShowOnlyDownloadedChapters: Boolean,
@ProtoNumber(55) override var opdsChapterSortOrder: SortOrder,
) : Settings
) : Settings {
@Serializable
class BackupSettingsDownloadConversionType(
@ProtoNumber(1) override val mimeType: String,
@ProtoNumber(2) override val target: String,
@ProtoNumber(3) override val compressionLevel: Float?,
) : SettingsDownloadConversion
}

View File

@@ -1,6 +1,7 @@
package suwayomi.tachidesk.manga.impl.download.fileProvider
import eu.kanade.tachiyomi.source.local.metadata.COMIC_INFO_FILE
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
@@ -8,6 +9,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample
import libcore.net.MimeUtils
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.selectAll
@@ -21,8 +23,14 @@ import suwayomi.tachidesk.manga.impl.util.getChapterDownloadPath
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.server.serverConfig
import suwayomi.tachidesk.util.ConversionUtil
import java.io.File
import java.io.IOException
import java.io.InputStream
import javax.imageio.IIOImage
import javax.imageio.ImageIO
import javax.imageio.ImageWriteParam
sealed class FileType {
data class RegularFile(
@@ -61,6 +69,8 @@ abstract class ChaptersFilesProvider<Type : FileType>(
val mangaId: Int,
val chapterId: Int,
) : DownloadedFilesProvider {
protected val logger = KotlinLogging.logger {}
protected abstract fun getImageFiles(): List<Type>
protected abstract fun getImageInputStream(image: Type): InputStream
@@ -75,7 +85,7 @@ abstract class ChaptersFilesProvider<Type : FileType>(
val image = images[index]
val imageFileType = image.getExtension()
return Pair(getImageInputStream(image).buffered(), "image/$imageFileType")
return Pair(getImageInputStream(image).buffered(), MimeUtils.guessMimeTypeFromExtension(imageFileType) ?: "image/$imageFileType")
}
fun getImageCount(): Int = getImageFiles().filter { it.getName() != COMIC_INFO_FILE }.size
@@ -166,6 +176,8 @@ abstract class ChaptersFilesProvider<Type : FileType>(
},
)
maybeConvertChapterImages(downloadCacheFolder)
handleSuccessfulDownload()
transaction {
@@ -185,4 +197,64 @@ abstract class ChaptersFilesProvider<Type : FileType>(
abstract override fun delete(): Boolean
abstract fun getAsArchiveStream(): Pair<InputStream, Long>
private fun maybeConvertChapterImages(chapterCacheFolder: File) {
if (chapterCacheFolder.isDirectory) {
val conv = serverConfig.downloadConversions.value
chapterCacheFolder
.listFiles()
.orEmpty()
.filter { it.name != COMIC_INFO_FILE }
.forEach {
val imageType = MimeUtils.guessMimeTypeFromExtension(it.extension) ?: return@forEach
val targetConversion =
conv.getOrElse(imageType) {
conv.getOrElse("default") {
logger.debug { "Skipping conversion of $it since no conversion specified" }
return@forEach
}
}
val targetMime = targetConversion.target
if (imageType == targetMime || targetMime == "none") return@forEach // nothing to do
logger.debug { "Converting $it to $targetMime" }
val targetExtension = MimeUtils.guessExtensionFromMimeType(targetMime) ?: targetMime.removePrefix("image/")
val outFile = File(it.parentFile, it.nameWithoutExtension + "." + targetExtension)
val writers = ImageIO.getImageWritersByMIMEType(targetMime)
val writer =
try {
writers.next()
} catch (_: NoSuchElementException) {
logger.warn { "Conversion aborted: No reader for target format $targetMime" }
return@forEach
}
val writerParams = writer.defaultWriteParam
targetConversion.compressionLevel?.let {
writerParams.compressionMode = ImageWriteParam.MODE_EXPLICIT
writerParams.compressionQuality = it
}
val success =
try {
ImageIO.createImageOutputStream(outFile)
} catch (e: IOException) {
logger.warn(e) { "Conversion aborted" }
return@forEach
}.use { outStream ->
writer.setOutput(outStream)
val inImage = ConversionUtil.readImage(it) ?: return@use false
writer.write(null, IIOImage(inImage, null, null), writerParams)
return@use true
}
writer.dispose()
if (success) {
it.delete()
} else {
logger.warn { "Conversion aborted: No reader for image $it" }
outFile.delete()
}
}
}
}
}

View File

@@ -19,6 +19,16 @@ fun interface RetrieveFile1Args<A> : RetrieveFile {
override fun executeGetImage(vararg args: Any): Pair<InputStream, String> = execute(args[0] as A)
}
@Suppress("UNCHECKED_CAST")
fun interface RetrieveFile2Args<A, B> : RetrieveFile {
fun execute(
a: A,
b: B,
): Pair<InputStream, String>
override fun executeGetImage(vararg args: Any): Pair<InputStream, String> = execute(args[0] as A, args[1] as B)
}
fun interface FileRetriever {
fun getImage(): RetrieveFile
}

View File

@@ -29,7 +29,6 @@ import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
class KitsuApi(
private val client: OkHttpClient,
@@ -151,7 +150,7 @@ class KitsuApi(
withIOContext {
val jsonObject =
buildJsonObject {
put("params", "query=${URLEncoder.encode(query, StandardCharsets.UTF_8.name())}$ALGOLIA_FILTER")
put("params", "query=${URLEncoder.encode(query, Charsets.UTF_8)}$ALGOLIA_FILTER")
}
with(json) {