From 8ef2877040349531328b46fa8911fa580e9924e2 Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Mon, 1 Sep 2025 23:02:58 +0200 Subject: [PATCH] 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 df0078b7251e9e33580b16467ac9646034fbd4bc 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 --- .editorconfig | 5 +- .../xyz/nulldev/ts/config/ConfigManager.kt | 3 +- server/build.gradle.kts | 20 + .../server-config-generate/build.gradle.kts | 62 ++ .../generation/GenerateSettingsMain.kt | 36 + .../generation/KotlinFileGeneratorHelper.kt | 37 + .../SettingsBackupServerSettingsGenerator.kt | 84 ++ .../SettingsBackupSettingsHandlerGenerator.kt | 137 ++++ .../generation/SettingsConfigFileGenerator.kt | 109 +++ .../settings/generation/SettingsGenerator.kt | 82 ++ .../SettingsGraphqlTypeGenerator.kt | 173 +++++ server/server-config/build.gradle.kts | 39 + .../tachidesk/graphql/types/AuthMode.kt | 0 .../graphql/types/KoreaderSyncTypes.kt | 14 + .../tachidesk/graphql/types/SettingTypes.kt | 21 + .../tachidesk/graphql/types/WebUITypes.kt | 46 ++ .../BackupSettingsDownloadConversionType.kt | 14 + .../impl/extension/ExtensionConstants.kt | 9 + .../suwayomi/tachidesk/server/ServerConfig.kt | 721 ++++++++++++++++++ .../server/settings/SettingDelegate.kt | 581 ++++++++++++++ .../tachidesk/server/settings/SettingGroup.kt | 22 + .../server/settings/SettingsRegistry.kt | 72 ++ .../server/util/ConfigTypeRegistration.kt | 20 + .../tachidesk/server/util/DurationType.kt | 0 .../server/util/MutableStateFlowType.kt | 0 .../graphql/mutations/SettingsMutation.kt | 212 +---- ...erSyncTypes.kt => KoreaderSyncPayloads.kt} | 13 - .../tachidesk/graphql/types/SettingsType.kt | 417 ---------- .../impl/backup/proto/ProtoBackupExport.kt | 111 +-- .../impl/backup/proto/ProtoBackupImport.kt | 23 +- .../proto/models/BackupServerSettings.kt | 95 --- .../BackupSettingsDownloadConversionType.kt | 12 + .../manga/impl/download/DownloadManager.kt | 2 +- .../fileProvider/ChaptersFilesProvider.kt | 4 +- .../tachidesk/manga/impl/update/Updater.kt | 8 +- .../opds/impl/FeedBuilderInternal.kt | 2 +- .../tachidesk/opds/impl/OpdsFeedBuilder.kt | 2 +- .../opds/repository/ChapterRepository.kt | 3 +- .../opds/repository/MangaRepository.kt | 2 +- .../opds/repository/NavigationRepository.kt | 2 +- .../suwayomi/tachidesk/server/ServerConfig.kt | 263 ------- .../suwayomi/tachidesk/server/ServerSetup.kt | 7 +- .../server/settings/SettingsAsMap.kt | 24 + .../server/settings/SettingsUpdater.kt | 51 ++ .../server/settings/SettingsValidator.kt | 26 + .../src/main/resources/server-reference.conf | 99 --- .../src/test/resources/server-reference.conf | 86 --- settings.gradle.kts | 2 + 48 files changed, 2443 insertions(+), 1330 deletions(-) create mode 100644 server/server-config-generate/build.gradle.kts create mode 100644 server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/GenerateSettingsMain.kt create mode 100644 server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/KotlinFileGeneratorHelper.kt create mode 100644 server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/SettingsBackupServerSettingsGenerator.kt create mode 100644 server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/SettingsBackupSettingsHandlerGenerator.kt create mode 100644 server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/SettingsConfigFileGenerator.kt create mode 100644 server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/SettingsGenerator.kt create mode 100644 server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/SettingsGraphqlTypeGenerator.kt create mode 100644 server/server-config/build.gradle.kts rename server/{ => server-config}/src/main/kotlin/suwayomi/tachidesk/graphql/types/AuthMode.kt (100%) create mode 100644 server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/KoreaderSyncTypes.kt create mode 100644 server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingTypes.kt create mode 100644 server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/WebUITypes.kt create mode 100644 server/server-config/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupSettingsDownloadConversionType.kt create mode 100644 server/server-config/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/ExtensionConstants.kt create mode 100644 server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt create mode 100644 server/server-config/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingDelegate.kt create mode 100644 server/server-config/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingGroup.kt create mode 100644 server/server-config/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingsRegistry.kt create mode 100644 server/server-config/src/main/kotlin/suwayomi/tachidesk/server/util/ConfigTypeRegistration.kt rename server/{ => server-config}/src/main/kotlin/suwayomi/tachidesk/server/util/DurationType.kt (100%) rename server/{ => server-config}/src/main/kotlin/suwayomi/tachidesk/server/util/MutableStateFlowType.kt (100%) rename server/src/main/kotlin/suwayomi/tachidesk/graphql/types/{KoreaderSyncTypes.kt => KoreaderSyncPayloads.kt} (64%) delete mode 100644 server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt delete mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupSettingsDownloadConversionType.kt delete mode 100644 server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingsAsMap.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingsUpdater.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingsValidator.kt delete mode 100644 server/src/main/resources/server-reference.conf delete mode 100644 server/src/test/resources/server-reference.conf diff --git a/.editorconfig b/.editorconfig index 6e41a310..ef4a665b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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 \ No newline at end of file +ktlint_standard_no-consecutive-comments=disabled + +[**/generated/**] +ktlint=disabled \ No newline at end of file diff --git a/AndroidCompat/Config/src/main/java/xyz/nulldev/ts/config/ConfigManager.kt b/AndroidCompat/Config/src/main/java/xyz/nulldev/ts/config/ConfigManager.kt index af24ea7c..acca16e2 100644 --- a/AndroidCompat/Config/src/main/java/xyz/nulldev/ts/config/ConfigManager.kt +++ b/AndroidCompat/Config/src/main/java/xyz/nulldev/ts/config/ConfigManager.kt @@ -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) diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 8bb12695..2906f186 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -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") + } } diff --git a/server/server-config-generate/build.gradle.kts b/server/server-config-generate/build.gradle.kts new file mode 100644 index 00000000..9ec02064 --- /dev/null +++ b/server/server-config-generate/build.gradle.kts @@ -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("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"), + ) + } +} \ No newline at end of file diff --git a/server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/GenerateSettingsMain.kt b/server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/GenerateSettingsMain.kt new file mode 100644 index 00000000..f3a4401f --- /dev/null +++ b/server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/GenerateSettingsMain.kt @@ -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) + } +} diff --git a/server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/KotlinFileGeneratorHelper.kt b/server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/KotlinFileGeneratorHelper.kt new file mode 100644 index 00000000..a934426d --- /dev/null +++ b/server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/KotlinFileGeneratorHelper.kt @@ -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, + settings: List, + ): String = + buildString { + staticImports.forEach { appendLine("import $it") } + settings + .mapNotNull { it.typeInfo.imports } + .flatten() + .distinct() + .forEach { appendLine("import $it") } + + appendLine() + } +} diff --git a/server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/SettingsBackupServerSettingsGenerator.kt b/server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/SettingsBackupServerSettingsGenerator.kt new file mode 100644 index 00000000..f1570280 --- /dev/null +++ b/server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/SettingsBackupServerSettingsGenerator.kt @@ -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, + 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) { + appendLine( + KotlinFileGeneratorHelper.createImports( + listOf( + "kotlinx.serialization.Serializable", + "kotlinx.serialization.protobuf.ProtoNumber", + "suwayomi.tachidesk.graphql.types.Settings", + ), + settings, + ), + ) + } + + private fun StringBuilder.writeClass(sortedSettings: List) { + appendLine("@Serializable") + appendLine("data class BackupServerSettings(") + + writeSettings(sortedSettings, indentation = 4) + + appendLine(") : Settings") + appendLine() + } + + private fun StringBuilder.writeSettings( + sortedSettings: List, + 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}") +} diff --git a/server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/SettingsBackupSettingsHandlerGenerator.kt b/server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/SettingsBackupSettingsHandlerGenerator.kt new file mode 100644 index 00000000..862f3310 --- /dev/null +++ b/server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/SettingsBackupSettingsHandlerGenerator.kt @@ -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, + 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) { + 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>) { + appendLine("object BackupSettingsHandler {") + + writeBackupFunction(groupedSettings) + appendLine() + writeRestoreFunction(groupedSettings.values.flatten()) + + appendLine("}") + appendLine() + } + + private fun StringBuilder.writeBackupFunction(groupedSettings: Map>) { + 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) { + 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>, + 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" + } +} diff --git a/server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/SettingsConfigFileGenerator.kt b/server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/SettingsConfigFileGenerator.kt new file mode 100644 index 00000000..318634d9 --- /dev/null +++ b/server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/SettingsConfigFileGenerator.kt @@ -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, + ) { + // 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, + 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>) { + 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) + } +} diff --git a/server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/SettingsGenerator.kt b/server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/SettingsGenerator.kt new file mode 100644 index 00000000..b4b713a8 --- /dev/null +++ b/server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/SettingsGenerator.kt @@ -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).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) + } +} diff --git a/server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/SettingsGraphqlTypeGenerator.kt b/server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/SettingsGraphqlTypeGenerator.kt new file mode 100644 index 00000000..56501c53 --- /dev/null +++ b/server/server-config-generate/src/main/kotlin/suwayomi/tachidesk/server/settings/generation/SettingsGraphqlTypeGenerator.kt @@ -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, + 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) { + 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>) { + appendLine("interface Settings : Node {") + + writeSettings(groupedSettings, indentation = 4, asType = true, isOverride = false, isNullable = true, isInterface = true) + + appendLine("}") + appendLine() + } + + private fun StringBuilder.writePartialSettingsType(groupedSettings: Map>) { + 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>) { + 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>, + 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" + } +} diff --git a/server/server-config/build.gradle.kts b/server/server-config/build.gradle.kts new file mode 100644 index 00000000..24fc8901 --- /dev/null +++ b/server/server-config/build.gradle.kts @@ -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) +} + diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/AuthMode.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/AuthMode.kt similarity index 100% rename from server/src/main/kotlin/suwayomi/tachidesk/graphql/types/AuthMode.kt rename to server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/AuthMode.kt diff --git a/server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/KoreaderSyncTypes.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/KoreaderSyncTypes.kt new file mode 100644 index 00000000..21044db4 --- /dev/null +++ b/server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/KoreaderSyncTypes.kt @@ -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, +} diff --git a/server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingTypes.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingTypes.kt new file mode 100644 index 00000000..e9d4945a --- /dev/null +++ b/server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingTypes.kt @@ -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 diff --git a/server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/WebUITypes.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/WebUITypes.kt new file mode 100644 index 00000000..c57aab63 --- /dev/null +++ b/server/server-config/src/main/kotlin/suwayomi/tachidesk/graphql/types/WebUITypes.kt @@ -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 + } +} diff --git a/server/server-config/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupSettingsDownloadConversionType.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupSettingsDownloadConversionType.kt new file mode 100644 index 00000000..300125fb --- /dev/null +++ b/server/server-config/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupSettingsDownloadConversionType.kt @@ -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 \ No newline at end of file diff --git a/server/server-config/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/ExtensionConstants.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/ExtensionConstants.kt new file mode 100644 index 00000000..2b4b7394 --- /dev/null +++ b/server/server-config/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/ExtensionConstants.kt @@ -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() +} \ No newline at end of file diff --git a/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt new file mode 100644 index 00000000..480ab5eb --- /dev/null +++ b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt @@ -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 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 by IntSetting( + protoNumber = 2, + group = SettingGroup.NETWORK, + defaultValue = 4567, + min = 1, + max = 65535, + ) + + val socksProxyEnabled: MutableStateFlow by BooleanSetting( + protoNumber = 3, + group = SettingGroup.PROXY, + defaultValue = false, + ) + + val socksProxyVersion: MutableStateFlow by IntSetting( + protoNumber = 4, + group = SettingGroup.PROXY, + defaultValue = 5, + min = 4, + max = 5, + ) + + val socksProxyHost: MutableStateFlow by StringSetting( + protoNumber = 5, + group = SettingGroup.PROXY, + defaultValue = "", + ) + + val socksProxyPort: MutableStateFlow by StringSetting( + protoNumber = 6, + group = SettingGroup.PROXY, + defaultValue = "", + ) + + val socksProxyUsername: MutableStateFlow by StringSetting( + protoNumber = 7, + group = SettingGroup.PROXY, + defaultValue = "", + ) + + val socksProxyPassword: MutableStateFlow by StringSetting( + protoNumber = 8, + group = SettingGroup.PROXY, + defaultValue = "", + ) + + val webUIFlavor: MutableStateFlow 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 by BooleanSetting( + protoNumber = 10, + group = SettingGroup.WEB_UI, + defaultValue = true, + description = "Open client on startup", + ) + + val webUIInterface: MutableStateFlow 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 by PathSetting( + protoNumber = 12, + group = SettingGroup.WEB_UI, + defaultValue = "", + mustExist = true, + ) + + val webUIChannel: MutableStateFlow 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 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 by BooleanSetting( + protoNumber = 15, + defaultValue = false, + group = SettingGroup.DOWNLOADER, + ) + + val downloadsPath: MutableStateFlow by PathSetting( + protoNumber = 16, + group = SettingGroup.DOWNLOADER, + defaultValue = "", + mustExist = true, + ) + + val autoDownloadNewChapters: MutableStateFlow by BooleanSetting( + protoNumber = 17, + defaultValue = false, + group = SettingGroup.DOWNLOADER, + ) + + val excludeEntryWithUnreadChapters: MutableStateFlow by BooleanSetting( + protoNumber = 18, + group = SettingGroup.DOWNLOADER, + defaultValue = true, + description = "Exclude entries with unread chapters from auto-download", + ) + + val autoDownloadAheadLimit: MutableStateFlow 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 by DisableableIntSetting( + protoNumber = 20, + group = SettingGroup.DOWNLOADER, + defaultValue = 0, + min = 0, + description = "Maximum number of new chapters to auto-download", + ) + + val autoDownloadIgnoreReUploads: MutableStateFlow by BooleanSetting( + protoNumber = 21, + group = SettingGroup.DOWNLOADER, + defaultValue = false, + description = "Ignore re-uploaded chapters from auto-download", + ) + + val extensionRepos: MutableStateFlow> by ListSetting( + 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", + ), + description = "example: [\"https://github.com/MY_ACCOUNT/MY_REPO/tree/repo\"]", + ) + + val maxSourcesInParallel: MutableStateFlow 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 by BooleanSetting( + protoNumber = 24, + defaultValue = true, + group = SettingGroup.LIBRARY_UPDATES, + ) + + val excludeNotStarted: MutableStateFlow by BooleanSetting( + protoNumber = 25, + defaultValue = true, + group = SettingGroup.LIBRARY_UPDATES, + ) + + val excludeCompleted: MutableStateFlow by BooleanSetting( + protoNumber = 26, + defaultValue = true, + group = SettingGroup.LIBRARY_UPDATES, + ) + + val globalUpdateInterval: MutableStateFlow by DisableableDoubleSetting( + protoNumber = 27, + group = SettingGroup.LIBRARY_UPDATES, + defaultValue = 12.hours.inWholeHours.toDouble(), + min = 6.0, + description = "Time in hours", + ) + + val updateMangas: MutableStateFlow 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 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 by StringSetting( + protoNumber = 30, + group = SettingGroup.AUTH, + defaultValue = "", + ) + + val authPassword: MutableStateFlow by StringSetting( + protoNumber = 31, + group = SettingGroup.AUTH, + defaultValue = "", + ) + + val debugLogsEnabled: MutableStateFlow by BooleanSetting( + protoNumber = 32, + defaultValue = false, + group = SettingGroup.MISC, + ) + + val gqlDebugLogsEnabled: MutableStateFlow by MigratedConfigValue( + protoNumber = 33, + defaultValue = false, + group = SettingGroup.MISC, + deprecated = + SettingsRegistry.SettingDeprecated( + message = "Removed - does not do anything", + ), + ) + + val systemTrayEnabled: MutableStateFlow by BooleanSetting( + protoNumber = 34, + defaultValue = true, + group = SettingGroup.MISC, + ) + + val maxLogFiles: MutableStateFlow 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 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 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 by PathSetting( + protoNumber = 38, + group = SettingGroup.BACKUP, + defaultValue = "", + mustExist = true, + ) + + val backupTime: MutableStateFlow 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 by DisableableIntSetting( + protoNumber = 40, + group = SettingGroup.BACKUP, + defaultValue = 1, + min = 0, + description = "Time in days", + ) + + val backupTTL: MutableStateFlow by DisableableIntSetting( + protoNumber = 41, + group = SettingGroup.BACKUP, + defaultValue = 14.days.inWholeDays.toInt(), + min = 0, + description = "Backup retention in days", + ) + + val localSourcePath: MutableStateFlow by PathSetting( + protoNumber = 42, + group = SettingGroup.LOCAL_SOURCE, + defaultValue = "", + mustExist = true, + ) + + val flareSolverrEnabled: MutableStateFlow by BooleanSetting( + protoNumber = 43, + defaultValue = false, + group = SettingGroup.CLOUDFLARE, + ) + + val flareSolverrUrl: MutableStateFlow by StringSetting( + protoNumber = 44, + group = SettingGroup.CLOUDFLARE, + defaultValue = "http://localhost:8191", + ) + + val flareSolverrTimeout: MutableStateFlow by IntSetting( + protoNumber = 45, + group = SettingGroup.CLOUDFLARE, + defaultValue = 60.seconds.inWholeSeconds.toInt(), + min = 0, + description = "Time in seconds", + ) + + val flareSolverrSessionName: MutableStateFlow by StringSetting( + protoNumber = 46, + group = SettingGroup.CLOUDFLARE, + defaultValue = "suwayomi", + ) + + val flareSolverrSessionTtl: MutableStateFlow by IntSetting( + protoNumber = 47, + group = SettingGroup.CLOUDFLARE, + defaultValue = 15.minutes.inWholeMinutes.toInt(), + min = 0, + description = "Time in minutes", + ) + + val flareSolverrAsResponseFallback: MutableStateFlow by BooleanSetting( + protoNumber = 48, + defaultValue = false, + group = SettingGroup.CLOUDFLARE, + ) + + val opdsUseBinaryFileSizes: MutableStateFlow 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 by IntSetting( + protoNumber = 50, + group = SettingGroup.OPDS, + defaultValue = 100, + min = 10, + max = 5000, + ) + + val opdsEnablePageReadProgress: MutableStateFlow by BooleanSetting( + protoNumber = 51, + defaultValue = true, + group = SettingGroup.OPDS, + ) + + val opdsMarkAsReadOnDownload: MutableStateFlow by BooleanSetting( + protoNumber = 52, + defaultValue = false, + group = SettingGroup.OPDS, + ) + + val opdsShowOnlyUnreadChapters: MutableStateFlow by BooleanSetting( + protoNumber = 53, + defaultValue = false, + group = SettingGroup.OPDS, + ) + + val opdsShowOnlyDownloadedChapters: MutableStateFlow by BooleanSetting( + protoNumber = 54, + defaultValue = false, + group = SettingGroup.OPDS, + ) + + val opdsChapterSortOrder: MutableStateFlow 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 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> by MapSetting( + protoNumber = 57, + defaultValue = emptyMap(), + group = SettingGroup.DOWNLOADER, + typeInfo = + SettingsRegistry.PartialTypeInfo( + specificType = "List", + interfaceType = "List", + backupType = "List", + imports = + listOf( + "suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSettingsDownloadConversionType", + ), + convertToGqlType = { value -> + @Suppress("UNCHECKED_CAST") + val castedValue = value as Map + + castedValue.map { + SettingsDownloadConversionType( + it.key, + it.value.target, + it.value.compressionLevel, + ) + } + }, + convertToInternalType = { list -> + @Suppress("UNCHECKED_CAST") + val castedList = list as List + + castedList.associate { + it.mimeType to + DownloadConversion( + target = it.target, + compressionLevel = it.compressionLevel, + ) + } + }, + convertToBackupType = { value -> + @Suppress("UNCHECKED_CAST") + val castedValue = value as Map + + 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 by StringSetting( + protoNumber = 58, + group = SettingGroup.AUTH, + defaultValue = "suwayomi-server-api", + ) + + val koreaderSyncServerUrl: MutableStateFlow by StringSetting( + protoNumber = 59, + group = SettingGroup.KOREADER_SYNC, + defaultValue = "http://localhost:17200", + ) + + val koreaderSyncUsername: MutableStateFlow by StringSetting( + protoNumber = 60, + group = SettingGroup.KOREADER_SYNC, + defaultValue = "", + ) + + val koreaderSyncUserkey: MutableStateFlow by StringSetting( + protoNumber = 61, + group = SettingGroup.KOREADER_SYNC, + defaultValue = "", + ) + + val koreaderSyncDeviceId: MutableStateFlow by StringSetting( + protoNumber = 62, + group = SettingGroup.KOREADER_SYNC, + defaultValue = "", + ) + + val koreaderSyncChecksumMethod: MutableStateFlow 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 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 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 by DurationSetting( + protoNumber = 66, + group = SettingGroup.AUTH, + defaultValue = 5.minutes, + min = 0.seconds, + ) + + val jwtRefreshExpiry: MutableStateFlow by DurationSetting( + protoNumber = 67, + group = SettingGroup.AUTH, + defaultValue = 60.days, + min = 0.seconds, + ) + + val webUIEnabled: MutableStateFlow by BooleanSetting( + protoNumber = 68, + group = SettingGroup.WEB_UI, + defaultValue = true, + requiresRestart = true, + ) + + /** ****************************************************************** **/ + /** **/ + /** Renamed settings **/ + /** **/ + + /** ****************************************************************** **/ + val basicAuthUsername: MutableStateFlow 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 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 subscribeTo( + flow: Flow, + onChange: suspend (value: T) -> Unit, + ignoreInitialValue: Boolean = true, + ) { + val actualFlow = + if (ignoreInitialValue) { + flow.drop(1) + } else { + flow + } + + val sharedFlow = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + actualFlow.distinctUntilChanged().mapLatest { sharedFlow.emit(it) }.launchIn(mutableConfigValueScope) + sharedFlow.onEach { onChange(it) }.launchIn(mutableConfigValueScope) + } + + fun subscribeTo( + flow: Flow, + onChange: suspend () -> Unit, + ignoreInitialValue: Boolean = true, + ) { + subscribeTo(flow, { _ -> onChange() }, ignoreInitialValue) + } + + fun subscribeTo( + mutableStateFlow: MutableStateFlow, + onChange: suspend (value: T) -> Unit, + ignoreInitialValue: Boolean = true, + ) { + subscribeTo(mutableStateFlow.asStateFlow(), onChange, ignoreInitialValue) + } + + fun subscribeTo( + mutableStateFlow: MutableStateFlow, + onChange: suspend () -> Unit, + ignoreInitialValue: Boolean = true, + ) { + subscribeTo(mutableStateFlow.asStateFlow(), { _ -> onChange() }, ignoreInitialValue) + } + + companion object { + fun register(getConfig: () -> Config) = + ServerConfig { + getConfig().getConfig( + SERVER_CONFIG_MODULE_NAME, + ) + } + } +} diff --git a/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingDelegate.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingDelegate.kt new file mode 100644 index 00000000..a92d0279 --- /dev/null +++ b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingDelegate.kt @@ -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( + 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? = null + lateinit var propertyName: String + lateinit var moduleName: String + + operator fun provideDelegate( + thisRef: ServerConfig, + property: KProperty<*>, + ): SettingDelegate { + 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 R> getValue( + thisRef: ServerConfig, + property: KProperty<*>, + ): ReifiedT { + if (flow != null) { + return flow as ReifiedT + } + + val stateFlow = thisRef.overridableConfig.getValue(thisRef, property) + @Suppress("UNCHECKED_CAST") + flow = stateFlow as MutableStateFlow + + // 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(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( + 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? = null + lateinit var propertyName: String + lateinit var moduleName: String + + operator fun provideDelegate( + thisRef: ServerConfig, + property: KProperty<*>, + ): MigratedConfigValue { + 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 { + 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( + 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>( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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>( + protoNumber: Int, + defaultValue: T, + enumClass: KClass, + typeInfo: SettingsRegistry.PartialTypeInfo? = null, + group: SettingGroup, + deprecated: SettingsRegistry.SettingDeprecated? = null, + requiresRestart: Boolean? = null, + description: String? = null, +) : SettingDelegate( + 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( + 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( + protoNumber: Int, + defaultValue: List, + 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>( + 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( + protoNumber: Int, + defaultValue: Map, + validator: ((Map) -> String?)? = null, + typeInfo: SettingsRegistry.PartialTypeInfo? = null, + group: SettingGroup, + deprecated: SettingsRegistry.SettingDeprecated? = null, + requiresRestart: Boolean? = null, + description: String? = null, +) : SettingDelegate>( + protoNumber = protoNumber, + defaultValue = defaultValue, + validator = validator, + typeInfo = typeInfo, + group = group, + deprecated = deprecated, + requiresRestart = requiresRestart, + description = description, + ) diff --git a/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingGroup.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingGroup.kt new file mode 100644 index 00000000..5ae1d5a8 --- /dev/null +++ b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingGroup.kt @@ -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 +} diff --git a/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingsRegistry.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingsRegistry.kt new file mode 100644 index 00000000..4500e449 --- /dev/null +++ b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingsRegistry.kt @@ -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? + 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? = 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? = 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() + + fun register(metadata: SettingMetadata) { + settings[metadata.name] = metadata + } + + fun get(name: String): SettingMetadata? = settings[name] + + fun getAll(): Map = settings.toMap() +} diff --git a/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/util/ConfigTypeRegistration.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/util/ConfigTypeRegistration.kt new file mode 100644 index 00000000..a39ca478 --- /dev/null +++ b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/util/ConfigTypeRegistration.kt @@ -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 + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/util/DurationType.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/util/DurationType.kt similarity index 100% rename from server/src/main/kotlin/suwayomi/tachidesk/server/util/DurationType.kt rename to server/server-config/src/main/kotlin/suwayomi/tachidesk/server/util/DurationType.kt diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/util/MutableStateFlowType.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/util/MutableStateFlowType.kt similarity index 100% rename from server/src/main/kotlin/suwayomi/tachidesk/server/util/MutableStateFlowType.kt rename to server/server-config/src/main/kotlin/suwayomi/tachidesk/server/util/MutableStateFlowType.kt diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt index 9060a831..aea9ad3e 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt @@ -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 validateValue( - value: T?, - exception: Exception, - validate: (value: T) -> Boolean, -) { - if (value != null) { - validateValue(exception) { validate(value) } - } -} - -private fun 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 updateSetting( - newSetting: SettingType?, - configSetting: MutableStateFlow, - ) { - if (newSetting == null) { - return - } - - configSetting.value = newSetting - } - - private fun updateSetting( - newSetting: RealSettingType?, - configSetting: MutableStateFlow, - 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()) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/KoreaderSyncTypes.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/KoreaderSyncPayloads.kt similarity index 64% rename from server/src/main/kotlin/suwayomi/tachidesk/graphql/types/KoreaderSyncTypes.kt rename to server/src/main/kotlin/suwayomi/tachidesk/graphql/types/KoreaderSyncPayloads.kt index 26343f7f..e74535f7 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/KoreaderSyncTypes.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/KoreaderSyncPayloads.kt @@ -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?, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt deleted file mode 100644 index 9896b054..00000000 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt +++ /dev/null @@ -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? - - // extension - val extensionRepos: List? - - // 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?, - // extension - override val extensionRepos: List?, - // 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, - // extension - override val extensionRepos: List, - // 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, - ) -} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt index 24bd2c7f..61c3a36c 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt @@ -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, - ) - } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupImport.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupImport.kt index 2f45a759..8e9246cb 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupImport.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupImport.kt @@ -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) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt deleted file mode 100644 index f0f4b245..00000000 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt +++ /dev/null @@ -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, - @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?, - @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 -} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupSettingsDownloadConversionType.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupSettingsDownloadConversionType.kt new file mode 100644 index 00000000..c25aea92 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupSettingsDownloadConversionType.kt @@ -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 diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/DownloadManager.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/DownloadManager.kt index fc535a36..07ebbfd6 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/DownloadManager.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/DownloadManager.kt @@ -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 } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/ChaptersFilesProvider.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/ChaptersFilesProvider.kt index 13b1d283..a082b1d7 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/ChaptersFilesProvider.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/ChaptersFilesProvider.kt @@ -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( private fun convertPage( page: File, - conversion: ServerConfig.DownloadConversion, + conversion: DownloadConversion, ) { val (targetMime, compressionLevel) = conversion diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/update/Updater.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/update/Updater.kt index 9f632cb4..42f0a086 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/update/Updater.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/update/Updater.kt @@ -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 diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/FeedBuilderInternal.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/FeedBuilderInternal.kt index 3b18750e..63c156ff 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/FeedBuilderInternal.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/FeedBuilderInternal.kt @@ -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() diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt index cb8acb79..f08bf8a9 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt @@ -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) = diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/ChapterRepository.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/ChapterRepository.kt index e6a18634..ed347de0 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/ChapterRepository.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/ChapterRepository.kt @@ -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( diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/MangaRepository.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/MangaRepository.kt index 795e703f..c3f04c64 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/MangaRepository.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/MangaRepository.kt @@ -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. diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/NavigationRepository.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/NavigationRepository.kt index 34eaea25..9b3a6daa 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/NavigationRepository.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/repository/NavigationRepository.kt @@ -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> = mapOf( diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt deleted file mode 100644 index a3c0e9a4..00000000 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt +++ /dev/null @@ -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? = null - - inline operator fun , reified R> getValue( - thisRef: ServerConfig, - property: KProperty<*>, - ): T { - if (flow != null) { - return flow as T - } - - val stateFlow = overridableConfig.getValue(thisRef, property) - @Suppress("UNCHECKED_CAST") - flow = stateFlow as MutableStateFlow - - stateFlow - .drop(1) - .distinctUntilChanged() - .filter { it != thisRef.overridableConfig.getConfig().getValue(thisRef, property) } - .onEach { GlobalConfigManager.updateValue("$moduleName.${property.name}", it as Any) } - .launchIn(mutableConfigValueScope) - - return stateFlow - } - } - - open inner class MigratedConfigValue( - private val readMigrated: () -> T, - private val setMigrated: (T) -> Unit, - ) { - private var flow: MutableStateFlow? = null - - operator fun getValue( - thisRef: ServerConfig, - property: KProperty<*>, - ): MutableStateFlow { - 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 by OverrideConfigValue() - val port: MutableStateFlow by OverrideConfigValue() - - // proxy - val socksProxyEnabled: MutableStateFlow by OverrideConfigValue() - val socksProxyVersion: MutableStateFlow by OverrideConfigValue() - val socksProxyHost: MutableStateFlow by OverrideConfigValue() - val socksProxyPort: MutableStateFlow by OverrideConfigValue() - val socksProxyUsername: MutableStateFlow by OverrideConfigValue() - val socksProxyPassword: MutableStateFlow by OverrideConfigValue() - - // webUI - val webUIEnabled: MutableStateFlow by OverrideConfigValue() - val webUIFlavor: MutableStateFlow by OverrideConfigValue() - val initialOpenInBrowserEnabled: MutableStateFlow by OverrideConfigValue() - val webUIInterface: MutableStateFlow by OverrideConfigValue() - val electronPath: MutableStateFlow by OverrideConfigValue() - val webUIChannel: MutableStateFlow by OverrideConfigValue() - val webUIUpdateCheckInterval: MutableStateFlow by OverrideConfigValue() - - // downloader - val downloadAsCbz: MutableStateFlow by OverrideConfigValue() - val downloadsPath: MutableStateFlow by OverrideConfigValue() - val autoDownloadNewChapters: MutableStateFlow by OverrideConfigValue() - val excludeEntryWithUnreadChapters: MutableStateFlow by OverrideConfigValue() - val autoDownloadNewChaptersLimit: MutableStateFlow by OverrideConfigValue() - val autoDownloadIgnoreReUploads: MutableStateFlow by OverrideConfigValue() - val downloadConversions: MutableStateFlow> by OverrideConfigValue() - - data class DownloadConversion( - val target: String, - val compressionLevel: Double? = null, - ) - - // extensions - val extensionRepos: MutableStateFlow> by OverrideConfigValue() - - // requests - val maxSourcesInParallel: MutableStateFlow by OverrideConfigValue() - - // updater - val excludeUnreadChapters: MutableStateFlow by OverrideConfigValue() - val excludeNotStarted: MutableStateFlow by OverrideConfigValue() - val excludeCompleted: MutableStateFlow by OverrideConfigValue() - val globalUpdateInterval: MutableStateFlow by OverrideConfigValue() - val updateMangas: MutableStateFlow by OverrideConfigValue() - - // Authentication - val authMode: MutableStateFlow by OverrideConfigValue() - val authUsername: MutableStateFlow by OverrideConfigValue() - val authPassword: MutableStateFlow by OverrideConfigValue() - val jwtAudience: MutableStateFlow by OverrideConfigValue() - val jwtTokenExpiry: MutableStateFlow by OverrideConfigValue() - val jwtRefreshExpiry: MutableStateFlow by OverrideConfigValue() - val basicAuthEnabled: MutableStateFlow by MigratedConfigValue({ - authMode.value == AuthMode.BASIC_AUTH - }) { - authMode.value = if (it) AuthMode.BASIC_AUTH else AuthMode.NONE - } - val basicAuthUsername: MutableStateFlow by MigratedConfigValue({ authUsername.value }) { - authUsername.value = it - } - val basicAuthPassword: MutableStateFlow by MigratedConfigValue({ authPassword.value }) { - authPassword.value = it - } - - // misc - val debugLogsEnabled: MutableStateFlow by OverrideConfigValue() - val systemTrayEnabled: MutableStateFlow by OverrideConfigValue() - val maxLogFiles: MutableStateFlow by OverrideConfigValue() - val maxLogFileSize: MutableStateFlow by OverrideConfigValue() - val maxLogFolderSize: MutableStateFlow by OverrideConfigValue() - - // backup - val backupPath: MutableStateFlow by OverrideConfigValue() - val backupTime: MutableStateFlow by OverrideConfigValue() - val backupInterval: MutableStateFlow by OverrideConfigValue() - val backupTTL: MutableStateFlow by OverrideConfigValue() - - // local source - val localSourcePath: MutableStateFlow by OverrideConfigValue() - - // cloudflare bypass - val flareSolverrEnabled: MutableStateFlow by OverrideConfigValue() - val flareSolverrUrl: MutableStateFlow by OverrideConfigValue() - val flareSolverrTimeout: MutableStateFlow by OverrideConfigValue() - val flareSolverrSessionName: MutableStateFlow by OverrideConfigValue() - val flareSolverrSessionTtl: MutableStateFlow by OverrideConfigValue() - val flareSolverrAsResponseFallback: MutableStateFlow by OverrideConfigValue() - - // opds settings - val opdsUseBinaryFileSizes: MutableStateFlow by OverrideConfigValue() - val opdsItemsPerPage: MutableStateFlow by OverrideConfigValue() - val opdsEnablePageReadProgress: MutableStateFlow by OverrideConfigValue() - val opdsMarkAsReadOnDownload: MutableStateFlow by OverrideConfigValue() - val opdsShowOnlyUnreadChapters: MutableStateFlow by OverrideConfigValue() - val opdsShowOnlyDownloadedChapters: MutableStateFlow by OverrideConfigValue() - val opdsChapterSortOrder: MutableStateFlow by OverrideConfigValue() - - // koreader sync - val koreaderSyncServerUrl: MutableStateFlow by OverrideConfigValue() - val koreaderSyncUsername: MutableStateFlow by OverrideConfigValue() - val koreaderSyncUserkey: MutableStateFlow by OverrideConfigValue() - val koreaderSyncDeviceId: MutableStateFlow by OverrideConfigValue() - val koreaderSyncChecksumMethod: MutableStateFlow by OverrideConfigValue() - val koreaderSyncStrategy: MutableStateFlow by OverrideConfigValue() - val koreaderSyncPercentageTolerance: MutableStateFlow by OverrideConfigValue() - - @OptIn(ExperimentalCoroutinesApi::class) - fun subscribeTo( - flow: Flow, - onChange: suspend (value: T) -> Unit, - ignoreInitialValue: Boolean = true, - ) { - val actualFlow = - if (ignoreInitialValue) { - flow.drop(1) - } else { - flow - } - - val sharedFlow = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) - actualFlow.distinctUntilChanged().mapLatest { sharedFlow.emit(it) }.launchIn(mutableConfigValueScope) - sharedFlow.onEach { onChange(it) }.launchIn(mutableConfigValueScope) - } - - fun subscribeTo( - flow: Flow, - onChange: suspend () -> Unit, - ignoreInitialValue: Boolean = true, - ) { - subscribeTo(flow, { _ -> onChange() }, ignoreInitialValue) - } - - fun subscribeTo( - mutableStateFlow: MutableStateFlow, - onChange: suspend (value: T) -> Unit, - ignoreInitialValue: Boolean = true, - ) { - subscribeTo(mutableStateFlow.asStateFlow(), onChange, ignoreInitialValue) - } - - fun subscribeTo( - mutableStateFlow: MutableStateFlow, - onChange: suspend () -> Unit, - ignoreInitialValue: Boolean = true, - ) { - subscribeTo(mutableStateFlow.asStateFlow(), { _ -> onChange() }, ignoreInitialValue) - } - - companion object { - fun register(getConfig: () -> Config) = - ServerConfig { - getConfig().getConfig( - SERVER_CONFIG_MODULE_NAME, - ) - } - } -} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt index bff30e3e..a9825aaa 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt @@ -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 }, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingsAsMap.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingsAsMap.kt new file mode 100644 index 00000000..456bb7f5 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingsAsMap.kt @@ -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 { + val map = mutableMapOf() + + 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).get(this) + map[property.name] = value + } catch (e: Exception) { + // Skip properties that can't be accessed + } + } + + return map +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingsUpdater.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingsUpdater.kt new file mode 100644 index 00000000..2146596e --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingsUpdater.kt @@ -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> + + 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).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) + } + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingsValidator.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingsValidator.kt new file mode 100644 index 00000000..34d09ed4 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingsValidator.kt @@ -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, + ignoreNull: Boolean?, + ): List = + 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 = validateAll(settings.asMap(), ignoreNull) +} diff --git a/server/src/main/resources/server-reference.conf b/server/src/main/resources/server-reference.conf deleted file mode 100644 index 4f1f3d9f..00000000 --- a/server/src/main/resources/server-reference.conf +++ /dev/null @@ -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) diff --git a/server/src/test/resources/server-reference.conf b/server/src/test/resources/server-reference.conf deleted file mode 100644 index c5904e27..00000000 --- a/server/src/test/resources/server-reference.conf +++ /dev/null @@ -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" diff --git a/settings.gradle.kts b/settings.gradle.kts index 60ff2bd0..73d533e5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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")