[#1496] Image conversion (#1505)

* [#1496] First conversion attempt

* [#1496] Configurable conversion

* Fix: allow nested configs (map)

* [#1496] Support explicit `none` conversion

* Use MimeUtils for provided download

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

* Lint

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

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

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

* Rewrite config handling, improve custom types

* Lint

* Add format to pages mutation

* Lint

* Standardize url encode

* Lint

* Config: Allow additional conversion parameters

* Implement conversion quality parameter

* Lint

* Implement a conversion util to allow fallback readers

* Add downloadConversions to api and backup, fix updateValue issues

* Lint

* Minor cleanup

* Update libs.versions.toml

---------

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

View File

@@ -10,10 +10,11 @@ package xyz.nulldev.ts.config
import ch.qos.logback.classic.Level import ch.qos.logback.classic.Level
import com.typesafe.config.Config import com.typesafe.config.Config
import com.typesafe.config.ConfigFactory import com.typesafe.config.ConfigFactory
import com.typesafe.config.ConfigObject
import com.typesafe.config.ConfigValue import com.typesafe.config.ConfigValue
import com.typesafe.config.ConfigValueFactory
import com.typesafe.config.parser.ConfigDocument import com.typesafe.config.parser.ConfigDocument
import com.typesafe.config.parser.ConfigDocumentFactory import com.typesafe.config.parser.ConfigDocumentFactory
import io.github.config4k.toConfig
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@@ -113,7 +114,7 @@ open class ConfigManager {
) { ) {
mutex.withLock { mutex.withLock {
val actualValue = if (value is Enum<*>) value.name else value 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) updateUserConfigFile(path, configValue)
internalConfig = internalConfig.withValue(path, configValue) internalConfig = internalConfig.withValue(path, configValue)
@@ -142,8 +143,13 @@ open class ConfigManager {
val serverConfig = ConfigFactory.parseResources("server-reference.conf") val serverConfig = ConfigFactory.parseResources("server-reference.conf")
val userConfig = getUserConfig() val userConfig = getUserConfig()
val hasMissingSettings = serverConfig.entrySet().any { !userConfig.hasPath(it.key) } // NOTE: if more than 1 dot is included, that's a nested setting, which we need to filter out here
val hasOutdatedSettings = userConfig.entrySet().any { !serverConfig.hasPath(it.key) } 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 val isUserConfigOutdated = hasMissingSettings || hasOutdatedSettings
if (!isUserConfigOutdated) { if (!isUserConfigOutdated) {
return return
@@ -159,7 +165,8 @@ open class ConfigManager {
.filter { .filter {
serverConfig.hasPath( serverConfig.hasPath(
it.key, it.key,
) ) ||
it.key.count { c -> c == '.' } > 1
}.forEach { newUserConfigDoc = newUserConfigDoc.withValue(it.key, it.value) } }.forEach { newUserConfigDoc = newUserConfigDoc.withValue(it.key, it.value) }
newUserConfigDoc = newUserConfigDoc =

View File

@@ -8,9 +8,13 @@ package xyz.nulldev.ts.config
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import com.typesafe.config.Config import com.typesafe.config.Config
import com.typesafe.config.ConfigException
import com.typesafe.config.ConfigFactory 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.getValue
import io.github.config4k.readers.SelectReader
import io.github.config4k.toConfig
import kotlin.reflect.KProperty import kotlin.reflect.KProperty
/** /**
@@ -26,7 +30,7 @@ abstract class ConfigModule(
*/ */
abstract class SystemPropertyOverridableConfigModule( abstract class SystemPropertyOverridableConfigModule(
getConfig: () -> Config, getConfig: () -> Config,
moduleName: String, val moduleName: String,
) : ConfigModule(getConfig) { ) : ConfigModule(getConfig) {
val overridableConfig = SystemPropertyOverrideDelegate(getConfig, moduleName) val overridableConfig = SystemPropertyOverrideDelegate(getConfig, moduleName)
} }
@@ -46,20 +50,31 @@ class SystemPropertyOverrideDelegate(
val combined = val combined =
System.getProperty( System.getProperty(
"$CONFIG_PREFIX.$moduleName.${property.name}", "$CONFIG_PREFIX.$moduleName.${property.name}",
if (T::class.simpleName == "List") { configValue!!
ConfigValueFactory.fromAnyRef(configValue).render() .toConfig("internal")
} else { .root()
configValue.toString() .render()
}, .removePrefix("internal="),
) )
val combinedConfig =
try {
ConfigFactory.parseString(combined)
} catch (_: ConfigException) {
ConfigFactory.parseString("internal=$combined")
}
return when (T::class.simpleName) { val genericType = object : TypeReference<T>() {}.genericType()
"Int" -> combined.toInt() val clazz = ClassContainer(T::class, genericType)
"Boolean" -> combined.toBoolean() val reader = SelectReader.getReader(clazz)
"Double" -> combined.toDouble() val path = property.name
"List" -> ConfigFactory.parseString("internal=" + combined).getStringList("internal").orEmpty()
// add more types as needed val result = reader(combinedConfig, "internal")
else -> combined // covers String return try {
} as T result as T
} catch (e: Exception) {
throw result
?.let { e }
?: ConfigException.BadPath(path, "take a look at your config")
}
} }
} }

View File

@@ -47,5 +47,5 @@ dependencies {
// OpenJDK lacks native JPEG encoder and native WEBP decoder // OpenJDK lacks native JPEG encoder and native WEBP decoder
implementation(libs.bundles.twelvemonkeys) implementation(libs.bundles.twelvemonkeys)
implementation(libs.sejda.webp) implementation(libs.imageio.webp)
} }

View File

@@ -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-jpeg = { module = "com.twelvemonkeys.imageio:imageio-jpeg", version.ref = "twelvemonkeys" }
twelvemonkeys-imageio-webp = { module = "com.twelvemonkeys.imageio:imageio-webp", 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 # Testing
mockk = "io.mockk:mockk:1.14.4" mockk = "io.mockk:mockk:1.14.4"

View File

@@ -16,6 +16,7 @@ import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReadyById
import suwayomi.tachidesk.manga.model.table.ChapterMetaTable import suwayomi.tachidesk.manga.model.table.ChapterMetaTable
import suwayomi.tachidesk.manga.model.table.ChapterTable import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.server.JavalinSetup.future import suwayomi.tachidesk.server.JavalinSetup.future
import java.net.URLEncoder
import java.time.Instant import java.time.Instant
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
@@ -226,7 +227,15 @@ class ChapterMutation {
data class FetchChapterPagesInput( data class FetchChapterPagesInput(
val clientMutationId: String? = null, val clientMutationId: String? = null,
val chapterId: Int, val chapterId: Int,
) val format: String? = null,
) {
fun toParams(): Map<String, String> =
buildMap {
if (!format.isNullOrBlank()) {
put("format", format)
}
}
}
data class FetchChapterPagesPayload( data class FetchChapterPagesPayload(
val clientMutationId: String?, val clientMutationId: String?,
@@ -236,16 +245,32 @@ class ChapterMutation {
fun fetchChapterPages(input: FetchChapterPagesInput): CompletableFuture<DataFetcherResult<FetchChapterPagesPayload?>> { fun fetchChapterPages(input: FetchChapterPagesInput): CompletableFuture<DataFetcherResult<FetchChapterPagesPayload?>> {
val (clientMutationId, chapterId) = input val (clientMutationId, chapterId) = input
val paramsMap = input.toParams()
return future { return future {
asDataFetcherResult { asDataFetcherResult {
val chapter = getChapterDownloadReadyById(chapterId) 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( FetchChapterPagesPayload(
clientMutationId = clientMutationId, clientMutationId = clientMutationId,
pages = pages =
List(chapter.pageCount) { index -> 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), chapter = ChapterType(chapter),
) )

View File

@@ -111,6 +111,18 @@ class SettingsMutation {
configSetting.value = newSetting configSetting.value = newSetting
} }
private fun <SettingType : Any, RealSettingType : Any> updateSetting(
newSetting: RealSettingType?,
configSetting: MutableStateFlow<SettingType>,
mapper: (RealSettingType) -> SettingType,
) {
if (newSetting == null) {
return
}
configSetting.value = mapper(newSetting)
}
@GraphQLIgnore @GraphQLIgnore
fun updateSettings(settings: Settings) { fun updateSettings(settings: Settings) {
updateSetting(settings.ip, serverConfig.ip) updateSetting(settings.ip, serverConfig.ip)
@@ -140,6 +152,15 @@ class SettingsMutation {
updateSetting(settings.autoDownloadAheadLimit, serverConfig.autoDownloadNewChaptersLimit) // deprecated updateSetting(settings.autoDownloadAheadLimit, serverConfig.autoDownloadNewChaptersLimit) // deprecated
updateSetting(settings.autoDownloadNewChaptersLimit, serverConfig.autoDownloadNewChaptersLimit) updateSetting(settings.autoDownloadNewChaptersLimit, serverConfig.autoDownloadNewChaptersLimit)
updateSetting(settings.autoDownloadIgnoreReUploads, serverConfig.autoDownloadIgnoreReUploads) 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 // extension
updateSetting(settings.extensionRepos, serverConfig.extensionRepos) updateSetting(settings.extensionRepos, serverConfig.extensionRepos)
@@ -218,7 +239,12 @@ class SettingsMutation {
val (clientMutationId) = input val (clientMutationId) = input
GlobalConfigManager.resetUserConfig() 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) val settings = SettingsType(defaultServerConfig)
updateSettings(settings) updateSettings(settings)

View File

@@ -48,6 +48,7 @@ interface Settings : Node {
val autoDownloadAheadLimit: Int? val autoDownloadAheadLimit: Int?
val autoDownloadNewChaptersLimit: Int? val autoDownloadNewChaptersLimit: Int?
val autoDownloadIgnoreReUploads: Boolean? val autoDownloadIgnoreReUploads: Boolean?
val downloadConversions: List<SettingsDownloadConversion>?
// extension // extension
val extensionRepos: List<String>? val extensionRepos: List<String>?
@@ -113,6 +114,18 @@ interface Settings : Node {
val opdsChapterSortOrder: SortOrder? 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( data class PartialSettingsType(
override val ip: String?, override val ip: String?,
override val port: Int?, override val port: Int?,
@@ -142,6 +155,7 @@ data class PartialSettingsType(
override val autoDownloadAheadLimit: Int?, override val autoDownloadAheadLimit: Int?,
override val autoDownloadNewChaptersLimit: Int?, override val autoDownloadNewChaptersLimit: Int?,
override val autoDownloadIgnoreReUploads: Boolean?, override val autoDownloadIgnoreReUploads: Boolean?,
override val downloadConversions: List<SettingsDownloadConversionType>?,
// extension // extension
override val extensionRepos: List<String>?, override val extensionRepos: List<String>?,
// requests // requests
@@ -222,7 +236,8 @@ class SettingsType(
) )
override val autoDownloadAheadLimit: Int, override val autoDownloadAheadLimit: Int,
override val autoDownloadNewChaptersLimit: Int, override val autoDownloadNewChaptersLimit: Int,
override val autoDownloadIgnoreReUploads: Boolean?, override val autoDownloadIgnoreReUploads: Boolean,
override val downloadConversions: List<SettingsDownloadConversionType>,
// extension // extension
override val extensionRepos: List<String>, override val extensionRepos: List<String>,
// requests // requests
@@ -299,6 +314,13 @@ class SettingsType(
config.autoDownloadNewChaptersLimit.value, // deprecated config.autoDownloadNewChaptersLimit.value, // deprecated
config.autoDownloadNewChaptersLimit.value, config.autoDownloadNewChaptersLimit.value,
config.autoDownloadIgnoreReUploads.value, config.autoDownloadIgnoreReUploads.value,
config.downloadConversions.value.map {
SettingsDownloadConversionType(
it.key,
it.value.target,
it.value.compressionLevel,
)
},
// extension // extension
config.extensionRepos.value, config.extensionRepos.value,
// requests // requests

View File

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

View File

@@ -11,6 +11,7 @@ import eu.kanade.tachiyomi.source.local.LocalSource
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import libcore.net.MimeUtils
import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.selectAll 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.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.PageTable 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.File
import java.io.InputStream import java.io.InputStream
import javax.imageio.ImageIO
object Page { object Page {
/** /**
@@ -45,6 +50,7 @@ object Page {
mangaId: Int, mangaId: Int,
chapterIndex: Int, chapterIndex: Int,
index: Int, index: Int,
format: String? = null,
progressFlow: ((StateFlow<Int>) -> Unit)? = null, progressFlow: ((StateFlow<Int>) -> Unit)? = null,
): Pair<InputStream, String> { ): Pair<InputStream, String> {
val mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() } val mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
@@ -61,7 +67,7 @@ object Page {
try { try {
if (chapterEntry[ChapterTable.isDownloaded]) { if (chapterEntry[ChapterTable.isDownloaded]) {
return ChapterDownloadHelper.getImage(mangaId, chapterId, index) return convertImageResponse(ChapterDownloadHelper.getImage(mangaId, chapterId, index), format)
} }
} catch (_: Exception) { } catch (_: Exception) {
// ignore and fetch again // ignore and fetch again
@@ -90,12 +96,15 @@ object Page {
// is of archive format // is of archive format
if (LocalSource.pageCache.containsKey(chapterEntry[ChapterTable.url])) { if (LocalSource.pageCache.containsKey(chapterEntry[ChapterTable.url])) {
val pageStream = LocalSource.pageCache[chapterEntry[ChapterTable.url]]!![index] 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 // is of directory format
val imageFile = File(tachiyomiPage.imageUrl!!) 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]) val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
@@ -115,9 +124,38 @@ object Page {
val cacheSaveDir = getChapterCachePath(mangaId, chapterId) val cacheSaveDir = getChapterCachePath(mangaId, chapterId)
// Note: don't care about invalidating cache because OS cache is not permanent // Note: don't care about invalidating cache because OS cache is not permanent
return getImageResponse(cacheSaveDir, fileName) { return convertImageResponse(
getImageResponse(cacheSaveDir, fileName) {
source.getImage(tachiyomiPage) 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" */ /** converts 0 to "001" */

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,16 @@ fun interface RetrieveFile1Args<A> : RetrieveFile {
override fun executeGetImage(vararg args: Any): Pair<InputStream, String> = execute(args[0] as A) 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 interface FileRetriever {
fun getImage(): RetrieveFile fun getImage(): RetrieveFile
} }

View File

@@ -29,7 +29,6 @@ import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets
class KitsuApi( class KitsuApi(
private val client: OkHttpClient, private val client: OkHttpClient,
@@ -151,7 +150,7 @@ class KitsuApi(
withIOContext { withIOContext {
val jsonObject = val jsonObject =
buildJsonObject { 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) { with(json) {

View File

@@ -2,7 +2,6 @@ package suwayomi.tachidesk.opds.util
import suwayomi.tachidesk.server.serverConfig import suwayomi.tachidesk.server.serverConfig
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.text.Normalizer import java.text.Normalizer
/** /**
@@ -15,7 +14,7 @@ object OpdsStringUtil {
* Encodes a string to be used in OPDS URLs. * Encodes a string to be used in OPDS URLs.
* @return The URL-encoded string * @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. * Converts a string into a URL-friendly slug.

View File

@@ -1,27 +0,0 @@
package suwayomi.tachidesk.server
interface ConfigAdapter<T> {
fun toType(configValue: String): T
}
object StringConfigAdapter : ConfigAdapter<String> {
override fun toType(configValue: String): String = configValue
}
object IntConfigAdapter : ConfigAdapter<Int> {
override fun toType(configValue: String): Int = configValue.toInt()
}
object BooleanConfigAdapter : ConfigAdapter<Boolean> {
override fun toType(configValue: String): Boolean = configValue.toBoolean()
}
object DoubleConfigAdapter : ConfigAdapter<Double> {
override fun toType(configValue: String): Double = configValue.toDouble()
}
class EnumConfigAdapter<T : Enum<T>>(
private val enumClass: Class<T>,
) : ConfigAdapter<T> {
override fun toType(configValue: String): T = java.lang.Enum.valueOf(enumClass, configValue.uppercase())
}

View File

@@ -33,7 +33,6 @@ import suwayomi.tachidesk.server.util.WebInterfaceManager
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.IOException import java.io.IOException
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
import kotlin.concurrent.thread import kotlin.concurrent.thread
import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.days
@@ -168,7 +167,7 @@ object JavalinSetup {
return@beforeMatched return@beforeMatched
} }
val authMode = serverConfig.authMode.value ?: AuthMode.NONE val authMode = serverConfig.authMode.value
fun credentialsValid(): Boolean { fun credentialsValid(): Boolean {
val basicAuthCredentials = ctx.basicAuthCredentials() ?: return false val basicAuthCredentials = ctx.basicAuthCredentials() ?: return false
@@ -187,7 +186,7 @@ object JavalinSetup {
} }
if (authMode == AuthMode.SIMPLE_LOGIN && !cookieValid()) { 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) ctx.header("Location", url)
throw RedirectResponse(HttpStatus.SEE_OTHER) throw RedirectResponse(HttpStatus.SEE_OTHER)
} }

View File

@@ -8,6 +8,7 @@ package suwayomi.tachidesk.server
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import com.typesafe.config.Config import com.typesafe.config.Config
import io.github.config4k.getValue
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -38,39 +39,29 @@ const val SERVER_CONFIG_MODULE_NAME = "server"
class ServerConfig( class ServerConfig(
getConfig: () -> Config, getConfig: () -> Config,
val moduleName: String = SERVER_CONFIG_MODULE_NAME,
) : SystemPropertyOverridableConfigModule( ) : SystemPropertyOverridableConfigModule(
getConfig, getConfig,
moduleName, SERVER_CONFIG_MODULE_NAME,
) { ) {
open inner class OverrideConfigValue<T>( open inner class OverrideConfigValue {
private val configAdapter: ConfigAdapter<out Any>, var flow: MutableStateFlow<Any>? = null
) {
private var flow: MutableStateFlow<T>? = null
open fun getValueFromConfig( inline operator fun <reified T : MutableStateFlow<R>, reified R> getValue(
thisRef: ServerConfig, thisRef: ServerConfig,
property: KProperty<*>, property: KProperty<*>,
): Any = configAdapter.toType(overridableConfig.getValue<ServerConfig, String>(thisRef, property)) ): T {
operator fun getValue(
thisRef: ServerConfig,
property: KProperty<*>,
): MutableStateFlow<T> {
if (flow != null) { if (flow != null) {
return flow!! return flow as T
} }
val stateFlow = overridableConfig.getValue<ServerConfig, T>(thisRef, property)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val value = getValueFromConfig(thisRef, property) as T flow = stateFlow as MutableStateFlow<Any>
val stateFlow = MutableStateFlow(value)
flow = stateFlow
stateFlow stateFlow
.drop(1) .drop(1)
.distinctUntilChanged() .distinctUntilChanged()
.filter { it != getValueFromConfig(thisRef, property) } .filter { it != thisRef.overridableConfig.getConfig().getValue<ServerConfig, R>(thisRef, property) }
.onEach { GlobalConfigManager.updateValue("$moduleName.${property.name}", it as Any) } .onEach { GlobalConfigManager.updateValue("$moduleName.${property.name}", it as Any) }
.launchIn(mutableConfigValueScope) .launchIn(mutableConfigValueScope)
@@ -78,29 +69,12 @@ class ServerConfig(
} }
} }
inner class OverrideConfigValues<T>(
private val configAdapter: ConfigAdapter<out Any>,
) : OverrideConfigValue<T>(configAdapter) {
override fun getValueFromConfig(
thisRef: ServerConfig,
property: KProperty<*>,
): Any =
overridableConfig
.getValue<ServerConfig, List<String>>(thisRef, property)
.map { configAdapter.toType(it) }
}
open inner class MigratedConfigValue<T>( open inner class MigratedConfigValue<T>(
private val readMigrated: () -> Any, private val readMigrated: () -> T,
private val setMigrated: (T) -> Unit, private val setMigrated: (T) -> Unit,
) { ) {
private var flow: MutableStateFlow<T>? = null private var flow: MutableStateFlow<T>? = null
open fun getValueFromConfig(
thisRef: ServerConfig,
property: KProperty<*>,
): Any = readMigrated()
operator fun getValue( operator fun getValue(
thisRef: ServerConfig, thisRef: ServerConfig,
property: KProperty<*>, property: KProperty<*>,
@@ -109,8 +83,7 @@ class ServerConfig(
return flow!! return flow!!
} }
@Suppress("UNCHECKED_CAST") val value = readMigrated()
val value = getValueFromConfig(thisRef, property) as T
val stateFlow = MutableStateFlow(value) val stateFlow = MutableStateFlow(value)
flow = stateFlow flow = stateFlow
@@ -118,7 +91,7 @@ class ServerConfig(
stateFlow stateFlow
.drop(1) .drop(1)
.distinctUntilChanged() .distinctUntilChanged()
.filter { it != getValueFromConfig(thisRef, property) } .filter { it != readMigrated() }
.onEach(setMigrated) .onEach(setMigrated)
.launchIn(mutableConfigValueScope) .launchIn(mutableConfigValueScope)
@@ -126,51 +99,57 @@ class ServerConfig(
} }
} }
val ip: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter) val ip: MutableStateFlow<String> by OverrideConfigValue()
val port: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter) val port: MutableStateFlow<Int> by OverrideConfigValue()
// proxy // proxy
val socksProxyEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter) val socksProxyEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
val socksProxyVersion: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter) val socksProxyVersion: MutableStateFlow<Int> by OverrideConfigValue()
val socksProxyHost: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter) val socksProxyHost: MutableStateFlow<String> by OverrideConfigValue()
val socksProxyPort: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter) val socksProxyPort: MutableStateFlow<String> by OverrideConfigValue()
val socksProxyUsername: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter) val socksProxyUsername: MutableStateFlow<String> by OverrideConfigValue()
val socksProxyPassword: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter) val socksProxyPassword: MutableStateFlow<String> by OverrideConfigValue()
// webUI // webUI
val webUIEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter) val webUIEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
val webUIFlavor: MutableStateFlow<WebUIFlavor> by OverrideConfigValue(EnumConfigAdapter(WebUIFlavor::class.java)) val webUIFlavor: MutableStateFlow<WebUIFlavor> by OverrideConfigValue()
val initialOpenInBrowserEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter) val initialOpenInBrowserEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
val webUIInterface: MutableStateFlow<WebUIInterface> by OverrideConfigValue(EnumConfigAdapter(WebUIInterface::class.java)) val webUIInterface: MutableStateFlow<WebUIInterface> by OverrideConfigValue()
val electronPath: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter) val electronPath: MutableStateFlow<String> by OverrideConfigValue()
val webUIChannel: MutableStateFlow<WebUIChannel> by OverrideConfigValue(EnumConfigAdapter(WebUIChannel::class.java)) val webUIChannel: MutableStateFlow<WebUIChannel> by OverrideConfigValue()
val webUIUpdateCheckInterval: MutableStateFlow<Double> by OverrideConfigValue(DoubleConfigAdapter) val webUIUpdateCheckInterval: MutableStateFlow<Double> by OverrideConfigValue()
// downloader // downloader
val downloadAsCbz: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter) val downloadAsCbz: MutableStateFlow<Boolean> by OverrideConfigValue()
val downloadsPath: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter) val downloadsPath: MutableStateFlow<String> by OverrideConfigValue()
val autoDownloadNewChapters: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter) val autoDownloadNewChapters: MutableStateFlow<Boolean> by OverrideConfigValue()
val excludeEntryWithUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter) val excludeEntryWithUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue()
val autoDownloadNewChaptersLimit: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter) val autoDownloadNewChaptersLimit: MutableStateFlow<Int> by OverrideConfigValue()
val autoDownloadIgnoreReUploads: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter) val autoDownloadIgnoreReUploads: MutableStateFlow<Boolean> by OverrideConfigValue()
val downloadConversions: MutableStateFlow<Map<String, DownloadConversion>> by OverrideConfigValue()
data class DownloadConversion(
val target: String,
val compressionLevel: Float? = null,
)
// extensions // extensions
val extensionRepos: MutableStateFlow<List<String>> by OverrideConfigValues(StringConfigAdapter) val extensionRepos: MutableStateFlow<List<String>> by OverrideConfigValue()
// requests // requests
val maxSourcesInParallel: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter) val maxSourcesInParallel: MutableStateFlow<Int> by OverrideConfigValue()
// updater // updater
val excludeUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter) val excludeUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue()
val excludeNotStarted: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter) val excludeNotStarted: MutableStateFlow<Boolean> by OverrideConfigValue()
val excludeCompleted: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter) val excludeCompleted: MutableStateFlow<Boolean> by OverrideConfigValue()
val globalUpdateInterval: MutableStateFlow<Double> by OverrideConfigValue(DoubleConfigAdapter) val globalUpdateInterval: MutableStateFlow<Double> by OverrideConfigValue()
val updateMangas: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter) val updateMangas: MutableStateFlow<Boolean> by OverrideConfigValue()
// Authentication // Authentication
val authMode: MutableStateFlow<AuthMode> by OverrideConfigValue(EnumConfigAdapter(AuthMode::class.java)) val authMode: MutableStateFlow<AuthMode> by OverrideConfigValue()
val authUsername: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter) val authUsername: MutableStateFlow<String> by OverrideConfigValue()
val authPassword: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter) val authPassword: MutableStateFlow<String> by OverrideConfigValue()
val basicAuthEnabled: MutableStateFlow<Boolean> by MigratedConfigValue({ val basicAuthEnabled: MutableStateFlow<Boolean> by MigratedConfigValue({
authMode.value == AuthMode.BASIC_AUTH authMode.value == AuthMode.BASIC_AUTH
}) { }) {
@@ -184,37 +163,37 @@ class ServerConfig(
} }
// misc // misc
val debugLogsEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter) val debugLogsEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
val systemTrayEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter) val systemTrayEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
val maxLogFiles: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter) val maxLogFiles: MutableStateFlow<Int> by OverrideConfigValue()
val maxLogFileSize: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter) val maxLogFileSize: MutableStateFlow<String> by OverrideConfigValue()
val maxLogFolderSize: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter) val maxLogFolderSize: MutableStateFlow<String> by OverrideConfigValue()
// backup // backup
val backupPath: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter) val backupPath: MutableStateFlow<String> by OverrideConfigValue()
val backupTime: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter) val backupTime: MutableStateFlow<String> by OverrideConfigValue()
val backupInterval: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter) val backupInterval: MutableStateFlow<Int> by OverrideConfigValue()
val backupTTL: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter) val backupTTL: MutableStateFlow<Int> by OverrideConfigValue()
// local source // local source
val localSourcePath: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter) val localSourcePath: MutableStateFlow<String> by OverrideConfigValue()
// cloudflare bypass // cloudflare bypass
val flareSolverrEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter) val flareSolverrEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
val flareSolverrUrl: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter) val flareSolverrUrl: MutableStateFlow<String> by OverrideConfigValue()
val flareSolverrTimeout: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter) val flareSolverrTimeout: MutableStateFlow<Int> by OverrideConfigValue()
val flareSolverrSessionName: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter) val flareSolverrSessionName: MutableStateFlow<String> by OverrideConfigValue()
val flareSolverrSessionTtl: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter) val flareSolverrSessionTtl: MutableStateFlow<Int> by OverrideConfigValue()
val flareSolverrAsResponseFallback: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter) val flareSolverrAsResponseFallback: MutableStateFlow<Boolean> by OverrideConfigValue()
// opds settings // opds settings
val opdsUseBinaryFileSizes: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter) val opdsUseBinaryFileSizes: MutableStateFlow<Boolean> by OverrideConfigValue()
val opdsItemsPerPage: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter) val opdsItemsPerPage: MutableStateFlow<Int> by OverrideConfigValue()
val opdsEnablePageReadProgress: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter) val opdsEnablePageReadProgress: MutableStateFlow<Boolean> by OverrideConfigValue()
val opdsMarkAsReadOnDownload: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter) val opdsMarkAsReadOnDownload: MutableStateFlow<Boolean> by OverrideConfigValue()
val opdsShowOnlyUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter) val opdsShowOnlyUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue()
val opdsShowOnlyDownloadedChapters: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter) val opdsShowOnlyDownloadedChapters: MutableStateFlow<Boolean> by OverrideConfigValue()
val opdsChapterSortOrder: MutableStateFlow<SortOrder> by OverrideConfigValue(EnumConfigAdapter(SortOrder::class.java)) val opdsChapterSortOrder: MutableStateFlow<SortOrder> by OverrideConfigValue()
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
fun <T> subscribeTo( fun <T> subscribeTo(
@@ -259,6 +238,11 @@ class ServerConfig(
} }
companion object { companion object {
fun register(getConfig: () -> Config) = ServerConfig({ getConfig().getConfig(SERVER_CONFIG_MODULE_NAME) }) fun register(getConfig: () -> Config) =
ServerConfig {
getConfig().getConfig(
SERVER_CONFIG_MODULE_NAME,
)
}
} }
} }

View File

@@ -13,13 +13,14 @@ import com.typesafe.config.Config
import com.typesafe.config.ConfigException import com.typesafe.config.ConfigException
import com.typesafe.config.ConfigRenderOptions import com.typesafe.config.ConfigRenderOptions
import com.typesafe.config.ConfigValue import com.typesafe.config.ConfigValue
import com.typesafe.config.ConfigValueFactory
import com.typesafe.config.parser.ConfigDocument import com.typesafe.config.parser.ConfigDocument
import dev.datlag.kcef.KCEF import dev.datlag.kcef.KCEF
import eu.kanade.tachiyomi.App import eu.kanade.tachiyomi.App
import eu.kanade.tachiyomi.createAppModule import eu.kanade.tachiyomi.createAppModule
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.local.LocalSource 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.github.oshai.kotlinlogging.KotlinLogging
import io.javalin.json.JavalinJackson import io.javalin.json.JavalinJackson
import io.javalin.json.JsonMapper 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.IUpdater
import suwayomi.tachidesk.manga.impl.update.Updater import suwayomi.tachidesk.manga.impl.update.Updater
import suwayomi.tachidesk.manga.impl.util.lang.renameTo import suwayomi.tachidesk.manga.impl.util.lang.renameTo
import suwayomi.tachidesk.server.BooleanConfigAdapter
import suwayomi.tachidesk.server.database.databaseUp import suwayomi.tachidesk.server.database.databaseUp
import suwayomi.tachidesk.server.generated.BuildConfig import suwayomi.tachidesk.server.generated.BuildConfig
import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex
import suwayomi.tachidesk.server.util.MutableStateFlowType
import suwayomi.tachidesk.server.util.SystemTray import suwayomi.tachidesk.server.util.SystemTray
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@@ -147,7 +148,7 @@ fun <T : Any> migrateConfig(
if (typedValue != null) { if (typedValue != null) {
return configDocument.withValue( return configDocument.withValue(
toConfigKey, toConfigKey,
ConfigValueFactory.fromAnyRef(typedValue), typedValue.toConfig("internal").getValue("internal"),
) )
} }
} catch (_: ConfigException) { } catch (_: ConfigException) {
@@ -174,6 +175,7 @@ fun applicationSetup() {
mainLoop.start() mainLoop.start()
// register Tachidesk's config which is dubbed "ServerConfig" // register Tachidesk's config which is dubbed "ServerConfig"
registerCustomType(MutableStateFlowType())
GlobalConfigManager.registerModule( GlobalConfigManager.registerModule(
ServerConfig.register { GlobalConfigManager.config }, ServerConfig.register { GlobalConfigManager.config },
) )

View File

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

View File

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

View File

@@ -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.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.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.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 # extension repos
server.extensionRepos = [ server.extensionRepos = [

View File

@@ -31,6 +31,10 @@ class TestUpdater : IUpdater {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
override fun deleteLastAutomatedUpdateTimestamp() {
TODO("Not yet implemented")
}
override fun addCategoriesToUpdateQueue( override fun addCategoriesToUpdateQueue(
categories: List<CategoryDataClass>, categories: List<CategoryDataClass>,
clear: Boolean?, clear: Boolean?,

View File

@@ -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.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.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.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 # extension repos
server.extensionRepos = [ server.extensionRepos = [