Feature/streamline settings (#1614)

* Cleanup graphql setting mutation

* Validate values read from config

* Generate server-reference.conf files from ServerConfig

* Remove unnecessary enum value handling in config value update

Commit df0078b725 introduced the usage of config4k, which handles enums automatically. Thus, this handling is outdated and not needed anymore

* Generate gql SettingsType from ServerConfig

* Extract settings backup logic

* Generate settings backup files

* Move "group" arg to second position

To make it easier to detect and have it at the same position consistently for all settings.

* Remove setting generation from compilation

* Extract setting generation code into new module

* Extract pure setting generation code into new module

* Remove generated settings files from src tree

* Force each setting to set a default value
This commit is contained in:
schroda
2025-09-01 23:02:58 +02:00
committed by GitHub
parent 11b2a6b616
commit 8ef2877040
48 changed files with 2443 additions and 1330 deletions

View File

@@ -0,0 +1,39 @@
plugins {
id(
libs.plugins.kotlin.jvm
.get()
.pluginId,
)
}
dependencies {
// Core Kotlin
implementation(kotlin("stdlib-jdk8"))
implementation(kotlin("reflect"))
// Coroutines for MutableStateFlow
implementation(libs.coroutines.core)
implementation(libs.coroutines.jdk8)
// Config handling
implementation(libs.config)
implementation(libs.config4k)
// Logging
implementation(libs.slf4japi)
implementation(libs.kotlinlogging)
// Database (for SortOrder enum used in ServerConfig)
implementation(libs.exposed.core)
// GraphQL types used in ServerConfig
implementation(libs.graphql.kotlin.scheme)
// AndroidCompat for SystemPropertyOverridableConfigModule
implementation(projects.androidCompat.config)
// Serialization
implementation(libs.serialization.json)
implementation(libs.serialization.protobuf)
}

View File

@@ -0,0 +1,14 @@
package suwayomi.tachidesk.graphql.types
enum class AuthMode {
NONE,
BASIC_AUTH,
SIMPLE_LOGIN,
UI_LOGIN,
// TODO: ACCOUNT for #623
;
companion object {
fun from(channel: String): AuthMode = entries.find { it.name.lowercase() == channel.lowercase() } ?: NONE
}
}

View File

@@ -0,0 +1,14 @@
package suwayomi.tachidesk.graphql.types
enum class KoreaderSyncChecksumMethod {
BINARY,
FILENAME,
}
enum class KoreaderSyncStrategy {
PROMPT, // Ask on conflict
SILENT, // Always use latest
SEND, // Send changes only
RECEIVE, // Receive changes only
DISABLED,
}

View File

@@ -0,0 +1,21 @@
package suwayomi.tachidesk.graphql.types
// These types belong to SettingsType.kt. However, since that file is auto-generated, these types need to be placed in
// a "static" file.
data class DownloadConversion(
val target: String,
val compressionLevel: Double? = null,
)
interface SettingsDownloadConversion {
val mimeType: String
val target: String
val compressionLevel: Double?
}
class SettingsDownloadConversionType(
override val mimeType: String,
override val target: String,
override val compressionLevel: Double?,
) : SettingsDownloadConversion

View File

@@ -0,0 +1,46 @@
package suwayomi.tachidesk.graphql.types
enum class WebUIInterface {
BROWSER,
ELECTRON,
}
enum class WebUIChannel {
BUNDLED, // the default webUI version bundled with the server release
STABLE,
PREVIEW,
;
companion object {
fun from(channel: String): WebUIChannel = entries.find { it.name.lowercase() == channel.lowercase() } ?: STABLE
}
}
enum class WebUIFlavor(
val uiName: String,
val repoUrl: String,
val versionMappingUrl: String,
val latestReleaseInfoUrl: String,
val baseFileName: String,
) {
WEBUI(
"WebUI",
"https://github.com/Suwayomi/Suwayomi-WebUI-preview",
"https://raw.githubusercontent.com/Suwayomi/Suwayomi-WebUI/master/versionToServerVersionMapping.json",
"https://api.github.com/repos/Suwayomi/Suwayomi-WebUI-preview/releases/latest",
"Suwayomi-WebUI",
),
VUI(
"VUI",
"https://github.com/Suwayomi/Suwayomi-VUI",
"https://raw.githubusercontent.com/Suwayomi/Suwayomi-VUI/master/versionToServerVersionMapping.json",
"https://api.github.com/repos/Suwayomi/Suwayomi-VUI/releases/latest",
"Suwayomi-VUI",
),
CUSTOM("Custom", "", "", "", ""),
;
companion object {
fun from(flavor: String): WebUIFlavor = entries.find { it.name.lowercase() == flavor.lowercase() } ?: WEBUI
}
}

View File

@@ -0,0 +1,14 @@
package suwayomi.tachidesk.manga.impl.backup.proto.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import suwayomi.tachidesk.graphql.types.SettingsDownloadConversion
@OptIn(ExperimentalSerializationApi::class)
@Serializable
class BackupSettingsDownloadConversionType(
@ProtoNumber(1) override val mimeType: String,
@ProtoNumber(2) override val target: String,
@ProtoNumber(3) override val compressionLevel: Double?,
) : SettingsDownloadConversion

View File

@@ -0,0 +1,9 @@
package suwayomi.tachidesk.manga.impl.extension
object ExtensionsList {
val repoMatchRegex =
(
"https:\\/\\/(?>www\\.|raw\\.)?(github|githubusercontent)\\.com" +
"\\/([^\\/]+)\\/([^\\/]+)(?>(?>\\/tree|\\/blob)?\\/([^\\/\\n]*))?(?>\\/([^\\/\\n]*\\.json)?)?"
).toRegex()
}

View File

@@ -0,0 +1,721 @@
package suwayomi.tachidesk.server
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import com.typesafe.config.Config
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import org.jetbrains.exposed.sql.SortOrder
import suwayomi.tachidesk.graphql.types.AuthMode
import suwayomi.tachidesk.graphql.types.DownloadConversion
import suwayomi.tachidesk.graphql.types.KoreaderSyncChecksumMethod
import suwayomi.tachidesk.graphql.types.KoreaderSyncStrategy
import suwayomi.tachidesk.graphql.types.SettingsDownloadConversionType
import suwayomi.tachidesk.graphql.types.WebUIChannel
import suwayomi.tachidesk.graphql.types.WebUIFlavor
import suwayomi.tachidesk.graphql.types.WebUIInterface
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSettingsDownloadConversionType
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList.repoMatchRegex
import suwayomi.tachidesk.server.settings.BooleanSetting
import suwayomi.tachidesk.server.settings.DisableableDoubleSetting
import suwayomi.tachidesk.server.settings.DisableableIntSetting
import suwayomi.tachidesk.server.settings.DoubleSetting
import suwayomi.tachidesk.server.settings.DurationSetting
import suwayomi.tachidesk.server.settings.EnumSetting
import suwayomi.tachidesk.server.settings.IntSetting
import suwayomi.tachidesk.server.settings.ListSetting
import suwayomi.tachidesk.server.settings.MapSetting
import suwayomi.tachidesk.server.settings.MigratedConfigValue
import suwayomi.tachidesk.server.settings.PathSetting
import suwayomi.tachidesk.server.settings.SettingGroup
import suwayomi.tachidesk.server.settings.SettingsRegistry
import suwayomi.tachidesk.server.settings.StringSetting
import xyz.nulldev.ts.config.SystemPropertyOverridableConfigModule
import kotlin.collections.associate
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
val mutableConfigValueScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
const val SERVER_CONFIG_MODULE_NAME = "server"
// Settings are ordered by "protoNumber".
class ServerConfig(
getConfig: () -> Config,
) : SystemPropertyOverridableConfigModule(
getConfig,
SERVER_CONFIG_MODULE_NAME,
) {
val ip: MutableStateFlow<String> by StringSetting(
protoNumber = 1,
group = SettingGroup.NETWORK,
defaultValue = "0.0.0.0",
pattern = "^((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}$".toRegex(),
)
val port: MutableStateFlow<Int> by IntSetting(
protoNumber = 2,
group = SettingGroup.NETWORK,
defaultValue = 4567,
min = 1,
max = 65535,
)
val socksProxyEnabled: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 3,
group = SettingGroup.PROXY,
defaultValue = false,
)
val socksProxyVersion: MutableStateFlow<Int> by IntSetting(
protoNumber = 4,
group = SettingGroup.PROXY,
defaultValue = 5,
min = 4,
max = 5,
)
val socksProxyHost: MutableStateFlow<String> by StringSetting(
protoNumber = 5,
group = SettingGroup.PROXY,
defaultValue = "",
)
val socksProxyPort: MutableStateFlow<String> by StringSetting(
protoNumber = 6,
group = SettingGroup.PROXY,
defaultValue = "",
)
val socksProxyUsername: MutableStateFlow<String> by StringSetting(
protoNumber = 7,
group = SettingGroup.PROXY,
defaultValue = "",
)
val socksProxyPassword: MutableStateFlow<String> by StringSetting(
protoNumber = 8,
group = SettingGroup.PROXY,
defaultValue = "",
)
val webUIFlavor: MutableStateFlow<WebUIFlavor> by EnumSetting(
protoNumber = 9,
group = SettingGroup.WEB_UI,
defaultValue = WebUIFlavor.WEBUI,
enumClass = WebUIFlavor::class,
typeInfo = SettingsRegistry.PartialTypeInfo(imports = listOf("suwayomi.tachidesk.graphql.types.WebUIFlavor")),
)
val initialOpenInBrowserEnabled: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 10,
group = SettingGroup.WEB_UI,
defaultValue = true,
description = "Open client on startup",
)
val webUIInterface: MutableStateFlow<WebUIInterface> by EnumSetting(
protoNumber = 11,
group = SettingGroup.WEB_UI,
defaultValue = WebUIInterface.BROWSER,
enumClass = WebUIInterface::class,
typeInfo = SettingsRegistry.PartialTypeInfo(imports = listOf("suwayomi.tachidesk.graphql.types.WebUIInterface")),
)
val electronPath: MutableStateFlow<String> by PathSetting(
protoNumber = 12,
group = SettingGroup.WEB_UI,
defaultValue = "",
mustExist = true,
)
val webUIChannel: MutableStateFlow<WebUIChannel> by EnumSetting(
protoNumber = 13,
group = SettingGroup.WEB_UI,
defaultValue = WebUIChannel.STABLE,
enumClass = WebUIChannel::class,
typeInfo = SettingsRegistry.PartialTypeInfo(imports = listOf("suwayomi.tachidesk.graphql.types.WebUIChannel")),
)
val webUIUpdateCheckInterval: MutableStateFlow<Double> by DisableableDoubleSetting(
protoNumber = 14,
group = SettingGroup.WEB_UI,
defaultValue = 23.hours.inWholeHours.toDouble(),
min = 0.0,
max = 23.0,
description = "Time in hours",
)
val downloadAsCbz: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 15,
defaultValue = false,
group = SettingGroup.DOWNLOADER,
)
val downloadsPath: MutableStateFlow<String> by PathSetting(
protoNumber = 16,
group = SettingGroup.DOWNLOADER,
defaultValue = "",
mustExist = true,
)
val autoDownloadNewChapters: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 17,
defaultValue = false,
group = SettingGroup.DOWNLOADER,
)
val excludeEntryWithUnreadChapters: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 18,
group = SettingGroup.DOWNLOADER,
defaultValue = true,
description = "Exclude entries with unread chapters from auto-download",
)
val autoDownloadAheadLimit: MutableStateFlow<Int> by MigratedConfigValue(
protoNumber = 19,
defaultValue = 0,
group = SettingGroup.DOWNLOADER,
deprecated =
SettingsRegistry.SettingDeprecated(
replaceWith = "autoDownloadNewChaptersLimit",
message = "Replaced with autoDownloadNewChaptersLimit",
),
readMigrated = { autoDownloadNewChaptersLimit.value },
setMigrated = { autoDownloadNewChaptersLimit.value = it },
)
val autoDownloadNewChaptersLimit: MutableStateFlow<Int> by DisableableIntSetting(
protoNumber = 20,
group = SettingGroup.DOWNLOADER,
defaultValue = 0,
min = 0,
description = "Maximum number of new chapters to auto-download",
)
val autoDownloadIgnoreReUploads: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 21,
group = SettingGroup.DOWNLOADER,
defaultValue = false,
description = "Ignore re-uploaded chapters from auto-download",
)
val extensionRepos: MutableStateFlow<List<String>> by ListSetting<String>(
protoNumber = 22,
group = SettingGroup.EXTENSION,
defaultValue = emptyList(),
itemValidator = { url ->
if (url.matches(repoMatchRegex)) {
null
} else {
"Invalid repository URL format"
}
},
itemToValidValue = { url ->
if (url.matches(repoMatchRegex)) {
url
} else {
null
}
},
typeInfo =
SettingsRegistry.PartialTypeInfo(
specificType = "List<String>",
),
description = "example: [\"https://github.com/MY_ACCOUNT/MY_REPO/tree/repo\"]",
)
val maxSourcesInParallel: MutableStateFlow<Int> by IntSetting(
protoNumber = 23,
group = SettingGroup.EXTENSION,
defaultValue = 6,
min = 1,
max = 20,
description =
"How many different sources can do requests (library update, downloads) in parallel. " +
"Library update/downloads are grouped by source and all manga of a source are updated/downloaded synchronously",
)
val excludeUnreadChapters: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 24,
defaultValue = true,
group = SettingGroup.LIBRARY_UPDATES,
)
val excludeNotStarted: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 25,
defaultValue = true,
group = SettingGroup.LIBRARY_UPDATES,
)
val excludeCompleted: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 26,
defaultValue = true,
group = SettingGroup.LIBRARY_UPDATES,
)
val globalUpdateInterval: MutableStateFlow<Double> by DisableableDoubleSetting(
protoNumber = 27,
group = SettingGroup.LIBRARY_UPDATES,
defaultValue = 12.hours.inWholeHours.toDouble(),
min = 6.0,
description = "Time in hours",
)
val updateMangas: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 28,
group = SettingGroup.LIBRARY_UPDATES,
defaultValue = false,
description = "Update manga metadata and thumbnail along with the chapter list update during the library update.",
)
val basicAuthEnabled: MutableStateFlow<Boolean> by MigratedConfigValue(
protoNumber = 29,
defaultValue = false,
group = SettingGroup.AUTH,
deprecated =
SettingsRegistry.SettingDeprecated(
replaceWith = "authMode",
message = "Removed - prefer authMode",
),
readMigrated = { authMode.value == AuthMode.BASIC_AUTH },
setMigrated = { authMode.value = if (it) AuthMode.BASIC_AUTH else AuthMode.NONE },
typeInfo =
SettingsRegistry.PartialTypeInfo(
restoreLegacy = { value ->
value.takeIf { authMode.value == AuthMode.NONE }
},
),
)
val authUsername: MutableStateFlow<String> by StringSetting(
protoNumber = 30,
group = SettingGroup.AUTH,
defaultValue = "",
)
val authPassword: MutableStateFlow<String> by StringSetting(
protoNumber = 31,
group = SettingGroup.AUTH,
defaultValue = "",
)
val debugLogsEnabled: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 32,
defaultValue = false,
group = SettingGroup.MISC,
)
val gqlDebugLogsEnabled: MutableStateFlow<Boolean> by MigratedConfigValue(
protoNumber = 33,
defaultValue = false,
group = SettingGroup.MISC,
deprecated =
SettingsRegistry.SettingDeprecated(
message = "Removed - does not do anything",
),
)
val systemTrayEnabled: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 34,
defaultValue = true,
group = SettingGroup.MISC,
)
val maxLogFiles: MutableStateFlow<Int> by IntSetting(
protoNumber = 35,
group = SettingGroup.MISC,
defaultValue = 31,
min = 0,
description = "The max number of days to keep files before they get deleted",
)
private val logbackSizePattern = "^[0-9]+(|kb|KB|mb|MB|gb|GB)$".toRegex()
val maxLogFileSize: MutableStateFlow<String> by StringSetting(
protoNumber = 36,
group = SettingGroup.MISC,
defaultValue = "10mb",
pattern = logbackSizePattern,
description = "Maximum log file size - values: 1 (bytes), 1KB (kilobytes), 1MB (megabytes), 1GB (gigabytes)",
)
val maxLogFolderSize: MutableStateFlow<String> by StringSetting(
protoNumber = 37,
group = SettingGroup.MISC,
defaultValue = "100mb",
pattern = logbackSizePattern,
description = "Maximum log folder size - values: 1 (bytes), 1KB (kilobytes), 1MB (megabytes), 1GB (gigabytes)",
)
val backupPath: MutableStateFlow<String> by PathSetting(
protoNumber = 38,
group = SettingGroup.BACKUP,
defaultValue = "",
mustExist = true,
)
val backupTime: MutableStateFlow<String> by StringSetting(
protoNumber = 39,
group = SettingGroup.BACKUP,
defaultValue = "00:00",
pattern = "^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$".toRegex(),
description = "Daily backup time (HH:MM) ; range: [00:00, 23:59]",
)
val backupInterval: MutableStateFlow<Int> by DisableableIntSetting(
protoNumber = 40,
group = SettingGroup.BACKUP,
defaultValue = 1,
min = 0,
description = "Time in days",
)
val backupTTL: MutableStateFlow<Int> by DisableableIntSetting(
protoNumber = 41,
group = SettingGroup.BACKUP,
defaultValue = 14.days.inWholeDays.toInt(),
min = 0,
description = "Backup retention in days",
)
val localSourcePath: MutableStateFlow<String> by PathSetting(
protoNumber = 42,
group = SettingGroup.LOCAL_SOURCE,
defaultValue = "",
mustExist = true,
)
val flareSolverrEnabled: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 43,
defaultValue = false,
group = SettingGroup.CLOUDFLARE,
)
val flareSolverrUrl: MutableStateFlow<String> by StringSetting(
protoNumber = 44,
group = SettingGroup.CLOUDFLARE,
defaultValue = "http://localhost:8191",
)
val flareSolverrTimeout: MutableStateFlow<Int> by IntSetting(
protoNumber = 45,
group = SettingGroup.CLOUDFLARE,
defaultValue = 60.seconds.inWholeSeconds.toInt(),
min = 0,
description = "Time in seconds",
)
val flareSolverrSessionName: MutableStateFlow<String> by StringSetting(
protoNumber = 46,
group = SettingGroup.CLOUDFLARE,
defaultValue = "suwayomi",
)
val flareSolverrSessionTtl: MutableStateFlow<Int> by IntSetting(
protoNumber = 47,
group = SettingGroup.CLOUDFLARE,
defaultValue = 15.minutes.inWholeMinutes.toInt(),
min = 0,
description = "Time in minutes",
)
val flareSolverrAsResponseFallback: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 48,
defaultValue = false,
group = SettingGroup.CLOUDFLARE,
)
val opdsUseBinaryFileSizes: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 49,
group = SettingGroup.OPDS,
defaultValue = false,
description = "Display file size in binary (KiB, MiB, GiB) instead of decimal (KB, MB, GB)",
)
val opdsItemsPerPage: MutableStateFlow<Int> by IntSetting(
protoNumber = 50,
group = SettingGroup.OPDS,
defaultValue = 100,
min = 10,
max = 5000,
)
val opdsEnablePageReadProgress: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 51,
defaultValue = true,
group = SettingGroup.OPDS,
)
val opdsMarkAsReadOnDownload: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 52,
defaultValue = false,
group = SettingGroup.OPDS,
)
val opdsShowOnlyUnreadChapters: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 53,
defaultValue = false,
group = SettingGroup.OPDS,
)
val opdsShowOnlyDownloadedChapters: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 54,
defaultValue = false,
group = SettingGroup.OPDS,
)
val opdsChapterSortOrder: MutableStateFlow<SortOrder> by EnumSetting(
protoNumber = 55,
group = SettingGroup.OPDS,
defaultValue = SortOrder.DESC,
enumClass = SortOrder::class,
typeInfo = SettingsRegistry.PartialTypeInfo(imports = listOf("org.jetbrains.exposed.sql.SortOrder")),
)
val authMode: MutableStateFlow<AuthMode> by EnumSetting(
protoNumber = 56,
group = SettingGroup.AUTH,
defaultValue = AuthMode.NONE,
enumClass = AuthMode::class,
typeInfo = SettingsRegistry.PartialTypeInfo(imports = listOf("suwayomi.tachidesk.graphql.types.AuthMode")),
)
val downloadConversions: MutableStateFlow<Map<String, DownloadConversion>> by MapSetting<String, DownloadConversion>(
protoNumber = 57,
defaultValue = emptyMap(),
group = SettingGroup.DOWNLOADER,
typeInfo =
SettingsRegistry.PartialTypeInfo(
specificType = "List<SettingsDownloadConversionType>",
interfaceType = "List<SettingsDownloadConversion>",
backupType = "List<BackupSettingsDownloadConversionType>",
imports =
listOf(
"suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSettingsDownloadConversionType",
),
convertToGqlType = { value ->
@Suppress("UNCHECKED_CAST")
val castedValue = value as Map<String, DownloadConversion>
castedValue.map {
SettingsDownloadConversionType(
it.key,
it.value.target,
it.value.compressionLevel,
)
}
},
convertToInternalType = { list ->
@Suppress("UNCHECKED_CAST")
val castedList = list as List<SettingsDownloadConversionType>
castedList.associate {
it.mimeType to
DownloadConversion(
target = it.target,
compressionLevel = it.compressionLevel,
)
}
},
convertToBackupType = { value ->
@Suppress("UNCHECKED_CAST")
val castedValue = value as Map<String, DownloadConversion>
castedValue.map {
BackupSettingsDownloadConversionType(
it.key,
it.value.target,
it.value.compressionLevel,
)
}
},
),
description =
"""
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
}
""".trimIndent(),
)
val jwtAudience: MutableStateFlow<String> by StringSetting(
protoNumber = 58,
group = SettingGroup.AUTH,
defaultValue = "suwayomi-server-api",
)
val koreaderSyncServerUrl: MutableStateFlow<String> by StringSetting(
protoNumber = 59,
group = SettingGroup.KOREADER_SYNC,
defaultValue = "http://localhost:17200",
)
val koreaderSyncUsername: MutableStateFlow<String> by StringSetting(
protoNumber = 60,
group = SettingGroup.KOREADER_SYNC,
defaultValue = "",
)
val koreaderSyncUserkey: MutableStateFlow<String> by StringSetting(
protoNumber = 61,
group = SettingGroup.KOREADER_SYNC,
defaultValue = "",
)
val koreaderSyncDeviceId: MutableStateFlow<String> by StringSetting(
protoNumber = 62,
group = SettingGroup.KOREADER_SYNC,
defaultValue = "",
)
val koreaderSyncChecksumMethod: MutableStateFlow<KoreaderSyncChecksumMethod> by EnumSetting(
protoNumber = 63,
group = SettingGroup.KOREADER_SYNC,
defaultValue = KoreaderSyncChecksumMethod.BINARY,
enumClass = KoreaderSyncChecksumMethod::class,
typeInfo = SettingsRegistry.PartialTypeInfo(imports = listOf("suwayomi.tachidesk.graphql.types.KoreaderSyncChecksumMethod")),
)
val koreaderSyncStrategy: MutableStateFlow<KoreaderSyncStrategy> by EnumSetting(
protoNumber = 64,
group = SettingGroup.KOREADER_SYNC,
defaultValue = KoreaderSyncStrategy.DISABLED,
enumClass = KoreaderSyncStrategy::class,
typeInfo = SettingsRegistry.PartialTypeInfo(imports = listOf("suwayomi.tachidesk.graphql.types.KoreaderSyncStrategy")),
)
val koreaderSyncPercentageTolerance: MutableStateFlow<Double> by DoubleSetting(
protoNumber = 65,
group = SettingGroup.KOREADER_SYNC,
defaultValue = 0.000000000000001,
min = 0.000000000000001,
max = 1.0,
description = "Absolute tolerance for progress comparison",
)
val jwtTokenExpiry: MutableStateFlow<Duration> by DurationSetting(
protoNumber = 66,
group = SettingGroup.AUTH,
defaultValue = 5.minutes,
min = 0.seconds,
)
val jwtRefreshExpiry: MutableStateFlow<Duration> by DurationSetting(
protoNumber = 67,
group = SettingGroup.AUTH,
defaultValue = 60.days,
min = 0.seconds,
)
val webUIEnabled: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 68,
group = SettingGroup.WEB_UI,
defaultValue = true,
requiresRestart = true,
)
/** ****************************************************************** **/
/** **/
/** Renamed settings **/
/** **/
/** ****************************************************************** **/
val basicAuthUsername: MutableStateFlow<String> by MigratedConfigValue(
protoNumber = 99991,
defaultValue = "",
group = SettingGroup.AUTH,
deprecated =
SettingsRegistry.SettingDeprecated(
replaceWith = "authUsername",
message = "Removed - prefer authUsername",
),
readMigrated = { authUsername.value },
setMigrated = { authUsername.value = it },
)
val basicAuthPassword: MutableStateFlow<String> by MigratedConfigValue(
protoNumber = 99992,
defaultValue = "",
group = SettingGroup.AUTH,
deprecated =
SettingsRegistry.SettingDeprecated(
replaceWith = "authPassword",
message = "Removed - prefer authPassword",
),
readMigrated = { authPassword.value },
setMigrated = { authPassword.value = it },
)
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> subscribeTo(
flow: Flow<T>,
onChange: suspend (value: T) -> Unit,
ignoreInitialValue: Boolean = true,
) {
val actualFlow =
if (ignoreInitialValue) {
flow.drop(1)
} else {
flow
}
val sharedFlow = MutableSharedFlow<T>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
actualFlow.distinctUntilChanged().mapLatest { sharedFlow.emit(it) }.launchIn(mutableConfigValueScope)
sharedFlow.onEach { onChange(it) }.launchIn(mutableConfigValueScope)
}
fun <T> subscribeTo(
flow: Flow<T>,
onChange: suspend () -> Unit,
ignoreInitialValue: Boolean = true,
) {
subscribeTo(flow, { _ -> onChange() }, ignoreInitialValue)
}
fun <T> subscribeTo(
mutableStateFlow: MutableStateFlow<T>,
onChange: suspend (value: T) -> Unit,
ignoreInitialValue: Boolean = true,
) {
subscribeTo(mutableStateFlow.asStateFlow(), onChange, ignoreInitialValue)
}
fun <T> subscribeTo(
mutableStateFlow: MutableStateFlow<T>,
onChange: suspend () -> Unit,
ignoreInitialValue: Boolean = true,
) {
subscribeTo(mutableStateFlow.asStateFlow(), { _ -> onChange() }, ignoreInitialValue)
}
companion object {
fun register(getConfig: () -> Config) =
ServerConfig {
getConfig().getConfig(
SERVER_CONFIG_MODULE_NAME,
)
}
}
}

