mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-02 18:34:39 -05:00
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:
39
server/server-config/build.gradle.kts
Normal file
39
server/server-config/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user