mirror of
https://github.com/Suwayomi/Tachidesk.git
synced 2025-12-10 06:42:07 +01:00
Feature/streamline settings (#1614)
* Cleanup graphql setting mutation
* Validate values read from config
* Generate server-reference.conf files from ServerConfig
* Remove unnecessary enum value handling in config value update
Commit df0078b725 introduced the usage of config4k, which handles enums automatically. Thus, this handling is outdated and not needed anymore
* Generate gql SettingsType from ServerConfig
* Extract settings backup logic
* Generate settings backup files
* Move "group" arg to second position
To make it easier to detect and have it at the same position consistently for all settings.
* Remove setting generation from compilation
* Extract setting generation code into new module
* Extract pure setting generation code into new module
* Remove generated settings files from src tree
* Force each setting to set a default value
This commit is contained in:
@@ -8,4 +8,7 @@ ij_kotlin_name_count_to_use_star_import_for_members=2147483647
|
||||
|
||||
ktlint_standard_discouraged-comment-location=disabled
|
||||
ktlint_standard_if-else-wrapping=disabled
|
||||
ktlint_standard_no-consecutive-comments=disabled
|
||||
ktlint_standard_no-consecutive-comments=disabled
|
||||
|
||||
[**/generated/**]
|
||||
ktlint=disabled
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
62
server/server-config-generate/build.gradle.kts
Normal file
62
server/server-config-generate/build.gradle.kts
Normal file
@@ -0,0 +1,62 @@
|
||||
plugins {
|
||||
id(
|
||||
libs.plugins.kotlin.jvm
|
||||
.get()
|
||||
.pluginId,
|
||||
)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Core Kotlin
|
||||
implementation(kotlin("stdlib-jdk8"))
|
||||
implementation(kotlin("reflect"))
|
||||
|
||||
// Config handling
|
||||
implementation(libs.config)
|
||||
implementation(libs.config4k)
|
||||
|
||||
// Logging
|
||||
implementation(libs.slf4japi)
|
||||
implementation(libs.kotlinlogging)
|
||||
|
||||
// Serialization
|
||||
implementation(libs.serialization.json)
|
||||
implementation(libs.serialization.protobuf)
|
||||
|
||||
// Depend on server-config module for access to ServerConfig and SettingsRegistry
|
||||
implementation(projects.server.serverConfig)
|
||||
}
|
||||
|
||||
tasks {
|
||||
register<JavaExec>("generateSettings") {
|
||||
group = "build setup"
|
||||
description = "Generates settings from ServerConfig"
|
||||
|
||||
dependsOn(compileKotlin)
|
||||
|
||||
// Use this module's classpath which includes server-config as dependency
|
||||
classpath = sourceSets.main.get().runtimeClasspath
|
||||
|
||||
mainClass.set("suwayomi.tachidesk.server.settings.generation.SettingsGeneratorKt")
|
||||
|
||||
// Get reference to server project for file paths
|
||||
val serverProject = project(":server")
|
||||
|
||||
// Set working directory to the server module directory
|
||||
workingDir = serverProject.projectDir
|
||||
|
||||
inputs.files(
|
||||
serverProject.sourceSets.main.get().allSource.filter {
|
||||
it.name.contains("ServerConfig") || it.name.contains("Settings")
|
||||
},
|
||||
)
|
||||
|
||||
outputs.files(
|
||||
serverProject.file("build/generated/src/main/resources/server-reference.conf"),
|
||||
serverProject.file("build/generated/src/test/resources/server-reference.conf"),
|
||||
serverProject.file("build/generated/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt"),
|
||||
serverProject.file("build/generated/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt"),
|
||||
serverProject.file("build/generated/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/handlers/BackupSettingsHandler.kt"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package suwayomi.tachidesk.server.settings.generation
|
||||
|
||||
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
||||
|
||||
internal fun String.addIndentation(times: Int): String = this.prependIndent(" ".repeat(times))
|
||||
|
||||
object KotlinFileGeneratorHelper {
|
||||
fun createFileHeader(packageName: String): String =
|
||||
buildString {
|
||||
appendLine("@file:Suppress(\"ktlint\")")
|
||||
appendLine()
|
||||
appendLine("/*")
|
||||
appendLine(" * Copyright (C) Contributors to the Suwayomi project")
|
||||
appendLine(" *")
|
||||
appendLine(" * This Source Code Form is subject to the terms of the Mozilla Public")
|
||||
appendLine(" * License, v. 2.0. If a copy of the MPL was not distributed with this")
|
||||
appendLine(" * file, You can obtain one at https://mozilla.org/MPL/2.0/. */")
|
||||
appendLine()
|
||||
appendLine("package $packageName")
|
||||
appendLine()
|
||||
}
|
||||
|
||||
fun createImports(
|
||||
staticImports: List<String>,
|
||||
settings: List<SettingsRegistry.SettingMetadata>,
|
||||
): String =
|
||||
buildString {
|
||||
staticImports.forEach { appendLine("import $it") }
|
||||
settings
|
||||
.mapNotNull { it.typeInfo.imports }
|
||||
.flatten()
|
||||
.distinct()
|
||||
.forEach { appendLine("import $it") }
|
||||
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package suwayomi.tachidesk.server.settings.generation
|
||||
|
||||
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
||||
import java.io.File
|
||||
import kotlin.text.appendLine
|
||||
|
||||
object SettingsBackupServerSettingsGenerator {
|
||||
fun generate(
|
||||
settings: Map<String, SettingsRegistry.SettingMetadata>,
|
||||
outputFile: File,
|
||||
) {
|
||||
outputFile.parentFile.mkdirs()
|
||||
|
||||
val settingsToInclude = settings.values
|
||||
|
||||
if (settingsToInclude.isEmpty()) {
|
||||
println("Warning: No settings found to create BackupServerSettings from.")
|
||||
return
|
||||
}
|
||||
|
||||
val sortedSettings = settingsToInclude.sortedBy { it.protoNumber }
|
||||
|
||||
outputFile.writeText(
|
||||
buildString {
|
||||
appendLine(KotlinFileGeneratorHelper.createFileHeader("suwayomi.tachidesk.manga.impl.backup.proto.models"))
|
||||
writeImports(sortedSettings)
|
||||
writeClass(sortedSettings)
|
||||
},
|
||||
)
|
||||
|
||||
println("BackupServerSettingsGenerator generated successfully! Total settings: ${settingsToInclude.size}")
|
||||
}
|
||||
|
||||
private fun StringBuilder.writeImports(settings: List<SettingsRegistry.SettingMetadata>) {
|
||||
appendLine(
|
||||
KotlinFileGeneratorHelper.createImports(
|
||||
listOf(
|
||||
"kotlinx.serialization.Serializable",
|
||||
"kotlinx.serialization.protobuf.ProtoNumber",
|
||||
"suwayomi.tachidesk.graphql.types.Settings",
|
||||
),
|
||||
settings,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun StringBuilder.writeClass(sortedSettings: List<SettingsRegistry.SettingMetadata>) {
|
||||
appendLine("@Serializable")
|
||||
appendLine("data class BackupServerSettings(")
|
||||
|
||||
writeSettings(sortedSettings, indentation = 4)
|
||||
|
||||
appendLine(") : Settings")
|
||||
appendLine()
|
||||
}
|
||||
|
||||
private fun StringBuilder.writeSettings(
|
||||
sortedSettings: List<SettingsRegistry.SettingMetadata>,
|
||||
indentation: Int,
|
||||
) {
|
||||
sortedSettings.forEach { setting ->
|
||||
val deprecated = setting.deprecated
|
||||
if (deprecated != null) {
|
||||
val replaceWithSuffix = deprecated.replaceWith?.let { ", ReplaceWith(\"$it\")" } ?: ""
|
||||
appendLine(
|
||||
"@Deprecated(\"${deprecated.message}\"$replaceWithSuffix)".addIndentation(
|
||||
indentation,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
appendLine(
|
||||
"@ProtoNumber(${setting.protoNumber}) override var ${setting.name}: ${getSettingType(setting)},"
|
||||
.addIndentation(indentation),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSettingType(setting: SettingsRegistry.SettingMetadata): String =
|
||||
setting.typeInfo.backupType
|
||||
?: setting.typeInfo.specificType
|
||||
?: setting.typeInfo.type.simpleName
|
||||
?: throw RuntimeException("Unknown setting type: ${setting.typeInfo}")
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package suwayomi.tachidesk.server.settings.generation
|
||||
|
||||
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
||||
import java.io.File
|
||||
import kotlin.text.appendLine
|
||||
|
||||
object SettingsBackupSettingsHandlerGenerator {
|
||||
fun generate(
|
||||
settings: Map<String, SettingsRegistry.SettingMetadata>,
|
||||
outputFile: File,
|
||||
) {
|
||||
outputFile.parentFile.mkdirs()
|
||||
|
||||
val settingsToInclude = settings.values
|
||||
|
||||
if (settingsToInclude.isEmpty()) {
|
||||
println("Warning: No settings found to create BackupServerSettings from.")
|
||||
return
|
||||
}
|
||||
|
||||
val groupedSettings = settingsToInclude.groupBy { it.group }
|
||||
|
||||
outputFile.writeText(
|
||||
buildString {
|
||||
appendLine(KotlinFileGeneratorHelper.createFileHeader("suwayomi.tachidesk.manga.impl.backup.proto.handlers"))
|
||||
writeImports(groupedSettings.values.flatten())
|
||||
writeHandler(groupedSettings)
|
||||
},
|
||||
)
|
||||
|
||||
println("BackupServerSettings generated successfully! Total settings: ${settingsToInclude.size}")
|
||||
}
|
||||
|
||||
private fun StringBuilder.writeImports(settings: List<SettingsRegistry.SettingMetadata>) {
|
||||
appendLine(
|
||||
KotlinFileGeneratorHelper.createImports(
|
||||
listOf(
|
||||
"suwayomi.tachidesk.graphql.mutations.SettingsMutation",
|
||||
"suwayomi.tachidesk.manga.impl.backup.BackupFlags",
|
||||
"suwayomi.tachidesk.manga.impl.backup.proto.models.BackupServerSettings",
|
||||
"suwayomi.tachidesk.server.serverConfig",
|
||||
"suwayomi.tachidesk.server.settings.SettingsRegistry",
|
||||
),
|
||||
settings,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun StringBuilder.writeHandler(groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>) {
|
||||
appendLine("object BackupSettingsHandler {")
|
||||
|
||||
writeBackupFunction(groupedSettings)
|
||||
appendLine()
|
||||
writeRestoreFunction(groupedSettings.values.flatten())
|
||||
|
||||
appendLine("}")
|
||||
appendLine()
|
||||
}
|
||||
|
||||
private fun StringBuilder.writeBackupFunction(groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>) {
|
||||
val indentation = 4
|
||||
val contentIndentation = indentation * 2
|
||||
|
||||
appendLine("fun backup(flags: BackupFlags): BackupServerSettings? {".addIndentation(indentation))
|
||||
appendLine("if (!flags.includeServerSettings) { return null }".addIndentation(contentIndentation))
|
||||
appendLine()
|
||||
appendLine("return BackupServerSettings(".addIndentation(contentIndentation))
|
||||
writeSettings(groupedSettings, indentation * 3)
|
||||
appendLine(")".addIndentation(contentIndentation))
|
||||
appendLine("}".addIndentation(indentation))
|
||||
}
|
||||
|
||||
private fun StringBuilder.writeRestoreFunction(settings: List<SettingsRegistry.SettingMetadata>) {
|
||||
val indentation = 4
|
||||
val contentIndentation = indentation * 2
|
||||
|
||||
appendLine("fun restore(backupServerSettings: BackupServerSettings?) {".addIndentation(indentation))
|
||||
appendLine("if (backupServerSettings == null) { return }".addIndentation(contentIndentation))
|
||||
appendLine()
|
||||
appendLine("SettingsMutation().updateSettings(".addIndentation(contentIndentation))
|
||||
appendLine("backupServerSettings.copy(".addIndentation(indentation * 3))
|
||||
|
||||
val deprecatedSettings = settings.filter { it.typeInfo.restoreLegacy != null }
|
||||
deprecatedSettings.forEach { setting ->
|
||||
appendLine(
|
||||
"${setting.name} = SettingsRegistry.get(\"${setting.name}\")!!.typeInfo.restoreLegacy!!(".addIndentation(indentation * 4) +
|
||||
"backupServerSettings.${setting.name}" +
|
||||
") as ${getSettingType(setting, false)},",
|
||||
)
|
||||
}
|
||||
appendLine("),".addIndentation(indentation * 3))
|
||||
appendLine(")".addIndentation(contentIndentation))
|
||||
appendLine("}".addIndentation(indentation))
|
||||
}
|
||||
|
||||
private fun StringBuilder.writeSettings(
|
||||
groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>,
|
||||
indentation: Int,
|
||||
) {
|
||||
groupedSettings.forEach { (group, settings) ->
|
||||
appendLine("// $group".addIndentation(indentation))
|
||||
settings.forEach { setting -> writeSetting(setting, indentation) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun StringBuilder.writeSetting(
|
||||
setting: SettingsRegistry.SettingMetadata,
|
||||
indentation: Int,
|
||||
) {
|
||||
appendLine("${setting.name} = ${getConfigAccess(setting)},".addIndentation(indentation))
|
||||
}
|
||||
|
||||
private fun getSettingType(
|
||||
setting: SettingsRegistry.SettingMetadata,
|
||||
asBackup: Boolean,
|
||||
): String {
|
||||
val possibleType = setting.typeInfo.specificType ?: setting.typeInfo.type.simpleName
|
||||
|
||||
val exception = RuntimeException("Unknown setting type: ${setting.typeInfo}")
|
||||
|
||||
if (asBackup) {
|
||||
return setting.typeInfo.backupType ?: possibleType ?: throw exception
|
||||
}
|
||||
|
||||
return possibleType ?: throw exception
|
||||
}
|
||||
|
||||
private fun getConfigAccess(setting: SettingsRegistry.SettingMetadata): String {
|
||||
if (setting.typeInfo.convertToBackupType != null) {
|
||||
return "SettingsRegistry.get(\"${setting.name}\")!!.typeInfo.convertToBackupType!!(" +
|
||||
"serverConfig.${setting.name}.value" +
|
||||
") as ${getSettingType(setting, true)}"
|
||||
}
|
||||
|
||||
return "serverConfig.${setting.name}.value"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package suwayomi.tachidesk.server.settings.generation
|
||||
|
||||
import com.typesafe.config.ConfigRenderOptions
|
||||
import io.github.config4k.toConfig
|
||||
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
||||
import java.io.File
|
||||
|
||||
object SettingsConfigFileGenerator {
|
||||
private const val SERVER_PREFIX = "server."
|
||||
|
||||
fun generate(
|
||||
outputDir: File,
|
||||
testOutputDir: File,
|
||||
settings: Map<String, SettingsRegistry.SettingMetadata>,
|
||||
) {
|
||||
// Config files only include up-to-date settings.
|
||||
val settingsToInclude = settings.filterValues { it.deprecated == null }
|
||||
|
||||
if (settingsToInclude.isEmpty()) {
|
||||
println("Warning: No settings found to write to config files.")
|
||||
return
|
||||
}
|
||||
|
||||
generateServerReferenceConf(settingsToInclude, outputDir)
|
||||
generateServerReferenceConf(settingsToInclude, testOutputDir)
|
||||
|
||||
println("Settings config file generated successfully! Total settings: ${settingsToInclude.size}")
|
||||
println("- Main config: ${outputDir.resolve("server-reference.conf").absolutePath}")
|
||||
println("- Test config: ${testOutputDir.resolve("server-reference.conf").absolutePath}")
|
||||
}
|
||||
|
||||
private fun generateServerReferenceConf(
|
||||
settings: Map<String, SettingsRegistry.SettingMetadata>,
|
||||
outputDir: File,
|
||||
) {
|
||||
outputDir.mkdirs()
|
||||
val outputFile = outputDir.resolve("server-reference.conf")
|
||||
|
||||
val groupedSettings = settings.values.groupBy { it.group }
|
||||
|
||||
// Write the config with comments
|
||||
outputFile.writeText(
|
||||
buildString {
|
||||
writeSettings(groupedSettings)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun StringBuilder.writeSettings(groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>) {
|
||||
val renderOptions =
|
||||
ConfigRenderOptions
|
||||
.defaults()
|
||||
.setOriginComments(false)
|
||||
.setComments(false)
|
||||
.setFormatted(true)
|
||||
.setJson(false)
|
||||
|
||||
var isFirstGroup = true
|
||||
groupedSettings.forEach { (groupName, groupSettings) ->
|
||||
// Prevent empty line at start of the file
|
||||
if (!isFirstGroup) {
|
||||
appendLine()
|
||||
}
|
||||
isFirstGroup = false
|
||||
|
||||
appendLine("# $groupName")
|
||||
|
||||
groupSettings.forEach { setting ->
|
||||
writeSetting(setting, renderOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun StringBuilder.writeSetting(
|
||||
setting: SettingsRegistry.SettingMetadata,
|
||||
renderOptions: ConfigRenderOptions,
|
||||
) {
|
||||
val key = "$SERVER_PREFIX${setting.name}"
|
||||
|
||||
val configValue = setting.defaultValue.toConfig("internal").getValue("internal")
|
||||
var renderedValue = configValue.render(renderOptions)
|
||||
|
||||
// Force quotes on all string values for consistency
|
||||
// Check if it's a string value that's not already quoted
|
||||
if (setting.defaultValue is String && !renderedValue.startsWith("\"")) {
|
||||
renderedValue = "\"$renderedValue\""
|
||||
}
|
||||
|
||||
val settingString = "$key = $renderedValue"
|
||||
|
||||
val description = setting.description
|
||||
if (description != null) {
|
||||
val descriptionLines = description.split("\n")
|
||||
|
||||
if (descriptionLines.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
appendLine("$settingString # ${descriptionLines[0]}")
|
||||
descriptionLines.drop(1).forEach { line ->
|
||||
appendLine("# $line")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
appendLine(settingString)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package suwayomi.tachidesk.server.settings.generation
|
||||
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import suwayomi.tachidesk.server.ServerConfig
|
||||
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
||||
import suwayomi.tachidesk.server.util.ConfigTypeRegistration
|
||||
import java.io.File
|
||||
import kotlin.reflect.KProperty1
|
||||
import kotlin.reflect.full.memberProperties
|
||||
|
||||
/**
|
||||
* Utility to generate settings files from ServerConfig and SettingsRegistry
|
||||
* This can be run as a standalone main function to generate all required files
|
||||
*/
|
||||
object SettingsGenerator {
|
||||
init {
|
||||
// Register custom types for config serialization
|
||||
ConfigTypeRegistration.registerCustomTypes()
|
||||
triggerSettingRegistration()
|
||||
}
|
||||
|
||||
/**
|
||||
* Force registration of all settings without full ServerConfig instantiation
|
||||
*/
|
||||
private fun triggerSettingRegistration() {
|
||||
// This creates a minimal instance just to trigger delegate registration
|
||||
try {
|
||||
val mockConfig =
|
||||
ConfigFactory.parseString(
|
||||
"""
|
||||
server {
|
||||
ip = "0.0.0.0"
|
||||
port = 4567
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val tempConfig = ServerConfig { mockConfig.getConfig("server") }
|
||||
// Access all properties to trigger delegate registrations
|
||||
tempConfig::class.memberProperties.forEach { prop ->
|
||||
try {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(prop as KProperty1<ServerConfig, Any?>).get(tempConfig)
|
||||
} catch (e: Exception) {
|
||||
// Ignore errors during registration
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Registration failed, but we tried
|
||||
}
|
||||
}
|
||||
|
||||
fun generate(
|
||||
outputDir: File,
|
||||
testOutputDir: File,
|
||||
graphqlOutputDir: File,
|
||||
backupSettingsOutputDir: File,
|
||||
backupSettingsHandlerOutputDir: File,
|
||||
) {
|
||||
val settings = SettingsRegistry.getAll()
|
||||
|
||||
if (settings.isEmpty()) {
|
||||
println("Warning: No settings found in registry. Settings might not be initialized.")
|
||||
return
|
||||
}
|
||||
|
||||
println(" - Total: ${settings.size}")
|
||||
println(" - Deprecated: ${settings.values.count { it.deprecated != null }}")
|
||||
println(" - Require restart: ${settings.values.count { it.requiresRestart }}")
|
||||
|
||||
SettingsConfigFileGenerator.generate(outputDir, testOutputDir, settings)
|
||||
|
||||
val settingsTypeFile = graphqlOutputDir.resolve("SettingsType.kt")
|
||||
SettingsGraphqlTypeGenerator.generate(settings, settingsTypeFile)
|
||||
|
||||
val backupServerSettingsFile = backupSettingsOutputDir.resolve("BackupServerSettings.kt")
|
||||
SettingsBackupServerSettingsGenerator.generate(settings, backupServerSettingsFile)
|
||||
|
||||
val backupSettingsHandlerFile = backupSettingsHandlerOutputDir.resolve("BackupSettingsHandler.kt")
|
||||
SettingsBackupSettingsHandlerGenerator.generate(settings, backupSettingsHandlerFile)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package suwayomi.tachidesk.server.settings.generation
|
||||
|
||||
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
||||
import java.io.File
|
||||
import kotlin.text.appendLine
|
||||
|
||||
object SettingsGraphqlTypeGenerator {
|
||||
fun generate(
|
||||
settings: Map<String, SettingsRegistry.SettingMetadata>,
|
||||
outputFile: File,
|
||||
) {
|
||||
outputFile.parentFile.mkdirs()
|
||||
|
||||
val settingsToInclude = settings.values
|
||||
|
||||
if (settingsToInclude.isEmpty()) {
|
||||
println("Warning: No settings found to create graphql type from.")
|
||||
return
|
||||
}
|
||||
|
||||
val groupedSettings = settingsToInclude.groupBy { it.group }
|
||||
|
||||
outputFile.writeText(
|
||||
buildString {
|
||||
appendLine(KotlinFileGeneratorHelper.createFileHeader("suwayomi.tachidesk.graphql.types"))
|
||||
writeImports(groupedSettings.values.flatten())
|
||||
writeSettingsInterface(groupedSettings)
|
||||
writePartialSettingsType(groupedSettings)
|
||||
writeSettingsType(groupedSettings)
|
||||
},
|
||||
)
|
||||
|
||||
println("Graphql type generated successfully! Total settings: ${settingsToInclude.size}")
|
||||
}
|
||||
|
||||
private fun StringBuilder.writeImports(settings: List<SettingsRegistry.SettingMetadata>) {
|
||||
appendLine(
|
||||
KotlinFileGeneratorHelper.createImports(
|
||||
listOf(
|
||||
"com.expediagroup.graphql.generator.annotations.GraphQLDeprecated",
|
||||
"com.expediagroup.graphql.generator.annotations.GraphQLIgnore",
|
||||
"suwayomi.tachidesk.graphql.server.primitives.Node",
|
||||
"suwayomi.tachidesk.server.ServerConfig",
|
||||
"suwayomi.tachidesk.server.serverConfig",
|
||||
"suwayomi.tachidesk.server.settings.SettingsRegistry",
|
||||
),
|
||||
settings,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun StringBuilder.writeSettingsInterface(groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>) {
|
||||
appendLine("interface Settings : Node {")
|
||||
|
||||
writeSettings(groupedSettings, indentation = 4, asType = true, isOverride = false, isNullable = true, isInterface = true)
|
||||
|
||||
appendLine("}")
|
||||
appendLine()
|
||||
}
|
||||
|
||||
private fun StringBuilder.writePartialSettingsType(groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>) {
|
||||
appendLine("data class PartialSettingsType(")
|
||||
|
||||
writeSettings(groupedSettings, indentation = 4, asType = true, isOverride = true, isNullable = true, isInterface = false)
|
||||
|
||||
appendLine(") : Settings")
|
||||
appendLine()
|
||||
}
|
||||
|
||||
private fun StringBuilder.writeSettingsType(groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>) {
|
||||
appendLine("class SettingsType(")
|
||||
|
||||
writeSettings(groupedSettings, indentation = 4, asType = true, isOverride = true, isNullable = false, isInterface = false)
|
||||
|
||||
appendLine(") : Settings {")
|
||||
|
||||
// Write secondary constructor
|
||||
val indentation = 4
|
||||
appendLine("@Suppress(\"UNCHECKED_CAST\")".addIndentation(indentation))
|
||||
appendLine("constructor(config: ServerConfig = serverConfig) : this(".addIndentation(indentation))
|
||||
|
||||
writeSettings(
|
||||
groupedSettings,
|
||||
indentation = indentation * 2,
|
||||
asType = false,
|
||||
isOverride = false,
|
||||
isNullable = false,
|
||||
isInterface = false,
|
||||
)
|
||||
|
||||
appendLine(")".addIndentation(indentation))
|
||||
|
||||
appendLine("}")
|
||||
appendLine()
|
||||
}
|
||||
|
||||
private fun StringBuilder.writeSettings(
|
||||
groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>,
|
||||
indentation: Int,
|
||||
asType: Boolean,
|
||||
isOverride: Boolean,
|
||||
isNullable: Boolean,
|
||||
isInterface: Boolean,
|
||||
) {
|
||||
groupedSettings.forEach { (group, settings) ->
|
||||
appendLine("// $group".addIndentation(indentation))
|
||||
settings.forEach { setting -> writeSetting(setting, indentation, asType, isOverride, isNullable, isInterface) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun StringBuilder.writeSetting(
|
||||
setting: SettingsRegistry.SettingMetadata,
|
||||
indentation: Int,
|
||||
asType: Boolean,
|
||||
isOverride: Boolean,
|
||||
isNullable: Boolean,
|
||||
isInterface: Boolean,
|
||||
) {
|
||||
if (!asType) {
|
||||
appendLine("${getConfigAccess(setting)},".addIndentation(indentation))
|
||||
return
|
||||
}
|
||||
|
||||
if (setting.requiresRestart) {
|
||||
appendLine("@GraphQLIgnore".addIndentation(indentation))
|
||||
}
|
||||
|
||||
val deprecated = setting.deprecated
|
||||
if (deprecated != null) {
|
||||
val replaceWithSuffix = deprecated.replaceWith?.let { ", ReplaceWith(\"$it\")" } ?: ""
|
||||
appendLine(
|
||||
"@GraphQLDeprecated(\"${deprecated.message}\"$replaceWithSuffix)".addIndentation(
|
||||
indentation,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val overridePrefix = if (isOverride) "override " else ""
|
||||
val nullableSuffix = if (isNullable) "?" else ""
|
||||
val commaSuffix = if (isOverride) "," else ""
|
||||
appendLine(
|
||||
"${overridePrefix}val ${setting.name}: ${getGraphQLType(
|
||||
setting,
|
||||
isInterface,
|
||||
)}$nullableSuffix$commaSuffix".addIndentation(indentation),
|
||||
)
|
||||
}
|
||||
|
||||
private fun getGraphQLType(
|
||||
setting: SettingsRegistry.SettingMetadata,
|
||||
isInterface: Boolean,
|
||||
): String {
|
||||
val possibleType = setting.typeInfo.specificType ?: setting.typeInfo.type.simpleName
|
||||
|
||||
val exception = RuntimeException("Unknown setting type: ${setting.typeInfo}")
|
||||
|
||||
if (isInterface) {
|
||||
return setting.typeInfo.interfaceType ?: possibleType ?: throw exception
|
||||
}
|
||||
|
||||
return possibleType ?: throw exception
|
||||
}
|
||||
|
||||
private fun getConfigAccess(setting: SettingsRegistry.SettingMetadata): String {
|
||||
if (setting.typeInfo.convertToGqlType != null) {
|
||||
return "SettingsRegistry.get(\"${setting.name}\")!!.typeInfo.convertToGqlType!!(" +
|
||||
"config.${setting.name}.value" +
|
||||
") as ${getGraphQLType(setting, false)}"
|
||||
}
|
||||
|
||||
return "config.${setting.name}.value"
|
||||
}
|
||||
}
|
||||
39
server/server-config/build.gradle.kts
Normal file
39
server/server-config/build.gradle.kts
Normal file
@@ -0,0 +1,39 @@
|
||||
plugins {
|
||||
id(
|
||||
libs.plugins.kotlin.jvm
|
||||
.get()
|
||||
.pluginId,
|
||||
)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Core Kotlin
|
||||
implementation(kotlin("stdlib-jdk8"))
|
||||
implementation(kotlin("reflect"))
|
||||
|
||||
// Coroutines for MutableStateFlow
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(libs.coroutines.jdk8)
|
||||
|
||||
// Config handling
|
||||
implementation(libs.config)
|
||||
implementation(libs.config4k)
|
||||
|
||||
// Logging
|
||||
implementation(libs.slf4japi)
|
||||
implementation(libs.kotlinlogging)
|
||||
|
||||
// Database (for SortOrder enum used in ServerConfig)
|
||||
implementation(libs.exposed.core)
|
||||
|
||||
// GraphQL types used in ServerConfig
|
||||
implementation(libs.graphql.kotlin.scheme)
|
||||
|
||||
// AndroidCompat for SystemPropertyOverridableConfigModule
|
||||
implementation(projects.androidCompat.config)
|
||||
|
||||
// Serialization
|
||||
implementation(libs.serialization.json)
|
||||
implementation(libs.serialization.protobuf)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package suwayomi.tachidesk.graphql.types
|
||||
|
||||
enum class KoreaderSyncChecksumMethod {
|
||||
BINARY,
|
||||
FILENAME,
|
||||
}
|
||||
|
||||
enum class KoreaderSyncStrategy {
|
||||
PROMPT, // Ask on conflict
|
||||
SILENT, // Always use latest
|
||||
SEND, // Send changes only
|
||||
RECEIVE, // Receive changes only
|
||||
DISABLED,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package suwayomi.tachidesk.graphql.types
|
||||
|
||||
// These types belong to SettingsType.kt. However, since that file is auto-generated, these types need to be placed in
|
||||
// a "static" file.
|
||||
|
||||
data class DownloadConversion(
|
||||
val target: String,
|
||||
val compressionLevel: Double? = null,
|
||||
)
|
||||
|
||||
interface SettingsDownloadConversion {
|
||||
val mimeType: String
|
||||
val target: String
|
||||
val compressionLevel: Double?
|
||||
}
|
||||
|
||||
class SettingsDownloadConversionType(
|
||||
override val mimeType: String,
|
||||
override val target: String,
|
||||
override val compressionLevel: Double?,
|
||||
) : SettingsDownloadConversion
|
||||
@@ -0,0 +1,46 @@
|
||||
package suwayomi.tachidesk.graphql.types
|
||||
|
||||
enum class WebUIInterface {
|
||||
BROWSER,
|
||||
ELECTRON,
|
||||
}
|
||||
|
||||
enum class WebUIChannel {
|
||||
BUNDLED, // the default webUI version bundled with the server release
|
||||
STABLE,
|
||||
PREVIEW,
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun from(channel: String): WebUIChannel = entries.find { it.name.lowercase() == channel.lowercase() } ?: STABLE
|
||||
}
|
||||
}
|
||||
|
||||
enum class WebUIFlavor(
|
||||
val uiName: String,
|
||||
val repoUrl: String,
|
||||
val versionMappingUrl: String,
|
||||
val latestReleaseInfoUrl: String,
|
||||
val baseFileName: String,
|
||||
) {
|
||||
WEBUI(
|
||||
"WebUI",
|
||||
"https://github.com/Suwayomi/Suwayomi-WebUI-preview",
|
||||
"https://raw.githubusercontent.com/Suwayomi/Suwayomi-WebUI/master/versionToServerVersionMapping.json",
|
||||
"https://api.github.com/repos/Suwayomi/Suwayomi-WebUI-preview/releases/latest",
|
||||
"Suwayomi-WebUI",
|
||||
),
|
||||
VUI(
|
||||
"VUI",
|
||||
"https://github.com/Suwayomi/Suwayomi-VUI",
|
||||
"https://raw.githubusercontent.com/Suwayomi/Suwayomi-VUI/master/versionToServerVersionMapping.json",
|
||||
"https://api.github.com/repos/Suwayomi/Suwayomi-VUI/releases/latest",
|
||||
"Suwayomi-VUI",
|
||||
),
|
||||
CUSTOM("Custom", "", "", "", ""),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun from(flavor: String): WebUIFlavor = entries.find { it.name.lowercase() == flavor.lowercase() } ?: WEBUI
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.proto.models
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.protobuf.ProtoNumber
|
||||
import suwayomi.tachidesk.graphql.types.SettingsDownloadConversion
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
class BackupSettingsDownloadConversionType(
|
||||
@ProtoNumber(1) override val mimeType: String,
|
||||
@ProtoNumber(2) override val target: String,
|
||||
@ProtoNumber(3) override val compressionLevel: Double?,
|
||||
) : SettingsDownloadConversion
|
||||
@@ -0,0 +1,9 @@
|
||||
package suwayomi.tachidesk.manga.impl.extension
|
||||
|
||||
object ExtensionsList {
|
||||
val repoMatchRegex =
|
||||
(
|
||||
"https:\\/\\/(?>www\\.|raw\\.)?(github|githubusercontent)\\.com" +
|
||||
"\\/([^\\/]+)\\/([^\\/]+)(?>(?>\\/tree|\\/blob)?\\/([^\\/\\n]*))?(?>\\/([^\\/\\n]*\\.json)?)?"
|
||||
).toRegex()
|
||||
}
|
||||
@@ -0,0 +1,721 @@
|
||||
package suwayomi.tachidesk.server
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import com.typesafe.config.Config
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.jetbrains.exposed.sql.SortOrder
|
||||
import suwayomi.tachidesk.graphql.types.AuthMode
|
||||
import suwayomi.tachidesk.graphql.types.DownloadConversion
|
||||
import suwayomi.tachidesk.graphql.types.KoreaderSyncChecksumMethod
|
||||
import suwayomi.tachidesk.graphql.types.KoreaderSyncStrategy
|
||||
import suwayomi.tachidesk.graphql.types.SettingsDownloadConversionType
|
||||
import suwayomi.tachidesk.graphql.types.WebUIChannel
|
||||
import suwayomi.tachidesk.graphql.types.WebUIFlavor
|
||||
import suwayomi.tachidesk.graphql.types.WebUIInterface
|
||||
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSettingsDownloadConversionType
|
||||
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList.repoMatchRegex
|
||||
import suwayomi.tachidesk.server.settings.BooleanSetting
|
||||
import suwayomi.tachidesk.server.settings.DisableableDoubleSetting
|
||||
import suwayomi.tachidesk.server.settings.DisableableIntSetting
|
||||
import suwayomi.tachidesk.server.settings.DoubleSetting
|
||||
import suwayomi.tachidesk.server.settings.DurationSetting
|
||||
import suwayomi.tachidesk.server.settings.EnumSetting
|
||||
import suwayomi.tachidesk.server.settings.IntSetting
|
||||
import suwayomi.tachidesk.server.settings.ListSetting
|
||||
import suwayomi.tachidesk.server.settings.MapSetting
|
||||
import suwayomi.tachidesk.server.settings.MigratedConfigValue
|
||||
import suwayomi.tachidesk.server.settings.PathSetting
|
||||
import suwayomi.tachidesk.server.settings.SettingGroup
|
||||
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
||||
import suwayomi.tachidesk.server.settings.StringSetting
|
||||
import xyz.nulldev.ts.config.SystemPropertyOverridableConfigModule
|
||||
import kotlin.collections.associate
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
val mutableConfigValueScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
const val SERVER_CONFIG_MODULE_NAME = "server"
|
||||
|
||||
// Settings are ordered by "protoNumber".
|
||||
class ServerConfig(
|
||||
getConfig: () -> Config,
|
||||
) : SystemPropertyOverridableConfigModule(
|
||||
getConfig,
|
||||
SERVER_CONFIG_MODULE_NAME,
|
||||
) {
|
||||
val ip: MutableStateFlow<String> by StringSetting(
|
||||
protoNumber = 1,
|
||||
group = SettingGroup.NETWORK,
|
||||
defaultValue = "0.0.0.0",
|
||||
pattern = "^((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}$".toRegex(),
|
||||
)
|
||||
|
||||
val port: MutableStateFlow<Int> by IntSetting(
|
||||
protoNumber = 2,
|
||||
group = SettingGroup.NETWORK,
|
||||
defaultValue = 4567,
|
||||
min = 1,
|
||||
max = 65535,
|
||||
)
|
||||
|
||||
val socksProxyEnabled: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 3,
|
||||
group = SettingGroup.PROXY,
|
||||
defaultValue = false,
|
||||
)
|
||||
|
||||
val socksProxyVersion: MutableStateFlow<Int> by IntSetting(
|
||||
protoNumber = 4,
|
||||
group = SettingGroup.PROXY,
|
||||
defaultValue = 5,
|
||||
min = 4,
|
||||
max = 5,
|
||||
)
|
||||
|
||||
val socksProxyHost: MutableStateFlow<String> by StringSetting(
|
||||
protoNumber = 5,
|
||||
group = SettingGroup.PROXY,
|
||||
defaultValue = "",
|
||||
)
|
||||
|
||||
val socksProxyPort: MutableStateFlow<String> by StringSetting(
|
||||
protoNumber = 6,
|
||||
group = SettingGroup.PROXY,
|
||||
defaultValue = "",
|
||||
)
|
||||
|
||||
val socksProxyUsername: MutableStateFlow<String> by StringSetting(
|
||||
protoNumber = 7,
|
||||
group = SettingGroup.PROXY,
|
||||
defaultValue = "",
|
||||
)
|
||||
|
||||
val socksProxyPassword: MutableStateFlow<String> by StringSetting(
|
||||
protoNumber = 8,
|
||||
group = SettingGroup.PROXY,
|
||||
defaultValue = "",
|
||||
)
|
||||
|
||||
val webUIFlavor: MutableStateFlow<WebUIFlavor> by EnumSetting(
|
||||
protoNumber = 9,
|
||||
group = SettingGroup.WEB_UI,
|
||||
defaultValue = WebUIFlavor.WEBUI,
|
||||
enumClass = WebUIFlavor::class,
|
||||
typeInfo = SettingsRegistry.PartialTypeInfo(imports = listOf("suwayomi.tachidesk.graphql.types.WebUIFlavor")),
|
||||
)
|
||||
|
||||
val initialOpenInBrowserEnabled: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 10,
|
||||
group = SettingGroup.WEB_UI,
|
||||
defaultValue = true,
|
||||
description = "Open client on startup",
|
||||
)
|
||||
|
||||
val webUIInterface: MutableStateFlow<WebUIInterface> by EnumSetting(
|
||||
protoNumber = 11,
|
||||
group = SettingGroup.WEB_UI,
|
||||
defaultValue = WebUIInterface.BROWSER,
|
||||
enumClass = WebUIInterface::class,
|
||||
typeInfo = SettingsRegistry.PartialTypeInfo(imports = listOf("suwayomi.tachidesk.graphql.types.WebUIInterface")),
|
||||
)
|
||||
|
||||
val electronPath: MutableStateFlow<String> by PathSetting(
|
||||
protoNumber = 12,
|
||||
group = SettingGroup.WEB_UI,
|
||||
defaultValue = "",
|
||||
mustExist = true,
|
||||
)
|
||||
|
||||
val webUIChannel: MutableStateFlow<WebUIChannel> by EnumSetting(
|
||||
protoNumber = 13,
|
||||
group = SettingGroup.WEB_UI,
|
||||
defaultValue = WebUIChannel.STABLE,
|
||||
enumClass = WebUIChannel::class,
|
||||
typeInfo = SettingsRegistry.PartialTypeInfo(imports = listOf("suwayomi.tachidesk.graphql.types.WebUIChannel")),
|
||||
)
|
||||
|
||||
val webUIUpdateCheckInterval: MutableStateFlow<Double> by DisableableDoubleSetting(
|
||||
protoNumber = 14,
|
||||
group = SettingGroup.WEB_UI,
|
||||
defaultValue = 23.hours.inWholeHours.toDouble(),
|
||||
min = 0.0,
|
||||
max = 23.0,
|
||||
description = "Time in hours",
|
||||
)
|
||||
|
||||
val downloadAsCbz: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 15,
|
||||
defaultValue = false,
|
||||
group = SettingGroup.DOWNLOADER,
|
||||
)
|
||||
|
||||
val downloadsPath: MutableStateFlow<String> by PathSetting(
|
||||
protoNumber = 16,
|
||||
group = SettingGroup.DOWNLOADER,
|
||||
defaultValue = "",
|
||||
mustExist = true,
|
||||
)
|
||||
|
||||
val autoDownloadNewChapters: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 17,
|
||||
defaultValue = false,
|
||||
group = SettingGroup.DOWNLOADER,
|
||||
)
|
||||
|
||||
val excludeEntryWithUnreadChapters: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 18,
|
||||
group = SettingGroup.DOWNLOADER,
|
||||
defaultValue = true,
|
||||
description = "Exclude entries with unread chapters from auto-download",
|
||||
)
|
||||
|
||||
val autoDownloadAheadLimit: MutableStateFlow<Int> by MigratedConfigValue(
|
||||
protoNumber = 19,
|
||||
defaultValue = 0,
|
||||
group = SettingGroup.DOWNLOADER,
|
||||
deprecated =
|
||||
SettingsRegistry.SettingDeprecated(
|
||||
replaceWith = "autoDownloadNewChaptersLimit",
|
||||
message = "Replaced with autoDownloadNewChaptersLimit",
|
||||
),
|
||||
readMigrated = { autoDownloadNewChaptersLimit.value },
|
||||
setMigrated = { autoDownloadNewChaptersLimit.value = it },
|
||||
)
|
||||
|
||||
val autoDownloadNewChaptersLimit: MutableStateFlow<Int> by DisableableIntSetting(
|
||||
protoNumber = 20,
|
||||
group = SettingGroup.DOWNLOADER,
|
||||
defaultValue = 0,
|
||||
min = 0,
|
||||
description = "Maximum number of new chapters to auto-download",
|
||||
)
|
||||
|
||||
val autoDownloadIgnoreReUploads: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 21,
|
||||
group = SettingGroup.DOWNLOADER,
|
||||
defaultValue = false,
|
||||
description = "Ignore re-uploaded chapters from auto-download",
|
||||
)
|
||||
|
||||
val extensionRepos: MutableStateFlow<List<String>> by ListSetting<String>(
|
||||
protoNumber = 22,
|
||||
group = SettingGroup.EXTENSION,
|
||||
defaultValue = emptyList(),
|
||||
itemValidator = { url ->
|
||||
if (url.matches(repoMatchRegex)) {
|
||||
null
|
||||
} else {
|
||||
"Invalid repository URL format"
|
||||
}
|
||||
},
|
||||
itemToValidValue = { url ->
|
||||
if (url.matches(repoMatchRegex)) {
|
||||
url
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
typeInfo =
|
||||
SettingsRegistry.PartialTypeInfo(
|
||||
specificType = "List<String>",
|
||||
),
|
||||
description = "example: [\"https://github.com/MY_ACCOUNT/MY_REPO/tree/repo\"]",
|
||||
)
|
||||
|
||||
val maxSourcesInParallel: MutableStateFlow<Int> by IntSetting(
|
||||
protoNumber = 23,
|
||||
group = SettingGroup.EXTENSION,
|
||||
defaultValue = 6,
|
||||
min = 1,
|
||||
max = 20,
|
||||
description =
|
||||
"How many different sources can do requests (library update, downloads) in parallel. " +
|
||||
"Library update/downloads are grouped by source and all manga of a source are updated/downloaded synchronously",
|
||||
)
|
||||
|
||||
val excludeUnreadChapters: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 24,
|
||||
defaultValue = true,
|
||||
group = SettingGroup.LIBRARY_UPDATES,
|
||||
)
|
||||
|
||||
val excludeNotStarted: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 25,
|
||||
defaultValue = true,
|
||||
group = SettingGroup.LIBRARY_UPDATES,
|
||||
)
|
||||
|
||||
val excludeCompleted: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 26,
|
||||
defaultValue = true,
|
||||
group = SettingGroup.LIBRARY_UPDATES,
|
||||
)
|
||||
|
||||
val globalUpdateInterval: MutableStateFlow<Double> by DisableableDoubleSetting(
|
||||
protoNumber = 27,
|
||||
group = SettingGroup.LIBRARY_UPDATES,
|
||||
defaultValue = 12.hours.inWholeHours.toDouble(),
|
||||
min = 6.0,
|
||||
description = "Time in hours",
|
||||
)
|
||||
|
||||
val updateMangas: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 28,
|
||||
group = SettingGroup.LIBRARY_UPDATES,
|
||||
defaultValue = false,
|
||||
description = "Update manga metadata and thumbnail along with the chapter list update during the library update.",
|
||||
)
|
||||
|
||||
val basicAuthEnabled: MutableStateFlow<Boolean> by MigratedConfigValue(
|
||||
protoNumber = 29,
|
||||
defaultValue = false,
|
||||
group = SettingGroup.AUTH,
|
||||
deprecated =
|
||||
SettingsRegistry.SettingDeprecated(
|
||||
replaceWith = "authMode",
|
||||
message = "Removed - prefer authMode",
|
||||
),
|
||||
readMigrated = { authMode.value == AuthMode.BASIC_AUTH },
|
||||
setMigrated = { authMode.value = if (it) AuthMode.BASIC_AUTH else AuthMode.NONE },
|
||||
typeInfo =
|
||||
SettingsRegistry.PartialTypeInfo(
|
||||
restoreLegacy = { value ->
|
||||
value.takeIf { authMode.value == AuthMode.NONE }
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
val authUsername: MutableStateFlow<String> by StringSetting(
|
||||
protoNumber = 30,
|
||||
group = SettingGroup.AUTH,
|
||||
defaultValue = "",
|
||||
)
|
||||
|
||||
val authPassword: MutableStateFlow<String> by StringSetting(
|
||||
protoNumber = 31,
|
||||
group = SettingGroup.AUTH,
|
||||
defaultValue = "",
|
||||
)
|
||||
|
||||
val debugLogsEnabled: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 32,
|
||||
defaultValue = false,
|
||||
group = SettingGroup.MISC,
|
||||
)
|
||||
|
||||
val gqlDebugLogsEnabled: MutableStateFlow<Boolean> by MigratedConfigValue(
|
||||
protoNumber = 33,
|
||||
defaultValue = false,
|
||||
group = SettingGroup.MISC,
|
||||
deprecated =
|
||||
SettingsRegistry.SettingDeprecated(
|
||||
message = "Removed - does not do anything",
|
||||
),
|
||||
)
|
||||
|
||||
val systemTrayEnabled: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 34,
|
||||
defaultValue = true,
|
||||
group = SettingGroup.MISC,
|
||||
)
|
||||
|
||||
val maxLogFiles: MutableStateFlow<Int> by IntSetting(
|
||||
protoNumber = 35,
|
||||
group = SettingGroup.MISC,
|
||||
defaultValue = 31,
|
||||
min = 0,
|
||||
description = "The max number of days to keep files before they get deleted",
|
||||
)
|
||||
|
||||
private val logbackSizePattern = "^[0-9]+(|kb|KB|mb|MB|gb|GB)$".toRegex()
|
||||
val maxLogFileSize: MutableStateFlow<String> by StringSetting(
|
||||
protoNumber = 36,
|
||||
group = SettingGroup.MISC,
|
||||
defaultValue = "10mb",
|
||||
pattern = logbackSizePattern,
|
||||
description = "Maximum log file size - values: 1 (bytes), 1KB (kilobytes), 1MB (megabytes), 1GB (gigabytes)",
|
||||
)
|
||||
|
||||
val maxLogFolderSize: MutableStateFlow<String> by StringSetting(
|
||||
protoNumber = 37,
|
||||
group = SettingGroup.MISC,
|
||||
defaultValue = "100mb",
|
||||
pattern = logbackSizePattern,
|
||||
description = "Maximum log folder size - values: 1 (bytes), 1KB (kilobytes), 1MB (megabytes), 1GB (gigabytes)",
|
||||
)
|
||||
|
||||
val backupPath: MutableStateFlow<String> by PathSetting(
|
||||
protoNumber = 38,
|
||||
group = SettingGroup.BACKUP,
|
||||
defaultValue = "",
|
||||
mustExist = true,
|
||||
)
|
||||
|
||||
val backupTime: MutableStateFlow<String> by StringSetting(
|
||||
protoNumber = 39,
|
||||
group = SettingGroup.BACKUP,
|
||||
defaultValue = "00:00",
|
||||
pattern = "^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$".toRegex(),
|
||||
description = "Daily backup time (HH:MM) ; range: [00:00, 23:59]",
|
||||
)
|
||||
|
||||
val backupInterval: MutableStateFlow<Int> by DisableableIntSetting(
|
||||
protoNumber = 40,
|
||||
group = SettingGroup.BACKUP,
|
||||
defaultValue = 1,
|
||||
min = 0,
|
||||
description = "Time in days",
|
||||
)
|
||||
|
||||
val backupTTL: MutableStateFlow<Int> by DisableableIntSetting(
|
||||
protoNumber = 41,
|
||||
group = SettingGroup.BACKUP,
|
||||
defaultValue = 14.days.inWholeDays.toInt(),
|
||||
min = 0,
|
||||
description = "Backup retention in days",
|
||||
)
|
||||
|
||||
val localSourcePath: MutableStateFlow<String> by PathSetting(
|
||||
protoNumber = 42,
|
||||
group = SettingGroup.LOCAL_SOURCE,
|
||||
defaultValue = "",
|
||||
mustExist = true,
|
||||
)
|
||||
|
||||
val flareSolverrEnabled: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 43,
|
||||
defaultValue = false,
|
||||
group = SettingGroup.CLOUDFLARE,
|
||||
)
|
||||
|
||||
val flareSolverrUrl: MutableStateFlow<String> by StringSetting(
|
||||
protoNumber = 44,
|
||||
group = SettingGroup.CLOUDFLARE,
|
||||
defaultValue = "http://localhost:8191",
|
||||
)
|
||||
|
||||
val flareSolverrTimeout: MutableStateFlow<Int> by IntSetting(
|
||||
protoNumber = 45,
|
||||
group = SettingGroup.CLOUDFLARE,
|
||||
defaultValue = 60.seconds.inWholeSeconds.toInt(),
|
||||
min = 0,
|
||||
description = "Time in seconds",
|
||||
)
|
||||
|
||||
val flareSolverrSessionName: MutableStateFlow<String> by StringSetting(
|
||||
protoNumber = 46,
|
||||
group = SettingGroup.CLOUDFLARE,
|
||||
defaultValue = "suwayomi",
|
||||
)
|
||||
|
||||
val flareSolverrSessionTtl: MutableStateFlow<Int> by IntSetting(
|
||||
protoNumber = 47,
|
||||
group = SettingGroup.CLOUDFLARE,
|
||||
defaultValue = 15.minutes.inWholeMinutes.toInt(),
|
||||
min = 0,
|
||||
description = "Time in minutes",
|
||||
)
|
||||
|
||||
val flareSolverrAsResponseFallback: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 48,
|
||||
defaultValue = false,
|
||||
group = SettingGroup.CLOUDFLARE,
|
||||
)
|
||||
|
||||
val opdsUseBinaryFileSizes: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 49,
|
||||
group = SettingGroup.OPDS,
|
||||
defaultValue = false,
|
||||
description = "Display file size in binary (KiB, MiB, GiB) instead of decimal (KB, MB, GB)",
|
||||
)
|
||||
|
||||
val opdsItemsPerPage: MutableStateFlow<Int> by IntSetting(
|
||||
protoNumber = 50,
|
||||
group = SettingGroup.OPDS,
|
||||
defaultValue = 100,
|
||||
min = 10,
|
||||
max = 5000,
|
||||
)
|
||||
|
||||
val opdsEnablePageReadProgress: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 51,
|
||||
defaultValue = true,
|
||||
group = SettingGroup.OPDS,
|
||||
)
|
||||
|
||||
val opdsMarkAsReadOnDownload: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 52,
|
||||
defaultValue = false,
|
||||
group = SettingGroup.OPDS,
|
||||
)
|
||||
|
||||
val opdsShowOnlyUnreadChapters: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 53,
|
||||
defaultValue = false,
|
||||
group = SettingGroup.OPDS,
|
||||
)
|
||||
|
||||
val opdsShowOnlyDownloadedChapters: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 54,
|
||||
defaultValue = false,
|
||||
group = SettingGroup.OPDS,
|
||||
)
|
||||
|
||||
val opdsChapterSortOrder: MutableStateFlow<SortOrder> by EnumSetting(
|
||||
protoNumber = 55,
|
||||
group = SettingGroup.OPDS,
|
||||
defaultValue = SortOrder.DESC,
|
||||
enumClass = SortOrder::class,
|
||||
typeInfo = SettingsRegistry.PartialTypeInfo(imports = listOf("org.jetbrains.exposed.sql.SortOrder")),
|
||||
)
|
||||
|
||||
val authMode: MutableStateFlow<AuthMode> by EnumSetting(
|
||||
protoNumber = 56,
|
||||
group = SettingGroup.AUTH,
|
||||
defaultValue = AuthMode.NONE,
|
||||
enumClass = AuthMode::class,
|
||||
typeInfo = SettingsRegistry.PartialTypeInfo(imports = listOf("suwayomi.tachidesk.graphql.types.AuthMode")),
|
||||
)
|
||||
|
||||
val downloadConversions: MutableStateFlow<Map<String, DownloadConversion>> by MapSetting<String, DownloadConversion>(
|
||||
protoNumber = 57,
|
||||
defaultValue = emptyMap(),
|
||||
group = SettingGroup.DOWNLOADER,
|
||||
typeInfo =
|
||||
SettingsRegistry.PartialTypeInfo(
|
||||
specificType = "List<SettingsDownloadConversionType>",
|
||||
interfaceType = "List<SettingsDownloadConversion>",
|
||||
backupType = "List<BackupSettingsDownloadConversionType>",
|
||||
imports =
|
||||
listOf(
|
||||
"suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSettingsDownloadConversionType",
|
||||
),
|
||||
convertToGqlType = { value ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val castedValue = value as Map<String, DownloadConversion>
|
||||
|
||||
castedValue.map {
|
||||
SettingsDownloadConversionType(
|
||||
it.key,
|
||||
it.value.target,
|
||||
it.value.compressionLevel,
|
||||
)
|
||||
}
|
||||
},
|
||||
convertToInternalType = { list ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val castedList = list as List<SettingsDownloadConversionType>
|
||||
|
||||
castedList.associate {
|
||||
it.mimeType to
|
||||
DownloadConversion(
|
||||
target = it.target,
|
||||
compressionLevel = it.compressionLevel,
|
||||
)
|
||||
}
|
||||
},
|
||||
convertToBackupType = { value ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val castedValue = value as Map<String, DownloadConversion>
|
||||
|
||||
castedValue.map {
|
||||
BackupSettingsDownloadConversionType(
|
||||
it.key,
|
||||
it.value.target,
|
||||
it.value.compressionLevel,
|
||||
)
|
||||
}
|
||||
},
|
||||
),
|
||||
description =
|
||||
"""
|
||||
map input mime type to conversion information, or "default" for others
|
||||
server.downloadConversions."image/webp" = {
|
||||
target = "image/jpeg" # image type to convert to
|
||||
compressionLevel = 0.8 # quality in range [0,1], leave away to use default compression
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val jwtAudience: MutableStateFlow<String> by StringSetting(
|
||||
protoNumber = 58,
|
||||
group = SettingGroup.AUTH,
|
||||
defaultValue = "suwayomi-server-api",
|
||||
)
|
||||
|
||||
val koreaderSyncServerUrl: MutableStateFlow<String> by StringSetting(
|
||||
protoNumber = 59,
|
||||
group = SettingGroup.KOREADER_SYNC,
|
||||
defaultValue = "http://localhost:17200",
|
||||
)
|
||||
|
||||
val koreaderSyncUsername: MutableStateFlow<String> by StringSetting(
|
||||
protoNumber = 60,
|
||||
group = SettingGroup.KOREADER_SYNC,
|
||||
defaultValue = "",
|
||||
)
|
||||
|
||||
val koreaderSyncUserkey: MutableStateFlow<String> by StringSetting(
|
||||
protoNumber = 61,
|
||||
group = SettingGroup.KOREADER_SYNC,
|
||||
defaultValue = "",
|
||||
)
|
||||
|
||||
val koreaderSyncDeviceId: MutableStateFlow<String> by StringSetting(
|
||||
protoNumber = 62,
|
||||
group = SettingGroup.KOREADER_SYNC,
|
||||
defaultValue = "",
|
||||
)
|
||||
|
||||
val koreaderSyncChecksumMethod: MutableStateFlow<KoreaderSyncChecksumMethod> by EnumSetting(
|
||||
protoNumber = 63,
|
||||
group = SettingGroup.KOREADER_SYNC,
|
||||
defaultValue = KoreaderSyncChecksumMethod.BINARY,
|
||||
enumClass = KoreaderSyncChecksumMethod::class,
|
||||
typeInfo = SettingsRegistry.PartialTypeInfo(imports = listOf("suwayomi.tachidesk.graphql.types.KoreaderSyncChecksumMethod")),
|
||||
)
|
||||
|
||||
val koreaderSyncStrategy: MutableStateFlow<KoreaderSyncStrategy> by EnumSetting(
|
||||
protoNumber = 64,
|
||||
group = SettingGroup.KOREADER_SYNC,
|
||||
defaultValue = KoreaderSyncStrategy.DISABLED,
|
||||
enumClass = KoreaderSyncStrategy::class,
|
||||
typeInfo = SettingsRegistry.PartialTypeInfo(imports = listOf("suwayomi.tachidesk.graphql.types.KoreaderSyncStrategy")),
|
||||
)
|
||||
|
||||
val koreaderSyncPercentageTolerance: MutableStateFlow<Double> by DoubleSetting(
|
||||
protoNumber = 65,
|
||||
group = SettingGroup.KOREADER_SYNC,
|
||||
defaultValue = 0.000000000000001,
|
||||
min = 0.000000000000001,
|
||||
max = 1.0,
|
||||
description = "Absolute tolerance for progress comparison",
|
||||
)
|
||||
|
||||
val jwtTokenExpiry: MutableStateFlow<Duration> by DurationSetting(
|
||||
protoNumber = 66,
|
||||
group = SettingGroup.AUTH,
|
||||
defaultValue = 5.minutes,
|
||||
min = 0.seconds,
|
||||
)
|
||||
|
||||
val jwtRefreshExpiry: MutableStateFlow<Duration> by DurationSetting(
|
||||
protoNumber = 67,
|
||||
group = SettingGroup.AUTH,
|
||||
defaultValue = 60.days,
|
||||
min = 0.seconds,
|
||||
)
|
||||
|
||||
val webUIEnabled: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 68,
|
||||
group = SettingGroup.WEB_UI,
|
||||
defaultValue = true,
|
||||
requiresRestart = true,
|
||||
)
|
||||
|
||||
/** ****************************************************************** **/
|
||||
/** **/
|
||||
/** Renamed settings **/
|
||||
/** **/
|
||||
|
||||
/** ****************************************************************** **/
|
||||
val basicAuthUsername: MutableStateFlow<String> by MigratedConfigValue(
|
||||
protoNumber = 99991,
|
||||
defaultValue = "",
|
||||
group = SettingGroup.AUTH,
|
||||
deprecated =
|
||||
SettingsRegistry.SettingDeprecated(
|
||||
replaceWith = "authUsername",
|
||||
message = "Removed - prefer authUsername",
|
||||
),
|
||||
readMigrated = { authUsername.value },
|
||||
setMigrated = { authUsername.value = it },
|
||||
)
|
||||
|
||||
val basicAuthPassword: MutableStateFlow<String> by MigratedConfigValue(
|
||||
protoNumber = 99992,
|
||||
defaultValue = "",
|
||||
group = SettingGroup.AUTH,
|
||||
deprecated =
|
||||
SettingsRegistry.SettingDeprecated(
|
||||
replaceWith = "authPassword",
|
||||
message = "Removed - prefer authPassword",
|
||||
),
|
||||
readMigrated = { authPassword.value },
|
||||
setMigrated = { authPassword.value = it },
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun <T> subscribeTo(
|
||||
flow: Flow<T>,
|
||||
onChange: suspend (value: T) -> Unit,
|
||||
ignoreInitialValue: Boolean = true,
|
||||
) {
|
||||
val actualFlow =
|
||||
if (ignoreInitialValue) {
|
||||
flow.drop(1)
|
||||
} else {
|
||||
flow
|
||||
}
|
||||
|
||||
val sharedFlow = MutableSharedFlow<T>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
actualFlow.distinctUntilChanged().mapLatest { sharedFlow.emit(it) }.launchIn(mutableConfigValueScope)
|
||||
sharedFlow.onEach { onChange(it) }.launchIn(mutableConfigValueScope)
|
||||
}
|
||||
|
||||
fun <T> subscribeTo(
|
||||
flow: Flow<T>,
|
||||
onChange: suspend () -> Unit,
|
||||
ignoreInitialValue: Boolean = true,
|
||||
) {
|
||||
subscribeTo(flow, { _ -> onChange() }, ignoreInitialValue)
|
||||
}
|
||||
|
||||
fun <T> subscribeTo(
|
||||
mutableStateFlow: MutableStateFlow<T>,
|
||||
onChange: suspend (value: T) -> Unit,
|
||||
ignoreInitialValue: Boolean = true,
|
||||
) {
|
||||
subscribeTo(mutableStateFlow.asStateFlow(), onChange, ignoreInitialValue)
|
||||
}
|
||||
|
||||
fun <T> subscribeTo(
|
||||
mutableStateFlow: MutableStateFlow<T>,
|
||||
onChange: suspend () -> Unit,
|
||||
ignoreInitialValue: Boolean = true,
|
||||
) {
|
||||
subscribeTo(mutableStateFlow.asStateFlow(), { _ -> onChange() }, ignoreInitialValue)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun register(getConfig: () -> Config) =
|
||||
ServerConfig {
|
||||
getConfig().getConfig(
|
||||
SERVER_CONFIG_MODULE_NAME,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
package suwayomi.tachidesk.server.settings
|
||||
|
||||
import io.github.config4k.getValue
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import suwayomi.tachidesk.server.ServerConfig
|
||||
import suwayomi.tachidesk.server.mutableConfigValueScope
|
||||
import xyz.nulldev.ts.config.GlobalConfigManager
|
||||
import java.io.File
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.reflect.KProperty
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Base delegate for settings to read values from the config file with automatic setting registration and validation
|
||||
*/
|
||||
open class SettingDelegate<T : Any>(
|
||||
protected val protoNumber: Int,
|
||||
val defaultValue: T,
|
||||
val validator: ((T) -> String?)? = null,
|
||||
val toValidValue: ((T) -> T)? = null,
|
||||
protected val group: SettingGroup,
|
||||
protected val requiresRestart: Boolean? = null,
|
||||
protected val typeInfo: SettingsRegistry.PartialTypeInfo? = null,
|
||||
protected val deprecated: SettingsRegistry.SettingDeprecated? = null,
|
||||
protected val description: String? = null,
|
||||
) {
|
||||
var flow: MutableStateFlow<T>? = null
|
||||
lateinit var propertyName: String
|
||||
lateinit var moduleName: String
|
||||
|
||||
operator fun provideDelegate(
|
||||
thisRef: ServerConfig,
|
||||
property: KProperty<*>,
|
||||
): SettingDelegate<T> {
|
||||
propertyName = property.name
|
||||
moduleName = thisRef.moduleName
|
||||
|
||||
SettingsRegistry.register(
|
||||
SettingsRegistry.SettingMetadata(
|
||||
protoNumber = protoNumber,
|
||||
name = propertyName,
|
||||
typeInfo =
|
||||
SettingsRegistry.TypeInfo(
|
||||
type = typeInfo?.type ?: defaultValue::class,
|
||||
specificType = typeInfo?.specificType,
|
||||
interfaceType = typeInfo?.interfaceType,
|
||||
backupType = typeInfo?.backupType,
|
||||
imports = typeInfo?.imports,
|
||||
convertToGqlType = typeInfo?.convertToGqlType,
|
||||
convertToInternalType = typeInfo?.convertToInternalType,
|
||||
convertToBackupType = typeInfo?.convertToBackupType,
|
||||
),
|
||||
defaultValue = defaultValue,
|
||||
validator =
|
||||
validator?.let { validate ->
|
||||
{ value ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
validate(value as T)
|
||||
}
|
||||
},
|
||||
group = group.value,
|
||||
deprecated = deprecated,
|
||||
requiresRestart = requiresRestart ?: false,
|
||||
description =
|
||||
run {
|
||||
val defaultValueString =
|
||||
when (defaultValue) {
|
||||
is String -> "\"$defaultValue\""
|
||||
else -> defaultValue
|
||||
}
|
||||
val defaultValueComment = "default: $defaultValueString"
|
||||
|
||||
if (description != null) {
|
||||
"$defaultValueComment ; $description"
|
||||
} else {
|
||||
defaultValueComment
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
inline operator fun <reified ReifiedT : MutableStateFlow<R>, reified R> getValue(
|
||||
thisRef: ServerConfig,
|
||||
property: KProperty<*>,
|
||||
): ReifiedT {
|
||||
if (flow != null) {
|
||||
return flow as ReifiedT
|
||||
}
|
||||
|
||||
val stateFlow = thisRef.overridableConfig.getValue<ServerConfig, ReifiedT>(thisRef, property)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
flow = stateFlow as MutableStateFlow<T>
|
||||
|
||||
// Validate config value and optionally fallback to default value
|
||||
validator?.let { validate ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val initialValue = stateFlow.value
|
||||
val error = validate(initialValue)
|
||||
if (error != null) {
|
||||
KotlinLogging.logger { }.warn {
|
||||
"Invalid config value ($initialValue) for $moduleName.$propertyName: $error. Using default value: $defaultValue"
|
||||
}
|
||||
|
||||
stateFlow.value = toValidValue?.let { it(initialValue) } ?: defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
stateFlow
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.filter { it != thisRef.overridableConfig.getConfig().getValue<ServerConfig, R>(thisRef, property) }
|
||||
.onEach { value ->
|
||||
validator?.let { validate ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val error = validate(value as T)
|
||||
if (error != null) {
|
||||
throw IllegalArgumentException("Setting $propertyName: $error")
|
||||
}
|
||||
}
|
||||
|
||||
GlobalConfigManager.updateValue("$moduleName.$propertyName", value as Any)
|
||||
}.launchIn(mutableConfigValueScope)
|
||||
|
||||
return stateFlow
|
||||
}
|
||||
}
|
||||
|
||||
class MigratedConfigValue<T : Any>(
|
||||
private val protoNumber: Int,
|
||||
private val defaultValue: T,
|
||||
private val group: SettingGroup,
|
||||
private val requiresRestart: Boolean? = null,
|
||||
private val typeInfo: SettingsRegistry.PartialTypeInfo? = null,
|
||||
private val deprecated: SettingsRegistry.SettingDeprecated,
|
||||
private val readMigrated: (() -> T) = { defaultValue },
|
||||
private val setMigrated: ((T) -> Unit) = {},
|
||||
) {
|
||||
var flow: MutableStateFlow<T>? = null
|
||||
lateinit var propertyName: String
|
||||
lateinit var moduleName: String
|
||||
|
||||
operator fun provideDelegate(
|
||||
thisRef: ServerConfig,
|
||||
property: KProperty<*>,
|
||||
): MigratedConfigValue<T> {
|
||||
propertyName = property.name
|
||||
moduleName = thisRef.moduleName
|
||||
|
||||
SettingsRegistry.register(
|
||||
SettingsRegistry.SettingMetadata(
|
||||
protoNumber = protoNumber,
|
||||
name = propertyName,
|
||||
typeInfo =
|
||||
SettingsRegistry.TypeInfo(
|
||||
type = typeInfo?.type ?: defaultValue::class,
|
||||
specificType = typeInfo?.specificType,
|
||||
backupType = typeInfo?.backupType,
|
||||
imports = typeInfo?.imports,
|
||||
restoreLegacy = typeInfo?.restoreLegacy,
|
||||
),
|
||||
defaultValue = defaultValue,
|
||||
group = group.value,
|
||||
deprecated = deprecated,
|
||||
requiresRestart = requiresRestart ?: false,
|
||||
),
|
||||
)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
operator fun getValue(
|
||||
thisRef: ServerConfig,
|
||||
property: KProperty<*>,
|
||||
): MutableStateFlow<T> {
|
||||
if (flow != null) {
|
||||
return flow!!
|
||||
}
|
||||
|
||||
val value = readMigrated()
|
||||
|
||||
val stateFlow = MutableStateFlow(value)
|
||||
flow = stateFlow
|
||||
|
||||
stateFlow
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.filter { it != readMigrated() }
|
||||
.onEach(setMigrated)
|
||||
.launchIn(mutableConfigValueScope)
|
||||
|
||||
return stateFlow
|
||||
}
|
||||
}
|
||||
|
||||
// Specialized delegates for common types
|
||||
class StringSetting(
|
||||
protoNumber: Int,
|
||||
defaultValue: String,
|
||||
pattern: Regex? = null,
|
||||
maxLength: Int? = null,
|
||||
group: SettingGroup,
|
||||
deprecated: SettingsRegistry.SettingDeprecated? = null,
|
||||
requiresRestart: Boolean? = null,
|
||||
description: String? = null,
|
||||
) : SettingDelegate<String>(
|
||||
protoNumber = protoNumber,
|
||||
defaultValue = defaultValue,
|
||||
validator = { value ->
|
||||
when {
|
||||
pattern != null && !value.matches(pattern) ->
|
||||
"Value must match pattern: ${pattern.pattern}"
|
||||
maxLength != null && value.length > maxLength ->
|
||||
"Value must not exceed $maxLength characters"
|
||||
else -> null
|
||||
}
|
||||
},
|
||||
toValidValue = { value ->
|
||||
if (pattern != null && !value.matches(pattern)) {
|
||||
defaultValue
|
||||
} else {
|
||||
maxLength?.let { value.take(it) } ?: value
|
||||
}
|
||||
},
|
||||
group = group,
|
||||
deprecated = deprecated,
|
||||
requiresRestart = requiresRestart,
|
||||
description = description,
|
||||
)
|
||||
|
||||
abstract class RangeSetting<T : Comparable<T>>(
|
||||
protoNumber: Int,
|
||||
defaultValue: T,
|
||||
min: T? = null,
|
||||
max: T? = null,
|
||||
validator: ((T) -> String?)? = null,
|
||||
toValidValue: ((T) -> T)? = null,
|
||||
group: SettingGroup,
|
||||
typeInfo: SettingsRegistry.PartialTypeInfo? = null,
|
||||
deprecated: SettingsRegistry.SettingDeprecated? = null,
|
||||
requiresRestart: Boolean? = null,
|
||||
description: String? = null,
|
||||
) : SettingDelegate<T>(
|
||||
protoNumber = protoNumber,
|
||||
defaultValue = defaultValue,
|
||||
validator =
|
||||
validator ?: { value ->
|
||||
when {
|
||||
min != null && value < min -> "Value must be at least $min"
|
||||
max != null && value > max -> "Value must not exceed $max"
|
||||
else -> null
|
||||
}
|
||||
},
|
||||
toValidValue =
|
||||
toValidValue ?: { value ->
|
||||
val coerceAtLeast = min?.let { value.coerceAtLeast(min) } ?: value
|
||||
val coerceAtMost = max?.let { coerceAtLeast.coerceAtMost(max) } ?: value
|
||||
|
||||
coerceAtMost
|
||||
},
|
||||
group = group,
|
||||
typeInfo = typeInfo,
|
||||
deprecated = deprecated,
|
||||
requiresRestart = requiresRestart,
|
||||
description =
|
||||
run {
|
||||
val defaultDescription = "range: [${min ?: "-∞"}, ${max ?: "+∞"}]"
|
||||
|
||||
if (description != null) {
|
||||
"$defaultDescription ; $description"
|
||||
} else {
|
||||
defaultDescription
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
class IntSetting(
|
||||
protoNumber: Int,
|
||||
defaultValue: Int,
|
||||
min: Int? = null,
|
||||
max: Int? = null,
|
||||
customValidator: ((Int) -> String?)? = null,
|
||||
customToValidValue: ((Int) -> Int)? = null,
|
||||
group: SettingGroup,
|
||||
deprecated: SettingsRegistry.SettingDeprecated? = null,
|
||||
requiresRestart: Boolean? = null,
|
||||
description: String? = null,
|
||||
) : RangeSetting<Int>(
|
||||
protoNumber = protoNumber,
|
||||
defaultValue = defaultValue,
|
||||
min = min,
|
||||
max = max,
|
||||
validator = customValidator,
|
||||
toValidValue = customToValidValue,
|
||||
group = group,
|
||||
deprecated = deprecated,
|
||||
requiresRestart = requiresRestart,
|
||||
description = description,
|
||||
)
|
||||
|
||||
class DisableableIntSetting(
|
||||
protoNumber: Int,
|
||||
defaultValue: Int,
|
||||
min: Int? = null,
|
||||
max: Int? = null,
|
||||
group: SettingGroup,
|
||||
deprecated: SettingsRegistry.SettingDeprecated? = null,
|
||||
requiresRestart: Boolean? = null,
|
||||
description: String? = null,
|
||||
) : RangeSetting<Int>(
|
||||
protoNumber = protoNumber,
|
||||
defaultValue = defaultValue,
|
||||
min = min,
|
||||
max = max,
|
||||
validator = { value ->
|
||||
when {
|
||||
value == 0 -> null
|
||||
min != null && value < min -> "Value must be 0.0 or at least $min"
|
||||
max != null && value > max -> "Value must be 0.0 or not exceed $max"
|
||||
else -> null
|
||||
}
|
||||
},
|
||||
toValidValue = { value ->
|
||||
if (value == 0) {
|
||||
value
|
||||
} else {
|
||||
val coerceAtLeast = min?.let { value.coerceAtLeast(min) } ?: value
|
||||
val coerceAtMost = max?.let { coerceAtLeast.coerceAtMost(max) } ?: value
|
||||
|
||||
coerceAtMost
|
||||
}
|
||||
},
|
||||
group = group,
|
||||
deprecated = deprecated,
|
||||
requiresRestart = requiresRestart,
|
||||
description =
|
||||
run {
|
||||
if (description != null) {
|
||||
"0 == disabled ; $description"
|
||||
} else {
|
||||
description
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
class DoubleSetting(
|
||||
protoNumber: Int,
|
||||
defaultValue: Double,
|
||||
min: Double? = null,
|
||||
max: Double? = null,
|
||||
customValidator: ((Double) -> String?)? = null,
|
||||
customToValidValue: ((Double) -> Double)? = null,
|
||||
group: SettingGroup,
|
||||
deprecated: SettingsRegistry.SettingDeprecated? = null,
|
||||
requiresRestart: Boolean? = null,
|
||||
description: String? = null,
|
||||
) : RangeSetting<Double>(
|
||||
protoNumber = protoNumber,
|
||||
defaultValue = defaultValue,
|
||||
min = min,
|
||||
max = max,
|
||||
validator = customValidator,
|
||||
toValidValue = customToValidValue,
|
||||
group = group,
|
||||
deprecated = deprecated,
|
||||
requiresRestart = requiresRestart,
|
||||
description = description,
|
||||
)
|
||||
|
||||
class DisableableDoubleSetting(
|
||||
protoNumber: Int,
|
||||
defaultValue: Double,
|
||||
min: Double? = null,
|
||||
max: Double? = null,
|
||||
group: SettingGroup,
|
||||
deprecated: SettingsRegistry.SettingDeprecated? = null,
|
||||
requiresRestart: Boolean? = null,
|
||||
description: String? = null,
|
||||
) : RangeSetting<Double>(
|
||||
protoNumber = protoNumber,
|
||||
defaultValue = defaultValue,
|
||||
min = min,
|
||||
max = max,
|
||||
validator = { value ->
|
||||
when {
|
||||
value == 0.0 -> null
|
||||
min != null && value < min -> "Value must 0.0 or be at least $min"
|
||||
max != null && value > max -> "Value must 0.0 or not exceed $max"
|
||||
else -> null
|
||||
}
|
||||
},
|
||||
toValidValue = { value ->
|
||||
if (value == 0.0) {
|
||||
value
|
||||
} else {
|
||||
val coerceAtLeast = min?.let { value.coerceAtLeast(min) } ?: value
|
||||
val coerceAtMost = max?.let { coerceAtLeast.coerceAtMost(max) } ?: value
|
||||
|
||||
coerceAtMost
|
||||
}
|
||||
},
|
||||
group = group,
|
||||
deprecated = deprecated,
|
||||
requiresRestart = requiresRestart,
|
||||
description =
|
||||
run {
|
||||
if (description != null) {
|
||||
"0.0 == disabled ; $description"
|
||||
} else {
|
||||
description
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
class BooleanSetting(
|
||||
protoNumber: Int,
|
||||
defaultValue: Boolean,
|
||||
group: SettingGroup,
|
||||
deprecated: SettingsRegistry.SettingDeprecated? = null,
|
||||
requiresRestart: Boolean? = null,
|
||||
description: String? = null,
|
||||
) : SettingDelegate<Boolean>(
|
||||
protoNumber = protoNumber,
|
||||
defaultValue = defaultValue,
|
||||
validator = null,
|
||||
group = group,
|
||||
deprecated = deprecated,
|
||||
requiresRestart = requiresRestart,
|
||||
description = description,
|
||||
)
|
||||
|
||||
class PathSetting(
|
||||
protoNumber: Int,
|
||||
defaultValue: String,
|
||||
mustExist: Boolean = false,
|
||||
group: SettingGroup,
|
||||
deprecated: SettingsRegistry.SettingDeprecated? = null,
|
||||
requiresRestart: Boolean? = null,
|
||||
description: String? = null,
|
||||
) : SettingDelegate<String>(
|
||||
protoNumber = protoNumber,
|
||||
defaultValue = defaultValue,
|
||||
validator = { value ->
|
||||
if (mustExist && value.isNotEmpty() && !File(value).exists()) {
|
||||
"Path does not exist: $value"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
group = group,
|
||||
deprecated = deprecated,
|
||||
requiresRestart = requiresRestart,
|
||||
description = description,
|
||||
)
|
||||
|
||||
class EnumSetting<T : Enum<T>>(
|
||||
protoNumber: Int,
|
||||
defaultValue: T,
|
||||
enumClass: KClass<T>,
|
||||
typeInfo: SettingsRegistry.PartialTypeInfo? = null,
|
||||
group: SettingGroup,
|
||||
deprecated: SettingsRegistry.SettingDeprecated? = null,
|
||||
requiresRestart: Boolean? = null,
|
||||
description: String? = null,
|
||||
) : SettingDelegate<T>(
|
||||
protoNumber = protoNumber,
|
||||
defaultValue = defaultValue,
|
||||
validator = { value ->
|
||||
if (!enumClass.java.isInstance(value)) {
|
||||
"Invalid enum value for ${enumClass.simpleName}"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
typeInfo = typeInfo,
|
||||
group = group,
|
||||
deprecated = deprecated,
|
||||
requiresRestart = requiresRestart,
|
||||
description =
|
||||
run {
|
||||
val defaultDescription = "options: ${enumClass.java.enumConstants.joinToString()}"
|
||||
|
||||
if (description != null) {
|
||||
"$description ; $defaultDescription"
|
||||
} else {
|
||||
defaultDescription
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
class DurationSetting(
|
||||
protoNumber: Int,
|
||||
defaultValue: Duration,
|
||||
min: Duration? = null,
|
||||
max: Duration? = null,
|
||||
customValidator: ((Duration) -> String?)? = null,
|
||||
customToValidValue: ((Duration) -> Duration)? = null,
|
||||
group: SettingGroup,
|
||||
deprecated: SettingsRegistry.SettingDeprecated? = null,
|
||||
requiresRestart: Boolean? = null,
|
||||
description: String? = null,
|
||||
) : RangeSetting<Duration>(
|
||||
protoNumber = protoNumber,
|
||||
defaultValue = defaultValue,
|
||||
min = min,
|
||||
max = max,
|
||||
validator = customValidator,
|
||||
toValidValue = customToValidValue,
|
||||
typeInfo =
|
||||
SettingsRegistry.PartialTypeInfo(
|
||||
imports = listOf("kotlin.time.Duration"),
|
||||
),
|
||||
group = group,
|
||||
deprecated = deprecated,
|
||||
requiresRestart = requiresRestart,
|
||||
description = description,
|
||||
)
|
||||
|
||||
class ListSetting<T>(
|
||||
protoNumber: Int,
|
||||
defaultValue: List<T>,
|
||||
itemValidator: ((T) -> String?)? = null,
|
||||
itemToValidValue: ((T) -> T?)? = null,
|
||||
typeInfo: SettingsRegistry.PartialTypeInfo? = null,
|
||||
group: SettingGroup,
|
||||
deprecated: SettingsRegistry.SettingDeprecated? = null,
|
||||
requiresRestart: Boolean? = null,
|
||||
description: String? = null,
|
||||
) : SettingDelegate<List<T>>(
|
||||
protoNumber = protoNumber,
|
||||
defaultValue = defaultValue,
|
||||
validator = { list ->
|
||||
if (itemValidator != null) {
|
||||
list.firstNotNullOfOrNull { item ->
|
||||
itemValidator(item)?.let { error -> "Invalid item: $error" }
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
toValidValue = { list ->
|
||||
if (itemToValidValue != null) {
|
||||
list.mapNotNull(itemToValidValue)
|
||||
} else {
|
||||
defaultValue
|
||||
}
|
||||
},
|
||||
typeInfo = typeInfo,
|
||||
group = group,
|
||||
deprecated = deprecated,
|
||||
requiresRestart = requiresRestart,
|
||||
description = description,
|
||||
)
|
||||
|
||||
class MapSetting<K, V>(
|
||||
protoNumber: Int,
|
||||
defaultValue: Map<K, V>,
|
||||
validator: ((Map<K, V>) -> String?)? = null,
|
||||
typeInfo: SettingsRegistry.PartialTypeInfo? = null,
|
||||
group: SettingGroup,
|
||||
deprecated: SettingsRegistry.SettingDeprecated? = null,
|
||||
requiresRestart: Boolean? = null,
|
||||
description: String? = null,
|
||||
) : SettingDelegate<Map<K, V>>(
|
||||
protoNumber = protoNumber,
|
||||
defaultValue = defaultValue,
|
||||
validator = validator,
|
||||
typeInfo = typeInfo,
|
||||
group = group,
|
||||
deprecated = deprecated,
|
||||
requiresRestart = requiresRestart,
|
||||
description = description,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
package suwayomi.tachidesk.server.settings
|
||||
|
||||
enum class SettingGroup(
|
||||
val value: String,
|
||||
) {
|
||||
NETWORK("Network"),
|
||||
PROXY("Proxy"),
|
||||
WEB_UI("WebUI"),
|
||||
DOWNLOADER("Downloader"),
|
||||
EXTENSION("Extension/Source"),
|
||||
LIBRARY_UPDATES("Library updates"),
|
||||
AUTH("Authentication"),
|
||||
MISC("Misc"),
|
||||
BACKUP("Backup"),
|
||||
LOCAL_SOURCE("Local source"),
|
||||
CLOUDFLARE("Cloudflare"),
|
||||
OPDS("OPDS"),
|
||||
KOREADER_SYNC("KOReader sync"),
|
||||
;
|
||||
|
||||
override fun toString(): String = value
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package suwayomi.tachidesk.server.settings
|
||||
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* Registry to track all settings for automatic updating and validation
|
||||
*/
|
||||
object SettingsRegistry {
|
||||
data class SettingDeprecated(
|
||||
val replaceWith: String? = null,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
interface ITypeInfo {
|
||||
val type: KClass<*>?
|
||||
val specificType: String?
|
||||
val interfaceType: String?
|
||||
val backupType: String?
|
||||
val imports: List<String>?
|
||||
val convertToGqlType: ((configValue: Any) -> Any)?
|
||||
val convertToInternalType: ((gqlValue: Any) -> Any)?
|
||||
val convertToBackupType: ((gqlValue: Any) -> Any)?
|
||||
val restoreLegacy: ((backupValue: Any?) -> Any?)?
|
||||
}
|
||||
|
||||
data class TypeInfo(
|
||||
override val type: KClass<*>,
|
||||
override val specificType: String? = null,
|
||||
override val interfaceType: String? = null,
|
||||
override val backupType: String? = null,
|
||||
override val imports: List<String>? = null,
|
||||
override val convertToGqlType: ((configValue: Any) -> Any)? = null,
|
||||
override val convertToInternalType: ((gqlValue: Any) -> Any)? = null,
|
||||
override val convertToBackupType: ((gqlValue: Any) -> Any)? = null,
|
||||
override val restoreLegacy: ((backupValue: Any?) -> Any?)? = null,
|
||||
) : ITypeInfo
|
||||
|
||||
data class PartialTypeInfo(
|
||||
override val type: KClass<*>? = null,
|
||||
override val specificType: String? = null,
|
||||
override val interfaceType: String? = null,
|
||||
override val backupType: String? = null,
|
||||
override val imports: List<String>? = null,
|
||||
override val convertToGqlType: ((configValue: Any) -> Any)? = null,
|
||||
override val convertToInternalType: ((gqlValue: Any) -> Any)? = null,
|
||||
override val convertToBackupType: ((gqlValue: Any) -> Any)? = null,
|
||||
override val restoreLegacy: ((backupValue: Any?) -> Any?)? = null,
|
||||
) : ITypeInfo
|
||||
|
||||
data class SettingMetadata(
|
||||
val protoNumber: Int,
|
||||
val name: String,
|
||||
val typeInfo: TypeInfo,
|
||||
val defaultValue: Any,
|
||||
val validator: ((Any?) -> String?)? = null,
|
||||
val convertGqlToInternalType: ((Any?) -> Any?)? = null,
|
||||
val group: String,
|
||||
val deprecated: SettingDeprecated? = null,
|
||||
val requiresRestart: Boolean,
|
||||
val description: String? = null,
|
||||
)
|
||||
|
||||
private val settings = mutableMapOf<String, SettingMetadata>()
|
||||
|
||||
fun register(metadata: SettingMetadata) {
|
||||
settings[metadata.name] = metadata
|
||||
}
|
||||
|
||||
fun get(name: String): SettingMetadata? = settings[name]
|
||||
|
||||
fun getAll(): Map<String, SettingMetadata> = settings.toMap()
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package suwayomi.tachidesk.server.util
|
||||
|
||||
import io.github.config4k.registerCustomType
|
||||
|
||||
/**
|
||||
* Central place for registering custom types for config serialization/deserialization
|
||||
* This ensures consistency between runtime config handling and config file generation
|
||||
*/
|
||||
object ConfigTypeRegistration {
|
||||
private var registered = false
|
||||
|
||||
fun registerCustomTypes() {
|
||||
if (registered) return
|
||||
|
||||
registerCustomType(MutableStateFlowType())
|
||||
registerCustomType(DurationType())
|
||||
|
||||
registered = true
|
||||
}
|
||||
}
|
||||
@@ -2,54 +2,17 @@ package suwayomi.tachidesk.graphql.mutations
|
||||
|
||||
import com.expediagroup.graphql.generator.annotations.GraphQLIgnore
|
||||
import graphql.schema.DataFetchingEnvironment
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import suwayomi.tachidesk.graphql.server.getAttribute
|
||||
import suwayomi.tachidesk.graphql.types.PartialSettingsType
|
||||
import suwayomi.tachidesk.graphql.types.Settings
|
||||
import suwayomi.tachidesk.graphql.types.SettingsType
|
||||
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList.repoMatchRegex
|
||||
import suwayomi.tachidesk.server.JavalinSetup.Attribute
|
||||
import suwayomi.tachidesk.server.JavalinSetup.getAttribute
|
||||
import suwayomi.tachidesk.server.SERVER_CONFIG_MODULE_NAME
|
||||
import suwayomi.tachidesk.server.ServerConfig
|
||||
import suwayomi.tachidesk.server.serverConfig
|
||||
import suwayomi.tachidesk.server.settings.SettingsUpdater
|
||||
import suwayomi.tachidesk.server.settings.SettingsValidator
|
||||
import suwayomi.tachidesk.server.user.requireUser
|
||||
import xyz.nulldev.ts.config.GlobalConfigManager
|
||||
import java.io.File
|
||||
|
||||
private fun validateValue(
|
||||
exception: Exception,
|
||||
validate: () -> Boolean,
|
||||
) {
|
||||
if (!validate()) {
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> validateValue(
|
||||
value: T?,
|
||||
exception: Exception,
|
||||
validate: (value: T) -> Boolean,
|
||||
) {
|
||||
if (value != null) {
|
||||
validateValue(exception) { validate(value) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> validateValue(
|
||||
value: T?,
|
||||
name: String,
|
||||
validate: (value: T) -> Boolean,
|
||||
) {
|
||||
validateValue(value, Exception("Invalid value for \"$name\" [$value]"), validate)
|
||||
}
|
||||
|
||||
private fun validateFilePath(
|
||||
value: String?,
|
||||
name: String,
|
||||
) {
|
||||
validateValue(value, name) { File(it).exists() }
|
||||
}
|
||||
|
||||
class SettingsMutation {
|
||||
data class SetSettingsInput(
|
||||
@@ -62,176 +25,14 @@ class SettingsMutation {
|
||||
val settings: SettingsType,
|
||||
)
|
||||
|
||||
private fun validateSettings(settings: Settings) {
|
||||
validateValue(settings.ip, "ip") { it.matches("^((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}$".toRegex()) }
|
||||
|
||||
// proxy
|
||||
validateValue(settings.socksProxyVersion, "socksProxyVersion") { it == 4 || it == 5 }
|
||||
|
||||
// webUI
|
||||
validateFilePath(settings.electronPath, "electronPath")
|
||||
validateValue(settings.webUIUpdateCheckInterval, "webUIUpdateCheckInterval") { it == 0.0 || it in 1.0..23.0 }
|
||||
|
||||
// downloader
|
||||
validateFilePath(settings.downloadsPath, "downloadsPath")
|
||||
validateValue(settings.autoDownloadNewChaptersLimit, "autoDownloadNewChaptersLimit") { it >= 0 }
|
||||
|
||||
// extensions
|
||||
validateValue(settings.extensionRepos, "extensionRepos") { it.all { repoUrl -> repoUrl.matches(repoMatchRegex) } }
|
||||
|
||||
// requests
|
||||
validateValue(settings.maxSourcesInParallel, "maxSourcesInParallel") { it in 1..20 }
|
||||
|
||||
// updater
|
||||
validateValue(settings.globalUpdateInterval, "globalUpdateInterval") { it == 0.0 || it >= 6 }
|
||||
|
||||
// misc
|
||||
validateValue(settings.maxLogFiles, "maxLogFiles") { it >= 0 }
|
||||
|
||||
val logbackSizePattern = "^[0-9]+(|kb|KB|mb|MB|gb|GB)$".toRegex()
|
||||
validateValue(settings.maxLogFileSize, "maxLogFolderSize") { it.matches(logbackSizePattern) }
|
||||
validateValue(settings.maxLogFolderSize, "maxLogFolderSize") { it.matches(logbackSizePattern) }
|
||||
|
||||
// backup
|
||||
validateFilePath(settings.backupPath, "backupPath")
|
||||
validateValue(settings.backupTime, "backupTime") { it.matches("^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$".toRegex()) }
|
||||
validateValue(settings.backupInterval, "backupInterval") { it == 0 || it >= 1 }
|
||||
validateValue(settings.backupTTL, "backupTTL") { it == 0 || it >= 1 }
|
||||
|
||||
// local source
|
||||
validateFilePath(settings.localSourcePath, "localSourcePath")
|
||||
|
||||
// opds
|
||||
validateValue(settings.opdsItemsPerPage, "opdsItemsPerPage") { it in 10..5000 }
|
||||
}
|
||||
|
||||
private fun <SettingType : Any> updateSetting(
|
||||
newSetting: SettingType?,
|
||||
configSetting: MutableStateFlow<SettingType>,
|
||||
) {
|
||||
if (newSetting == null) {
|
||||
return
|
||||
}
|
||||
|
||||
configSetting.value = newSetting
|
||||
}
|
||||
|
||||
private fun <SettingType : Any, RealSettingType : Any> updateSetting(
|
||||
newSetting: RealSettingType?,
|
||||
configSetting: MutableStateFlow<SettingType>,
|
||||
mapper: (RealSettingType) -> SettingType,
|
||||
) {
|
||||
if (newSetting == null) {
|
||||
return
|
||||
}
|
||||
|
||||
configSetting.value = mapper(newSetting)
|
||||
}
|
||||
|
||||
@GraphQLIgnore
|
||||
fun updateSettings(settings: Settings) {
|
||||
updateSetting(settings.ip, serverConfig.ip)
|
||||
updateSetting(settings.port, serverConfig.port)
|
||||
|
||||
// proxy
|
||||
updateSetting(settings.socksProxyEnabled, serverConfig.socksProxyEnabled)
|
||||
updateSetting(settings.socksProxyVersion, serverConfig.socksProxyVersion)
|
||||
updateSetting(settings.socksProxyHost, serverConfig.socksProxyHost)
|
||||
updateSetting(settings.socksProxyPort, serverConfig.socksProxyPort)
|
||||
updateSetting(settings.socksProxyUsername, serverConfig.socksProxyUsername)
|
||||
updateSetting(settings.socksProxyPassword, serverConfig.socksProxyPassword)
|
||||
|
||||
// webUI
|
||||
updateSetting(settings.webUIFlavor, serverConfig.webUIFlavor)
|
||||
updateSetting(settings.initialOpenInBrowserEnabled, serverConfig.initialOpenInBrowserEnabled)
|
||||
updateSetting(settings.webUIInterface, serverConfig.webUIInterface)
|
||||
updateSetting(settings.electronPath, serverConfig.electronPath)
|
||||
updateSetting(settings.webUIChannel, serverConfig.webUIChannel)
|
||||
updateSetting(settings.webUIUpdateCheckInterval, serverConfig.webUIUpdateCheckInterval)
|
||||
|
||||
// downloader
|
||||
updateSetting(settings.downloadAsCbz, serverConfig.downloadAsCbz)
|
||||
updateSetting(settings.downloadsPath, serverConfig.downloadsPath)
|
||||
updateSetting(settings.autoDownloadNewChapters, serverConfig.autoDownloadNewChapters)
|
||||
updateSetting(settings.excludeEntryWithUnreadChapters, serverConfig.excludeEntryWithUnreadChapters)
|
||||
updateSetting(settings.autoDownloadAheadLimit, serverConfig.autoDownloadNewChaptersLimit) // deprecated
|
||||
updateSetting(settings.autoDownloadNewChaptersLimit, serverConfig.autoDownloadNewChaptersLimit)
|
||||
updateSetting(settings.autoDownloadIgnoreReUploads, serverConfig.autoDownloadIgnoreReUploads)
|
||||
updateSetting(settings.downloadConversions, serverConfig.downloadConversions) { list ->
|
||||
list.associate {
|
||||
it.mimeType to
|
||||
ServerConfig.DownloadConversion(
|
||||
target = it.target,
|
||||
compressionLevel = it.compressionLevel,
|
||||
)
|
||||
}
|
||||
val validationErrors = SettingsValidator.validate(settings, true)
|
||||
if (validationErrors.isNotEmpty()) {
|
||||
throw Exception("Validation errors: ${validationErrors.joinToString("; ")}")
|
||||
}
|
||||
|
||||
// extension
|
||||
updateSetting(settings.extensionRepos, serverConfig.extensionRepos)
|
||||
|
||||
// requests
|
||||
updateSetting(settings.maxSourcesInParallel, serverConfig.maxSourcesInParallel)
|
||||
|
||||
// updater
|
||||
updateSetting(settings.excludeUnreadChapters, serverConfig.excludeUnreadChapters)
|
||||
updateSetting(settings.excludeNotStarted, serverConfig.excludeNotStarted)
|
||||
updateSetting(settings.excludeCompleted, serverConfig.excludeCompleted)
|
||||
updateSetting(settings.globalUpdateInterval, serverConfig.globalUpdateInterval)
|
||||
updateSetting(settings.updateMangas, serverConfig.updateMangas)
|
||||
|
||||
// Authentication
|
||||
updateSetting(settings.authMode, serverConfig.authMode)
|
||||
updateSetting(settings.jwtAudience, serverConfig.jwtAudience)
|
||||
updateSetting(settings.jwtTokenExpiry, serverConfig.jwtTokenExpiry)
|
||||
updateSetting(settings.jwtRefreshExpiry, serverConfig.jwtRefreshExpiry)
|
||||
updateSetting(settings.authUsername, serverConfig.authUsername)
|
||||
updateSetting(settings.authPassword, serverConfig.authPassword)
|
||||
updateSetting(settings.basicAuthEnabled, serverConfig.basicAuthEnabled)
|
||||
updateSetting(settings.basicAuthUsername, serverConfig.basicAuthUsername)
|
||||
updateSetting(settings.basicAuthPassword, serverConfig.basicAuthPassword)
|
||||
|
||||
// misc
|
||||
updateSetting(settings.debugLogsEnabled, serverConfig.debugLogsEnabled)
|
||||
updateSetting(settings.systemTrayEnabled, serverConfig.systemTrayEnabled)
|
||||
updateSetting(settings.maxLogFiles, serverConfig.maxLogFiles)
|
||||
updateSetting(settings.maxLogFileSize, serverConfig.maxLogFileSize)
|
||||
updateSetting(settings.maxLogFolderSize, serverConfig.maxLogFolderSize)
|
||||
|
||||
// backup
|
||||
updateSetting(settings.backupPath, serverConfig.backupPath)
|
||||
updateSetting(settings.backupTime, serverConfig.backupTime)
|
||||
updateSetting(settings.backupInterval, serverConfig.backupInterval)
|
||||
updateSetting(settings.backupTTL, serverConfig.backupTTL)
|
||||
|
||||
// local source
|
||||
updateSetting(settings.localSourcePath, serverConfig.localSourcePath)
|
||||
|
||||
// cloudflare bypass
|
||||
updateSetting(settings.flareSolverrEnabled, serverConfig.flareSolverrEnabled)
|
||||
updateSetting(settings.flareSolverrUrl, serverConfig.flareSolverrUrl)
|
||||
updateSetting(settings.flareSolverrTimeout, serverConfig.flareSolverrTimeout)
|
||||
updateSetting(settings.flareSolverrSessionName, serverConfig.flareSolverrSessionName)
|
||||
updateSetting(settings.flareSolverrSessionTtl, serverConfig.flareSolverrSessionTtl)
|
||||
updateSetting(settings.flareSolverrAsResponseFallback, serverConfig.flareSolverrAsResponseFallback)
|
||||
|
||||
// opds
|
||||
updateSetting(settings.opdsUseBinaryFileSizes, serverConfig.opdsUseBinaryFileSizes)
|
||||
updateSetting(settings.opdsItemsPerPage, serverConfig.opdsItemsPerPage)
|
||||
updateSetting(settings.opdsEnablePageReadProgress, serverConfig.opdsEnablePageReadProgress)
|
||||
updateSetting(settings.opdsMarkAsReadOnDownload, serverConfig.opdsMarkAsReadOnDownload)
|
||||
updateSetting(settings.opdsShowOnlyUnreadChapters, serverConfig.opdsShowOnlyUnreadChapters)
|
||||
updateSetting(settings.opdsShowOnlyDownloadedChapters, serverConfig.opdsShowOnlyDownloadedChapters)
|
||||
updateSetting(settings.opdsChapterSortOrder, serverConfig.opdsChapterSortOrder)
|
||||
|
||||
// koreader sync
|
||||
updateSetting(settings.koreaderSyncServerUrl, serverConfig.koreaderSyncServerUrl)
|
||||
updateSetting(settings.koreaderSyncUsername, serverConfig.koreaderSyncUsername)
|
||||
updateSetting(settings.koreaderSyncUserkey, serverConfig.koreaderSyncUserkey)
|
||||
updateSetting(settings.koreaderSyncDeviceId, serverConfig.koreaderSyncDeviceId)
|
||||
updateSetting(settings.koreaderSyncChecksumMethod, serverConfig.koreaderSyncChecksumMethod)
|
||||
updateSetting(settings.koreaderSyncStrategy, serverConfig.koreaderSyncStrategy)
|
||||
updateSetting(settings.koreaderSyncPercentageTolerance, serverConfig.koreaderSyncPercentageTolerance)
|
||||
SettingsUpdater.updateAll(settings)
|
||||
}
|
||||
|
||||
fun setSettings(
|
||||
@@ -241,7 +42,6 @@ class SettingsMutation {
|
||||
dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser()
|
||||
val (clientMutationId, settings) = input
|
||||
|
||||
validateSettings(settings)
|
||||
updateSettings(settings)
|
||||
|
||||
return SetSettingsPayload(clientMutationId, SettingsType())
|
||||
|
||||
@@ -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?,
|
||||
@@ -1,417 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
package suwayomi.tachidesk.graphql.types
|
||||
|
||||
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
|
||||
import org.jetbrains.exposed.sql.SortOrder
|
||||
import suwayomi.tachidesk.graphql.server.primitives.Node
|
||||
import suwayomi.tachidesk.server.ServerConfig
|
||||
import suwayomi.tachidesk.server.serverConfig
|
||||
import kotlin.time.Duration
|
||||
|
||||
interface Settings : Node {
|
||||
val ip: String?
|
||||
val port: Int?
|
||||
|
||||
// proxy
|
||||
val socksProxyEnabled: Boolean?
|
||||
val socksProxyVersion: Int?
|
||||
val socksProxyHost: String?
|
||||
val socksProxyPort: String?
|
||||
val socksProxyUsername: String?
|
||||
val socksProxyPassword: String?
|
||||
|
||||
// webUI
|
||||
// requires restart (found no way to mutate (serve + "unserve") served files during runtime), exclude for now
|
||||
// val webUIEnabled: Boolean,
|
||||
val webUIFlavor: WebUIFlavor?
|
||||
val initialOpenInBrowserEnabled: Boolean?
|
||||
val webUIInterface: WebUIInterface?
|
||||
val electronPath: String?
|
||||
val webUIChannel: WebUIChannel?
|
||||
val webUIUpdateCheckInterval: Double?
|
||||
|
||||
// downloader
|
||||
val downloadAsCbz: Boolean?
|
||||
val downloadsPath: String?
|
||||
val autoDownloadNewChapters: Boolean?
|
||||
val excludeEntryWithUnreadChapters: Boolean?
|
||||
|
||||
@GraphQLDeprecated(
|
||||
"Replaced with autoDownloadNewChaptersLimit",
|
||||
replaceWith = ReplaceWith("autoDownloadNewChaptersLimit"),
|
||||
)
|
||||
val autoDownloadAheadLimit: Int?
|
||||
val autoDownloadNewChaptersLimit: Int?
|
||||
val autoDownloadIgnoreReUploads: Boolean?
|
||||
val downloadConversions: List<SettingsDownloadConversion>?
|
||||
|
||||
// extension
|
||||
val extensionRepos: List<String>?
|
||||
|
||||
// requests
|
||||
val maxSourcesInParallel: Int?
|
||||
|
||||
// updater
|
||||
val excludeUnreadChapters: Boolean?
|
||||
val excludeNotStarted: Boolean?
|
||||
val excludeCompleted: Boolean?
|
||||
val globalUpdateInterval: Double?
|
||||
val updateMangas: Boolean?
|
||||
|
||||
// Authentication
|
||||
val authMode: AuthMode?
|
||||
val jwtAudience: String?
|
||||
val jwtTokenExpiry: Duration?
|
||||
val jwtRefreshExpiry: Duration?
|
||||
val authUsername: String?
|
||||
val authPassword: String?
|
||||
|
||||
@GraphQLDeprecated("Removed - prefer authMode")
|
||||
val basicAuthEnabled: Boolean?
|
||||
|
||||
@GraphQLDeprecated("Removed - prefer authUsername")
|
||||
val basicAuthUsername: String?
|
||||
|
||||
@GraphQLDeprecated("Removed - prefer authPassword")
|
||||
val basicAuthPassword: String?
|
||||
|
||||
// misc
|
||||
val debugLogsEnabled: Boolean?
|
||||
|
||||
@GraphQLDeprecated("Removed - does not do anything")
|
||||
val gqlDebugLogsEnabled: Boolean?
|
||||
val systemTrayEnabled: Boolean?
|
||||
val maxLogFiles: Int?
|
||||
val maxLogFileSize: String?
|
||||
val maxLogFolderSize: String?
|
||||
|
||||
// backup
|
||||
val backupPath: String?
|
||||
val backupTime: String?
|
||||
val backupInterval: Int?
|
||||
val backupTTL: Int?
|
||||
|
||||
// local source
|
||||
val localSourcePath: String?
|
||||
|
||||
// cloudflare bypass
|
||||
val flareSolverrEnabled: Boolean?
|
||||
val flareSolverrUrl: String?
|
||||
val flareSolverrTimeout: Int?
|
||||
val flareSolverrSessionName: String?
|
||||
val flareSolverrSessionTtl: Int?
|
||||
val flareSolverrAsResponseFallback: Boolean?
|
||||
|
||||
// opds
|
||||
val opdsUseBinaryFileSizes: Boolean?
|
||||
val opdsItemsPerPage: Int?
|
||||
val opdsEnablePageReadProgress: Boolean?
|
||||
val opdsMarkAsReadOnDownload: Boolean?
|
||||
val opdsShowOnlyUnreadChapters: Boolean?
|
||||
val opdsShowOnlyDownloadedChapters: Boolean?
|
||||
val opdsChapterSortOrder: SortOrder?
|
||||
|
||||
// koreader sync
|
||||
val koreaderSyncServerUrl: String?
|
||||
val koreaderSyncUsername: String?
|
||||
val koreaderSyncUserkey: String?
|
||||
val koreaderSyncDeviceId: String?
|
||||
val koreaderSyncChecksumMethod: KoreaderSyncChecksumMethod?
|
||||
val koreaderSyncStrategy: KoreaderSyncStrategy?
|
||||
val koreaderSyncPercentageTolerance: Double?
|
||||
}
|
||||
|
||||
interface SettingsDownloadConversion {
|
||||
val mimeType: String
|
||||
val target: String
|
||||
val compressionLevel: Double?
|
||||
}
|
||||
|
||||
class SettingsDownloadConversionType(
|
||||
override val mimeType: String,
|
||||
override val target: String,
|
||||
override val compressionLevel: Double?,
|
||||
) : SettingsDownloadConversion
|
||||
|
||||
data class PartialSettingsType(
|
||||
override val ip: String?,
|
||||
override val port: Int?,
|
||||
// proxy
|
||||
override val socksProxyEnabled: Boolean?,
|
||||
override val socksProxyVersion: Int?,
|
||||
override val socksProxyHost: String?,
|
||||
override val socksProxyPort: String?,
|
||||
override val socksProxyUsername: String?,
|
||||
override val socksProxyPassword: String?,
|
||||
// webUI
|
||||
override val webUIFlavor: WebUIFlavor?,
|
||||
override val initialOpenInBrowserEnabled: Boolean?,
|
||||
override val webUIInterface: WebUIInterface?,
|
||||
override val electronPath: String?,
|
||||
override val webUIChannel: WebUIChannel?,
|
||||
override val webUIUpdateCheckInterval: Double?,
|
||||
// downloader
|
||||
override val downloadAsCbz: Boolean?,
|
||||
override val downloadsPath: String?,
|
||||
override val autoDownloadNewChapters: Boolean?,
|
||||
override val excludeEntryWithUnreadChapters: Boolean?,
|
||||
@GraphQLDeprecated(
|
||||
"Replaced with autoDownloadNewChaptersLimit",
|
||||
replaceWith = ReplaceWith("autoDownloadNewChaptersLimit"),
|
||||
)
|
||||
override val autoDownloadAheadLimit: Int?,
|
||||
override val autoDownloadNewChaptersLimit: Int?,
|
||||
override val autoDownloadIgnoreReUploads: Boolean?,
|
||||
override val downloadConversions: List<SettingsDownloadConversionType>?,
|
||||
// extension
|
||||
override val extensionRepos: List<String>?,
|
||||
// requests
|
||||
override val maxSourcesInParallel: Int?,
|
||||
// updater
|
||||
override val excludeUnreadChapters: Boolean?,
|
||||
override val excludeNotStarted: Boolean?,
|
||||
override val excludeCompleted: Boolean?,
|
||||
override val globalUpdateInterval: Double?,
|
||||
override val updateMangas: Boolean?,
|
||||
// Authentication
|
||||
override val authMode: AuthMode?,
|
||||
override val jwtAudience: String?,
|
||||
override val jwtTokenExpiry: Duration?,
|
||||
override val jwtRefreshExpiry: Duration?,
|
||||
override val authUsername: String?,
|
||||
override val authPassword: String?,
|
||||
@GraphQLDeprecated("Removed - prefer authMode")
|
||||
override val basicAuthEnabled: Boolean?,
|
||||
@GraphQLDeprecated("Removed - prefer authUsername")
|
||||
override val basicAuthUsername: String?,
|
||||
@GraphQLDeprecated("Removed - prefer authPassword")
|
||||
override val basicAuthPassword: String?,
|
||||
// misc
|
||||
override val debugLogsEnabled: Boolean?,
|
||||
@GraphQLDeprecated("Removed - does not do anything")
|
||||
override val gqlDebugLogsEnabled: Boolean?,
|
||||
override val systemTrayEnabled: Boolean?,
|
||||
override val maxLogFiles: Int?,
|
||||
override val maxLogFileSize: String?,
|
||||
override val maxLogFolderSize: String?,
|
||||
// backup
|
||||
override val backupPath: String?,
|
||||
override val backupTime: String?,
|
||||
override val backupInterval: Int?,
|
||||
override val backupTTL: Int?,
|
||||
// local source
|
||||
override val localSourcePath: String?,
|
||||
// cloudflare bypass
|
||||
override val flareSolverrEnabled: Boolean?,
|
||||
override val flareSolverrUrl: String?,
|
||||
override val flareSolverrTimeout: Int?,
|
||||
override val flareSolverrSessionName: String?,
|
||||
override val flareSolverrSessionTtl: Int?,
|
||||
override val flareSolverrAsResponseFallback: Boolean?,
|
||||
// opds
|
||||
override val opdsUseBinaryFileSizes: Boolean?,
|
||||
override val opdsItemsPerPage: Int?,
|
||||
override val opdsEnablePageReadProgress: Boolean?,
|
||||
override val opdsMarkAsReadOnDownload: Boolean?,
|
||||
override val opdsShowOnlyUnreadChapters: Boolean?,
|
||||
override val opdsShowOnlyDownloadedChapters: Boolean?,
|
||||
override val opdsChapterSortOrder: SortOrder?,
|
||||
// koreader sync
|
||||
override val koreaderSyncServerUrl: String?,
|
||||
override val koreaderSyncUsername: String?,
|
||||
override val koreaderSyncUserkey: String?,
|
||||
override val koreaderSyncDeviceId: String?,
|
||||
override val koreaderSyncChecksumMethod: KoreaderSyncChecksumMethod?,
|
||||
override val koreaderSyncStrategy: KoreaderSyncStrategy?,
|
||||
override val koreaderSyncPercentageTolerance: Double?,
|
||||
) : Settings
|
||||
|
||||
class SettingsType(
|
||||
override val ip: String,
|
||||
override val port: Int,
|
||||
// proxy
|
||||
override val socksProxyEnabled: Boolean,
|
||||
override val socksProxyVersion: Int,
|
||||
override val socksProxyHost: String,
|
||||
override val socksProxyPort: String,
|
||||
override val socksProxyUsername: String,
|
||||
override val socksProxyPassword: String,
|
||||
// webUI
|
||||
override val webUIFlavor: WebUIFlavor,
|
||||
override val initialOpenInBrowserEnabled: Boolean,
|
||||
override val webUIInterface: WebUIInterface,
|
||||
override val electronPath: String,
|
||||
override val webUIChannel: WebUIChannel,
|
||||
override val webUIUpdateCheckInterval: Double,
|
||||
// downloader
|
||||
override val downloadAsCbz: Boolean,
|
||||
override val downloadsPath: String,
|
||||
override val autoDownloadNewChapters: Boolean,
|
||||
override val excludeEntryWithUnreadChapters: Boolean,
|
||||
@GraphQLDeprecated(
|
||||
"Replaced with autoDownloadNewChaptersLimit",
|
||||
replaceWith = ReplaceWith("autoDownloadNewChaptersLimit"),
|
||||
)
|
||||
override val autoDownloadAheadLimit: Int,
|
||||
override val autoDownloadNewChaptersLimit: Int,
|
||||
override val autoDownloadIgnoreReUploads: Boolean,
|
||||
override val downloadConversions: List<SettingsDownloadConversionType>,
|
||||
// extension
|
||||
override val extensionRepos: List<String>,
|
||||
// requests
|
||||
override val maxSourcesInParallel: Int,
|
||||
// updater
|
||||
override val excludeUnreadChapters: Boolean,
|
||||
override val excludeNotStarted: Boolean,
|
||||
override val excludeCompleted: Boolean,
|
||||
override val globalUpdateInterval: Double,
|
||||
override val updateMangas: Boolean,
|
||||
// Authentication
|
||||
override val authMode: AuthMode,
|
||||
override val jwtAudience: String,
|
||||
override val jwtTokenExpiry: Duration,
|
||||
override val jwtRefreshExpiry: Duration,
|
||||
override val authUsername: String,
|
||||
override val authPassword: String,
|
||||
@GraphQLDeprecated("Removed - prefer authMode")
|
||||
override val basicAuthEnabled: Boolean,
|
||||
@GraphQLDeprecated("Removed - prefer authUsername")
|
||||
override val basicAuthUsername: String,
|
||||
@GraphQLDeprecated("Removed - prefer authPassword")
|
||||
override val basicAuthPassword: String,
|
||||
// misc
|
||||
override val debugLogsEnabled: Boolean,
|
||||
@GraphQLDeprecated("Removed - does not do anything")
|
||||
override val gqlDebugLogsEnabled: Boolean,
|
||||
override val systemTrayEnabled: Boolean,
|
||||
override val maxLogFiles: Int,
|
||||
override val maxLogFileSize: String,
|
||||
override val maxLogFolderSize: String,
|
||||
// backup
|
||||
override val backupPath: String,
|
||||
override val backupTime: String,
|
||||
override val backupInterval: Int,
|
||||
override val backupTTL: Int,
|
||||
// local source
|
||||
override val localSourcePath: String,
|
||||
// cloudflare bypass
|
||||
override val flareSolverrEnabled: Boolean,
|
||||
override val flareSolverrUrl: String,
|
||||
override val flareSolverrTimeout: Int,
|
||||
override val flareSolverrSessionName: String,
|
||||
override val flareSolverrSessionTtl: Int,
|
||||
override val flareSolverrAsResponseFallback: Boolean,
|
||||
// opds
|
||||
override val opdsUseBinaryFileSizes: Boolean,
|
||||
override val opdsItemsPerPage: Int,
|
||||
override val opdsEnablePageReadProgress: Boolean,
|
||||
override val opdsMarkAsReadOnDownload: Boolean,
|
||||
override val opdsShowOnlyUnreadChapters: Boolean,
|
||||
override val opdsShowOnlyDownloadedChapters: Boolean,
|
||||
override val opdsChapterSortOrder: SortOrder,
|
||||
// koreader sync
|
||||
override val koreaderSyncServerUrl: String,
|
||||
override val koreaderSyncUsername: String,
|
||||
override val koreaderSyncUserkey: String,
|
||||
override val koreaderSyncDeviceId: String,
|
||||
override val koreaderSyncChecksumMethod: KoreaderSyncChecksumMethod,
|
||||
override val koreaderSyncStrategy: KoreaderSyncStrategy,
|
||||
override val koreaderSyncPercentageTolerance: Double,
|
||||
) : Settings {
|
||||
constructor(config: ServerConfig = serverConfig) : this(
|
||||
config.ip.value,
|
||||
config.port.value,
|
||||
// proxy
|
||||
config.socksProxyEnabled.value,
|
||||
config.socksProxyVersion.value,
|
||||
config.socksProxyHost.value,
|
||||
config.socksProxyPort.value,
|
||||
config.socksProxyUsername.value,
|
||||
config.socksProxyPassword.value,
|
||||
// webUI
|
||||
config.webUIFlavor.value,
|
||||
config.initialOpenInBrowserEnabled.value,
|
||||
config.webUIInterface.value,
|
||||
config.electronPath.value,
|
||||
config.webUIChannel.value,
|
||||
config.webUIUpdateCheckInterval.value,
|
||||
// downloader
|
||||
config.downloadAsCbz.value,
|
||||
config.downloadsPath.value,
|
||||
config.autoDownloadNewChapters.value,
|
||||
config.excludeEntryWithUnreadChapters.value,
|
||||
config.autoDownloadNewChaptersLimit.value, // deprecated
|
||||
config.autoDownloadNewChaptersLimit.value,
|
||||
config.autoDownloadIgnoreReUploads.value,
|
||||
config.downloadConversions.value.map {
|
||||
SettingsDownloadConversionType(
|
||||
it.key,
|
||||
it.value.target,
|
||||
it.value.compressionLevel,
|
||||
)
|
||||
},
|
||||
// extension
|
||||
config.extensionRepos.value,
|
||||
// requests
|
||||
config.maxSourcesInParallel.value,
|
||||
// updater
|
||||
config.excludeUnreadChapters.value,
|
||||
config.excludeNotStarted.value,
|
||||
config.excludeCompleted.value,
|
||||
config.globalUpdateInterval.value,
|
||||
config.updateMangas.value,
|
||||
// Authentication
|
||||
config.authMode.value,
|
||||
config.jwtAudience.value,
|
||||
config.jwtTokenExpiry.value,
|
||||
config.jwtRefreshExpiry.value,
|
||||
config.authUsername.value,
|
||||
config.authPassword.value,
|
||||
config.basicAuthEnabled.value,
|
||||
config.basicAuthUsername.value,
|
||||
config.basicAuthPassword.value,
|
||||
// misc
|
||||
config.debugLogsEnabled.value,
|
||||
false,
|
||||
config.systemTrayEnabled.value,
|
||||
config.maxLogFiles.value,
|
||||
config.maxLogFileSize.value,
|
||||
config.maxLogFolderSize.value,
|
||||
// backup
|
||||
config.backupPath.value,
|
||||
config.backupTime.value,
|
||||
config.backupInterval.value,
|
||||
config.backupTTL.value,
|
||||
// local source
|
||||
config.localSourcePath.value,
|
||||
// cloudflare bypass
|
||||
config.flareSolverrEnabled.value,
|
||||
config.flareSolverrUrl.value,
|
||||
config.flareSolverrTimeout.value,
|
||||
config.flareSolverrSessionName.value,
|
||||
config.flareSolverrSessionTtl.value,
|
||||
config.flareSolverrAsResponseFallback.value,
|
||||
// opds
|
||||
config.opdsUseBinaryFileSizes.value,
|
||||
config.opdsItemsPerPage.value,
|
||||
config.opdsEnablePageReadProgress.value,
|
||||
config.opdsMarkAsReadOnDownload.value,
|
||||
config.opdsShowOnlyUnreadChapters.value,
|
||||
config.opdsShowOnlyDownloadedChapters.value,
|
||||
config.opdsChapterSortOrder.value,
|
||||
// koreader sync
|
||||
config.koreaderSyncServerUrl.value,
|
||||
config.koreaderSyncUsername.value,
|
||||
config.koreaderSyncUserkey.value,
|
||||
config.koreaderSyncDeviceId.value,
|
||||
config.koreaderSyncChecksumMethod.value,
|
||||
config.koreaderSyncStrategy.value,
|
||||
config.koreaderSyncPercentageTolerance.value,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.proto.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.protobuf.ProtoNumber
|
||||
import org.jetbrains.exposed.sql.SortOrder
|
||||
import suwayomi.tachidesk.graphql.types.AuthMode
|
||||
import suwayomi.tachidesk.graphql.types.KoreaderSyncChecksumMethod
|
||||
import suwayomi.tachidesk.graphql.types.KoreaderSyncStrategy
|
||||
import suwayomi.tachidesk.graphql.types.Settings
|
||||
import suwayomi.tachidesk.graphql.types.SettingsDownloadConversion
|
||||
import suwayomi.tachidesk.graphql.types.WebUIChannel
|
||||
import suwayomi.tachidesk.graphql.types.WebUIFlavor
|
||||
import suwayomi.tachidesk.graphql.types.WebUIInterface
|
||||
import kotlin.time.Duration
|
||||
|
||||
@Serializable
|
||||
data class BackupServerSettings(
|
||||
@ProtoNumber(1) override var ip: String,
|
||||
@ProtoNumber(2) override var port: Int,
|
||||
@ProtoNumber(3) override var socksProxyEnabled: Boolean,
|
||||
@ProtoNumber(4) override var socksProxyVersion: Int,
|
||||
@ProtoNumber(5) override var socksProxyHost: String,
|
||||
@ProtoNumber(6) override var socksProxyPort: String,
|
||||
@ProtoNumber(7) override var socksProxyUsername: String,
|
||||
@ProtoNumber(8) override var socksProxyPassword: String,
|
||||
@ProtoNumber(9) override var webUIFlavor: WebUIFlavor,
|
||||
@ProtoNumber(10) override var initialOpenInBrowserEnabled: Boolean,
|
||||
@ProtoNumber(11) override var webUIInterface: WebUIInterface,
|
||||
@ProtoNumber(12) override var electronPath: String,
|
||||
@ProtoNumber(13) override var webUIChannel: WebUIChannel,
|
||||
@ProtoNumber(14) override var webUIUpdateCheckInterval: Double,
|
||||
@ProtoNumber(15) override var downloadAsCbz: Boolean,
|
||||
@ProtoNumber(16) override var downloadsPath: String,
|
||||
@ProtoNumber(17) override var autoDownloadNewChapters: Boolean,
|
||||
@ProtoNumber(18) override var excludeEntryWithUnreadChapters: Boolean,
|
||||
@ProtoNumber(19) override var autoDownloadAheadLimit: Int,
|
||||
@ProtoNumber(20) override var autoDownloadNewChaptersLimit: Int,
|
||||
@ProtoNumber(21) override var autoDownloadIgnoreReUploads: Boolean,
|
||||
@ProtoNumber(22) override var extensionRepos: List<String>,
|
||||
@ProtoNumber(23) override var maxSourcesInParallel: Int,
|
||||
@ProtoNumber(24) override var excludeUnreadChapters: Boolean,
|
||||
@ProtoNumber(25) override var excludeNotStarted: Boolean,
|
||||
@ProtoNumber(26) override var excludeCompleted: Boolean,
|
||||
@ProtoNumber(27) override var globalUpdateInterval: Double,
|
||||
@ProtoNumber(28) override var updateMangas: Boolean,
|
||||
@ProtoNumber(29) override var basicAuthEnabled: Boolean?,
|
||||
@ProtoNumber(30) override var authUsername: String,
|
||||
@ProtoNumber(31) override var authPassword: String,
|
||||
@ProtoNumber(32) override var debugLogsEnabled: Boolean,
|
||||
@ProtoNumber(33) override var gqlDebugLogsEnabled: Boolean,
|
||||
@ProtoNumber(34) override var systemTrayEnabled: Boolean,
|
||||
@ProtoNumber(35) override var maxLogFiles: Int,
|
||||
@ProtoNumber(36) override var maxLogFileSize: String,
|
||||
@ProtoNumber(37) override var maxLogFolderSize: String,
|
||||
@ProtoNumber(38) override var backupPath: String,
|
||||
@ProtoNumber(39) override var backupTime: String,
|
||||
@ProtoNumber(40) override var backupInterval: Int,
|
||||
@ProtoNumber(41) override var backupTTL: Int,
|
||||
@ProtoNumber(42) override var localSourcePath: String,
|
||||
@ProtoNumber(43) override var flareSolverrEnabled: Boolean,
|
||||
@ProtoNumber(44) override var flareSolverrUrl: String,
|
||||
@ProtoNumber(45) override var flareSolverrTimeout: Int,
|
||||
@ProtoNumber(46) override var flareSolverrSessionName: String,
|
||||
@ProtoNumber(47) override var flareSolverrSessionTtl: Int,
|
||||
@ProtoNumber(48) override var flareSolverrAsResponseFallback: Boolean,
|
||||
@ProtoNumber(49) override var opdsUseBinaryFileSizes: Boolean,
|
||||
@ProtoNumber(50) override var opdsItemsPerPage: Int,
|
||||
@ProtoNumber(51) override var opdsEnablePageReadProgress: Boolean,
|
||||
@ProtoNumber(52) override var opdsMarkAsReadOnDownload: Boolean,
|
||||
@ProtoNumber(53) override var opdsShowOnlyUnreadChapters: Boolean,
|
||||
@ProtoNumber(54) override var opdsShowOnlyDownloadedChapters: Boolean,
|
||||
@ProtoNumber(55) override var opdsChapterSortOrder: SortOrder,
|
||||
@ProtoNumber(56) override var authMode: AuthMode,
|
||||
@ProtoNumber(57) override val downloadConversions: List<BackupSettingsDownloadConversionType>?,
|
||||
@ProtoNumber(58) override var jwtAudience: String?,
|
||||
@ProtoNumber(59) override var koreaderSyncServerUrl: String,
|
||||
@ProtoNumber(60) override var koreaderSyncUsername: String,
|
||||
@ProtoNumber(61) override var koreaderSyncUserkey: String,
|
||||
@ProtoNumber(62) override var koreaderSyncDeviceId: String,
|
||||
@ProtoNumber(63) override var koreaderSyncChecksumMethod: KoreaderSyncChecksumMethod,
|
||||
@ProtoNumber(64) override var koreaderSyncStrategy: KoreaderSyncStrategy,
|
||||
@ProtoNumber(65) override var koreaderSyncPercentageTolerance: Double,
|
||||
@ProtoNumber(66) override var jwtTokenExpiry: Duration?,
|
||||
@ProtoNumber(67) override var jwtRefreshExpiry: Duration?,
|
||||
// Deprecated settings
|
||||
@ProtoNumber(99991) override var basicAuthUsername: String?,
|
||||
@ProtoNumber(99992) override var basicAuthPassword: String?,
|
||||
) : Settings {
|
||||
@Serializable
|
||||
class BackupSettingsDownloadConversionType(
|
||||
@ProtoNumber(1) override val mimeType: String,
|
||||
@ProtoNumber(2) override val target: String,
|
||||
@ProtoNumber(3) override val compressionLevel: Double?,
|
||||
) : SettingsDownloadConversion
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
import suwayomi.tachidesk.graphql.types.DownloadConversion
|
||||
import suwayomi.tachidesk.manga.impl.Page
|
||||
import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReady
|
||||
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
|
||||
@@ -25,7 +26,6 @@ import suwayomi.tachidesk.manga.impl.util.getChapterDownloadPath
|
||||
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse
|
||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
import suwayomi.tachidesk.server.ServerConfig
|
||||
import suwayomi.tachidesk.server.serverConfig
|
||||
import suwayomi.tachidesk.util.ConversionUtil
|
||||
import java.io.File
|
||||
@@ -261,7 +261,7 @@ abstract class ChaptersFilesProvider<Type : FileType>(
|
||||
|
||||
private fun convertPage(
|
||||
page: File,
|
||||
conversion: ServerConfig.DownloadConversion,
|
||||
conversion: DownloadConversion,
|
||||
) {
|
||||
val (targetMime, compressionLevel) = conversion
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -28,7 +28,7 @@ import java.util.Locale
|
||||
|
||||
object NavigationRepository {
|
||||
private val opdsItemsPerPageBounded: Int
|
||||
get() = serverConfig.opdsItemsPerPage.value.coerceIn(10, 5000)
|
||||
get() = serverConfig.opdsItemsPerPage.value
|
||||
|
||||
private val rootSectionDetails: Map<String, Triple<String, StringResource, StringResource>> =
|
||||
mapOf(
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
package suwayomi.tachidesk.server
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import com.typesafe.config.Config
|
||||
import io.github.config4k.getValue
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.jetbrains.exposed.sql.SortOrder
|
||||
import suwayomi.tachidesk.graphql.types.AuthMode
|
||||
import suwayomi.tachidesk.graphql.types.KoreaderSyncChecksumMethod
|
||||
import suwayomi.tachidesk.graphql.types.KoreaderSyncStrategy
|
||||
import suwayomi.tachidesk.graphql.types.WebUIChannel
|
||||
import suwayomi.tachidesk.graphql.types.WebUIFlavor
|
||||
import suwayomi.tachidesk.graphql.types.WebUIInterface
|
||||
import xyz.nulldev.ts.config.GlobalConfigManager
|
||||
import xyz.nulldev.ts.config.SystemPropertyOverridableConfigModule
|
||||
import kotlin.reflect.KProperty
|
||||
import kotlin.time.Duration
|
||||
|
||||
val mutableConfigValueScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
const val SERVER_CONFIG_MODULE_NAME = "server"
|
||||
|
||||
class ServerConfig(
|
||||
getConfig: () -> Config,
|
||||
) : SystemPropertyOverridableConfigModule(
|
||||
getConfig,
|
||||
SERVER_CONFIG_MODULE_NAME,
|
||||
) {
|
||||
open inner class OverrideConfigValue {
|
||||
var flow: MutableStateFlow<Any>? = null
|
||||
|
||||
inline operator fun <reified T : MutableStateFlow<R>, reified R> getValue(
|
||||
thisRef: ServerConfig,
|
||||
property: KProperty<*>,
|
||||
): T {
|
||||
if (flow != null) {
|
||||
return flow as T
|
||||
}
|
||||
|
||||
val stateFlow = overridableConfig.getValue<ServerConfig, T>(thisRef, property)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
flow = stateFlow as MutableStateFlow<Any>
|
||||
|
||||
stateFlow
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.filter { it != thisRef.overridableConfig.getConfig().getValue<ServerConfig, R>(thisRef, property) }
|
||||
.onEach { GlobalConfigManager.updateValue("$moduleName.${property.name}", it as Any) }
|
||||
.launchIn(mutableConfigValueScope)
|
||||
|
||||
return stateFlow
|
||||
}
|
||||
}
|
||||
|
||||
open inner class MigratedConfigValue<T>(
|
||||
private val readMigrated: () -> T,
|
||||
private val setMigrated: (T) -> Unit,
|
||||
) {
|
||||
private var flow: MutableStateFlow<T>? = null
|
||||
|
||||
operator fun getValue(
|
||||
thisRef: ServerConfig,
|
||||
property: KProperty<*>,
|
||||
): MutableStateFlow<T> {
|
||||
if (flow != null) {
|
||||
return flow!!
|
||||
}
|
||||
|
||||
val value = readMigrated()
|
||||
|
||||
val stateFlow = MutableStateFlow(value)
|
||||
flow = stateFlow
|
||||
|
||||
stateFlow
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.filter { it != readMigrated() }
|
||||
.onEach(setMigrated)
|
||||
.launchIn(mutableConfigValueScope)
|
||||
|
||||
return stateFlow
|
||||
}
|
||||
}
|
||||
|
||||
val ip: MutableStateFlow<String> by OverrideConfigValue()
|
||||
val port: MutableStateFlow<Int> by OverrideConfigValue()
|
||||
|
||||
// proxy
|
||||
val socksProxyEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
|
||||
val socksProxyVersion: MutableStateFlow<Int> by OverrideConfigValue()
|
||||
val socksProxyHost: MutableStateFlow<String> by OverrideConfigValue()
|
||||
val socksProxyPort: MutableStateFlow<String> by OverrideConfigValue()
|
||||
val socksProxyUsername: MutableStateFlow<String> by OverrideConfigValue()
|
||||
val socksProxyPassword: MutableStateFlow<String> by OverrideConfigValue()
|
||||
|
||||
// webUI
|
||||
val webUIEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
|
||||
val webUIFlavor: MutableStateFlow<WebUIFlavor> by OverrideConfigValue()
|
||||
val initialOpenInBrowserEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
|
||||
val webUIInterface: MutableStateFlow<WebUIInterface> by OverrideConfigValue()
|
||||
val electronPath: MutableStateFlow<String> by OverrideConfigValue()
|
||||
val webUIChannel: MutableStateFlow<WebUIChannel> by OverrideConfigValue()
|
||||
val webUIUpdateCheckInterval: MutableStateFlow<Double> by OverrideConfigValue()
|
||||
|
||||
// downloader
|
||||
val downloadAsCbz: MutableStateFlow<Boolean> by OverrideConfigValue()
|
||||
val downloadsPath: MutableStateFlow<String> by OverrideConfigValue()
|
||||
val autoDownloadNewChapters: MutableStateFlow<Boolean> by OverrideConfigValue()
|
||||
val excludeEntryWithUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue()
|
||||
val autoDownloadNewChaptersLimit: MutableStateFlow<Int> by OverrideConfigValue()
|
||||
val autoDownloadIgnoreReUploads: MutableStateFlow<Boolean> by OverrideConfigValue()
|
||||
val downloadConversions: MutableStateFlow<Map<String, DownloadConversion>> by OverrideConfigValue()
|
||||
|
||||
data class DownloadConversion(
|
||||
val target: String,
|
||||
val compressionLevel: Double? = null,
|
||||
)
|
||||
|
||||
// extensions
|
||||
val extensionRepos: MutableStateFlow<List<String>> by OverrideConfigValue()
|
||||
|
||||
// requests
|
||||
val maxSourcesInParallel: MutableStateFlow<Int> by OverrideConfigValue()
|
||||
|
||||
// updater
|
||||
val excludeUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue()
|
||||
val excludeNotStarted: MutableStateFlow<Boolean> by OverrideConfigValue()
|
||||
val excludeCompleted: MutableStateFlow<Boolean> by OverrideConfigValue()
|
||||
val globalUpdateInterval: MutableStateFlow<Double> by OverrideConfigValue()
|
||||
val updateMangas: MutableStateFlow<Boolean> by OverrideConfigValue()
|
||||
|
||||
// Authentication
|
||||
val authMode: MutableStateFlow<AuthMode> by OverrideConfigValue()
|
||||
val authUsername: MutableStateFlow<String> by OverrideConfigValue()
|
||||
val authPassword: MutableStateFlow<String> by OverrideConfigValue()
|
||||
val jwtAudience: MutableStateFlow<String> by OverrideConfigValue()
|
||||
val jwtTokenExpiry: MutableStateFlow<Duration> by OverrideConfigValue()
|
||||
val jwtRefreshExpiry: MutableStateFlow<Duration> by OverrideConfigValue()
|
||||
val basicAuthEnabled: MutableStateFlow<Boolean> by MigratedConfigValue({
|
||||
authMode.value == AuthMode.BASIC_AUTH
|
||||
}) {
|
||||
authMode.value = if (it) AuthMode.BASIC_AUTH else AuthMode.NONE
|
||||
}
|
||||
val basicAuthUsername: MutableStateFlow<String> by MigratedConfigValue({ authUsername.value }) {
|
||||
authUsername.value = it
|
||||
}
|
||||
val basicAuthPassword: MutableStateFlow<String> by MigratedConfigValue({ authPassword.value }) {
|
||||
authPassword.value = it
|
||||
}
|
||||
|
||||
// misc
|
||||
val debugLogsEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
|
||||
val systemTrayEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
|
||||
val maxLogFiles: MutableStateFlow<Int> by OverrideConfigValue()
|
||||
val maxLogFileSize: MutableStateFlow<String> by OverrideConfigValue()
|
||||
val maxLogFolderSize: MutableStateFlow<String> by OverrideConfigValue()
|
||||
|
||||
// backup
|
||||
val backupPath: MutableStateFlow<String> by OverrideConfigValue()
|
||||
val backupTime: MutableStateFlow<String> by OverrideConfigValue()
|
||||
val backupInterval: MutableStateFlow<Int> by OverrideConfigValue()
|
||||
val backupTTL: MutableStateFlow<Int> by OverrideConfigValue()
|
||||
|
||||
// local source
|
||||
val localSourcePath: MutableStateFlow<String> by OverrideConfigValue()
|
||||
|
||||
// cloudflare bypass
|
||||
val flareSolverrEnabled: MutableStateFlow<Boolean> by OverrideConfigValue()
|
||||
val flareSolverrUrl: MutableStateFlow<String> by OverrideConfigValue()
|
||||
val flareSolverrTimeout: MutableStateFlow<Int> by OverrideConfigValue()
|
||||
val flareSolverrSessionName: MutableStateFlow<String> by OverrideConfigValue()
|
||||
val flareSolverrSessionTtl: MutableStateFlow<Int> by OverrideConfigValue()
|
||||
val flareSolverrAsResponseFallback: MutableStateFlow<Boolean> by OverrideConfigValue()
|
||||
|
||||
// opds settings
|
||||
val opdsUseBinaryFileSizes: MutableStateFlow<Boolean> by OverrideConfigValue()
|
||||
val opdsItemsPerPage: MutableStateFlow<Int> by OverrideConfigValue()
|
||||
val opdsEnablePageReadProgress: MutableStateFlow<Boolean> by OverrideConfigValue()
|
||||
val opdsMarkAsReadOnDownload: MutableStateFlow<Boolean> by OverrideConfigValue()
|
||||
val opdsShowOnlyUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue()
|
||||
val opdsShowOnlyDownloadedChapters: MutableStateFlow<Boolean> by OverrideConfigValue()
|
||||
val opdsChapterSortOrder: MutableStateFlow<SortOrder> by OverrideConfigValue()
|
||||
|
||||
// koreader sync
|
||||
val koreaderSyncServerUrl: MutableStateFlow<String> by OverrideConfigValue()
|
||||
val koreaderSyncUsername: MutableStateFlow<String> by OverrideConfigValue()
|
||||
val koreaderSyncUserkey: MutableStateFlow<String> by OverrideConfigValue()
|
||||
val koreaderSyncDeviceId: MutableStateFlow<String> by OverrideConfigValue()
|
||||
val koreaderSyncChecksumMethod: MutableStateFlow<KoreaderSyncChecksumMethod> by OverrideConfigValue()
|
||||
val koreaderSyncStrategy: MutableStateFlow<KoreaderSyncStrategy> by OverrideConfigValue()
|
||||
val koreaderSyncPercentageTolerance: MutableStateFlow<Double> by OverrideConfigValue()
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun <T> subscribeTo(
|
||||
flow: Flow<T>,
|
||||
onChange: suspend (value: T) -> Unit,
|
||||
ignoreInitialValue: Boolean = true,
|
||||
) {
|
||||
val actualFlow =
|
||||
if (ignoreInitialValue) {
|
||||
flow.drop(1)
|
||||
} else {
|
||||
flow
|
||||
}
|
||||
|
||||
val sharedFlow = MutableSharedFlow<T>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
actualFlow.distinctUntilChanged().mapLatest { sharedFlow.emit(it) }.launchIn(mutableConfigValueScope)
|
||||
sharedFlow.onEach { onChange(it) }.launchIn(mutableConfigValueScope)
|
||||
}
|
||||
|
||||
fun <T> subscribeTo(
|
||||
flow: Flow<T>,
|
||||
onChange: suspend () -> Unit,
|
||||
ignoreInitialValue: Boolean = true,
|
||||
) {
|
||||
subscribeTo(flow, { _ -> onChange() }, ignoreInitialValue)
|
||||
}
|
||||
|
||||
fun <T> subscribeTo(
|
||||
mutableStateFlow: MutableStateFlow<T>,
|
||||
onChange: suspend (value: T) -> Unit,
|
||||
ignoreInitialValue: Boolean = true,
|
||||
) {
|
||||
subscribeTo(mutableStateFlow.asStateFlow(), onChange, ignoreInitialValue)
|
||||
}
|
||||
|
||||
fun <T> subscribeTo(
|
||||
mutableStateFlow: MutableStateFlow<T>,
|
||||
onChange: suspend () -> Unit,
|
||||
ignoreInitialValue: Boolean = true,
|
||||
) {
|
||||
subscribeTo(mutableStateFlow.asStateFlow(), { _ -> onChange() }, ignoreInitialValue)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun register(getConfig: () -> Config) =
|
||||
ServerConfig {
|
||||
getConfig().getConfig(
|
||||
SERVER_CONFIG_MODULE_NAME,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package suwayomi.tachidesk.server.settings
|
||||
|
||||
import suwayomi.tachidesk.graphql.types.Settings
|
||||
import kotlin.reflect.KProperty1
|
||||
import kotlin.reflect.full.memberProperties
|
||||
|
||||
internal fun Settings.asMap(): Map<String, Any?> {
|
||||
val map = mutableMapOf<String, Any?>()
|
||||
|
||||
this::class.memberProperties.forEach { property ->
|
||||
try {
|
||||
// Skip the 'id' property from Node interface
|
||||
if (property.name == "id") return@forEach
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val value = (property as KProperty1<Settings, *>).get(this)
|
||||
map[property.name] = value
|
||||
} catch (e: Exception) {
|
||||
// Skip properties that can't be accessed
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package suwayomi.tachidesk.server.settings
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import suwayomi.tachidesk.graphql.types.Settings
|
||||
import suwayomi.tachidesk.server.ServerConfig
|
||||
import suwayomi.tachidesk.server.serverConfig
|
||||
import kotlin.reflect.KProperty1
|
||||
import kotlin.reflect.full.memberProperties
|
||||
|
||||
object SettingsUpdater {
|
||||
private fun updateSetting(
|
||||
name: String,
|
||||
value: Any,
|
||||
) {
|
||||
try {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val property =
|
||||
serverConfig::class
|
||||
.memberProperties
|
||||
.find { it.name == name } as? KProperty1<ServerConfig, MutableStateFlow<*>>
|
||||
|
||||
if (property != null) {
|
||||
val stateFlow = property.get(serverConfig)
|
||||
|
||||
val maybeConvertedValue =
|
||||
SettingsRegistry
|
||||
.get(name)
|
||||
?.typeInfo
|
||||
?.convertToInternalType
|
||||
?.invoke(value) ?: value
|
||||
|
||||
// Normal update - MigratedConfigValue handles deprecated mappings automatically
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(stateFlow as MutableStateFlow<Any>).value = maybeConvertedValue
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
KotlinLogging.logger { }.error(e) { "Failed to update setting $name due to" }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAll(settings: Settings) {
|
||||
settings
|
||||
.asMap()
|
||||
.forEach { (name, value) ->
|
||||
if (value != null) {
|
||||
updateSetting(name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package suwayomi.tachidesk.server.settings
|
||||
|
||||
import suwayomi.tachidesk.graphql.types.Settings
|
||||
|
||||
object SettingsValidator {
|
||||
private fun validateSingle(
|
||||
name: String,
|
||||
value: Any?,
|
||||
): String? {
|
||||
val metadata = SettingsRegistry.get(name) ?: return null
|
||||
return metadata.validator?.invoke(value)
|
||||
}
|
||||
|
||||
private fun validateAll(
|
||||
values: Map<String, Any?>,
|
||||
ignoreNull: Boolean?,
|
||||
): List<String> =
|
||||
values
|
||||
.filterValues { value -> ignoreNull == false || value != null }
|
||||
.mapNotNull { (name, value) -> validateSingle(name, value)?.let { error -> "$name: $error" } }
|
||||
|
||||
fun validate(
|
||||
settings: Settings,
|
||||
ignoreNull: Boolean = false,
|
||||
): List<String> = validateAll(settings.asMap(), ignoreNull)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user