View File

@@ -0,0 +1,581 @@
package suwayomi.tachidesk.server.settings
import io.github.config4k.getValue
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import suwayomi.tachidesk.server.ServerConfig
import suwayomi.tachidesk.server.mutableConfigValueScope
import xyz.nulldev.ts.config.GlobalConfigManager
import java.io.File
import kotlin.reflect.KClass
import kotlin.reflect.KProperty
import kotlin.time.Duration
/**
* Base delegate for settings to read values from the config file with automatic setting registration and validation
*/
open class SettingDelegate<T : Any>(
protected val protoNumber: Int,
val defaultValue: T,
val validator: ((T) -> String?)? = null,
val toValidValue: ((T) -> T)? = null,
protected val group: SettingGroup,
protected val requiresRestart: Boolean? = null,
protected val typeInfo: SettingsRegistry.PartialTypeInfo? = null,
protected val deprecated: SettingsRegistry.SettingDeprecated? = null,
protected val description: String? = null,
) {
var flow: MutableStateFlow<T>? = null
lateinit var propertyName: String
lateinit var moduleName: String
operator fun provideDelegate(
thisRef: ServerConfig,
property: KProperty<*>,
): SettingDelegate<T> {
propertyName = property.name
moduleName = thisRef.moduleName
SettingsRegistry.register(
SettingsRegistry.SettingMetadata(
protoNumber = protoNumber,
name = propertyName,
typeInfo =
SettingsRegistry.TypeInfo(
type = typeInfo?.type ?: defaultValue::class,
specificType = typeInfo?.specificType,
interfaceType = typeInfo?.interfaceType,
backupType = typeInfo?.backupType,
imports = typeInfo?.imports,
convertToGqlType = typeInfo?.convertToGqlType,
convertToInternalType = typeInfo?.convertToInternalType,
convertToBackupType = typeInfo?.convertToBackupType,
),
defaultValue = defaultValue,
validator =
validator?.let { validate ->
{ value ->
@Suppress("UNCHECKED_CAST")
validate(value as T)
}
},
group = group.value,
deprecated = deprecated,
requiresRestart = requiresRestart ?: false,
description =
run {
val defaultValueString =
when (defaultValue) {
is String -> "\"$defaultValue\""
else -> defaultValue
}
val defaultValueComment = "default: $defaultValueString"
if (description != null) {
"$defaultValueComment ; $description"
} else {
defaultValueComment
}
},
),
)
return this
}
inline operator fun <reified ReifiedT : MutableStateFlow<R>, reified R> getValue(
thisRef: ServerConfig,
property: KProperty<*>,
): ReifiedT {
if (flow != null) {
return flow as ReifiedT
}
val stateFlow = thisRef.overridableConfig.getValue<ServerConfig, ReifiedT>(thisRef, property)
@Suppress("UNCHECKED_CAST")
flow = stateFlow as MutableStateFlow<T>
// Validate config value and optionally fallback to default value
validator?.let { validate ->
@Suppress("UNCHECKED_CAST")
val initialValue = stateFlow.value
val error = validate(initialValue)
if (error != null) {
KotlinLogging.logger { }.warn {
"Invalid config value ($initialValue) for $moduleName.$propertyName: $error. Using default value: $defaultValue"
}
stateFlow.value = toValidValue?.let { it(initialValue) } ?: defaultValue
}
}
stateFlow
.drop(1)
.distinctUntilChanged()
.filter { it != thisRef.overridableConfig.getConfig().getValue<ServerConfig, R>(thisRef, property) }
.onEach { value ->
validator?.let { validate ->
@Suppress("UNCHECKED_CAST")
val error = validate(value as T)
if (error != null) {
throw IllegalArgumentException("Setting $propertyName: $error")
}
}
GlobalConfigManager.updateValue("$moduleName.$propertyName", value as Any)
}.launchIn(mutableConfigValueScope)
return stateFlow
}
}
class MigratedConfigValue<T : Any>(
private val protoNumber: Int,
private val defaultValue: T,
private val group: SettingGroup,
private val requiresRestart: Boolean? = null,
private val typeInfo: SettingsRegistry.PartialTypeInfo? = null,
private val deprecated: SettingsRegistry.SettingDeprecated,
private val readMigrated: (() -> T) = { defaultValue },
private val setMigrated: ((T) -> Unit) = {},
) {
var flow: MutableStateFlow<T>? = null
lateinit var propertyName: String
lateinit var moduleName: String
operator fun provideDelegate(
thisRef: ServerConfig,
property: KProperty<*>,
): MigratedConfigValue<T> {
propertyName = property.name
moduleName = thisRef.moduleName
SettingsRegistry.register(
SettingsRegistry.SettingMetadata(
protoNumber = protoNumber,
name = propertyName,
typeInfo =
SettingsRegistry.TypeInfo(
type = typeInfo?.type ?: defaultValue::class,
specificType = typeInfo?.specificType,
backupType = typeInfo?.backupType,
imports = typeInfo?.imports,
restoreLegacy = typeInfo?.restoreLegacy,
),
defaultValue = defaultValue,
group = group.value,
deprecated = deprecated,
requiresRestart = requiresRestart ?: false,
),
)
return this
}
operator fun getValue(
thisRef: ServerConfig,
property: KProperty<*>,
): MutableStateFlow<T> {
if (flow != null) {
return flow!!
}
val value = readMigrated()
val stateFlow = MutableStateFlow(value)
flow = stateFlow
stateFlow
.drop(1)
.distinctUntilChanged()
.filter { it != readMigrated() }
.onEach(setMigrated)
.launchIn(mutableConfigValueScope)
return stateFlow
}
}
// Specialized delegates for common types
class StringSetting(
protoNumber: Int,
defaultValue: String,
pattern: Regex? = null,
maxLength: Int? = null,
group: SettingGroup,
deprecated: SettingsRegistry.SettingDeprecated? = null,
requiresRestart: Boolean? = null,
description: String? = null,
) : SettingDelegate<String>(
protoNumber = protoNumber,
defaultValue = defaultValue,
validator = { value ->
when {
pattern != null && !value.matches(pattern) ->
"Value must match pattern: ${pattern.pattern}"
maxLength != null && value.length > maxLength ->
"Value must not exceed $maxLength characters"
else -> null
}
},
toValidValue = { value ->
if (pattern != null && !value.matches(pattern)) {
defaultValue
} else {
maxLength?.let { value.take(it) } ?: value
}
},
group = group,
deprecated = deprecated,
requiresRestart = requiresRestart,
description = description,
)
abstract class RangeSetting<T : Comparable<T>>(
protoNumber: Int,
defaultValue: T,
min: T? = null,
max: T? = null,
validator: ((T) -> String?)? = null,
toValidValue: ((T) -> T)? = null,
group: SettingGroup,
typeInfo: SettingsRegistry.PartialTypeInfo? = null,
deprecated: SettingsRegistry.SettingDeprecated? = null,
requiresRestart: Boolean? = null,
description: String? = null,
) : SettingDelegate<T>(
protoNumber = protoNumber,
defaultValue = defaultValue,
validator =
validator ?: { value ->
when {
min != null && value < min -> "Value must be at least $min"
max != null && value > max -> "Value must not exceed $max"
else -> null
}
},
toValidValue =
toValidValue ?: { value ->
val coerceAtLeast = min?.let { value.coerceAtLeast(min) } ?: value
val coerceAtMost = max?.let { coerceAtLeast.coerceAtMost(max) } ?: value
coerceAtMost
},
group = group,
typeInfo = typeInfo,
deprecated = deprecated,
requiresRestart = requiresRestart,
description =
run {
val defaultDescription = "range: [${min ?: "-∞"}, ${max ?: "+∞"}]"
if (description != null) {
"$defaultDescription ; $description"
} else {
defaultDescription
}
},
)
class IntSetting(
protoNumber: Int,
defaultValue: Int,
min: Int? = null,
max: Int? = null,
customValidator: ((Int) -> String?)? = null,
customToValidValue: ((Int) -> Int)? = null,
group: SettingGroup,
deprecated: SettingsRegistry.SettingDeprecated? = null,
requiresRestart: Boolean? = null,
description: String? = null,
) : RangeSetting<Int>(
protoNumber = protoNumber,
defaultValue = defaultValue,
min = min,
max = max,
validator = customValidator,
toValidValue = customToValidValue,
group = group,
deprecated = deprecated,
requiresRestart = requiresRestart,
description = description,
)
class DisableableIntSetting(
protoNumber: Int,
defaultValue: Int,
min: Int? = null,
max: Int? = null,
group: SettingGroup,
deprecated: SettingsRegistry.SettingDeprecated? = null,
requiresRestart: Boolean? = null,
description: String? = null,
) : RangeSetting<Int>(
protoNumber = protoNumber,
defaultValue = defaultValue,
min = min,
max = max,
validator = { value ->
when {
value == 0 -> null
min != null && value < min -> "Value must be 0.0 or at least $min"
max != null && value > max -> "Value must be 0.0 or not exceed $max"
else -> null
}
},
toValidValue = { value ->
if (value == 0) {
value
} else {
val coerceAtLeast = min?.let { value.coerceAtLeast(min) } ?: value
val coerceAtMost = max?.let { coerceAtLeast.coerceAtMost(max) } ?: value
coerceAtMost
}
},
group = group,
deprecated = deprecated,
requiresRestart = requiresRestart,
description =
run {
if (description != null) {
"0 == disabled ; $description"
} else {
description
}
},
)
class DoubleSetting(
protoNumber: Int,
defaultValue: Double,
min: Double? = null,
max: Double? = null,
customValidator: ((Double) -> String?)? = null,
customToValidValue: ((Double) -> Double)? = null,
group: SettingGroup,
deprecated: SettingsRegistry.SettingDeprecated? = null,
requiresRestart: Boolean? = null,
description: String? = null,
) : RangeSetting<Double>(
protoNumber = protoNumber,
defaultValue = defaultValue,
min = min,
max = max,
validator = customValidator,
toValidValue = customToValidValue,
group = group,
deprecated = deprecated,
requiresRestart = requiresRestart,
description = description,
)
class DisableableDoubleSetting(
protoNumber: Int,
defaultValue: Double,
min: Double? = null,
max: Double? = null,
group: SettingGroup,
deprecated: SettingsRegistry.SettingDeprecated? = null,
requiresRestart: Boolean? = null,
description: String? = null,
) : RangeSetting<Double>(
protoNumber = protoNumber,
defaultValue = defaultValue,
min = min,
max = max,
validator = { value ->
when {
value == 0.0 -> null
min != null && value < min -> "Value must 0.0 or be at least $min"
max != null && value > max -> "Value must 0.0 or not exceed $max"
else -> null
}
},
toValidValue = { value ->
if (value == 0.0) {
value
} else {
val coerceAtLeast = min?.let { value.coerceAtLeast(min) } ?: value
val coerceAtMost = max?.let { coerceAtLeast.coerceAtMost(max) } ?: value
coerceAtMost
}
},
group = group,
deprecated = deprecated,
requiresRestart = requiresRestart,
description =
run {
if (description != null) {
"0.0 == disabled ; $description"
} else {
description
}
},
)
class BooleanSetting(
protoNumber: Int,
defaultValue: Boolean,
group: SettingGroup,
deprecated: SettingsRegistry.SettingDeprecated? = null,
requiresRestart: Boolean? = null,
description: String? = null,
) : SettingDelegate<Boolean>(
protoNumber = protoNumber,
defaultValue = defaultValue,
validator = null,
group = group,
deprecated = deprecated,
requiresRestart = requiresRestart,
description = description,
)
class PathSetting(
protoNumber: Int,
defaultValue: String,
mustExist: Boolean = false,
group: SettingGroup,
deprecated: SettingsRegistry.SettingDeprecated? = null,
requiresRestart: Boolean? = null,
description: String? = null,
) : SettingDelegate<String>(
protoNumber = protoNumber,
defaultValue = defaultValue,
validator = { value ->
if (mustExist && value.isNotEmpty() && !File(value).exists()) {
"Path does not exist: $value"
} else {
null
}
},
group = group,
deprecated = deprecated,
requiresRestart = requiresRestart,
description = description,
)
class EnumSetting<T : Enum<T>>(
protoNumber: Int,
defaultValue: T,
enumClass: KClass<T>,
typeInfo: SettingsRegistry.PartialTypeInfo? = null,
group: SettingGroup,
deprecated: SettingsRegistry.SettingDeprecated? = null,
requiresRestart: Boolean? = null,
description: String? = null,
) : SettingDelegate<T>(
protoNumber = protoNumber,
defaultValue = defaultValue,
validator = { value ->
if (!enumClass.java.isInstance(value)) {
"Invalid enum value for ${enumClass.simpleName}"
} else {
null
}
},
typeInfo = typeInfo,
group = group,
deprecated = deprecated,
requiresRestart = requiresRestart,
description =
run {
val defaultDescription = "options: ${enumClass.java.enumConstants.joinToString()}"
if (description != null) {
"$description ; $defaultDescription"
} else {
defaultDescription
}
},
)
class DurationSetting(
protoNumber: Int,
defaultValue: Duration,
min: Duration? = null,
max: Duration? = null,
customValidator: ((Duration) -> String?)? = null,
customToValidValue: ((Duration) -> Duration)? = null,
group: SettingGroup,
deprecated: SettingsRegistry.SettingDeprecated? = null,
requiresRestart: Boolean? = null,
description: String? = null,
) : RangeSetting<Duration>(
protoNumber = protoNumber,
defaultValue = defaultValue,
min = min,
max = max,
validator = customValidator,
toValidValue = customToValidValue,
typeInfo =
SettingsRegistry.PartialTypeInfo(
imports = listOf("kotlin.time.Duration"),
),
group = group,
deprecated = deprecated,
requiresRestart = requiresRestart,
description = description,
)
class ListSetting<T>(
protoNumber: Int,
defaultValue: List<T>,
itemValidator: ((T) -> String?)? = null,
itemToValidValue: ((T) -> T?)? = null,
typeInfo: SettingsRegistry.PartialTypeInfo? = null,
group: SettingGroup,
deprecated: SettingsRegistry.SettingDeprecated? = null,
requiresRestart: Boolean? = null,
description: String? = null,
) : SettingDelegate<List<T>>(
protoNumber = protoNumber,
defaultValue = defaultValue,
validator = { list ->
if (itemValidator != null) {
list.firstNotNullOfOrNull { item ->
itemValidator(item)?.let { error -> "Invalid item: $error" }
}
} else {
null
}
},
toValidValue = { list ->
if (itemToValidValue != null) {
list.mapNotNull(itemToValidValue)
} else {
defaultValue
}
},
typeInfo = typeInfo,
group = group,
deprecated = deprecated,
requiresRestart = requiresRestart,
description = description,
)
class MapSetting<K, V>(
protoNumber: Int,
defaultValue: Map<K, V>,
validator: ((Map<K, V>) -> String?)? = null,
typeInfo: SettingsRegistry.PartialTypeInfo? = null,
group: SettingGroup,
deprecated: SettingsRegistry.SettingDeprecated? = null,
requiresRestart: Boolean? = null,
description: String? = null,
) : SettingDelegate<Map<K, V>>(
protoNumber = protoNumber,
defaultValue = defaultValue,
validator = validator,
typeInfo = typeInfo,
group = group,
deprecated = deprecated,
requiresRestart = requiresRestart,
description = description,
)

