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 <syer10@users.noreply.github.com>
This commit is contained in:
FadedSociety
2025-11-12 14:23:34 -07:00
committed by GitHub
parent 3e47859d88
commit 0a7e6cce87
16 changed files with 527 additions and 188 deletions

View File

@@ -475,7 +475,7 @@ object MangaController {
ctx.future {
future {
Page.getPageImage(
Page.getPageImageServe(
mangaId = mangaId,
chapterIndex = chapterIndex,
index = index,

View File

@@ -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<Int>) -> Unit)? = null,
): Pair<InputStream, String> {
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<InputStream, String> {
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<Int>) -> 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<InputStream, String>,
format: String? = null,
): Pair<InputStream, String> {
val imageExtension = MimeUtils.guessExtensionFromMimeType(image.second) ?: image.second.removePrefix("image/")
image: InputStream,
mime: String,
conversion: DownloadConversion,
): Pair<InputStream, String>? {
// 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<InputStream, String>? {
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<ImageWriter, ImageWriteParam>? {
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" */

View File

@@ -151,10 +151,12 @@ abstract class ChaptersFilesProvider<Type : FileType>(
try {
Page
.getPageImage(
.getPageImageDownload(
mangaId = download.mangaId,
chapterId = download.chapterId,
index = pageNum,
downloadCacheFolder,
fileName,
) { flow ->
pageProgressJob =
flow
@@ -167,8 +169,7 @@ abstract class ChaptersFilesProvider<Type : FileType>(
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<Type : FileType>(
},
)
maybeConvertPages(downloadCacheFolder)
handleSuccessfulDownload()
// Calculate and save Koreader hash for CBZ files
@@ -221,104 +220,4 @@ abstract class ChaptersFilesProvider<Type : FileType>(
abstract fun getAsArchiveStream(): Pair<InputStream, Long>
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<ImageWriter, ImageWriteParam>? {
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
}
}

View File

@@ -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

View File

@@ -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<String, String> {
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(

View File

@@ -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://")
}