diff --git a/AndroidCompat/Config/src/main/java/xyz/nulldev/ts/config/ConfigManager.kt b/AndroidCompat/Config/src/main/java/xyz/nulldev/ts/config/ConfigManager.kt index 4f67ea3a..af24ea7c 100644 --- a/AndroidCompat/Config/src/main/java/xyz/nulldev/ts/config/ConfigManager.kt +++ b/AndroidCompat/Config/src/main/java/xyz/nulldev/ts/config/ConfigManager.kt @@ -10,10 +10,11 @@ package xyz.nulldev.ts.config import ch.qos.logback.classic.Level import com.typesafe.config.Config import com.typesafe.config.ConfigFactory +import com.typesafe.config.ConfigObject import com.typesafe.config.ConfigValue -import com.typesafe.config.ConfigValueFactory import com.typesafe.config.parser.ConfigDocument import com.typesafe.config.parser.ConfigDocumentFactory +import io.github.config4k.toConfig import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -113,7 +114,7 @@ open class ConfigManager { ) { mutex.withLock { val actualValue = if (value is Enum<*>) value.name else value - val configValue = ConfigValueFactory.fromAnyRef(actualValue) + val configValue = actualValue.toConfig("internal").getValue("internal") updateUserConfigFile(path, configValue) internalConfig = internalConfig.withValue(path, configValue) @@ -142,8 +143,13 @@ open class ConfigManager { val serverConfig = ConfigFactory.parseResources("server-reference.conf") val userConfig = getUserConfig() - val hasMissingSettings = serverConfig.entrySet().any { !userConfig.hasPath(it.key) } - val hasOutdatedSettings = userConfig.entrySet().any { !serverConfig.hasPath(it.key) } + // NOTE: if more than 1 dot is included, that's a nested setting, which we need to filter out here + val refKeys = + serverConfig.root().entries.flatMap { + (it.value as? ConfigObject)?.entries?.map { e -> "${it.key}.${e.key}" }.orEmpty() + } + val hasMissingSettings = refKeys.any { !userConfig.hasPath(it) } + val hasOutdatedSettings = userConfig.entrySet().any { !refKeys.contains(it.key) && it.key.count { c -> c == '.' } <= 1 } val isUserConfigOutdated = hasMissingSettings || hasOutdatedSettings if (!isUserConfigOutdated) { return @@ -159,7 +165,8 @@ open class ConfigManager { .filter { serverConfig.hasPath( it.key, - ) + ) || + it.key.count { c -> c == '.' } > 1 }.forEach { newUserConfigDoc = newUserConfigDoc.withValue(it.key, it.value) } newUserConfigDoc = diff --git a/AndroidCompat/Config/src/main/java/xyz/nulldev/ts/config/ConfigModule.kt b/AndroidCompat/Config/src/main/java/xyz/nulldev/ts/config/ConfigModule.kt index d569bbf1..aa2b6281 100644 --- a/AndroidCompat/Config/src/main/java/xyz/nulldev/ts/config/ConfigModule.kt +++ b/AndroidCompat/Config/src/main/java/xyz/nulldev/ts/config/ConfigModule.kt @@ -8,9 +8,13 @@ package xyz.nulldev.ts.config * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import com.typesafe.config.Config +import com.typesafe.config.ConfigException import com.typesafe.config.ConfigFactory -import com.typesafe.config.ConfigValueFactory +import io.github.config4k.ClassContainer +import io.github.config4k.TypeReference import io.github.config4k.getValue +import io.github.config4k.readers.SelectReader +import io.github.config4k.toConfig import kotlin.reflect.KProperty /** @@ -26,7 +30,7 @@ abstract class ConfigModule( */ abstract class SystemPropertyOverridableConfigModule( getConfig: () -> Config, - moduleName: String, + val moduleName: String, ) : ConfigModule(getConfig) { val overridableConfig = SystemPropertyOverrideDelegate(getConfig, moduleName) } @@ -46,20 +50,31 @@ class SystemPropertyOverrideDelegate( val combined = System.getProperty( "$CONFIG_PREFIX.$moduleName.${property.name}", - if (T::class.simpleName == "List") { - ConfigValueFactory.fromAnyRef(configValue).render() - } else { - configValue.toString() - }, + configValue!! + .toConfig("internal") + .root() + .render() + .removePrefix("internal="), ) + val combinedConfig = + try { + ConfigFactory.parseString(combined) + } catch (_: ConfigException) { + ConfigFactory.parseString("internal=$combined") + } - return when (T::class.simpleName) { - "Int" -> combined.toInt() - "Boolean" -> combined.toBoolean() - "Double" -> combined.toDouble() - "List" -> ConfigFactory.parseString("internal=" + combined).getStringList("internal").orEmpty() - // add more types as needed - else -> combined // covers String - } as T + val genericType = object : TypeReference() {}.genericType() + val clazz = ClassContainer(T::class, genericType) + val reader = SelectReader.getReader(clazz) + val path = property.name + + val result = reader(combinedConfig, "internal") + return try { + result as T + } catch (e: Exception) { + throw result + ?.let { e } + ?: ConfigException.BadPath(path, "take a look at your config") + } } } diff --git a/AndroidCompat/build.gradle.kts b/AndroidCompat/build.gradle.kts index e5889b14..cdf20edc 100644 --- a/AndroidCompat/build.gradle.kts +++ b/AndroidCompat/build.gradle.kts @@ -47,5 +47,5 @@ dependencies { // OpenJDK lacks native JPEG encoder and native WEBP decoder implementation(libs.bundles.twelvemonkeys) - implementation(libs.sejda.webp) + implementation(libs.imageio.webp) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 221dd2dc..fbd28249 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -136,7 +136,7 @@ twelvemonkeys-imageio-metadata = { module = "com.twelvemonkeys.imageio:imageio-m twelvemonkeys-imageio-jpeg = { module = "com.twelvemonkeys.imageio:imageio-jpeg", version.ref = "twelvemonkeys" } twelvemonkeys-imageio-webp = { module = "com.twelvemonkeys.imageio:imageio-webp", version.ref = "twelvemonkeys" } -sejda-webp = "com.github.usefulness:webp-imageio:0.10.2" +imageio-webp = "com.github.usefulness:webp-imageio:0.10.2" # Testing mockk = "io.mockk:mockk:1.14.4" diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ChapterMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ChapterMutation.kt index 5c809293..50824807 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ChapterMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ChapterMutation.kt @@ -16,6 +16,7 @@ import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReadyById import suwayomi.tachidesk.manga.model.table.ChapterMetaTable import suwayomi.tachidesk.manga.model.table.ChapterTable import suwayomi.tachidesk.server.JavalinSetup.future +import java.net.URLEncoder import java.time.Instant import java.util.concurrent.CompletableFuture @@ -226,7 +227,15 @@ class ChapterMutation { data class FetchChapterPagesInput( val clientMutationId: String? = null, val chapterId: Int, - ) + val format: String? = null, + ) { + fun toParams(): Map = + buildMap { + if (!format.isNullOrBlank()) { + put("format", format) + } + } + } data class FetchChapterPagesPayload( val clientMutationId: String?, @@ -236,16 +245,32 @@ class ChapterMutation { fun fetchChapterPages(input: FetchChapterPagesInput): CompletableFuture> { val (clientMutationId, chapterId) = input + val paramsMap = input.toParams() return future { asDataFetcherResult { val chapter = getChapterDownloadReadyById(chapterId) + val params = + buildString { + if (paramsMap.isNotEmpty()) { + append("?") + paramsMap.entries.forEach { entry -> + if (length > 1) { + append("&") + } + append(entry.key) + append("=") + append(URLEncoder.encode(entry.value, Charsets.UTF_8)) + } + } + } + FetchChapterPagesPayload( clientMutationId = clientMutationId, pages = List(chapter.pageCount) { index -> - "/api/v1/manga/${chapter.mangaId}/chapter/${chapter.index}/page/$index" + "/api/v1/manga/${chapter.mangaId}/chapter/${chapter.index}/page/${index}$params" }, chapter = ChapterType(chapter), ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt index 7ad5748d..4cc0bc1c 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt @@ -111,6 +111,18 @@ class SettingsMutation { configSetting.value = newSetting } + private fun updateSetting( + newSetting: RealSettingType?, + configSetting: MutableStateFlow, + mapper: (RealSettingType) -> SettingType, + ) { + if (newSetting == null) { + return + } + + configSetting.value = mapper(newSetting) + } + @GraphQLIgnore fun updateSettings(settings: Settings) { updateSetting(settings.ip, serverConfig.ip) @@ -140,6 +152,15 @@ class SettingsMutation { updateSetting(settings.autoDownloadAheadLimit, serverConfig.autoDownloadNewChaptersLimit) // deprecated updateSetting(settings.autoDownloadNewChaptersLimit, serverConfig.autoDownloadNewChaptersLimit) updateSetting(settings.autoDownloadIgnoreReUploads, serverConfig.autoDownloadIgnoreReUploads) + updateSetting(settings.downloadConversions, serverConfig.downloadConversions) { list -> + list.associate { + it.mimeType to + ServerConfig.DownloadConversion( + target = it.target, + compressionLevel = it.compressionLevel, + ) + } + } // extension updateSetting(settings.extensionRepos, serverConfig.extensionRepos) @@ -218,7 +239,12 @@ class SettingsMutation { val (clientMutationId) = input GlobalConfigManager.resetUserConfig() - val defaultServerConfig = ServerConfig({ GlobalConfigManager.config.getConfig(SERVER_CONFIG_MODULE_NAME) }) + val defaultServerConfig = + ServerConfig { + GlobalConfigManager.config.getConfig( + SERVER_CONFIG_MODULE_NAME, + ) + } val settings = SettingsType(defaultServerConfig) updateSettings(settings) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt index d0002a90..6b5c65f3 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt @@ -48,6 +48,7 @@ interface Settings : Node { val autoDownloadAheadLimit: Int? val autoDownloadNewChaptersLimit: Int? val autoDownloadIgnoreReUploads: Boolean? + val downloadConversions: List? // extension val extensionRepos: List? @@ -113,6 +114,18 @@ interface Settings : Node { val opdsChapterSortOrder: SortOrder? } +interface SettingsDownloadConversion { + val mimeType: String + val target: String + val compressionLevel: Float? +} + +class SettingsDownloadConversionType( + override val mimeType: String, + override val target: String, + override val compressionLevel: Float?, +) : SettingsDownloadConversion + data class PartialSettingsType( override val ip: String?, override val port: Int?, @@ -142,6 +155,7 @@ data class PartialSettingsType( override val autoDownloadAheadLimit: Int?, override val autoDownloadNewChaptersLimit: Int?, override val autoDownloadIgnoreReUploads: Boolean?, + override val downloadConversions: List?, // extension override val extensionRepos: List?, // requests @@ -222,7 +236,8 @@ class SettingsType( ) override val autoDownloadAheadLimit: Int, override val autoDownloadNewChaptersLimit: Int, - override val autoDownloadIgnoreReUploads: Boolean?, + override val autoDownloadIgnoreReUploads: Boolean, + override val downloadConversions: List, // extension override val extensionRepos: List, // requests @@ -299,6 +314,13 @@ class SettingsType( config.autoDownloadNewChaptersLimit.value, // deprecated config.autoDownloadNewChaptersLimit.value, config.autoDownloadIgnoreReUploads.value, + config.downloadConversions.value.map { + SettingsDownloadConversionType( + it.key, + it.value.target, + it.value.compressionLevel, + ) + }, // extension config.extensionRepos.value, // requests 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 5a918891..c875d15b 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt @@ -403,6 +403,7 @@ object MangaController { pathParam("chapterIndex"), pathParam("index"), queryParam("updateProgress"), + queryParam("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 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 19c4f9bc..d8e1cf41 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Page.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Page.kt @@ -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) -> Unit)? = null, ): Pair { 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, + format: String? = null, + ): Pair { + 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" */ diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt index 47a1a0d4..d7846d1e 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt @@ -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 diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt index 64ba171b..89b14e04 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt @@ -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?, // extension @ProtoNumber(22) override var extensionRepos: List, // 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 +} 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 e85c5645..cdb93c06 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 @@ -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( val mangaId: Int, val chapterId: Int, ) : DownloadedFilesProvider { + protected val logger = KotlinLogging.logger {} + protected abstract fun getImageFiles(): List protected abstract fun getImageInputStream(image: Type): InputStream @@ -75,7 +85,7 @@ abstract class ChaptersFilesProvider( 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( }, ) + maybeConvertChapterImages(downloadCacheFolder) + handleSuccessfulDownload() transaction { @@ -185,4 +197,64 @@ abstract class ChaptersFilesProvider( abstract override fun delete(): Boolean abstract fun getAsArchiveStream(): Pair + + 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() + } + } + } + } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/FileRetriever.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/FileRetriever.kt index 81c09f80..75bffaf2 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/FileRetriever.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/FileRetriever.kt @@ -19,6 +19,16 @@ fun interface RetrieveFile1Args : RetrieveFile { override fun executeGetImage(vararg args: Any): Pair = execute(args[0] as A) } +@Suppress("UNCHECKED_CAST") +fun interface RetrieveFile2Args : RetrieveFile { + fun execute( + a: A, + b: B, + ): Pair + + override fun executeGetImage(vararg args: Any): Pair = execute(args[0] as A, args[1] as B) +} + fun interface FileRetriever { fun getImage(): RetrieveFile } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuApi.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuApi.kt index 0724f6f5..40345a1e 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuApi.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuApi.kt @@ -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) { diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/util/OpdsStringUtil.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/util/OpdsStringUtil.kt index e05120d2..22b1c5d4 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/util/OpdsStringUtil.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/util/OpdsStringUtil.kt @@ -2,7 +2,6 @@ package suwayomi.tachidesk.opds.util import suwayomi.tachidesk.server.serverConfig import java.net.URLEncoder -import java.nio.charset.StandardCharsets import java.text.Normalizer /** @@ -15,7 +14,7 @@ object OpdsStringUtil { * Encodes a string to be used in OPDS URLs. * @return The URL-encoded string */ - fun String.encodeForOpdsURL(): String = URLEncoder.encode(this, StandardCharsets.UTF_8.toString()) + fun String.encodeForOpdsURL(): String = URLEncoder.encode(this, Charsets.UTF_8) /** * Converts a string into a URL-friendly slug. diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/ConfigAdapters.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/ConfigAdapters.kt deleted file mode 100644 index 8276bed6..00000000 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/ConfigAdapters.kt +++ /dev/null @@ -1,27 +0,0 @@ -package suwayomi.tachidesk.server - -interface ConfigAdapter { - fun toType(configValue: String): T -} - -object StringConfigAdapter : ConfigAdapter { - override fun toType(configValue: String): String = configValue -} - -object IntConfigAdapter : ConfigAdapter { - override fun toType(configValue: String): Int = configValue.toInt() -} - -object BooleanConfigAdapter : ConfigAdapter { - override fun toType(configValue: String): Boolean = configValue.toBoolean() -} - -object DoubleConfigAdapter : ConfigAdapter { - override fun toType(configValue: String): Double = configValue.toDouble() -} - -class EnumConfigAdapter>( - private val enumClass: Class, -) : ConfigAdapter { - override fun toType(configValue: String): T = java.lang.Enum.valueOf(enumClass, configValue.uppercase()) -} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt index 9cff65b3..dc818351 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt @@ -33,7 +33,6 @@ import suwayomi.tachidesk.server.util.WebInterfaceManager import uy.kohesive.injekt.injectLazy import java.io.IOException import java.net.URLEncoder -import java.nio.charset.StandardCharsets import java.util.concurrent.CompletableFuture import kotlin.concurrent.thread import kotlin.time.Duration.Companion.days @@ -168,7 +167,7 @@ object JavalinSetup { return@beforeMatched } - val authMode = serverConfig.authMode.value ?: AuthMode.NONE + val authMode = serverConfig.authMode.value fun credentialsValid(): Boolean { val basicAuthCredentials = ctx.basicAuthCredentials() ?: return false @@ -187,7 +186,7 @@ object JavalinSetup { } if (authMode == AuthMode.SIMPLE_LOGIN && !cookieValid()) { - val url = "/login.html?redirect=" + URLEncoder.encode(ctx.fullUrl(), StandardCharsets.UTF_8) + val url = "/login.html?redirect=" + URLEncoder.encode(ctx.fullUrl(), Charsets.UTF_8) ctx.header("Location", url) throw RedirectResponse(HttpStatus.SEE_OTHER) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt index 7b2ec743..1b68b7c5 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt @@ -8,6 +8,7 @@ package suwayomi.tachidesk.server * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import com.typesafe.config.Config +import io.github.config4k.getValue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -38,39 +39,29 @@ const val SERVER_CONFIG_MODULE_NAME = "server" class ServerConfig( getConfig: () -> Config, - val moduleName: String = SERVER_CONFIG_MODULE_NAME, ) : SystemPropertyOverridableConfigModule( getConfig, - moduleName, + SERVER_CONFIG_MODULE_NAME, ) { - open inner class OverrideConfigValue( - private val configAdapter: ConfigAdapter, - ) { - private var flow: MutableStateFlow? = null + open inner class OverrideConfigValue { + var flow: MutableStateFlow? = null - open fun getValueFromConfig( + inline operator fun , reified R> getValue( thisRef: ServerConfig, property: KProperty<*>, - ): Any = configAdapter.toType(overridableConfig.getValue(thisRef, property)) - - operator fun getValue( - thisRef: ServerConfig, - property: KProperty<*>, - ): MutableStateFlow { + ): T { if (flow != null) { - return flow!! + return flow as T } + val stateFlow = overridableConfig.getValue(thisRef, property) @Suppress("UNCHECKED_CAST") - val value = getValueFromConfig(thisRef, property) as T - - val stateFlow = MutableStateFlow(value) - flow = stateFlow + flow = stateFlow as MutableStateFlow stateFlow .drop(1) .distinctUntilChanged() - .filter { it != getValueFromConfig(thisRef, property) } + .filter { it != thisRef.overridableConfig.getConfig().getValue(thisRef, property) } .onEach { GlobalConfigManager.updateValue("$moduleName.${property.name}", it as Any) } .launchIn(mutableConfigValueScope) @@ -78,29 +69,12 @@ class ServerConfig( } } - inner class OverrideConfigValues( - private val configAdapter: ConfigAdapter, - ) : OverrideConfigValue(configAdapter) { - override fun getValueFromConfig( - thisRef: ServerConfig, - property: KProperty<*>, - ): Any = - overridableConfig - .getValue>(thisRef, property) - .map { configAdapter.toType(it) } - } - open inner class MigratedConfigValue( - private val readMigrated: () -> Any, + private val readMigrated: () -> T, private val setMigrated: (T) -> Unit, ) { private var flow: MutableStateFlow? = null - open fun getValueFromConfig( - thisRef: ServerConfig, - property: KProperty<*>, - ): Any = readMigrated() - operator fun getValue( thisRef: ServerConfig, property: KProperty<*>, @@ -109,8 +83,7 @@ class ServerConfig( return flow!! } - @Suppress("UNCHECKED_CAST") - val value = getValueFromConfig(thisRef, property) as T + val value = readMigrated() val stateFlow = MutableStateFlow(value) flow = stateFlow @@ -118,7 +91,7 @@ class ServerConfig( stateFlow .drop(1) .distinctUntilChanged() - .filter { it != getValueFromConfig(thisRef, property) } + .filter { it != readMigrated() } .onEach(setMigrated) .launchIn(mutableConfigValueScope) @@ -126,51 +99,57 @@ class ServerConfig( } } - val ip: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) - val port: MutableStateFlow by OverrideConfigValue(IntConfigAdapter) + val ip: MutableStateFlow by OverrideConfigValue() + val port: MutableStateFlow by OverrideConfigValue() // proxy - val socksProxyEnabled: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) - val socksProxyVersion: MutableStateFlow by OverrideConfigValue(IntConfigAdapter) - val socksProxyHost: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) - val socksProxyPort: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) - val socksProxyUsername: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) - val socksProxyPassword: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) + val socksProxyEnabled: MutableStateFlow by OverrideConfigValue() + val socksProxyVersion: MutableStateFlow by OverrideConfigValue() + val socksProxyHost: MutableStateFlow by OverrideConfigValue() + val socksProxyPort: MutableStateFlow by OverrideConfigValue() + val socksProxyUsername: MutableStateFlow by OverrideConfigValue() + val socksProxyPassword: MutableStateFlow by OverrideConfigValue() // webUI - val webUIEnabled: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) - val webUIFlavor: MutableStateFlow by OverrideConfigValue(EnumConfigAdapter(WebUIFlavor::class.java)) - val initialOpenInBrowserEnabled: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) - val webUIInterface: MutableStateFlow by OverrideConfigValue(EnumConfigAdapter(WebUIInterface::class.java)) - val electronPath: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) - val webUIChannel: MutableStateFlow by OverrideConfigValue(EnumConfigAdapter(WebUIChannel::class.java)) - val webUIUpdateCheckInterval: MutableStateFlow by OverrideConfigValue(DoubleConfigAdapter) + val webUIEnabled: MutableStateFlow by OverrideConfigValue() + val webUIFlavor: MutableStateFlow by OverrideConfigValue() + val initialOpenInBrowserEnabled: MutableStateFlow by OverrideConfigValue() + val webUIInterface: MutableStateFlow by OverrideConfigValue() + val electronPath: MutableStateFlow by OverrideConfigValue() + val webUIChannel: MutableStateFlow by OverrideConfigValue() + val webUIUpdateCheckInterval: MutableStateFlow by OverrideConfigValue() // downloader - val downloadAsCbz: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) - val downloadsPath: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) - val autoDownloadNewChapters: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) - val excludeEntryWithUnreadChapters: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) - val autoDownloadNewChaptersLimit: MutableStateFlow by OverrideConfigValue(IntConfigAdapter) - val autoDownloadIgnoreReUploads: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) + val downloadAsCbz: MutableStateFlow by OverrideConfigValue() + val downloadsPath: MutableStateFlow by OverrideConfigValue() + val autoDownloadNewChapters: MutableStateFlow by OverrideConfigValue() + val excludeEntryWithUnreadChapters: MutableStateFlow by OverrideConfigValue() + val autoDownloadNewChaptersLimit: MutableStateFlow by OverrideConfigValue() + val autoDownloadIgnoreReUploads: MutableStateFlow by OverrideConfigValue() + val downloadConversions: MutableStateFlow> by OverrideConfigValue() + + data class DownloadConversion( + val target: String, + val compressionLevel: Float? = null, + ) // extensions - val extensionRepos: MutableStateFlow> by OverrideConfigValues(StringConfigAdapter) + val extensionRepos: MutableStateFlow> by OverrideConfigValue() // requests - val maxSourcesInParallel: MutableStateFlow by OverrideConfigValue(IntConfigAdapter) + val maxSourcesInParallel: MutableStateFlow by OverrideConfigValue() // updater - val excludeUnreadChapters: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) - val excludeNotStarted: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) - val excludeCompleted: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) - val globalUpdateInterval: MutableStateFlow by OverrideConfigValue(DoubleConfigAdapter) - val updateMangas: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) + val excludeUnreadChapters: MutableStateFlow by OverrideConfigValue() + val excludeNotStarted: MutableStateFlow by OverrideConfigValue() + val excludeCompleted: MutableStateFlow by OverrideConfigValue() + val globalUpdateInterval: MutableStateFlow by OverrideConfigValue() + val updateMangas: MutableStateFlow by OverrideConfigValue() // Authentication - val authMode: MutableStateFlow by OverrideConfigValue(EnumConfigAdapter(AuthMode::class.java)) - val authUsername: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) - val authPassword: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) + val authMode: MutableStateFlow by OverrideConfigValue() + val authUsername: MutableStateFlow by OverrideConfigValue() + val authPassword: MutableStateFlow by OverrideConfigValue() val basicAuthEnabled: MutableStateFlow by MigratedConfigValue({ authMode.value == AuthMode.BASIC_AUTH }) { @@ -184,37 +163,37 @@ class ServerConfig( } // misc - val debugLogsEnabled: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) - val systemTrayEnabled: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) - val maxLogFiles: MutableStateFlow by OverrideConfigValue(IntConfigAdapter) - val maxLogFileSize: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) - val maxLogFolderSize: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) + val debugLogsEnabled: MutableStateFlow by OverrideConfigValue() + val systemTrayEnabled: MutableStateFlow by OverrideConfigValue() + val maxLogFiles: MutableStateFlow by OverrideConfigValue() + val maxLogFileSize: MutableStateFlow by OverrideConfigValue() + val maxLogFolderSize: MutableStateFlow by OverrideConfigValue() // backup - val backupPath: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) - val backupTime: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) - val backupInterval: MutableStateFlow by OverrideConfigValue(IntConfigAdapter) - val backupTTL: MutableStateFlow by OverrideConfigValue(IntConfigAdapter) + val backupPath: MutableStateFlow by OverrideConfigValue() + val backupTime: MutableStateFlow by OverrideConfigValue() + val backupInterval: MutableStateFlow by OverrideConfigValue() + val backupTTL: MutableStateFlow by OverrideConfigValue() // local source - val localSourcePath: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) + val localSourcePath: MutableStateFlow by OverrideConfigValue() // cloudflare bypass - val flareSolverrEnabled: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) - val flareSolverrUrl: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) - val flareSolverrTimeout: MutableStateFlow by OverrideConfigValue(IntConfigAdapter) - val flareSolverrSessionName: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) - val flareSolverrSessionTtl: MutableStateFlow by OverrideConfigValue(IntConfigAdapter) - val flareSolverrAsResponseFallback: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) + val flareSolverrEnabled: MutableStateFlow by OverrideConfigValue() + val flareSolverrUrl: MutableStateFlow by OverrideConfigValue() + val flareSolverrTimeout: MutableStateFlow by OverrideConfigValue() + val flareSolverrSessionName: MutableStateFlow by OverrideConfigValue() + val flareSolverrSessionTtl: MutableStateFlow by OverrideConfigValue() + val flareSolverrAsResponseFallback: MutableStateFlow by OverrideConfigValue() // opds settings - val opdsUseBinaryFileSizes: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) - val opdsItemsPerPage: MutableStateFlow by OverrideConfigValue(IntConfigAdapter) - val opdsEnablePageReadProgress: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) - val opdsMarkAsReadOnDownload: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) - val opdsShowOnlyUnreadChapters: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) - val opdsShowOnlyDownloadedChapters: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) - val opdsChapterSortOrder: MutableStateFlow by OverrideConfigValue(EnumConfigAdapter(SortOrder::class.java)) + val opdsUseBinaryFileSizes: MutableStateFlow by OverrideConfigValue() + val opdsItemsPerPage: MutableStateFlow by OverrideConfigValue() + val opdsEnablePageReadProgress: MutableStateFlow by OverrideConfigValue() + val opdsMarkAsReadOnDownload: MutableStateFlow by OverrideConfigValue() + val opdsShowOnlyUnreadChapters: MutableStateFlow by OverrideConfigValue() + val opdsShowOnlyDownloadedChapters: MutableStateFlow by OverrideConfigValue() + val opdsChapterSortOrder: MutableStateFlow by OverrideConfigValue() @OptIn(ExperimentalCoroutinesApi::class) fun subscribeTo( @@ -259,6 +238,11 @@ class ServerConfig( } companion object { - fun register(getConfig: () -> Config) = ServerConfig({ getConfig().getConfig(SERVER_CONFIG_MODULE_NAME) }) + fun register(getConfig: () -> Config) = + ServerConfig { + getConfig().getConfig( + SERVER_CONFIG_MODULE_NAME, + ) + } } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt index 574db087..fbaf5550 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt @@ -13,13 +13,14 @@ import com.typesafe.config.Config import com.typesafe.config.ConfigException import com.typesafe.config.ConfigRenderOptions import com.typesafe.config.ConfigValue -import com.typesafe.config.ConfigValueFactory import com.typesafe.config.parser.ConfigDocument import dev.datlag.kcef.KCEF import eu.kanade.tachiyomi.App import eu.kanade.tachiyomi.createAppModule import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.source.local.LocalSource +import io.github.config4k.registerCustomType +import io.github.config4k.toConfig import io.github.oshai.kotlinlogging.KotlinLogging import io.javalin.json.JavalinJackson import io.javalin.json.JsonMapper @@ -44,10 +45,10 @@ import suwayomi.tachidesk.manga.impl.download.DownloadManager import suwayomi.tachidesk.manga.impl.update.IUpdater import suwayomi.tachidesk.manga.impl.update.Updater import suwayomi.tachidesk.manga.impl.util.lang.renameTo -import suwayomi.tachidesk.server.BooleanConfigAdapter import suwayomi.tachidesk.server.database.databaseUp import suwayomi.tachidesk.server.generated.BuildConfig import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex +import suwayomi.tachidesk.server.util.MutableStateFlowType import suwayomi.tachidesk.server.util.SystemTray import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -147,7 +148,7 @@ fun migrateConfig( if (typedValue != null) { return configDocument.withValue( toConfigKey, - ConfigValueFactory.fromAnyRef(typedValue), + typedValue.toConfig("internal").getValue("internal"), ) } } catch (_: ConfigException) { @@ -174,6 +175,7 @@ fun applicationSetup() { mainLoop.start() // register Tachidesk's config which is dubbed "ServerConfig" + registerCustomType(MutableStateFlowType()) GlobalConfigManager.registerModule( ServerConfig.register { GlobalConfigManager.config }, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/util/MutableStateFlowType.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/util/MutableStateFlowType.kt new file mode 100644 index 00000000..b3159811 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/util/MutableStateFlowType.kt @@ -0,0 +1,39 @@ +package suwayomi.tachidesk.server.util + +import com.typesafe.config.Config +import io.github.config4k.ClassContainer +import io.github.config4k.CustomType +import io.github.config4k.readers.SelectReader +import io.github.config4k.toConfig +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class MutableStateFlowType : CustomType { + override fun parse( + clazz: ClassContainer, + config: Config, + name: String, + ): Any? { + val reader = + SelectReader.getReader( + clazz.typeArguments.entries + .first() + .value, + ) + val path = name + val result = reader(config, path) + return MutableStateFlow(result) + } + + override fun testParse(clazz: ClassContainer): Boolean = + clazz.mapperClass.qualifiedName == "kotlinx.coroutines.flow.MutableStateFlow" || + clazz.mapperClass.qualifiedName == "kotlinx.coroutines.flow.StateFlow" || + clazz.mapperClass.qualifiedName == "kotlinx.coroutines.flow.StateFlowImpl" + + override fun testToConfig(obj: Any): Boolean = (obj as? StateFlow<*>)?.value != null + + override fun toConfig( + obj: Any, + name: String, + ): Config = (obj as StateFlow<*>).value!!.toConfig(name) +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/util/ConversionUtil.kt b/server/src/main/kotlin/suwayomi/tachidesk/util/ConversionUtil.kt new file mode 100644 index 00000000..2279837c --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/util/ConversionUtil.kt @@ -0,0 +1,52 @@ +package suwayomi.tachidesk.util + +import io.github.oshai.kotlinlogging.KotlinLogging +import java.awt.image.BufferedImage +import java.io.File +import java.io.InputStream +import javax.imageio.ImageIO + +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( + image: InputStream, + mimeType: String, + ): BufferedImage? { + val readers = ImageIO.getImageReadersByMIMEType(mimeType) + ImageIO.createImageInputStream(image).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 $mimeType" } + return null + } +} diff --git a/server/src/main/resources/server-reference.conf b/server/src/main/resources/server-reference.conf index 86c6a9ae..6242b450 100644 --- a/server/src/main/resources/server-reference.conf +++ b/server/src/main/resources/server-reference.conf @@ -26,6 +26,12 @@ server.autoDownloadNewChapters = false # if new chapters that have been retrieve server.excludeEntryWithUnreadChapters = true # ignore automatic chapter downloads of entries with unread chapters server.autoDownloadNewChaptersLimit = 0 # 0 to disable it - how many unread downloaded chapters should be available - if the limit is reached, new chapters won't be downloaded automatically. this limit will also be applied to the auto download of new chapters on an update server.autoDownloadIgnoreReUploads = false # decides if re-uploads should be ignored during auto download of new chapters +server.downloadConversions = {} +# map input mime type to conversion information, or "default" for others +# server.downloadConversions."image/webp" = { +# target = "image/jpeg" # image type to convert to +# compressionLevel = 0.8 # quality in range [0,1], leave away to use default compression +# } # extension repos server.extensionRepos = [ diff --git a/server/src/test/kotlin/suwayomi/tachidesk/manga/impl/update/TestUpdater.kt b/server/src/test/kotlin/suwayomi/tachidesk/manga/impl/update/TestUpdater.kt index 3b319a50..c600cb91 100644 --- a/server/src/test/kotlin/suwayomi/tachidesk/manga/impl/update/TestUpdater.kt +++ b/server/src/test/kotlin/suwayomi/tachidesk/manga/impl/update/TestUpdater.kt @@ -31,6 +31,10 @@ class TestUpdater : IUpdater { TODO("Not yet implemented") } + override fun deleteLastAutomatedUpdateTimestamp() { + TODO("Not yet implemented") + } + override fun addCategoriesToUpdateQueue( categories: List, clear: Boolean?, diff --git a/server/src/test/resources/server-reference.conf b/server/src/test/resources/server-reference.conf index 14ccfbe2..96f7420c 100644 --- a/server/src/test/resources/server-reference.conf +++ b/server/src/test/resources/server-reference.conf @@ -26,6 +26,12 @@ server.autoDownloadNewChapters = false # if new chapters that have been retrieve server.excludeEntryWithUnreadChapters = true # ignore automatic chapter downloads of entries with unread chapters server.autoDownloadNewChaptersLimit = 0 # 0 to disable it - how many unread downloaded chapters should be available - if the limit is reached, new chapters won't be downloaded automatically. this limit will also be applied to the auto download of new chapters on an update server.autoDownloadIgnoreReUploads = false # decides if re-uploads should be ignored during auto download of new chapters +server.downloadConversions = {} +# map input mime type to conversion information, or "default" for others +# server.downloadConversions."image/webp" = { +# target = "image/jpeg" # image type to convert to +# compressionLevel = 0.8 # quality in range [0,1], leave away to use default compression +# } # extension repos server.extensionRepos = [