View File

@@ -0,0 +1,22 @@
package suwayomi.tachidesk.server.settings
enum class SettingGroup(
val value: String,
) {
NETWORK("Network"),
PROXY("Proxy"),
WEB_UI("WebUI"),
DOWNLOADER("Downloader"),
EXTENSION("Extension/Source"),
LIBRARY_UPDATES("Library updates"),
AUTH("Authentication"),
MISC("Misc"),
BACKUP("Backup"),
LOCAL_SOURCE("Local source"),
CLOUDFLARE("Cloudflare"),
OPDS("OPDS"),
KOREADER_SYNC("KOReader sync"),
;
override fun toString(): String = value
}

View File

@@ -0,0 +1,72 @@
package suwayomi.tachidesk.server.settings
import kotlin.reflect.KClass
/**
* Registry to track all settings for automatic updating and validation
*/
object SettingsRegistry {
data class SettingDeprecated(
val replaceWith: String? = null,
val message: String,
)
interface ITypeInfo {
val type: KClass<*>?
val specificType: String?
val interfaceType: String?
val backupType: String?
val imports: List<String>?
val convertToGqlType: ((configValue: Any) -> Any)?
val convertToInternalType: ((gqlValue: Any) -> Any)?
val convertToBackupType: ((gqlValue: Any) -> Any)?
val restoreLegacy: ((backupValue: Any?) -> Any?)?
}
data class TypeInfo(
override val type: KClass<*>,
override val specificType: String? = null,
override val interfaceType: String? = null,
override val backupType: String? = null,
override val imports: List<String>? = null,
override val convertToGqlType: ((configValue: Any) -> Any)? = null,
override val convertToInternalType: ((gqlValue: Any) -> Any)? = null,
override val convertToBackupType: ((gqlValue: Any) -> Any)? = null,
override val restoreLegacy: ((backupValue: Any?) -> Any?)? = null,
) : ITypeInfo
data class PartialTypeInfo(
override val type: KClass<*>? = null,
override val specificType: String? = null,
override val interfaceType: String? = null,
override val backupType: String? = null,
override val imports: List<String>? = null,
override val convertToGqlType: ((configValue: Any) -> Any)? = null,
override val convertToInternalType: ((gqlValue: Any) -> Any)? = null,
override val convertToBackupType: ((gqlValue: Any) -> Any)? = null,
override val restoreLegacy: ((backupValue: Any?) -> Any?)? = null,
) : ITypeInfo
data class SettingMetadata(
val protoNumber: Int,
val name: String,
val typeInfo: TypeInfo,
val defaultValue: Any,
val validator: ((Any?) -> String?)? = null,
val convertGqlToInternalType: ((Any?) -> Any?)? = null,
val group: String,
val deprecated: SettingDeprecated? = null,
val requiresRestart: Boolean,
val description: String? = null,
)
private val settings = mutableMapOf<String, SettingMetadata>()
fun register(metadata: SettingMetadata) {
settings[metadata.name] = metadata
}
fun get(name: String): SettingMetadata? = settings[name]
fun getAll(): Map<String, SettingMetadata> = settings.toMap()
}

View File

@@ -0,0 +1,20 @@
package suwayomi.tachidesk.server.util
import io.github.config4k.registerCustomType
/**
* Central place for registering custom types for config serialization/deserialization
* This ensures consistency between runtime config handling and config file generation
*/
object ConfigTypeRegistration {
private var registered = false
fun registerCustomTypes() {
if (registered) return
registerCustomType(MutableStateFlowType())
registerCustomType(DurationType())
registered = true
}
}

View File

@@ -0,0 +1,31 @@
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 kotlin.time.Duration
class DurationType : CustomType {
override fun parse(
clazz: ClassContainer,
config: Config,
name: String,
): Any? {
val clazz = ClassContainer(String::class)
val reader = SelectReader.getReader(clazz)
val path = name
val result = reader(config, path) as String
return Duration.parse(result)
}
override fun testParse(clazz: ClassContainer): Boolean = clazz.mapperClass.qualifiedName == "kotlin.time.Duration"
override fun testToConfig(obj: Any): Boolean = obj as? Duration != null
override fun toConfig(
obj: Any,
name: String,
): Config = (obj as Duration).toString().toConfig(name)
}

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