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

@@ -8,4 +8,7 @@ ij_kotlin_name_count_to_use_star_import_for_members=2147483647
ktlint_standard_discouraged-comment-location=disabled
ktlint_standard_if-else-wrapping=disabled
ktlint_standard_no-consecutive-comments=disabled
ktlint_standard_no-consecutive-comments=disabled
[**/generated/**]
ktlint=disabled

View File

@@ -113,8 +113,7 @@ open class ConfigManager {
value: Any,
) {
mutex.withLock {
val actualValue = if (value is Enum<*>) value.name else value
val configValue = actualValue.toConfig("internal").getValue("internal")
val configValue = value.toConfig("internal").getValue("internal")
updateUserConfigFile(path, configValue)
internalConfig = internalConfig.withValue(path, configValue)

View File

@@ -92,6 +92,9 @@ dependencies {
// i18n
implementation(projects.server.i18n)
// Settings module
implementation(projects.server.serverConfig)
// uncomment to test extensions directly
// implementation(fileTree("lib/"))
implementation(kotlin("script-runtime"))
@@ -123,6 +126,15 @@ sourceSets {
main {
resources {
srcDir("src/main/resources")
srcDir("build/generated/src/main/resources")
}
kotlin {
srcDir("build/generated/src/main/kotlin")
}
}
test {
resources {
srcDir("build/generated/src/test/resources")
}
}
}
@@ -229,4 +241,12 @@ tasks {
runKtlintCheckOverMainSourceSet {
mustRunAfter(generateJte)
}
compileKotlin {
dependsOn(":server:server-config-generate:generateSettings")
}
processResources {
dependsOn(":server:server-config-generate:generateSettings")
}
}

View File

@@ -0,0 +1,62 @@
plugins {
id(
libs.plugins.kotlin.jvm
.get()
.pluginId,
)
}
dependencies {
// Core Kotlin
implementation(kotlin("stdlib-jdk8"))
implementation(kotlin("reflect"))
// Config handling
implementation(libs.config)
implementation(libs.config4k)
// Logging
implementation(libs.slf4japi)
implementation(libs.kotlinlogging)
// Serialization
implementation(libs.serialization.json)
implementation(libs.serialization.protobuf)
// Depend on server-config module for access to ServerConfig and SettingsRegistry
implementation(projects.server.serverConfig)
}
tasks {
register<JavaExec>("generateSettings") {
group = "build setup"
description = "Generates settings from ServerConfig"
dependsOn(compileKotlin)
// Use this module's classpath which includes server-config as dependency
classpath = sourceSets.main.get().runtimeClasspath
mainClass.set("suwayomi.tachidesk.server.settings.generation.SettingsGeneratorKt")
// Get reference to server project for file paths
val serverProject = project(":server")
// Set working directory to the server module directory
workingDir = serverProject.projectDir
inputs.files(
serverProject.sourceSets.main.get().allSource.filter {
it.name.contains("ServerConfig") || it.name.contains("Settings")
},
)
outputs.files(
serverProject.file("build/generated/src/main/resources/server-reference.conf"),
serverProject.file("build/generated/src/test/resources/server-reference.conf"),
serverProject.file("build/generated/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt"),
serverProject.file("build/generated/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt"),
serverProject.file("build/generated/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/handlers/BackupSettingsHandler.kt"),
)
}
}

View File

@@ -0,0 +1,36 @@
@file:JvmName("SettingsGeneratorKt")
package suwayomi.tachidesk.server.settings.generation
import java.io.File
/**
* Main function to generate settings files from ServerConfig
* This is called by the generateSettingsFiles Gradle task
*/
fun main() {
println("Generating settings files from ServerConfig registry...")
try {
// Set output directories relative to the current working directory (server module)
val outputDir = File("build/generated/src/main/resources")
val testOutputDir = File("build/generated/src/test/resources")
val graphqlOutputDir = File("build/generated/src/main/kotlin/suwayomi/tachidesk/graphql/types")
val backupSettingsOutputDir = File("build/generated/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models")
val backupSettingsHandlerOutputDir = File("build/generated/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/handlers")
SettingsGenerator.generate(
outputDir = outputDir,
testOutputDir = testOutputDir,
graphqlOutputDir = graphqlOutputDir,
backupSettingsOutputDir = backupSettingsOutputDir,
backupSettingsHandlerOutputDir = backupSettingsHandlerOutputDir,
)
println("✅ Settings files generation completed successfully!")
} catch (e: Exception) {
println("❌ Error generating settings files: ${e.message}")
e.printStackTrace()
System.exit(1)
}
}

View File

@@ -0,0 +1,37 @@
package suwayomi.tachidesk.server.settings.generation
import suwayomi.tachidesk.server.settings.SettingsRegistry
internal fun String.addIndentation(times: Int): String = this.prependIndent(" ".repeat(times))
object KotlinFileGeneratorHelper {
fun createFileHeader(packageName: String): String =
buildString {
appendLine("@file:Suppress(\"ktlint\")")
appendLine()
appendLine("/*")
appendLine(" * Copyright (C) Contributors to the Suwayomi project")
appendLine(" *")
appendLine(" * This Source Code Form is subject to the terms of the Mozilla Public")
appendLine(" * License, v. 2.0. If a copy of the MPL was not distributed with this")
appendLine(" * file, You can obtain one at https://mozilla.org/MPL/2.0/. */")
appendLine()
appendLine("package $packageName")
appendLine()
}
fun createImports(
staticImports: List<String>,
settings: List<SettingsRegistry.SettingMetadata>,
): String =
buildString {
staticImports.forEach { appendLine("import $it") }
settings
.mapNotNull { it.typeInfo.imports }
.flatten()
.distinct()
.forEach { appendLine("import $it") }
appendLine()
}
}

View File

@@ -0,0 +1,84 @@
package suwayomi.tachidesk.server.settings.generation
import suwayomi.tachidesk.server.settings.SettingsRegistry
import java.io.File
import kotlin.text.appendLine
object SettingsBackupServerSettingsGenerator {
fun generate(
settings: Map<String, SettingsRegistry.SettingMetadata>,
outputFile: File,
) {
outputFile.parentFile.mkdirs()
val settingsToInclude = settings.values
if (settingsToInclude.isEmpty()) {
println("Warning: No settings found to create BackupServerSettings from.")
return
}
val sortedSettings = settingsToInclude.sortedBy { it.protoNumber }
outputFile.writeText(
buildString {
appendLine(KotlinFileGeneratorHelper.createFileHeader("suwayomi.tachidesk.manga.impl.backup.proto.models"))
writeImports(sortedSettings)
writeClass(sortedSettings)
},
)
println("BackupServerSettingsGenerator generated successfully! Total settings: ${settingsToInclude.size}")
}
private fun StringBuilder.writeImports(settings: List<SettingsRegistry.SettingMetadata>) {
appendLine(
KotlinFileGeneratorHelper.createImports(
listOf(
"kotlinx.serialization.Serializable",
"kotlinx.serialization.protobuf.ProtoNumber",
"suwayomi.tachidesk.graphql.types.Settings",
),
settings,
),
)
}
private fun StringBuilder.writeClass(sortedSettings: List<SettingsRegistry.SettingMetadata>) {
appendLine("@Serializable")
appendLine("data class BackupServerSettings(")
writeSettings(sortedSettings, indentation = 4)
appendLine(") : Settings")
appendLine()
}
private fun StringBuilder.writeSettings(
sortedSettings: List<SettingsRegistry.SettingMetadata>,
indentation: Int,
) {
sortedSettings.forEach { setting ->
val deprecated = setting.deprecated
if (deprecated != null) {
val replaceWithSuffix = deprecated.replaceWith?.let { ", ReplaceWith(\"$it\")" } ?: ""
appendLine(
"@Deprecated(\"${deprecated.message}\"$replaceWithSuffix)".addIndentation(
indentation,
),
)
}
appendLine(
"@ProtoNumber(${setting.protoNumber}) override var ${setting.name}: ${getSettingType(setting)},"
.addIndentation(indentation),
)
}
}
private fun getSettingType(setting: SettingsRegistry.SettingMetadata): String =
setting.typeInfo.backupType
?: setting.typeInfo.specificType
?: setting.typeInfo.type.simpleName
?: throw RuntimeException("Unknown setting type: ${setting.typeInfo}")
}

View File

@@ -0,0 +1,137 @@
package suwayomi.tachidesk.server.settings.generation
import suwayomi.tachidesk.server.settings.SettingsRegistry
import java.io.File
import kotlin.text.appendLine
object SettingsBackupSettingsHandlerGenerator {
fun generate(
settings: Map<String, SettingsRegistry.SettingMetadata>,
outputFile: File,
) {
outputFile.parentFile.mkdirs()
val settingsToInclude = settings.values
if (settingsToInclude.isEmpty()) {
println("Warning: No settings found to create BackupServerSettings from.")
return
}
val groupedSettings = settingsToInclude.groupBy { it.group }
outputFile.writeText(
buildString {
appendLine(KotlinFileGeneratorHelper.createFileHeader("suwayomi.tachidesk.manga.impl.backup.proto.handlers"))
writeImports(groupedSettings.values.flatten())
writeHandler(groupedSettings)
},
)
println("BackupServerSettings generated successfully! Total settings: ${settingsToInclude.size}")
}
private fun StringBuilder.writeImports(settings: List<SettingsRegistry.SettingMetadata>) {
appendLine(
KotlinFileGeneratorHelper.createImports(
listOf(
"suwayomi.tachidesk.graphql.mutations.SettingsMutation",
"suwayomi.tachidesk.manga.impl.backup.BackupFlags",
"suwayomi.tachidesk.manga.impl.backup.proto.models.BackupServerSettings",
"suwayomi.tachidesk.server.serverConfig",
"suwayomi.tachidesk.server.settings.SettingsRegistry",
),
settings,
),
)
}
private fun StringBuilder.writeHandler(groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>) {
appendLine("object BackupSettingsHandler {")
writeBackupFunction(groupedSettings)
appendLine()
writeRestoreFunction(groupedSettings.values.flatten())
appendLine("}")
appendLine()
}
private fun StringBuilder.writeBackupFunction(groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>) {
val indentation = 4
val contentIndentation = indentation * 2
appendLine("fun backup(flags: BackupFlags): BackupServerSettings? {".addIndentation(indentation))
appendLine("if (!flags.includeServerSettings) { return null }".addIndentation(contentIndentation))
appendLine()
appendLine("return BackupServerSettings(".addIndentation(contentIndentation))
writeSettings(groupedSettings, indentation * 3)
appendLine(")".addIndentation(contentIndentation))
appendLine("}".addIndentation(indentation))
}
private fun StringBuilder.writeRestoreFunction(settings: List<SettingsRegistry.SettingMetadata>) {
val indentation = 4
val contentIndentation = indentation * 2
appendLine("fun restore(backupServerSettings: BackupServerSettings?) {".addIndentation(indentation))
appendLine("if (backupServerSettings == null) { return }".addIndentation(contentIndentation))
appendLine()
appendLine("SettingsMutation().updateSettings(".addIndentation(contentIndentation))
appendLine("backupServerSettings.copy(".addIndentation(indentation * 3))
val deprecatedSettings = settings.filter { it.typeInfo.restoreLegacy != null }
deprecatedSettings.forEach { setting ->
appendLine(
"${setting.name} = SettingsRegistry.get(\"${setting.name}\")!!.typeInfo.restoreLegacy!!(".addIndentation(indentation * 4) +
"backupServerSettings.${setting.name}" +
") as ${getSettingType(setting, false)},",
)
}
appendLine("),".addIndentation(indentation * 3))
appendLine(")".addIndentation(contentIndentation))
appendLine("}".addIndentation(indentation))
}
private fun StringBuilder.writeSettings(
groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>,
indentation: Int,
) {
groupedSettings.forEach { (group, settings) ->
appendLine("// $group".addIndentation(indentation))
settings.forEach { setting -> writeSetting(setting, indentation) }
}
}
private fun StringBuilder.writeSetting(
setting: SettingsRegistry.SettingMetadata,
indentation: Int,
) {
appendLine("${setting.name} = ${getConfigAccess(setting)},".addIndentation(indentation))
}
private fun getSettingType(
setting: SettingsRegistry.SettingMetadata,
asBackup: Boolean,
): String {
val possibleType = setting.typeInfo.specificType ?: setting.typeInfo.type.simpleName
val exception = RuntimeException("Unknown setting type: ${setting.typeInfo}")
if (asBackup) {
return setting.typeInfo.backupType ?: possibleType ?: throw exception
}
return possibleType ?: throw exception
}
private fun getConfigAccess(setting: SettingsRegistry.SettingMetadata): String {
if (setting.typeInfo.convertToBackupType != null) {
return "SettingsRegistry.get(\"${setting.name}\")!!.typeInfo.convertToBackupType!!(" +
"serverConfig.${setting.name}.value" +
") as ${getSettingType(setting, true)}"
}
return "serverConfig.${setting.name}.value"
}
}

View File

@@ -0,0 +1,109 @@
package suwayomi.tachidesk.server.settings.generation
import com.typesafe.config.ConfigRenderOptions
import io.github.config4k.toConfig
import suwayomi.tachidesk.server.settings.SettingsRegistry
import java.io.File
object SettingsConfigFileGenerator {
private const val SERVER_PREFIX = "server."
fun generate(
outputDir: File,
testOutputDir: File,
settings: Map<String, SettingsRegistry.SettingMetadata>,
) {
// Config files only include up-to-date settings.
val settingsToInclude = settings.filterValues { it.deprecated == null }
if (settingsToInclude.isEmpty()) {
println("Warning: No settings found to write to config files.")
return
}
generateServerReferenceConf(settingsToInclude, outputDir)
generateServerReferenceConf(settingsToInclude, testOutputDir)
println("Settings config file generated successfully! Total settings: ${settingsToInclude.size}")
println("- Main config: ${outputDir.resolve("server-reference.conf").absolutePath}")
println("- Test config: ${testOutputDir.resolve("server-reference.conf").absolutePath}")
}
private fun generateServerReferenceConf(
settings: Map<String, SettingsRegistry.SettingMetadata>,
outputDir: File,
) {
outputDir.mkdirs()
val outputFile = outputDir.resolve("server-reference.conf")
val groupedSettings = settings.values.groupBy { it.group }
// Write the config with comments
outputFile.writeText(
buildString {
writeSettings(groupedSettings)
},
)
}
private fun StringBuilder.writeSettings(groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>) {
val renderOptions =
ConfigRenderOptions
.defaults()
.setOriginComments(false)
.setComments(false)
.setFormatted(true)
.setJson(false)
var isFirstGroup = true
groupedSettings.forEach { (groupName, groupSettings) ->
// Prevent empty line at start of the file
if (!isFirstGroup) {
appendLine()
}
isFirstGroup = false
appendLine("# $groupName")
groupSettings.forEach { setting ->
writeSetting(setting, renderOptions)
}
}
}
private fun StringBuilder.writeSetting(
setting: SettingsRegistry.SettingMetadata,
renderOptions: ConfigRenderOptions,
) {
val key = "$SERVER_PREFIX${setting.name}"
val configValue = setting.defaultValue.toConfig("internal").getValue("internal")
var renderedValue = configValue.render(renderOptions)
// Force quotes on all string values for consistency
// Check if it's a string value that's not already quoted
if (setting.defaultValue is String && !renderedValue.startsWith("\"")) {
renderedValue = "\"$renderedValue\""
}
val settingString = "$key = $renderedValue"
val description = setting.description
if (description != null) {
val descriptionLines = description.split("\n")
if (descriptionLines.isEmpty()) {
return
}
appendLine("$settingString # ${descriptionLines[0]}")
descriptionLines.drop(1).forEach { line ->
appendLine("# $line")
}
return
}
appendLine(settingString)
}
}

View File

@@ -0,0 +1,82 @@
package suwayomi.tachidesk.server.settings.generation
import com.typesafe.config.ConfigFactory
import suwayomi.tachidesk.server.ServerConfig
import suwayomi.tachidesk.server.settings.SettingsRegistry
import suwayomi.tachidesk.server.util.ConfigTypeRegistration
import java.io.File
import kotlin.reflect.KProperty1
import kotlin.reflect.full.memberProperties
/**
* Utility to generate settings files from ServerConfig and SettingsRegistry
* This can be run as a standalone main function to generate all required files
*/
object SettingsGenerator {
init {
// Register custom types for config serialization
ConfigTypeRegistration.registerCustomTypes()
triggerSettingRegistration()
}
/**
* Force registration of all settings without full ServerConfig instantiation
*/
private fun triggerSettingRegistration() {
// This creates a minimal instance just to trigger delegate registration
try {
val mockConfig =
ConfigFactory.parseString(
"""
server {
ip = "0.0.0.0"
port = 4567
}
""".trimIndent(),
)
val tempConfig = ServerConfig { mockConfig.getConfig("server") }
// Access all properties to trigger delegate registrations
tempConfig::class.memberProperties.forEach { prop ->
try {
@Suppress("UNCHECKED_CAST")
(prop as KProperty1<ServerConfig, Any?>).get(tempConfig)
} catch (e: Exception) {
// Ignore errors during registration
}
}
} catch (e: Exception) {
// Registration failed, but we tried
}
}
fun generate(
outputDir: File,
testOutputDir: File,
graphqlOutputDir: File,
backupSettingsOutputDir: File,
backupSettingsHandlerOutputDir: File,
) {
val settings = SettingsRegistry.getAll()
if (settings.isEmpty()) {
println("Warning: No settings found in registry. Settings might not be initialized.")
return
}
println(" - Total: ${settings.size}")
println(" - Deprecated: ${settings.values.count { it.deprecated != null }}")
println(" - Require restart: ${settings.values.count { it.requiresRestart }}")
SettingsConfigFileGenerator.generate(outputDir, testOutputDir, settings)
val settingsTypeFile = graphqlOutputDir.resolve("SettingsType.kt")
SettingsGraphqlTypeGenerator.generate(settings, settingsTypeFile)
val backupServerSettingsFile = backupSettingsOutputDir.resolve("BackupServerSettings.kt")
SettingsBackupServerSettingsGenerator.generate(settings, backupServerSettingsFile)
val backupSettingsHandlerFile = backupSettingsHandlerOutputDir.resolve("BackupSettingsHandler.kt")
SettingsBackupSettingsHandlerGenerator.generate(settings, backupSettingsHandlerFile)
}
}

View File

@@ -0,0 +1,173 @@
package suwayomi.tachidesk.server.settings.generation
import suwayomi.tachidesk.server.settings.SettingsRegistry
import java.io.File
import kotlin.text.appendLine
object SettingsGraphqlTypeGenerator {
fun generate(
settings: Map<String, SettingsRegistry.SettingMetadata>,
outputFile: File,
) {
outputFile.parentFile.mkdirs()
val settingsToInclude = settings.values
if (settingsToInclude.isEmpty()) {
println("Warning: No settings found to create graphql type from.")
return
}
val groupedSettings = settingsToInclude.groupBy { it.group }
outputFile.writeText(
buildString {
appendLine(KotlinFileGeneratorHelper.createFileHeader("suwayomi.tachidesk.graphql.types"))
writeImports(groupedSettings.values.flatten())
writeSettingsInterface(groupedSettings)
writePartialSettingsType(groupedSettings)
writeSettingsType(groupedSettings)
},
)
println("Graphql type generated successfully! Total settings: ${settingsToInclude.size}")
}
private fun StringBuilder.writeImports(settings: List<SettingsRegistry.SettingMetadata>) {
appendLine(
KotlinFileGeneratorHelper.createImports(
listOf(
"com.expediagroup.graphql.generator.annotations.GraphQLDeprecated",
"com.expediagroup.graphql.generator.annotations.GraphQLIgnore",
"suwayomi.tachidesk.graphql.server.primitives.Node",
"suwayomi.tachidesk.server.ServerConfig",
"suwayomi.tachidesk.server.serverConfig",
"suwayomi.tachidesk.server.settings.SettingsRegistry",
),
settings,
),
)
}
private fun StringBuilder.writeSettingsInterface(groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>) {
appendLine("interface Settings : Node {")
writeSettings(groupedSettings, indentation = 4, asType = true, isOverride = false, isNullable = true, isInterface = true)
appendLine("}")
appendLine()
}
private fun StringBuilder.writePartialSettingsType(groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>) {
appendLine("data class PartialSettingsType(")
writeSettings(groupedSettings, indentation = 4, asType = true, isOverride = true, isNullable = true, isInterface = false)
appendLine(") : Settings")
appendLine()
}
private fun StringBuilder.writeSettingsType(groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>) {
appendLine("class SettingsType(")
writeSettings(groupedSettings, indentation = 4, asType = true, isOverride = true, isNullable = false, isInterface = false)
appendLine(") : Settings {")
// Write secondary constructor
val indentation = 4
appendLine("@Suppress(\"UNCHECKED_CAST\")".addIndentation(indentation))
appendLine("constructor(config: ServerConfig = serverConfig) : this(".addIndentation(indentation))
writeSettings(
groupedSettings,
indentation = indentation * 2,
asType = false,
isOverride = false,
isNullable = false,
isInterface = false,
)
appendLine(")".addIndentation(indentation))
appendLine("}")
appendLine()
}
private fun StringBuilder.writeSettings(
groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>,
indentation: Int,
asType: Boolean,
isOverride: Boolean,
isNullable: Boolean,
isInterface: Boolean,
) {
groupedSettings.forEach { (group, settings) ->
appendLine("// $group".addIndentation(indentation))
settings.forEach { setting -> writeSetting(setting, indentation, asType, isOverride, isNullable, isInterface) }
}
}
private fun StringBuilder.writeSetting(
setting: SettingsRegistry.SettingMetadata,
indentation: Int,
asType: Boolean,
isOverride: Boolean,
isNullable: Boolean,
isInterface: Boolean,
) {
if (!asType) {
appendLine("${getConfigAccess(setting)},".addIndentation(indentation))
return
}
if (setting.requiresRestart) {
appendLine("@GraphQLIgnore".addIndentation(indentation))
}
val deprecated = setting.deprecated
if (deprecated != null) {
val replaceWithSuffix = deprecated.replaceWith?.let { ", ReplaceWith(\"$it\")" } ?: ""
appendLine(
"@GraphQLDeprecated(\"${deprecated.message}\"$replaceWithSuffix)".addIndentation(
indentation,
),
)
}
val overridePrefix = if (isOverride) "override " else ""
val nullableSuffix = if (isNullable) "?" else ""
val commaSuffix = if (isOverride) "," else ""
appendLine(
"${overridePrefix}val ${setting.name}: ${getGraphQLType(
setting,
isInterface,
)}$nullableSuffix$commaSuffix".addIndentation(indentation),
)
}
private fun getGraphQLType(
setting: SettingsRegistry.SettingMetadata,
isInterface: Boolean,
): String {
val possibleType = setting.typeInfo.specificType ?: setting.typeInfo.type.simpleName
val exception = RuntimeException("Unknown setting type: ${setting.typeInfo}")
if (isInterface) {
return setting.typeInfo.interfaceType ?: possibleType ?: throw exception
}
return possibleType ?: throw exception
}
private fun getConfigAccess(setting: SettingsRegistry.SettingMetadata): String {
if (setting.typeInfo.convertToGqlType != null) {
return "SettingsRegistry.get(\"${setting.name}\")!!.typeInfo.convertToGqlType!!(" +
"config.${setting.name}.value" +
") as ${getGraphQLType(setting, false)}"
}
return "config.${setting.name}.value"
}
}

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

