mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-12 15:24:33 -05:00
* [#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:
@@ -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" */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user