mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-17 01:33:34 -05:00
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:
@@ -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" */
|
||||
|
||||
Reference in New Issue
Block a user