@@ -2,54 +2,17 @@ package suwayomi.tachidesk.graphql.mutations
import com.expediagroup.graphql.generator.annotations.GraphQLIgnore
import graphql.schema.DataFetchingEnvironment
import kotlinx.coroutines.flow.MutableStateFlow
import suwayomi.tachidesk.graphql.server.getAttribute
import suwayomi.tachidesk.graphql.types.PartialSettingsType
import suwayomi.tachidesk.graphql.types.Settings
import suwayomi.tachidesk.graphql.types.SettingsType
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList.repoMatchRegex
import suwayomi.tachidesk.server.JavalinSetup.Attribute
import suwayomi.tachidesk.server.JavalinSetup.getAttribute
import suwayomi.tachidesk.server.SERVER_CONFIG_MODULE_NAME
import suwayomi.tachidesk.server.ServerConfig
import suwayomi.tachidesk.server.serverConfig
import suwayomi.tachidesk.server.settings.SettingsUpdater
import suwayomi.tachidesk.server.settings.SettingsValidator
import suwayomi.tachidesk.server.user.requireUser
import xyz.nulldev.ts.config.GlobalConfigManager
import java.io.File
private fun validateValue(
exception: Exception,
validate: () -> Boolean,
) {
if (!validate()) {
throw exception
}
}
private fun <T> validateValue(
value: T?,
exception: Exception,
validate: (value: T) -> Boolean,
) {
if (value != null) {
validateValue(exception) { validate(value) }
}
}
private fun <T> validateValue(
value: T?,
name: String,
validate: (value: T) -> Boolean,
) {
validateValue(value, Exception("Invalid value for \"$name\" [$value]"), validate)
}
private fun validateFilePath(
value: String?,
name: String,
) {
validateValue(value, name) { File(it).exists() }
}
class SettingsMutation {
data class SetSettingsInput(
@@ -62,176 +25,14 @@ class SettingsMutation {
val settings: SettingsType,
)
private fun validateSettings(settings: Settings) {
validateValue(settings.ip, "ip") { it.matches("^((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}$".toRegex()) }
// proxy
validateValue(settings.socksProxyVersion, "socksProxyVersion") { it == 4 || it == 5 }
// webUI
validateFilePath(settings.electronPath, "electronPath")
validateValue(settings.webUIUpdateCheckInterval, "webUIUpdateCheckInterval") { it == 0.0 || it in 1.0..23.0 }
// downloader
validateFilePath(settings.downloadsPath, "downloadsPath")
validateValue(settings.autoDownloadNewChaptersLimit, "autoDownloadNewChaptersLimit") { it >= 0 }
// extensions
validateValue(settings.extensionRepos, "extensionRepos") { it.all { repoUrl -> repoUrl.matches(repoMatchRegex) } }
// requests
validateValue(settings.maxSourcesInParallel, "maxSourcesInParallel") { it in 1..20 }
// updater
validateValue(settings.globalUpdateInterval, "globalUpdateInterval") { it == 0.0 || it >= 6 }
// misc
validateValue(settings.maxLogFiles, "maxLogFiles") { it >= 0 }
val logbackSizePattern = "^[0-9]+(|kb|KB|mb|MB|gb|GB)$".toRegex()
validateValue(settings.maxLogFileSize, "maxLogFolderSize") { it.matches(logbackSizePattern) }
validateValue(settings.maxLogFolderSize, "maxLogFolderSize") { it.matches(logbackSizePattern) }
// backup
validateFilePath(settings.backupPath, "backupPath")
validateValue(settings.backupTime, "backupTime") { it.matches("^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$".toRegex()) }
validateValue(settings.backupInterval, "backupInterval") { it == 0 || it >= 1 }
validateValue(settings.backupTTL, "backupTTL") { it == 0 || it >= 1 }
// local source
validateFilePath(settings.localSourcePath, "localSourcePath")
// opds
validateValue(settings.opdsItemsPerPage, "opdsItemsPerPage") { it in 10..5000 }
}
private fun <SettingType : Any> updateSetting(
newSetting: SettingType?,
configSetting: MutableStateFlow<SettingType>,
) {
if (newSetting == null) {
return
}
configSetting.value = newSetting
}
private fun <SettingType : Any, RealSettingType : Any> updateSetting(
newSetting: RealSettingType?,
configSetting: MutableStateFlow<SettingType>,
mapper: (RealSettingType) -> SettingType,
) {
if (newSetting == null) {
return
}
configSetting.value = mapper(newSetting)
}
@GraphQLIgnore
fun updateSettings(settings: Settings) {
updateSetting(settings.ip, serverConfig.ip)
updateSetting(settings.port, serverConfig.port)
// proxy
updateSetting(settings.socksProxyEnabled, serverConfig.socksProxyEnabled)
updateSetting(settings.socksProxyVersion, serverConfig.socksProxyVersion)
updateSetting(settings.socksProxyHost, serverConfig.socksProxyHost)
updateSetting(settings.socksProxyPort, serverConfig.socksProxyPort)
updateSetting(settings.socksProxyUsername, serverConfig.socksProxyUsername)
updateSetting(settings.socksProxyPassword, serverConfig.socksProxyPassword)
// webUI
updateSetting(settings.webUIFlavor, serverConfig.webUIFlavor)
updateSetting(settings.initialOpenInBrowserEnabled, serverConfig.initialOpenInBrowserEnabled)
updateSetting(settings.webUIInterface, serverConfig.webUIInterface)
updateSetting(settings.electronPath, serverConfig.electronPath)
updateSetting(settings.webUIChannel, serverConfig.webUIChannel)
updateSetting(settings.webUIUpdateCheckInterval, serverConfig.webUIUpdateCheckInterval)
// downloader
updateSetting(settings.downloadAsCbz, serverConfig.downloadAsCbz)
updateSetting(settings.downloadsPath, serverConfig.downloadsPath)
updateSetting(settings.autoDownloadNewChapters, serverConfig.autoDownloadNewChapters)
updateSetting(settings.excludeEntryWithUnreadChapters, serverConfig.excludeEntryWithUnreadChapters)
updateSetting(settings.autoDownloadAheadLimit, serverConfig.autoDownloadNewChaptersLimit) // deprecated
updateSetting(settings.autoDownloadNewChaptersLimit, serverConfig.autoDownloadNewChaptersLimit)
updateSetting(settings.autoDownloadIgnoreReUploads, serverConfig.autoDownloadIgnoreReUploads)
updateSetting(settings.downloadConversions, serverConfig.downloadConversions) { list ->
list.associate {
it.mimeType to
ServerConfig.DownloadConversion(
target = it.target,
compressionLevel = it.compressionLevel,
)
}
val validationErrors = SettingsValidator.validate(settings, true)
if (validationErrors.isNotEmpty()) {
throw Exception("Validation errors: ${validationErrors.joinToString("; ")}")
}
// extension
updateSetting(settings.extensionRepos, serverConfig.extensionRepos)
// requests
updateSetting(settings.maxSourcesInParallel, serverConfig.maxSourcesInParallel)
// updater
updateSetting(settings.excludeUnreadChapters, serverConfig.excludeUnreadChapters)
updateSetting(settings.excludeNotStarted, serverConfig.excludeNotStarted)
updateSetting(settings.excludeCompleted, serverConfig.excludeCompleted)
updateSetting(settings.globalUpdateInterval, serverConfig.globalUpdateInterval)
updateSetting(settings.updateMangas, serverConfig.updateMangas)
// Authentication
updateSetting(settings.authMode, serverConfig.authMode)
updateSetting(settings.jwtAudience, serverConfig.jwtAudience)
updateSetting(settings.jwtTokenExpiry, serverConfig.jwtTokenExpiry)
updateSetting(settings.jwtRefreshExpiry, serverConfig.jwtRefreshExpiry)
updateSetting(settings.authUsername, serverConfig.authUsername)
updateSetting(settings.authPassword, serverConfig.authPassword)
updateSetting(settings.basicAuthEnabled, serverConfig.basicAuthEnabled)
updateSetting(settings.basicAuthUsername, serverConfig.basicAuthUsername)
updateSetting(settings.basicAuthPassword, serverConfig.basicAuthPassword)
// misc
updateSetting(settings.debugLogsEnabled, serverConfig.debugLogsEnabled)
updateSetting(settings.systemTrayEnabled, serverConfig.systemTrayEnabled)
updateSetting(settings.maxLogFiles, serverConfig.maxLogFiles)
updateSetting(settings.maxLogFileSize, serverConfig.maxLogFileSize)
updateSetting(settings.maxLogFolderSize, serverConfig.maxLogFolderSize)
// backup
updateSetting(settings.backupPath, serverConfig.backupPath)
updateSetting(settings.backupTime, serverConfig.backupTime)
updateSetting(settings.backupInterval, serverConfig.backupInterval)
updateSetting(settings.backupTTL, serverConfig.backupTTL)
// local source
updateSetting(settings.localSourcePath, serverConfig.localSourcePath)
// cloudflare bypass
updateSetting(settings.flareSolverrEnabled, serverConfig.flareSolverrEnabled)
updateSetting(settings.flareSolverrUrl, serverConfig.flareSolverrUrl)
updateSetting(settings.flareSolverrTimeout, serverConfig.flareSolverrTimeout)
updateSetting(settings.flareSolverrSessionName, serverConfig.flareSolverrSessionName)
updateSetting(settings.flareSolverrSessionTtl, serverConfig.flareSolverrSessionTtl)
updateSetting(settings.flareSolverrAsResponseFallback, serverConfig.flareSolverrAsResponseFallback)
// opds
updateSetting(settings.opdsUseBinaryFileSizes, serverConfig.opdsUseBinaryFileSizes)
updateSetting(settings.opdsItemsPerPage, serverConfig.opdsItemsPerPage)
updateSetting(settings.opdsEnablePageReadProgress, serverConfig.opdsEnablePageReadProgress)
updateSetting(settings.opdsMarkAsReadOnDownload, serverConfig.opdsMarkAsReadOnDownload)
updateSetting(settings.opdsShowOnlyUnreadChapters, serverConfig.opdsShowOnlyUnreadChapters)
updateSetting(settings.opdsShowOnlyDownloadedChapters, serverConfig.opdsShowOnlyDownloadedChapters)
updateSetting(settings.opdsChapterSortOrder, serverConfig.opdsChapterSortOrder)
// koreader sync
updateSetting(settings.koreaderSyncServerUrl, serverConfig.koreaderSyncServerUrl)
updateSetting(settings.koreaderSyncUsername, serverConfig.koreaderSyncUsername)
updateSetting(settings.koreaderSyncUserkey, serverConfig.koreaderSyncUserkey)
updateSetting(settings.koreaderSyncDeviceId, serverConfig.koreaderSyncDeviceId)
updateSetting(settings.koreaderSyncChecksumMethod, serverConfig.koreaderSyncChecksumMethod)
updateSetting(settings.koreaderSyncStrategy, serverConfig.koreaderSyncStrategy)
updateSetting(settings.koreaderSyncPercentageTolerance, serverConfig.koreaderSyncPercentageTolerance)
SettingsUpdater.updateAll(settings)
}
fun setSettings(
@@ -241,7 +42,6 @@ class SettingsMutation {
dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser()
val (clientMutationId, settings) = input
validateSettings(settings)
updateSettings(settings)
return SetSettingsPayload(clientMutationId, SettingsType())

View File

@@ -1,18 +1,5 @@
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,
}
data class KoSyncStatusPayload(
val isLoggedIn: Boolean,
val username: String?,

View File

@@ -1,417 +0,0 @@
/*
* 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/. */
package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
import org.jetbrains.exposed.sql.SortOrder
import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.server.ServerConfig
import suwayomi.tachidesk.server.serverConfig
import kotlin.time.Duration
interface Settings : Node {
val ip: String?
val port: Int?
// proxy
val socksProxyEnabled: Boolean?
val socksProxyVersion: Int?
val socksProxyHost: String?
val socksProxyPort: String?
val socksProxyUsername: String?
val socksProxyPassword: String?
// webUI
// requires restart (found no way to mutate (serve + "unserve") served files during runtime), exclude for now
// val webUIEnabled: Boolean,
val webUIFlavor: WebUIFlavor?
val initialOpenInBrowserEnabled: Boolean?
val webUIInterface: WebUIInterface?
val electronPath: String?
val webUIChannel: WebUIChannel?
val webUIUpdateCheckInterval: Double?
// downloader
val downloadAsCbz: Boolean?
val downloadsPath: String?
val autoDownloadNewChapters: Boolean?
val excludeEntryWithUnreadChapters: Boolean?
@GraphQLDeprecated(
"Replaced with autoDownloadNewChaptersLimit",
replaceWith = ReplaceWith("autoDownloadNewChaptersLimit"),
)
val autoDownloadAheadLimit: Int?
val autoDownloadNewChaptersLimit: Int?
val autoDownloadIgnoreReUploads: Boolean?
val downloadConversions: List<SettingsDownloadConversion>?
// extension
val extensionRepos: List<String>?
// requests
val maxSourcesInParallel: Int?
// updater
val excludeUnreadChapters: Boolean?
val excludeNotStarted: Boolean?
val excludeCompleted: Boolean?
val globalUpdateInterval: Double?
val updateMangas: Boolean?
// Authentication
val authMode: AuthMode?
val jwtAudience: String?
val jwtTokenExpiry: Duration?
val jwtRefreshExpiry: Duration?
val authUsername: String?
val authPassword: String?
@GraphQLDeprecated("Removed - prefer authMode")
val basicAuthEnabled: Boolean?
@GraphQLDeprecated("Removed - prefer authUsername")
val basicAuthUsername: String?
@GraphQLDeprecated("Removed - prefer authPassword")
val basicAuthPassword: String?
// misc
val debugLogsEnabled: Boolean?
@GraphQLDeprecated("Removed - does not do anything")
val gqlDebugLogsEnabled: Boolean?
val systemTrayEnabled: Boolean?
val maxLogFiles: Int?
val maxLogFileSize: String?
val maxLogFolderSize: String?
// backup
val backupPath: String?
val backupTime: String?
val backupInterval: Int?
val backupTTL: Int?
// local source
val localSourcePath: String?
// cloudflare bypass
val flareSolverrEnabled: Boolean?
val flareSolverrUrl: String?
val flareSolverrTimeout: Int?
val flareSolverrSessionName: String?
val flareSolverrSessionTtl: Int?
val flareSolverrAsResponseFallback: Boolean?
// opds
val opdsUseBinaryFileSizes: Boolean?
val opdsItemsPerPage: Int?
val opdsEnablePageReadProgress: Boolean?
val opdsMarkAsReadOnDownload: Boolean?
val opdsShowOnlyUnreadChapters: Boolean?
val opdsShowOnlyDownloadedChapters: Boolean?
val opdsChapterSortOrder: SortOrder?
// koreader sync
val koreaderSyncServerUrl: String?
val koreaderSyncUsername: String?
val koreaderSyncUserkey: String?
val koreaderSyncDeviceId: String?
val koreaderSyncChecksumMethod: KoreaderSyncChecksumMethod?
val koreaderSyncStrategy: KoreaderSyncStrategy?
val koreaderSyncPercentageTolerance: Double?
}
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
data class PartialSettingsType(
override val ip: String?,
override val port: Int?,
// proxy
override val socksProxyEnabled: Boolean?,
override val socksProxyVersion: Int?,
override val socksProxyHost: String?,
override val socksProxyPort: String?,
override val socksProxyUsername: String?,
override val socksProxyPassword: String?,
// webUI
override val webUIFlavor: WebUIFlavor?,
override val initialOpenInBrowserEnabled: Boolean?,
override val webUIInterface: WebUIInterface?,
override val electronPath: String?,
override val webUIChannel: WebUIChannel?,
override val webUIUpdateCheckInterval: Double?,
// downloader
override val downloadAsCbz: Boolean?,
override val downloadsPath: String?,
override val autoDownloadNewChapters: Boolean?,
override val excludeEntryWithUnreadChapters: Boolean?,
@GraphQLDeprecated(
"Replaced with autoDownloadNewChaptersLimit",
replaceWith = ReplaceWith("autoDownloadNewChaptersLimit"),
)
override val autoDownloadAheadLimit: Int?,
override val autoDownloadNewChaptersLimit: Int?,
override val autoDownloadIgnoreReUploads: Boolean?,
override val downloadConversions: List<SettingsDownloadConversionType>?,
// extension
override val extensionRepos: List<String>?,
// requests
override val maxSourcesInParallel: Int?,
// updater
override val excludeUnreadChapters: Boolean?,
override val excludeNotStarted: Boolean?,
override val excludeCompleted: Boolean?,
override val globalUpdateInterval: Double?,
override val updateMangas: Boolean?,
// Authentication
override val authMode: AuthMode?,
override val jwtAudience: String?,
override val jwtTokenExpiry: Duration?,
override val jwtRefreshExpiry: Duration?,
override val authUsername: String?,
override val authPassword: String?,
@GraphQLDeprecated("Removed - prefer authMode")
override val basicAuthEnabled: Boolean?,
@GraphQLDeprecated("Removed - prefer authUsername")
override val basicAuthUsername: String?,
@GraphQLDeprecated("Removed - prefer authPassword")
override val basicAuthPassword: String?,
// misc
override val debugLogsEnabled: Boolean?,
@GraphQLDeprecated("Removed - does not do anything")
override val gqlDebugLogsEnabled: Boolean?,
override val systemTrayEnabled: Boolean?,
override val maxLogFiles: Int?,
override val maxLogFileSize: String?,
override val maxLogFolderSize: String?,
// backup
override val backupPath: String?,
override val backupTime: String?,
override val backupInterval: Int?,
override val backupTTL: Int?,
// local source
override val localSourcePath: String?,
// cloudflare bypass
override val flareSolverrEnabled: Boolean?,
override val flareSolverrUrl: String?,
override val flareSolverrTimeout: Int?,
override val flareSolverrSessionName: String?,
override val flareSolverrSessionTtl: Int?,
override val flareSolverrAsResponseFallback: Boolean?,
// opds
override val opdsUseBinaryFileSizes: Boolean?,
override val opdsItemsPerPage: Int?,
override val opdsEnablePageReadProgress: Boolean?,
override val opdsMarkAsReadOnDownload: Boolean?,
override val opdsShowOnlyUnreadChapters: Boolean?,
override val opdsShowOnlyDownloadedChapters: Boolean?,
override val opdsChapterSortOrder: SortOrder?,
// koreader sync
override val koreaderSyncServerUrl: String?,
override val koreaderSyncUsername: String?,
override val koreaderSyncUserkey: String?,
override val koreaderSyncDeviceId: String?,
override val koreaderSyncChecksumMethod: KoreaderSyncChecksumMethod?,
override val koreaderSyncStrategy: KoreaderSyncStrategy?,
override val koreaderSyncPercentageTolerance: Double?,
) : Settings
class SettingsType(
override val ip: String,
override val port: Int,
// proxy
override val socksProxyEnabled: Boolean,
override val socksProxyVersion: Int,
override val socksProxyHost: String,
override val socksProxyPort: String,
override val socksProxyUsername: String,
override val socksProxyPassword: String,
// webUI
override val webUIFlavor: WebUIFlavor,
override val initialOpenInBrowserEnabled: Boolean,
override val webUIInterface: WebUIInterface,
override val electronPath: String,
override val webUIChannel: WebUIChannel,
override val webUIUpdateCheckInterval: Double,
// downloader
override val downloadAsCbz: Boolean,
override val downloadsPath: String,
override val autoDownloadNewChapters: Boolean,
override val excludeEntryWithUnreadChapters: Boolean,
@GraphQLDeprecated(
"Replaced with autoDownloadNewChaptersLimit",
replaceWith = ReplaceWith("autoDownloadNewChaptersLimit"),
)
override val autoDownloadAheadLimit: Int,
override val autoDownloadNewChaptersLimit: Int,
override val autoDownloadIgnoreReUploads: Boolean,
override val downloadConversions: List<SettingsDownloadConversionType>,
// extension
override val extensionRepos: List<String>,
// requests
override val maxSourcesInParallel: Int,
// updater
override val excludeUnreadChapters: Boolean,
override val excludeNotStarted: Boolean,
override val excludeCompleted: Boolean,
override val globalUpdateInterval: Double,
override val updateMangas: Boolean,
// Authentication
override val authMode: AuthMode,
override val jwtAudience: String,
override val jwtTokenExpiry: Duration,
override val jwtRefreshExpiry: Duration,
override val authUsername: String,
override val authPassword: String,
@GraphQLDeprecated("Removed - prefer authMode")
override val basicAuthEnabled: Boolean,
@GraphQLDeprecated("Removed - prefer authUsername")
override val basicAuthUsername: String,
@GraphQLDeprecated("Removed - prefer authPassword")
override val basicAuthPassword: String,
// misc
override val debugLogsEnabled: Boolean,
@GraphQLDeprecated("Removed - does not do anything")
override val gqlDebugLogsEnabled: Boolean,
override val systemTrayEnabled: Boolean,
override val maxLogFiles: Int,
override val maxLogFileSize: String,
override val maxLogFolderSize: String,
// backup
override val backupPath: String,
override val backupTime: String,
override val backupInterval: Int,
override val backupTTL: Int,
// local source
override val localSourcePath: String,
// cloudflare bypass
override val flareSolverrEnabled: Boolean,
override val flareSolverrUrl: String,
override val flareSolverrTimeout: Int,
override val flareSolverrSessionName: String,
override val flareSolverrSessionTtl: Int,
override val flareSolverrAsResponseFallback: Boolean,
// opds
override val opdsUseBinaryFileSizes: Boolean,
override val opdsItemsPerPage: Int,
override val opdsEnablePageReadProgress: Boolean,
override val opdsMarkAsReadOnDownload: Boolean,
override val opdsShowOnlyUnreadChapters: Boolean,
override val opdsShowOnlyDownloadedChapters: Boolean,
override val opdsChapterSortOrder: SortOrder,
// koreader sync
override val koreaderSyncServerUrl: String,
override val koreaderSyncUsername: String,
override val koreaderSyncUserkey: String,
override val koreaderSyncDeviceId: String,
override val koreaderSyncChecksumMethod: KoreaderSyncChecksumMethod,
override val koreaderSyncStrategy: KoreaderSyncStrategy,
override val koreaderSyncPercentageTolerance: Double,
) : Settings {
constructor(config: ServerConfig = serverConfig) : this(
config.ip.value,
config.port.value,
// proxy
config.socksProxyEnabled.value,
config.socksProxyVersion.value,
config.socksProxyHost.value,
config.socksProxyPort.value,
config.socksProxyUsername.value,
config.socksProxyPassword.value,
// webUI
config.webUIFlavor.value,
config.initialOpenInBrowserEnabled.value,
config.webUIInterface.value,
config.electronPath.value,
config.webUIChannel.value,
config.webUIUpdateCheckInterval.value,
// downloader
config.downloadAsCbz.value,
config.downloadsPath.value,
config.autoDownloadNewChapters.value,
config.excludeEntryWithUnreadChapters.value,
config.autoDownloadNewChaptersLimit.value, // deprecated
config.autoDownloadNewChaptersLimit.value,
config.autoDownloadIgnoreReUploads.value,
config.downloadConversions.value.map {
SettingsDownloadConversionType(
it.key,
it.value.target,
it.value.compressionLevel,
)
},
// extension
config.extensionRepos.value,
// requests
config.maxSourcesInParallel.value,
// updater
config.excludeUnreadChapters.value,
config.excludeNotStarted.value,
config.excludeCompleted.value,
config.globalUpdateInterval.value,
config.updateMangas.value,
// Authentication
config.authMode.value,
config.jwtAudience.value,
config.jwtTokenExpiry.value,
config.jwtRefreshExpiry.value,
config.authUsername.value,
config.authPassword.value,
config.basicAuthEnabled.value,
config.basicAuthUsername.value,
config.basicAuthPassword.value,
// misc
config.debugLogsEnabled.value,
false,
config.systemTrayEnabled.value,
config.maxLogFiles.value,
config.maxLogFileSize.value,
config.maxLogFolderSize.value,
// backup
config.backupPath.value,
config.backupTime.value,
config.backupInterval.value,
config.backupTTL.value,
// local source
config.localSourcePath.value,
// cloudflare bypass
config.flareSolverrEnabled.value,
config.flareSolverrUrl.value,
config.flareSolverrTimeout.value,
config.flareSolverrSessionName.value,
config.flareSolverrSessionTtl.value,
config.flareSolverrAsResponseFallback.value,
// opds
config.opdsUseBinaryFileSizes.value,
config.opdsItemsPerPage.value,
config.opdsEnablePageReadProgress.value,
config.opdsMarkAsReadOnDownload.value,
config.opdsShowOnlyUnreadChapters.value,
config.opdsShowOnlyDownloadedChapters.value,
config.opdsChapterSortOrder.value,
// koreader sync
config.koreaderSyncServerUrl.value,
config.koreaderSyncUsername.value,
config.koreaderSyncUserkey.value,
config.koreaderSyncDeviceId.value,
config.koreaderSyncChecksumMethod.value,
config.koreaderSyncStrategy.value,
config.koreaderSyncPercentageTolerance.value,
)
}

View File

@@ -31,13 +31,12 @@ import suwayomi.tachidesk.manga.impl.Chapter
import suwayomi.tachidesk.manga.impl.Manga
import suwayomi.tachidesk.manga.impl.Source
import suwayomi.tachidesk.manga.impl.backup.BackupFlags
import suwayomi.tachidesk.manga.impl.backup.proto.handlers.BackupSettingsHandler
import suwayomi.tachidesk.manga.impl.backup.proto.models.Backup
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupCategory
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupChapter
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupHistory
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupManga
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupServerSettings
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupServerSettings.BackupSettingsDownloadConversionType
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSource
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupTracking
import suwayomi.tachidesk.manga.impl.track.Track
@@ -97,15 +96,11 @@ object ProtoBackupExport : ProtoBackupBase() {
}
}
val (hour, minute) =
val (backupHour, backupMinute) =
serverConfig.backupTime.value
.split(":")
.map { it.toInt() }
val backupHour = hour.coerceAtLeast(0).coerceAtMost(23)
val backupMinute = minute.coerceAtLeast(0).coerceAtMost(59)
val backupInterval =
serverConfig.backupInterval.value.days
.coerceAtLeast(1.days)
val backupInterval = serverConfig.backupInterval.value.days
// trigger last backup in case the server wasn't running on the scheduled time
val lastAutomatedBackup = preferences.getLong(LAST_AUTOMATED_BACKUP_KEY, 0)
@@ -193,7 +188,7 @@ object ProtoBackupExport : ProtoBackupBase() {
backupCategories(flags),
backupExtensionInfo(databaseManga, flags),
backupGlobalMeta(flags),
backupServerSettings(flags),
BackupSettingsHandler.backup(flags),
)
}
@@ -378,102 +373,4 @@ object ProtoBackupExport : ProtoBackupBase() {
return GlobalMeta.getMetaMap()
}
private fun backupServerSettings(flags: BackupFlags): BackupServerSettings? {
if (!flags.includeServerSettings) {
return null
}
return BackupServerSettings(
ip = serverConfig.ip.value,
port = serverConfig.port.value,
// socks
socksProxyEnabled = serverConfig.socksProxyEnabled.value,
socksProxyVersion = serverConfig.socksProxyVersion.value,
socksProxyHost = serverConfig.socksProxyHost.value,
socksProxyPort = serverConfig.socksProxyPort.value,
socksProxyUsername = serverConfig.socksProxyUsername.value,
socksProxyPassword = serverConfig.socksProxyPassword.value,
// webUI
webUIFlavor = serverConfig.webUIFlavor.value,
initialOpenInBrowserEnabled = serverConfig.initialOpenInBrowserEnabled.value,
webUIInterface = serverConfig.webUIInterface.value,
electronPath = serverConfig.electronPath.value,
webUIChannel = serverConfig.webUIChannel.value,
webUIUpdateCheckInterval = serverConfig.webUIUpdateCheckInterval.value,
// downloader
downloadAsCbz = serverConfig.downloadAsCbz.value,
downloadsPath = serverConfig.downloadsPath.value,
autoDownloadNewChapters = serverConfig.autoDownloadNewChapters.value,
excludeEntryWithUnreadChapters = serverConfig.excludeEntryWithUnreadChapters.value,
autoDownloadAheadLimit = 0, // deprecated
autoDownloadNewChaptersLimit = serverConfig.autoDownloadNewChaptersLimit.value,
autoDownloadIgnoreReUploads = serverConfig.autoDownloadIgnoreReUploads.value,
downloadConversions =
serverConfig.downloadConversions.value.map {
BackupSettingsDownloadConversionType(
it.key,
it.value.target,
it.value.compressionLevel,
)
},
// extension
extensionRepos = serverConfig.extensionRepos.value,
// requests
maxSourcesInParallel = serverConfig.maxSourcesInParallel.value,
// updater
excludeUnreadChapters = serverConfig.excludeUnreadChapters.value,
excludeNotStarted = serverConfig.excludeNotStarted.value,
excludeCompleted = serverConfig.excludeCompleted.value,
globalUpdateInterval = serverConfig.globalUpdateInterval.value,
updateMangas = serverConfig.updateMangas.value,
// Authentication
authMode = serverConfig.authMode.value,
jwtAudience = serverConfig.jwtAudience.value,
jwtTokenExpiry = serverConfig.jwtTokenExpiry.value,
jwtRefreshExpiry = serverConfig.jwtRefreshExpiry.value,
authUsername = serverConfig.authUsername.value,
authPassword = serverConfig.authPassword.value,
basicAuthEnabled = false,
basicAuthUsername = null,
basicAuthPassword = null,
// misc
debugLogsEnabled = serverConfig.debugLogsEnabled.value,
gqlDebugLogsEnabled = false, // deprecated
systemTrayEnabled = serverConfig.systemTrayEnabled.value,
maxLogFiles = serverConfig.maxLogFiles.value,
maxLogFileSize = serverConfig.maxLogFileSize.value,
maxLogFolderSize = serverConfig.maxLogFolderSize.value,
// backup
backupPath = serverConfig.backupPath.value,
backupTime = serverConfig.backupTime.value,
backupInterval = serverConfig.backupInterval.value,
backupTTL = serverConfig.backupTTL.value,
// local source
localSourcePath = serverConfig.localSourcePath.value,
// cloudflare bypass
flareSolverrEnabled = serverConfig.flareSolverrEnabled.value,
flareSolverrUrl = serverConfig.flareSolverrUrl.value,
flareSolverrTimeout = serverConfig.flareSolverrTimeout.value,
flareSolverrSessionName = serverConfig.flareSolverrSessionName.value,
flareSolverrSessionTtl = serverConfig.flareSolverrSessionTtl.value,
flareSolverrAsResponseFallback = serverConfig.flareSolverrAsResponseFallback.value,
// opds
opdsUseBinaryFileSizes = serverConfig.opdsUseBinaryFileSizes.value,
opdsItemsPerPage = serverConfig.opdsItemsPerPage.value,
opdsEnablePageReadProgress = serverConfig.opdsEnablePageReadProgress.value,
opdsMarkAsReadOnDownload = serverConfig.opdsMarkAsReadOnDownload.value,
opdsShowOnlyUnreadChapters = serverConfig.opdsShowOnlyUnreadChapters.value,
opdsShowOnlyDownloadedChapters = serverConfig.opdsShowOnlyDownloadedChapters.value,
opdsChapterSortOrder = serverConfig.opdsChapterSortOrder.value,
// koreader sync
koreaderSyncServerUrl = serverConfig.koreaderSyncServerUrl.value,
koreaderSyncUsername = serverConfig.koreaderSyncUsername.value,
koreaderSyncUserkey = serverConfig.koreaderSyncUserkey.value,
koreaderSyncDeviceId = serverConfig.koreaderSyncDeviceId.value,
koreaderSyncChecksumMethod = serverConfig.koreaderSyncChecksumMethod.value,
koreaderSyncStrategy = serverConfig.koreaderSyncStrategy.value,
koreaderSyncPercentageTolerance = serverConfig.koreaderSyncPercentageTolerance.value,
)
}
}

View File

@@ -31,8 +31,6 @@ import org.jetbrains.exposed.sql.statements.BatchUpdateStatement
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.global.impl.GlobalMeta
import suwayomi.tachidesk.graphql.mutations.SettingsMutation
import suwayomi.tachidesk.graphql.types.AuthMode
import suwayomi.tachidesk.graphql.types.toStatus
import suwayomi.tachidesk.manga.impl.Category
import suwayomi.tachidesk.manga.impl.Category.modifyCategoriesMetas
@@ -43,12 +41,12 @@ import suwayomi.tachidesk.manga.impl.Manga.modifyMangasMetas
import suwayomi.tachidesk.manga.impl.Source.modifySourceMetas
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator.ValidationResult
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator.validate
import suwayomi.tachidesk.manga.impl.backup.proto.handlers.BackupSettingsHandler
import suwayomi.tachidesk.manga.impl.backup.proto.models.Backup
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupCategory
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupChapter
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupHistory
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupManga
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupServerSettings
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSource
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupTracking
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
@@ -58,7 +56,6 @@ import suwayomi.tachidesk.manga.model.dataclass.TrackRecordDataClass
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.server.database.dbTransaction
import suwayomi.tachidesk.server.serverConfig
import java.io.InputStream
import java.util.Date
import java.util.Timer
@@ -215,7 +212,7 @@ object ProtoBackupImport : ProtoBackupBase() {
BackupRestoreState.RestoringSettings(restoreCategories + restoreMeta + restoreSettings, restoreAmount),
)
restoreServerSettings(backup.serverSettings)
BackupSettingsHandler.restore(backup.serverSettings)
// Store source mapping for error messages
val sourceMapping = backup.getSourceMap()
@@ -522,21 +519,5 @@ object ProtoBackupImport : ProtoBackupBase() {
modifySourceMetas(backupSources.associateBy { it.sourceId }.mapValues { it.value.meta })
}
private fun restoreServerSettings(backupServerSettings: BackupServerSettings?) {
if (backupServerSettings == null) {
return
}
SettingsMutation().updateSettings(
backupServerSettings.copy(
// legacy settings cannot overwrite new settings
basicAuthEnabled =
backupServerSettings.basicAuthEnabled.takeIf {
serverConfig.authMode.value == AuthMode.NONE
},
),
)
}
private fun TrackRecordDataClass.forComparison() = this.copy(id = 0, mangaId = 0)
}

View File

@@ -1,95 +0,0 @@
package suwayomi.tachidesk.manga.impl.backup.proto.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import org.jetbrains.exposed.sql.SortOrder
import suwayomi.tachidesk.graphql.types.AuthMode
import suwayomi.tachidesk.graphql.types.KoreaderSyncChecksumMethod
import suwayomi.tachidesk.graphql.types.KoreaderSyncStrategy
import suwayomi.tachidesk.graphql.types.Settings
import suwayomi.tachidesk.graphql.types.SettingsDownloadConversion
import suwayomi.tachidesk.graphql.types.WebUIChannel
import suwayomi.tachidesk.graphql.types.WebUIFlavor
import suwayomi.tachidesk.graphql.types.WebUIInterface
import kotlin.time.Duration
@Serializable
data class BackupServerSettings(
@ProtoNumber(1) override var ip: String,
@ProtoNumber(2) override var port: Int,
@ProtoNumber(3) override var socksProxyEnabled: Boolean,
@ProtoNumber(4) override var socksProxyVersion: Int,
@ProtoNumber(5) override var socksProxyHost: String,
@ProtoNumber(6) override var socksProxyPort: String,
@ProtoNumber(7) override var socksProxyUsername: String,
@ProtoNumber(8) override var socksProxyPassword: String,
@ProtoNumber(9) override var webUIFlavor: WebUIFlavor,
@ProtoNumber(10) override var initialOpenInBrowserEnabled: Boolean,
@ProtoNumber(11) override var webUIInterface: WebUIInterface,
@ProtoNumber(12) override var electronPath: String,
@ProtoNumber(13) override var webUIChannel: WebUIChannel,
@ProtoNumber(14) override var webUIUpdateCheckInterval: Double,
@ProtoNumber(15) override var downloadAsCbz: Boolean,
@ProtoNumber(16) override var downloadsPath: String,
@ProtoNumber(17) override var autoDownloadNewChapters: Boolean,
@ProtoNumber(18) override var excludeEntryWithUnreadChapters: Boolean,
@ProtoNumber(19) override var autoDownloadAheadLimit: Int,
@ProtoNumber(20) override var autoDownloadNewChaptersLimit: Int,
@ProtoNumber(21) override var autoDownloadIgnoreReUploads: Boolean,
@ProtoNumber(22) override var extensionRepos: List<String>,
@ProtoNumber(23) override var maxSourcesInParallel: Int,
@ProtoNumber(24) override var excludeUnreadChapters: Boolean,
@ProtoNumber(25) override var excludeNotStarted: Boolean,
@ProtoNumber(26) override var excludeCompleted: Boolean,
@ProtoNumber(27) override var globalUpdateInterval: Double,
@ProtoNumber(28) override var updateMangas: Boolean,
@ProtoNumber(29) override var basicAuthEnabled: Boolean?,
@ProtoNumber(30) override var authUsername: String,
@ProtoNumber(31) override var authPassword: String,
@ProtoNumber(32) override var debugLogsEnabled: Boolean,
@ProtoNumber(33) override var gqlDebugLogsEnabled: Boolean,
@ProtoNumber(34) override var systemTrayEnabled: Boolean,
@ProtoNumber(35) override var maxLogFiles: Int,
@ProtoNumber(36) override var maxLogFileSize: String,
@ProtoNumber(37) override var maxLogFolderSize: String,
@ProtoNumber(38) override var backupPath: String,
@ProtoNumber(39) override var backupTime: String,
@ProtoNumber(40) override var backupInterval: Int,
@ProtoNumber(41) override var backupTTL: Int,
@ProtoNumber(42) override var localSourcePath: String,
@ProtoNumber(43) override var flareSolverrEnabled: Boolean,
@ProtoNumber(44) override var flareSolverrUrl: String,
@ProtoNumber(45) override var flareSolverrTimeout: Int,
@ProtoNumber(46) override var flareSolverrSessionName: String,
@ProtoNumber(47) override var flareSolverrSessionTtl: Int,
@ProtoNumber(48) override var flareSolverrAsResponseFallback: Boolean,
@ProtoNumber(49) override var opdsUseBinaryFileSizes: Boolean,
@ProtoNumber(50) override var opdsItemsPerPage: Int,
@ProtoNumber(51) override var opdsEnablePageReadProgress: Boolean,
@ProtoNumber(52) override var opdsMarkAsReadOnDownload: Boolean,
@ProtoNumber(53) override var opdsShowOnlyUnreadChapters: Boolean,
@ProtoNumber(54) override var opdsShowOnlyDownloadedChapters: Boolean,
@ProtoNumber(55) override var opdsChapterSortOrder: SortOrder,
@ProtoNumber(56) override var authMode: AuthMode,
@ProtoNumber(57) override val downloadConversions: List<BackupSettingsDownloadConversionType>?,
@ProtoNumber(58) override var jwtAudience: String?,
@ProtoNumber(59) override var koreaderSyncServerUrl: String,
@ProtoNumber(60) override var koreaderSyncUsername: String,
@ProtoNumber(61) override var koreaderSyncUserkey: String,
@ProtoNumber(62) override var koreaderSyncDeviceId: String,
@ProtoNumber(63) override var koreaderSyncChecksumMethod: KoreaderSyncChecksumMethod,
@ProtoNumber(64) override var koreaderSyncStrategy: KoreaderSyncStrategy,
@ProtoNumber(65) override var koreaderSyncPercentageTolerance: Double,
@ProtoNumber(66) override var jwtTokenExpiry: Duration?,
@ProtoNumber(67) override var jwtRefreshExpiry: Duration?,
// Deprecated settings
@ProtoNumber(99991) override var basicAuthUsername: String?,
@ProtoNumber(99992) override var basicAuthPassword: String?,
) : Settings {
@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,12 @@
package suwayomi.tachidesk.manga.impl.backup.proto.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import suwayomi.tachidesk.graphql.types.SettingsDownloadConversion
@Serializable
class BackupSettingsDownloadConversionType(
@ProtoNumber(1) override val mimeType: String,
@ProtoNumber(2) override val target: String,
@ProtoNumber(3) override val compressionLevel: Double?,
) : SettingsDownloadConversion

View File

@@ -261,7 +261,7 @@ object DownloadManager {
"Failed: ${downloadQueue.size - availableDownloads.size}"
}
if (runningDownloaders.size < serverConfig.maxSourcesInParallel.value.coerceAtLeast(1)) {
if (runningDownloaders.size < serverConfig.maxSourcesInParallel.value) {
availableDownloads
.asSequence()
.map { it.manga.sourceId }

View File

@@ -14,6 +14,7 @@ import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.graphql.types.DownloadConversion
import suwayomi.tachidesk.manga.impl.Page
import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReady
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
@@ -25,7 +26,6 @@ import suwayomi.tachidesk.manga.impl.util.getChapterDownloadPath
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.server.ServerConfig
import suwayomi.tachidesk.server.serverConfig
import suwayomi.tachidesk.util.ConversionUtil
import java.io.File
@@ -261,7 +261,7 @@ abstract class ChaptersFilesProvider<Type : FileType>(
private fun convertPage(
page: File,
conversion: ServerConfig.DownloadConversion,
conversion: DownloadConversion,
) {
val (targetMime, compressionLevel) = conversion

View File

@@ -96,8 +96,7 @@ class Updater : IUpdater {
serverConfig.subscribeTo(serverConfig.globalUpdateInterval, ::scheduleUpdateTask)
serverConfig.subscribeTo(
serverConfig.maxSourcesInParallel,
{ value ->
val newMaxPermits = value.coerceAtLeast(1).coerceAtMost(20)
{ newMaxPermits ->
val permitDifference = maxSourcesInParallel - newMaxPermits
maxSourcesInParallel = newMaxPermits
@@ -160,10 +159,7 @@ class Updater : IUpdater {
return
}
val updateInterval =
serverConfig.globalUpdateInterval.value.hours
.coerceAtLeast(6.hours)
.inWholeMilliseconds
val updateInterval = serverConfig.globalUpdateInterval.value.hours.inWholeMilliseconds
val lastAutomatedUpdate = getLastAutomatedUpdateTimestamp()
val isInitialScheduling = lastAutomatedUpdate == 0L

View File

@@ -27,7 +27,7 @@ class FeedBuilderInternal(
private val isSearchFeed: Boolean = false,
) {
private val opdsItemsPerPageBounded: Int
get() = serverConfig.opdsItemsPerPage.value.coerceIn(10, 5000)
get() = serverConfig.opdsItemsPerPage.value
private val feedAuthor = OpdsAuthorXml("Suwayomi", "https://suwayomi.org/")
private val feedGeneratedAt: String = OpdsDateUtil.formatCurrentInstantForOpds()

View File

@@ -597,7 +597,7 @@ object OpdsFeedBuilder {
"desc", "number_desc" -> ChapterTable.sourceOrder to SortOrder.DESC
"date_asc" -> ChapterTable.date_upload to SortOrder.ASC
"date_desc" -> ChapterTable.date_upload to SortOrder.DESC
else -> ChapterTable.sourceOrder to (serverConfig.opdsChapterSortOrder.value ?: SortOrder.ASC)
else -> ChapterTable.sourceOrder to (serverConfig.opdsChapterSortOrder.value)
}
val currentFilter = filterParam?.lowercase() ?: if (serverConfig.opdsShowOnlyUnreadChapters.value) "unread" else "all"
var (chapterEntries, totalChapters) =

View File

@@ -6,7 +6,6 @@ import org.jetbrains.exposed.sql.Op
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.andWhere
import org.jetbrains.exposed.sql.transactions.transaction
@@ -22,7 +21,7 @@ import suwayomi.tachidesk.server.serverConfig
object ChapterRepository {
private val opdsItemsPerPageBounded: Int
get() = serverConfig.opdsItemsPerPage.value.coerceIn(10, 5000)
get() = serverConfig.opdsItemsPerPage.value
private fun ResultRow.toOpdsChapterListAcqEntry(): OpdsChapterListAcqEntry =
OpdsChapterListAcqEntry(

View File

@@ -40,7 +40,7 @@ import suwayomi.tachidesk.server.serverConfig
*/
object MangaRepository {
private val opdsItemsPerPageBounded: Int
get() = serverConfig.opdsItemsPerPage.value.coerceIn(10, 5000)
get() = serverConfig.opdsItemsPerPage.value
/**
* Maps a database [ResultRow] to an [OpdsMangaAcqEntry] data transfer object.

View File

@@ -28,7 +28,7 @@ import java.util.Locale
object NavigationRepository {
private val opdsItemsPerPageBounded: Int
get() = serverConfig.opdsItemsPerPage.value.coerceIn(10, 5000)
get() = serverConfig.opdsItemsPerPage.value
private val rootSectionDetails: Map<String, Triple<String, StringResource, StringResource>> =
mapOf(

View File

@@ -1,263 +0,0 @@
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 io.github.config4k.getValue
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.filter
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.KoreaderSyncChecksumMethod
import suwayomi.tachidesk.graphql.types.KoreaderSyncStrategy
import suwayomi.tachidesk.graphql.types.WebUIChannel
import suwayomi.tachidesk.graphql.types.WebUIFlavor
import suwayomi.tachidesk.graphql.types.WebUIInterface
import xyz.nulldev.ts.config.GlobalConfigManager
import xyz.nulldev.ts.config.SystemPropertyOverridableConfigModule
import kotlin.reflect.KProperty
import kotlin.time.Duration
val mutableConfigValueScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
const val SERVER_CONFIG_MODULE_NAME = "server"
class ServerConfig(
getConfig: () -> Config,
) : SystemPropertyOverridableConfigModule(
getConfig,
SERVER_CONFIG_MODULE_NAME,
) {
open inner class OverrideConfigValue {
var flow: MutableStateFlow<Any>? = null
inline operator fun <reified T : MutableStateFlow<R>, reified R> getValue(
thisRef: ServerConfig,
property: KProperty<*>,
): T {
if (flow != null) {
return flow as T
}
val stateFlow = overridableConfig.getValue<ServerConfig, T>(thisRef, property)
@Suppress("UNCHECKED_CAST")
flow = stateFlow as MutableStateFlow<Any>
stateFlow
.drop(1)
.distinctUntilChanged()
.filter { it != thisRef.overridableConfig.getConfig().getValue<ServerConfig, R>(thisRef, property) }
.onEach { GlobalConfigManager.updateValue("$moduleName.${property.name}", it as Any) }
.launchIn(mutableConfigValueScope)
return stateFlow
}
}
open inner class MigratedConfigValue<T>(
private val readMigrated: () -> T,
private val setMigrated: (T) -> Unit,
) {
private var flow: MutableStateFlow<T>? = null
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
}
}
val ip: MutableStateFlow<String> by OverrideConfigValue()
val port: MutableStateFlow<Int> by OverrideConfigValue()
// proxy
val socksProxyEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
val socksProxyVersion: MutableStateFlow<Int> by OverrideConfigValue()
val socksProxyHost: MutableStateFlow<String> by OverrideConfigValue()
val socksProxyPort: MutableStateFlow<String> by OverrideConfigValue()
val socksProxyUsername: MutableStateFlow<String> by OverrideConfigValue()
val socksProxyPassword: MutableStateFlow<String> by OverrideConfigValue()
// webUI
val webUIEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
val webUIFlavor: MutableStateFlow<WebUIFlavor> by OverrideConfigValue()
val initialOpenInBrowserEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
val webUIInterface: MutableStateFlow<WebUIInterface> by OverrideConfigValue()
val electronPath: MutableStateFlow<String> by OverrideConfigValue()
val webUIChannel: MutableStateFlow<WebUIChannel> by OverrideConfigValue()
val webUIUpdateCheckInterval: MutableStateFlow<Double> by OverrideConfigValue()
// downloader
val downloadAsCbz: MutableStateFlow<Boolean> by OverrideConfigValue()
val downloadsPath: MutableStateFlow<String> by OverrideConfigValue()
val autoDownloadNewChapters: MutableStateFlow<Boolean> by OverrideConfigValue()
val excludeEntryWithUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue()
val autoDownloadNewChaptersLimit: MutableStateFlow<Int> by OverrideConfigValue()
val autoDownloadIgnoreReUploads: MutableStateFlow<Boolean> by OverrideConfigValue()
val downloadConversions: MutableStateFlow<Map<String, DownloadConversion>> by OverrideConfigValue()
data class DownloadConversion(
val target: String,
val compressionLevel: Double? = null,
)
// extensions
val extensionRepos: MutableStateFlow<List<String>> by OverrideConfigValue()
// requests
val maxSourcesInParallel: MutableStateFlow<Int> by OverrideConfigValue()
// updater
val excludeUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue()
val excludeNotStarted: MutableStateFlow<Boolean> by OverrideConfigValue()
val excludeCompleted: MutableStateFlow<Boolean> by OverrideConfigValue()
val globalUpdateInterval: MutableStateFlow<Double> by OverrideConfigValue()
val updateMangas: MutableStateFlow<Boolean> by OverrideConfigValue()
// Authentication
val authMode: MutableStateFlow<AuthMode> by OverrideConfigValue()
val authUsername: MutableStateFlow<String> by OverrideConfigValue()
val authPassword: MutableStateFlow<String> by OverrideConfigValue()
val jwtAudience: MutableStateFlow<String> by OverrideConfigValue()
val jwtTokenExpiry: MutableStateFlow<Duration> by OverrideConfigValue()
val jwtRefreshExpiry: MutableStateFlow<Duration> by OverrideConfigValue()
val basicAuthEnabled: MutableStateFlow<Boolean> by MigratedConfigValue({
authMode.value == AuthMode.BASIC_AUTH
}) {
authMode.value = if (it) AuthMode.BASIC_AUTH else AuthMode.NONE
}
val basicAuthUsername: MutableStateFlow<String> by MigratedConfigValue({ authUsername.value }) {
authUsername.value = it
}
val basicAuthPassword: MutableStateFlow<String> by MigratedConfigValue({ authPassword.value }) {
authPassword.value = it
}
// misc
val debugLogsEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
val systemTrayEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
val maxLogFiles: MutableStateFlow<Int> by OverrideConfigValue()
val maxLogFileSize: MutableStateFlow<String> by OverrideConfigValue()
val maxLogFolderSize: MutableStateFlow<String> by OverrideConfigValue()
// backup
val backupPath: MutableStateFlow<String> by OverrideConfigValue()
val backupTime: MutableStateFlow<String> by OverrideConfigValue()
val backupInterval: MutableStateFlow<Int> by OverrideConfigValue()
val backupTTL: MutableStateFlow<Int> by OverrideConfigValue()
// local source
val localSourcePath: MutableStateFlow<String> by OverrideConfigValue()
// cloudflare bypass
val flareSolverrEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
val flareSolverrUrl: MutableStateFlow<String> by OverrideConfigValue()
val flareSolverrTimeout: MutableStateFlow<Int> by OverrideConfigValue()
val flareSolverrSessionName: MutableStateFlow<String> by OverrideConfigValue()
val flareSolverrSessionTtl: MutableStateFlow<Int> by OverrideConfigValue()
val flareSolverrAsResponseFallback: MutableStateFlow<Boolean> by OverrideConfigValue()
// opds settings
val opdsUseBinaryFileSizes: MutableStateFlow<Boolean> by OverrideConfigValue()
val opdsItemsPerPage: MutableStateFlow<Int> by OverrideConfigValue()
val opdsEnablePageReadProgress: MutableStateFlow<Boolean> by OverrideConfigValue()
val opdsMarkAsReadOnDownload: MutableStateFlow<Boolean> by OverrideConfigValue()
val opdsShowOnlyUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue()
val opdsShowOnlyDownloadedChapters: MutableStateFlow<Boolean> by OverrideConfigValue()
val opdsChapterSortOrder: MutableStateFlow<SortOrder> by OverrideConfigValue()
// koreader sync
val koreaderSyncServerUrl: MutableStateFlow<String> by OverrideConfigValue()
val koreaderSyncUsername: MutableStateFlow<String> by OverrideConfigValue()
val koreaderSyncUserkey: MutableStateFlow<String> by OverrideConfigValue()
val koreaderSyncDeviceId: MutableStateFlow<String> by OverrideConfigValue()
val koreaderSyncChecksumMethod: MutableStateFlow<KoreaderSyncChecksumMethod> by OverrideConfigValue()
val koreaderSyncStrategy: MutableStateFlow<KoreaderSyncStrategy> by OverrideConfigValue()
val koreaderSyncPercentageTolerance: MutableStateFlow<Double> by OverrideConfigValue()
@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

@@ -19,7 +19,6 @@ import eu.kanade.tachiyomi.App
import eu.kanade.tachiyomi.createAppModule
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.local.LocalSource
import io.github.config4k.registerCustomType
import io.github.config4k.toConfig
import io.github.oshai.kotlinlogging.KotlinLogging
import io.javalin.json.JavalinJackson
@@ -48,8 +47,7 @@ import suwayomi.tachidesk.manga.impl.util.lang.renameTo
import suwayomi.tachidesk.server.database.databaseUp
import suwayomi.tachidesk.server.generated.BuildConfig
import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex
import suwayomi.tachidesk.server.util.DurationType
import suwayomi.tachidesk.server.util.MutableStateFlowType
import suwayomi.tachidesk.server.util.ConfigTypeRegistration
import suwayomi.tachidesk.server.util.SystemTray
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@@ -176,8 +174,7 @@ fun applicationSetup() {
mainLoop.start()
// register Tachidesk's config which is dubbed "ServerConfig"
registerCustomType(MutableStateFlowType())
registerCustomType(DurationType())
ConfigTypeRegistration.registerCustomTypes()
GlobalConfigManager.registerModule(
ServerConfig.register { GlobalConfigManager.config },
)

View File

@@ -0,0 +1,24 @@
package suwayomi.tachidesk.server.settings
import suwayomi.tachidesk.graphql.types.Settings
import kotlin.reflect.KProperty1
import kotlin.reflect.full.memberProperties
internal fun Settings.asMap(): Map<String, Any?> {
val map = mutableMapOf<String, Any?>()
this::class.memberProperties.forEach { property ->
try {
// Skip the 'id' property from Node interface
if (property.name == "id") return@forEach
@Suppress("UNCHECKED_CAST")
val value = (property as KProperty1<Settings, *>).get(this)
map[property.name] = value
} catch (e: Exception) {
// Skip properties that can't be accessed
}
}
return map
}

View File

@@ -0,0 +1,51 @@
package suwayomi.tachidesk.server.settings
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.flow.MutableStateFlow
import suwayomi.tachidesk.graphql.types.Settings
import suwayomi.tachidesk.server.ServerConfig
import suwayomi.tachidesk.server.serverConfig
import kotlin.reflect.KProperty1
import kotlin.reflect.full.memberProperties
object SettingsUpdater {
private fun updateSetting(
name: String,
value: Any,
) {
try {
@Suppress("UNCHECKED_CAST")
val property =
serverConfig::class
.memberProperties
.find { it.name == name } as? KProperty1<ServerConfig, MutableStateFlow<*>>
if (property != null) {
val stateFlow = property.get(serverConfig)
val maybeConvertedValue =
SettingsRegistry
.get(name)
?.typeInfo
?.convertToInternalType
?.invoke(value) ?: value
// Normal update - MigratedConfigValue handles deprecated mappings automatically
@Suppress("UNCHECKED_CAST")
(stateFlow as MutableStateFlow<Any>).value = maybeConvertedValue
}
} catch (e: Exception) {
KotlinLogging.logger { }.error(e) { "Failed to update setting $name due to" }
}
}
fun updateAll(settings: Settings) {
settings
.asMap()
.forEach { (name, value) ->
if (value != null) {
updateSetting(name, value)
}
}
}
}

View File

@@ -0,0 +1,26 @@
package suwayomi.tachidesk.server.settings
import suwayomi.tachidesk.graphql.types.Settings
object SettingsValidator {
private fun validateSingle(
name: String,
value: Any?,
): String? {
val metadata = SettingsRegistry.get(name) ?: return null
return metadata.validator?.invoke(value)
}
private fun validateAll(
values: Map<String, Any?>,
ignoreNull: Boolean?,
): List<String> =
values
.filterValues { value -> ignoreNull == false || value != null }
.mapNotNull { (name, value) -> validateSingle(name, value)?.let { error -> "$name: $error" } }
fun validate(
settings: Settings,
ignoreNull: Boolean = false,
): List<String> = validateAll(settings.asMap(), ignoreNull)
}

View File

@@ -1,99 +0,0 @@
# Server ip and port bindings
server.ip = "0.0.0.0"
server.port = 4567
# Socks5 proxy
server.socksProxyEnabled = false
server.socksProxyVersion = 5 # 4 or 5
server.socksProxyHost = ""
server.socksProxyPort = ""
server.socksProxyUsername = ""
server.socksProxyPassword = ""
# webUI
server.webUIEnabled = true
server.webUIFlavor = "WebUI" # "WebUI", "VUI" or "Custom"
server.initialOpenInBrowserEnabled = true
server.webUIInterface = "browser" # "browser" or "electron"
server.electronPath = ""
server.webUIChannel = "stable" # "bundled" (the version bundled with the server release), "stable" or "preview" - the webUI version that should be used
server.webUIUpdateCheckInterval = 23 # time in hours - 0 to disable auto update - range: 1 <= n < 24 - default 23 hours - how often the server should check for webUI updates
# downloader
server.downloadAsCbz = false
server.downloadsPath = ""
server.autoDownloadNewChapters = false # if new chapters that have been retrieved should get automatically downloaded
server.excludeEntryWithUnreadChapters = true # ignore automatic chapter downloads of entries with unread chapters
server.autoDownloadNewChaptersLimit = 0 # 0 to disable it - how many unread downloaded chapters should be available - if the limit is reached, new chapters won't be downloaded automatically. this limit will also be applied to the auto download of new chapters on an update
server.autoDownloadIgnoreReUploads = false # decides if re-uploads should be ignored during auto download of new chapters
server.downloadConversions = {}
# map input mime type to conversion information, or "default" for others
# server.downloadConversions."image/webp" = {
# target = "image/jpeg" # image type to convert to
# compressionLevel = 0.8 # quality in range [0,1], leave away to use default compression
# }
# extension repos
server.extensionRepos = [
# an example: https://github.com/MY_ACCOUNT/MY_REPO/tree/repo
]
# requests
server.maxSourcesInParallel = 6 # range: 1 <= n <= 20 - default: 6 - sets how many sources can do requests (updates, downloads) in parallel. updates/downloads are grouped by source and all mangas of a source are updated/downloaded synchronously
# updater
server.excludeUnreadChapters = true
server.excludeNotStarted = true
server.excludeCompleted = true
server.globalUpdateInterval = 12 # time in hours - 0 to disable it - (doesn't have to be full hours e.g. 12.5) - range: 6 <= n < ∞ - default: 12 hours - interval in which the global update will be automatically triggered
server.updateMangas = false # if the mangas should be updated along with the chapter list during a library/category update
# Authentication
server.authMode = "none" # none, basic_auth, simple_login or ui_login
server.authUsername = ""
server.authPassword = ""
server.jwtAudience = "suwayomi-server-api"
server.jwtTokenExpiry = "5m"
server.jwtRefreshExpiry = "60d"
# misc
server.debugLogsEnabled = false
server.systemTrayEnabled = true
server.maxLogFiles = 31 # the max number of days to keep files before they get deleted
server.maxLogFileSize = "10mb" # the max size of a log file - possible values: 1 (bytes), 1KB (kilobytes), 1MB (megabytes), 1GB (gigabytes)
server.maxLogFolderSize = "100mb" # the max size of all saved log files - possible values: 1 (bytes), 1KB (kilobytes), 1MB (megabytes), 1GB (gigabytes)
# backup
server.backupPath = ""
server.backupTime = "00:00" # range: hour: 0-23, minute: 0-59 - default: "00:00" - time of day at which the automated backup should be triggered
server.backupInterval = 1 # time in days - 0 to disable it - range: 1 <= n < ∞ - default: 1 day - interval in which the server will automatically create a backup
server.backupTTL = 14 # time in days - 0 to disable it - range: 1 <= n < ∞ - default: 14 days - how long backup files will be kept before they will get deleted
# local source
server.localSourcePath = ""
# Cloudflare bypass
server.flareSolverrEnabled = false
server.flareSolverrUrl = "http://localhost:8191"
server.flareSolverrTimeout = 60 # time in seconds
server.flareSolverrSessionName = "suwayomi"
server.flareSolverrSessionTtl = 15 # time in minutes
server.flareSolverrAsResponseFallback = false
# OPDS
server.opdsUseBinaryFileSizes = false # if the file sizes should be displayed in binary (KiB, MiB, GiB) or decimal (KB, MB, GB)
server.opdsItemsPerPage = 50 # Range (10 - 5000)
server.opdsEnablePageReadProgress = true
server.opdsMarkAsReadOnDownload = false
server.opdsShowOnlyUnreadChapters = false
server.opdsShowOnlyDownloadedChapters = false
server.opdsChapterSortOrder = "DESC" # "ASC", "DESC"
# Koreader Sync
server.koreaderSyncServerUrl = "http://localhost:17200"
server.koreaderSyncUsername = ""
server.koreaderSyncUserkey = ""
server.koreaderSyncDeviceId = ""
server.koreaderSyncChecksumMethod = "binary" # "binary" or "filename"
server.koreaderSyncStrategy = "disabled" # "prompt", "silent", "send", "receive", "disabled"
server.koreaderSyncPercentageTolerance = 0.00000000000001 # absolute tolerance for progress comparison from 1 (widest) to 1e-15 (strict)

View File

@@ -1,86 +0,0 @@
# Server ip and port bindings
server.ip = "0.0.0.0"
server.port = 4567
# Socks5 proxy
server.socksProxyEnabled = false
server.socksProxyVersion = 5 # 4 or 5
server.socksProxyHost = ""
server.socksProxyPort = ""
server.socksProxyUsername = ""
server.socksProxyPassword = ""
# webUI
server.webUIEnabled = true
server.webUIFlavor = "WebUI" # "WebUI", "VUI" or "Custom"
server.initialOpenInBrowserEnabled = true
server.webUIInterface = "browser" # "browser" or "electron"
server.electronPath = ""
server.webUIChannel = "stable" # "bundled" (the version bundled with the server release), "stable" or "preview" - the webUI version that should be used
server.webUIUpdateCheckInterval = 23 # time in hours - 0 to disable auto update - range: 1 <= n < 24 - default 23 hours - how often the server should check for webUI updates
# downloader
server.downloadAsCbz = false
server.downloadsPath = ""
server.autoDownloadNewChapters = false # if new chapters that have been retrieved should get automatically downloaded
server.excludeEntryWithUnreadChapters = true # ignore automatic chapter downloads of entries with unread chapters
server.autoDownloadNewChaptersLimit = 0 # 0 to disable it - how many unread downloaded chapters should be available - if the limit is reached, new chapters won't be downloaded automatically. this limit will also be applied to the auto download of new chapters on an update
server.autoDownloadIgnoreReUploads = false # decides if re-uploads should be ignored during auto download of new chapters
server.downloadConversions = {}
# map input mime type to conversion information, or "default" for others
# server.downloadConversions."image/webp" = {
# target = "image/jpeg" # image type to convert to
# compressionLevel = 0.8 # quality in range [0,1], leave away to use default compression
# }
# extension repos
server.extensionRepos = [
# an example: https://github.com/MY_ACCOUNT/MY_REPO/tree/repo
]
# requests
server.maxSourcesInParallel = 6 # range: 1 <= n <= 20 - default: 6 - sets how many sources can do requests (updates, downloads) in parallel. updates/downloads are grouped by source and all mangas of a source are updated/downloaded synchronously
# updater
server.excludeUnreadChapters = true
server.excludeNotStarted = true
server.excludeCompleted = true
server.globalUpdateInterval = 12 # time in hours - 0 to disable it - (doesn't have to be full hours e.g. 12.5) - range: 6 <= n < ∞ - default: 12 hours - interval in which the global update will be automatically triggered
server.updateMangas = false # if the mangas should be updated along with the chapter list during a library/category update
# Authentication
server.authMode = "none" # none, basic_auth, simple_login or ui_login
server.authUsername = ""
server.authPassword = ""
server.jwtAudience = "suwayomi-server-api"
server.jwtTokenExpiry = "5m"
server.jwtRefreshExpiry = "60d"
# misc
server.debugLogsEnabled = false
server.systemTrayEnabled = true
# backup
server.backupPath = ""
server.backupTime = "00:00" # range: hour: 0-23, minute: 0-59 - default: "00:00" - time of day at which the automated backup should be triggered
server.backupInterval = 1 # time in days - 0 to disable it - range: 1 <= n < ∞ - default: 1 day - interval in which the server will automatically create a backup
server.backupTTL = 14 # time in days - 0 to disable it - range: 1 <= n < ∞ - default: 14 days - how long backup files will be kept before they will get deleted
# local source
server.localSourcePath = ""
# Cloudflare bypass
server.flareSolverrEnabled = false
server.flareSolverrUrl = "http://localhost:8191"
server.flareSolverrTimeout = 60 # time in seconds
server.flareSolverrSessionName = "suwayomi"
server.flareSolverrSessionTtl = 15 # time in minutes
server.flareSolverrAsResponseFallback = false
# OPDS
server.opdsItemsPerPage = 50 # Range (10 - 5000)
server.opdsEnablePageReadProgress = true
server.opdsMarkAsReadOnDownload = false
server.opdsShowOnlyUnreadChapters = false
server.opdsShowOnlyDownloadedChapters = false
server.opdsChapterSortOrder = "DESC" # "ASC", "DESC"

View File

@@ -2,6 +2,8 @@ rootProject.name = System.getenv("ProductName") ?: "Suwayomi-Server"
include("server")
include("server:i18n")
include("server:server-config")
include("server:server-config-generate")
include("AndroidCompat")
include("AndroidCompat:Config")