[#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 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 =

View File

@@ -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<T>() {}.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")
}
}
}

View File

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

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

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.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<String, String> =
buildMap {
if (!format.isNullOrBlank()) {
put("format", format)
}
}
}
data class FetchChapterPagesPayload(
val clientMutationId: String?,
@@ -236,16 +245,32 @@ class ChapterMutation {
fun fetchChapterPages(input: FetchChapterPagesInput): CompletableFuture<DataFetcherResult<FetchChapterPagesPayload?>> {
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),
)

View File

@@ -111,6 +111,18 @@ class SettingsMutation {
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
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)

View File

@@ -48,6 +48,7 @@ interface Settings : Node {
val autoDownloadAheadLimit: Int?
val autoDownloadNewChaptersLimit: Int?
val autoDownloadIgnoreReUploads: Boolean?
val downloadConversions: List<SettingsDownloadConversion>?
// extension
val extensionRepos: List<String>?
@@ -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<SettingsDownloadConversionType>?,
// extension
override val extensionRepos: List<String>?,
// 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<SettingsDownloadConversionType>,
// extension
override val extensionRepos: List<String>,
// 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

View File

@@ -403,6 +403,7 @@ object MangaController {
pathParam<Int>("chapterIndex"),
pathParam<Int>("index"),
queryParam<Boolean?>("updateProgress"),
queryParam<String?>("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

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.online.HttpSource
import kotlinx.coroutines.flow.StateFlow
import libcore.net.MimeUtils
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.selectAll
@@ -23,8 +24,12 @@ import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.PageTable
import suwayomi.tachidesk.util.ConversionUtil
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStream
import javax.imageio.ImageIO
object Page {
/**
@@ -45,6 +50,7 @@ object Page {
mangaId: Int,
chapterIndex: Int,
index: Int,
format: String? = null,
progressFlow: ((StateFlow<Int>) -> Unit)? = null,
): Pair<InputStream, String> {
val mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
@@ -61,7 +67,7 @@ object Page {
try {
if (chapterEntry[ChapterTable.isDownloaded]) {
return ChapterDownloadHelper.getImage(mangaId, chapterId, index)
return convertImageResponse(ChapterDownloadHelper.getImage(mangaId, chapterId, index), format)
}
} catch (_: Exception) {
// ignore and fetch again
@@ -90,12 +96,15 @@ object Page {
// is of archive format
if (LocalSource.pageCache.containsKey(chapterEntry[ChapterTable.url])) {
val pageStream = LocalSource.pageCache[chapterEntry[ChapterTable.url]]!![index]
return pageStream() to (ImageUtil.findImageType { pageStream() }?.mime ?: "image/jpeg")
return convertImageResponse(pageStream() to (ImageUtil.findImageType { pageStream() }?.mime ?: "image/jpeg"), format)
}
// is of directory format
val imageFile = File(tachiyomiPage.imageUrl!!)
return imageFile.inputStream() to (ImageUtil.findImageType { imageFile.inputStream() }?.mime ?: "image/jpeg")
return convertImageResponse(
imageFile.inputStream() to (ImageUtil.findImageType { imageFile.inputStream() }?.mime ?: "image/jpeg"),
format,
)
}
val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
@@ -115,9 +124,38 @@ object Page {
val cacheSaveDir = getChapterCachePath(mangaId, chapterId)
// Note: don't care about invalidating cache because OS cache is not permanent
return getImageResponse(cacheSaveDir, fileName) {
source.getImage(tachiyomiPage)
return convertImageResponse(
getImageResponse(cacheSaveDir, fileName) {
source.getImage(tachiyomiPage)
},
format,
)
}
private suspend fun convertImageResponse(
image: Pair<InputStream, String>,
format: String? = null,
): Pair<InputStream, String> {
val imageExtension = MimeUtils.guessExtensionFromMimeType(image.second) ?: image.second.removePrefix("image/")
val targetExtension =
(if (format != imageExtension) format else null)
?: return image
val outStream = ByteArrayOutputStream()
val writers = ImageIO.getImageWritersBySuffix(targetExtension)
val writer = writers.next()
ImageIO.createImageOutputStream(outStream).use { o ->
writer.setOutput(o)
val inImage =
ConversionUtil.readImage(image.first, image.second)
?: throw NoSuchElementException("No conversion to $targetExtension possible")
writer.write(inImage)
}
writer.dispose()
val inStream = ByteArrayInputStream(outStream.toByteArray())
return Pair(inStream.buffered(), MimeUtils.guessMimeTypeFromExtension(targetExtension) ?: "image/$targetExtension")
}
/** converts 0 to "001" */

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

View File

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

View File

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

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)
}
@Suppress("UNCHECKED_CAST")
fun interface RetrieveFile2Args<A, B> : RetrieveFile {
fun execute(
a: A,
b: B,
): Pair<InputStream, String>
override fun executeGetImage(vararg args: Any): Pair<InputStream, String> = execute(args[0] as A, args[1] as B)
}
fun interface FileRetriever {
fun getImage(): RetrieveFile
}

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 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) {

View File

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

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

View File

@@ -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<T>(
private val configAdapter: ConfigAdapter<out Any>,
) {
private var flow: MutableStateFlow<T>? = null
open inner class OverrideConfigValue {
var flow: MutableStateFlow<Any>? = null
open fun getValueFromConfig(
inline operator fun <reified T : MutableStateFlow<R>, reified R> getValue(
thisRef: ServerConfig,
property: KProperty<*>,
): Any = configAdapter.toType(overridableConfig.getValue<ServerConfig, String>(thisRef, property))
operator fun getValue(
thisRef: ServerConfig,
property: KProperty<*>,
): MutableStateFlow<T> {
): T {
if (flow != null) {
return flow!!
return flow as T
}
val stateFlow = overridableConfig.getValue<ServerConfig, T>(thisRef, property)
@Suppress("UNCHECKED_CAST")
val value = getValueFromConfig(thisRef, property) as T
val stateFlow = MutableStateFlow(value)
flow = stateFlow
flow = stateFlow as MutableStateFlow<Any>
stateFlow
.drop(1)
.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) }
.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>(
private val readMigrated: () -> Any,
private val readMigrated: () -> T,
private val setMigrated: (T) -> Unit,
) {
private var flow: MutableStateFlow<T>? = 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<String> by OverrideConfigValue(StringConfigAdapter)
val port: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
val ip: MutableStateFlow<String> by OverrideConfigValue()
val port: MutableStateFlow<Int> by OverrideConfigValue()
// proxy
val socksProxyEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val socksProxyVersion: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
val socksProxyHost: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val socksProxyPort: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val socksProxyUsername: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val socksProxyPassword: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val socksProxyEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
val socksProxyVersion: MutableStateFlow<Int> by OverrideConfigValue()
val socksProxyHost: MutableStateFlow<String> by OverrideConfigValue()
val socksProxyPort: MutableStateFlow<String> by OverrideConfigValue()
val socksProxyUsername: MutableStateFlow<String> by OverrideConfigValue()
val socksProxyPassword: MutableStateFlow<String> by OverrideConfigValue()
// webUI
val webUIEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val webUIFlavor: MutableStateFlow<WebUIFlavor> by OverrideConfigValue(EnumConfigAdapter(WebUIFlavor::class.java))
val initialOpenInBrowserEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val webUIInterface: MutableStateFlow<WebUIInterface> by OverrideConfigValue(EnumConfigAdapter(WebUIInterface::class.java))
val electronPath: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val webUIChannel: MutableStateFlow<WebUIChannel> by OverrideConfigValue(EnumConfigAdapter(WebUIChannel::class.java))
val webUIUpdateCheckInterval: MutableStateFlow<Double> by OverrideConfigValue(DoubleConfigAdapter)
val webUIEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
val webUIFlavor: MutableStateFlow<WebUIFlavor> by OverrideConfigValue()
val initialOpenInBrowserEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
val webUIInterface: MutableStateFlow<WebUIInterface> by OverrideConfigValue()
val electronPath: MutableStateFlow<String> by OverrideConfigValue()
val webUIChannel: MutableStateFlow<WebUIChannel> by OverrideConfigValue()
val webUIUpdateCheckInterval: MutableStateFlow<Double> by OverrideConfigValue()
// downloader
val downloadAsCbz: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val downloadsPath: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val autoDownloadNewChapters: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val excludeEntryWithUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val autoDownloadNewChaptersLimit: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
val autoDownloadIgnoreReUploads: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val downloadAsCbz: MutableStateFlow<Boolean> by OverrideConfigValue()
val downloadsPath: MutableStateFlow<String> by OverrideConfigValue()
val autoDownloadNewChapters: MutableStateFlow<Boolean> by OverrideConfigValue()
val excludeEntryWithUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue()
val autoDownloadNewChaptersLimit: MutableStateFlow<Int> by OverrideConfigValue()
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
val extensionRepos: MutableStateFlow<List<String>> by OverrideConfigValues(StringConfigAdapter)
val extensionRepos: MutableStateFlow<List<String>> by OverrideConfigValue()
// requests
val maxSourcesInParallel: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
val maxSourcesInParallel: MutableStateFlow<Int> by OverrideConfigValue()
// updater
val excludeUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val excludeNotStarted: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val excludeCompleted: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val globalUpdateInterval: MutableStateFlow<Double> by OverrideConfigValue(DoubleConfigAdapter)
val updateMangas: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val excludeUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue()
val excludeNotStarted: MutableStateFlow<Boolean> by OverrideConfigValue()
val excludeCompleted: MutableStateFlow<Boolean> by OverrideConfigValue()
val globalUpdateInterval: MutableStateFlow<Double> by OverrideConfigValue()
val updateMangas: MutableStateFlow<Boolean> by OverrideConfigValue()
// Authentication
val authMode: MutableStateFlow<AuthMode> by OverrideConfigValue(EnumConfigAdapter(AuthMode::class.java))
val authUsername: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val authPassword: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val authMode: MutableStateFlow<AuthMode> by OverrideConfigValue()
val authUsername: MutableStateFlow<String> by OverrideConfigValue()
val authPassword: MutableStateFlow<String> by OverrideConfigValue()
val basicAuthEnabled: MutableStateFlow<Boolean> by MigratedConfigValue({
authMode.value == AuthMode.BASIC_AUTH
}) {
@@ -184,37 +163,37 @@ class ServerConfig(
}
// misc
val debugLogsEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val systemTrayEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val maxLogFiles: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
val maxLogFileSize: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val maxLogFolderSize: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val debugLogsEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
val systemTrayEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
val maxLogFiles: MutableStateFlow<Int> by OverrideConfigValue()
val maxLogFileSize: MutableStateFlow<String> by OverrideConfigValue()
val maxLogFolderSize: MutableStateFlow<String> by OverrideConfigValue()
// backup
val backupPath: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val backupTime: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val backupInterval: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
val backupTTL: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
val backupPath: MutableStateFlow<String> by OverrideConfigValue()
val backupTime: MutableStateFlow<String> by OverrideConfigValue()
val backupInterval: MutableStateFlow<Int> by OverrideConfigValue()
val backupTTL: MutableStateFlow<Int> by OverrideConfigValue()
// local source
val localSourcePath: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val localSourcePath: MutableStateFlow<String> by OverrideConfigValue()
// cloudflare bypass
val flareSolverrEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val flareSolverrUrl: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val flareSolverrTimeout: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
val flareSolverrSessionName: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val flareSolverrSessionTtl: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
val flareSolverrAsResponseFallback: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val flareSolverrEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
val flareSolverrUrl: MutableStateFlow<String> by OverrideConfigValue()
val flareSolverrTimeout: MutableStateFlow<Int> by OverrideConfigValue()
val flareSolverrSessionName: MutableStateFlow<String> by OverrideConfigValue()
val flareSolverrSessionTtl: MutableStateFlow<Int> by OverrideConfigValue()
val flareSolverrAsResponseFallback: MutableStateFlow<Boolean> by OverrideConfigValue()
// opds settings
val opdsUseBinaryFileSizes: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val opdsItemsPerPage: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
val opdsEnablePageReadProgress: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val opdsMarkAsReadOnDownload: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val opdsShowOnlyUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val opdsShowOnlyDownloadedChapters: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val opdsChapterSortOrder: MutableStateFlow<SortOrder> by OverrideConfigValue(EnumConfigAdapter(SortOrder::class.java))
val opdsUseBinaryFileSizes: MutableStateFlow<Boolean> by OverrideConfigValue()
val opdsItemsPerPage: MutableStateFlow<Int> by OverrideConfigValue()
val opdsEnablePageReadProgress: MutableStateFlow<Boolean> by OverrideConfigValue()
val opdsMarkAsReadOnDownload: MutableStateFlow<Boolean> by OverrideConfigValue()
val opdsShowOnlyUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue()
val opdsShowOnlyDownloadedChapters: MutableStateFlow<Boolean> by OverrideConfigValue()
val opdsChapterSortOrder: MutableStateFlow<SortOrder> by OverrideConfigValue()
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> 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,
)
}
}
}

View File

@@ -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 <T : Any> 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 },
)

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.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 = [

View File

@@ -31,6 +31,10 @@ class TestUpdater : IUpdater {
TODO("Not yet implemented")
}
override fun deleteLastAutomatedUpdateTimestamp() {
TODO("Not yet implemented")
}
override fun addCategoriesToUpdateQueue(
categories: List<CategoryDataClass>,
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.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 = [