Feature/listen to server config value changes (#617)

* Make server config value changes subscribable

* Make server config value changes subscribable - Update usage

* Add util functions to listen to server config value changes

* Listen to server config value changes - Auto backups

* Listen to server config value changes - Auto global update

* Listen to server config value changes - WebUI auto updates

* Listen to server config value changes - Javalin update ip and port

* Listen to server config value changes - Update socks proxy

* Listen to server config value changes - Update debug log level

* Listen to server config value changes - Update system tray icon

* Update config values one at a time

In case settings are changed in quick succession it's possible that each setting update reverts the change of the previous changed setting because the internal config hasn't been updated yet.

E.g.
1. settingA changed
2. settingB changed
3. settingA updates config file
4. settingB updates config file (internal config hasn't been updated yet with change from settingA)
5. settingA updates internal config (settingA updated)
6. settingB updates internal config (settingB updated, settingA outdated)

now settingA is unchanged because settingB reverted its change while updating the config with its new value

* Always add log interceptor to OkHttpClient

In case debug logs are disabled then the KotlinLogging log level will be set to level > debug and thus, these logs won't get logged

* Rename "maxParallelUpdateRequests" to "maxSourcesInParallel"

* Use server setting "maxSourcesInParallel" for downloads

* Listen to server config value changes - downloads

* Always use latest server settings - Browser

* Always use latest server settings - folders

* [Test] Fix type error
This commit is contained in:
schroda
2023-08-12 17:47:41 +02:00
committed by GitHub
parent 01ab912bd9
commit 321fbe22dd
22 changed files with 368 additions and 143 deletions

View File

@@ -14,6 +14,8 @@ import com.typesafe.config.ConfigValue
import com.typesafe.config.ConfigValueFactory
import com.typesafe.config.parser.ConfigDocument
import com.typesafe.config.parser.ConfigDocumentFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import mu.KotlinLogging
import java.io.File
@@ -32,6 +34,8 @@ open class ConfigManager {
val loadedModules: Map<Class<out ConfigModule>, ConfigModule>
get() = generatedModules
private val mutex = Mutex()
/**
* Get a config module
*/
@@ -98,11 +102,13 @@ open class ConfigManager {
userConfigFile.writeText(newFileContent)
}
fun updateValue(path: String, value: Any) {
val configValue = ConfigValueFactory.fromAnyRef(value)
suspend fun updateValue(path: String, value: Any) {
mutex.withLock {
val configValue = ConfigValueFactory.fromAnyRef(value)
updateUserConfigFile(path, configValue)
internalConfig = internalConfig.withValue(path, configValue)
updateUserConfigFile(path, configValue)
internalConfig = internalConfig.withValue(path, configValue)
}
}
/**

View File

@@ -26,10 +26,6 @@ abstract class SystemPropertyOverridableConfigModule(getConfig: () -> Config, mo
/** Defines a config property that is overridable with jvm `-D` commandline arguments prefixed with [CONFIG_PREFIX] */
class SystemPropertyOverrideDelegate(val getConfig: () -> Config, val moduleName: String) {
operator fun <R> setValue(thisRef: R, property: KProperty<*>, value: Any) {
GlobalConfigManager.updateValue("$moduleName.${property.name}", value)
}
inline operator fun <R, reified T> getValue(thisRef: R, property: KProperty<*>): T {
val configValue: T = getConfig().getValue(thisRef, property)

View File

@@ -20,7 +20,6 @@ import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor
import mu.KotlinLogging
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import suwayomi.tachidesk.server.serverConfig
import java.net.CookieHandler
import java.net.CookieManager
import java.net.CookiePolicy
@@ -53,18 +52,16 @@ class NetworkHelper(context: Context) {
.callTimeout(2, TimeUnit.MINUTES)
.addInterceptor(UserAgentInterceptor())
if (serverConfig.debugLogsEnabled) {
val httpLoggingInterceptor = HttpLoggingInterceptor(object : HttpLoggingInterceptor.Logger {
val logger = KotlinLogging.logger { }
val httpLoggingInterceptor = HttpLoggingInterceptor(object : HttpLoggingInterceptor.Logger {
val logger = KotlinLogging.logger { }
override fun log(message: String) {
logger.debug { message }
}
}).apply {
level = HttpLoggingInterceptor.Level.BASIC
override fun log(message: String) {
logger.debug { message }
}
builder.addInterceptor(httpLoggingInterceptor)
}).apply {
level = HttpLoggingInterceptor.Level.BASIC
}
builder.addInterceptor(httpLoggingInterceptor)
// when (preferences.dohProvider()) {
// PREF_DOH_CLOUDFLARE -> builder.dohCloudflare()

View File

@@ -87,8 +87,8 @@ object CFClearance {
LaunchOptions()
.setHeadless(false)
.apply {
if (serverConfig.socksProxyEnabled) {
setProxy("socks5://${serverConfig.socksProxyHost}:${serverConfig.socksProxyPort}")
if (serverConfig.socksProxyEnabled.value) {
setProxy("socks5://${serverConfig.socksProxyHost.value}:${serverConfig.socksProxyPort.value}")
}
}
).use { browser ->

View File

@@ -3,7 +3,6 @@ package suwayomi.tachidesk.graphql.mutations
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeout
import suwayomi.tachidesk.graphql.types.UpdateState.DOWNLOADING
import suwayomi.tachidesk.graphql.types.UpdateState.FINISHED
import suwayomi.tachidesk.graphql.types.UpdateState.STOPPED
import suwayomi.tachidesk.graphql.types.WebUIUpdateInfo
import suwayomi.tachidesk.graphql.types.WebUIUpdateStatus
@@ -37,7 +36,7 @@ class InfoMutation {
input.clientMutationId,
WebUIUpdateStatus(
info = WebUIUpdateInfo(
channel = serverConfig.webUIChannel,
channel = serverConfig.webUIChannel.value,
tag = version,
updateAvailable
),

View File

@@ -55,7 +55,7 @@ class InfoQuery {
return future {
val (version, updateAvailable) = WebInterfaceManager.isUpdateAvailable()
WebUIUpdateInfo(
channel = serverConfig.webUIChannel,
channel = serverConfig.webUIChannel.value,
tag = version,
updateAvailable
)

View File

@@ -210,7 +210,7 @@ object Chapter {
val wasInitialFetch = currentNumberOfChapters == 0
// make sure to ignore initial fetch
val downloadNewChapters = serverConfig.autoDownloadNewChapters && !wasInitialFetch && areNewChaptersAvailable
val downloadNewChapters = serverConfig.autoDownloadNewChapters.value && !wasInitialFetch && areNewChaptersAvailable
if (!downloadNewChapters) {
return
}

View File

@@ -35,7 +35,7 @@ object ChapterDownloadHelper {
val chapterFolder = File(getChapterDownloadPath(mangaId, chapterId))
val cbzFile = File(getChapterCbzPath(mangaId, chapterId))
if (cbzFile.exists()) return ArchiveProvider(mangaId, chapterId)
if (!chapterFolder.exists() && serverConfig.downloadAsCbz) return ArchiveProvider(mangaId, chapterId)
if (!chapterFolder.exists() && serverConfig.downloadAsCbz.value) return ArchiveProvider(mangaId, chapterId)
return FolderProvider(mangaId, chapterId)
}
}

View File

@@ -8,6 +8,7 @@ package suwayomi.tachidesk.manga.impl.backup.proto
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import kotlinx.coroutines.flow.combine
import mu.KotlinLogging
import okio.buffer
import okio.gzip
@@ -53,10 +54,22 @@ object ProtoBackupExport : ProtoBackupBase() {
private const val lastAutomatedBackupKey = "lastAutomatedBackupKey"
private val preferences = Preferences.userNodeForPackage(ProtoBackupExport::class.java)
init {
serverConfig.subscribeTo(
combine(serverConfig.backupInterval, serverConfig.backupTime) { interval, timeOfDay ->
Pair(
interval,
timeOfDay
)
},
::scheduleAutomatedBackupTask
)
}
fun scheduleAutomatedBackupTask() {
HAScheduler.descheduleCron(backupSchedulerJobId)
val areAutomatedBackupsDisabled = serverConfig.backupInterval == 0
val areAutomatedBackupsDisabled = serverConfig.backupInterval.value == 0
if (areAutomatedBackupsDisabled) {
return
}
@@ -67,10 +80,10 @@ object ProtoBackupExport : ProtoBackupBase() {
preferences.putLong(lastAutomatedBackupKey, System.currentTimeMillis())
}
val (hour, minute) = serverConfig.backupTime.split(":").map { it.toInt() }
val (hour, minute) = 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.days.coerceAtLeast(1.days)
val backupInterval = serverConfig.backupInterval.value.days.coerceAtLeast(1.days)
// trigger last backup in case the server wasn't running on the scheduled time
val lastAutomatedBackup = preferences.getLong(lastAutomatedBackupKey, System.currentTimeMillis())
@@ -105,9 +118,9 @@ object ProtoBackupExport : ProtoBackupBase() {
}
private fun cleanupAutomatedBackups() {
logger.debug { "Cleanup automated backups (ttl= ${serverConfig.backupTTL})" }
logger.debug { "Cleanup automated backups (ttl= ${serverConfig.backupTTL.value})" }
val isCleanupDisabled = serverConfig.backupTTL == 0
val isCleanupDisabled = serverConfig.backupTTL.value == 0
if (isCleanupDisabled) {
return
}
@@ -133,7 +146,7 @@ object ProtoBackupExport : ProtoBackupBase() {
val lastAccessTime = file.lastModified()
val isTTLReached =
System.currentTimeMillis() - lastAccessTime >= serverConfig.backupTTL.days.coerceAtLeast(1.days).inWholeMilliseconds
System.currentTimeMillis() - lastAccessTime >= serverConfig.backupTTL.value.days.coerceAtLeast(1.days).inWholeMilliseconds
if (isTTLReached) {
file.delete()
}

View File

@@ -42,6 +42,7 @@ import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.toDataClass
import suwayomi.tachidesk.server.serverConfig
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.ConcurrentHashMap
@@ -51,8 +52,6 @@ import kotlin.time.Duration.Companion.seconds
private val logger = KotlinLogging.logger {}
private const val MAX_SOURCES_IN_PARAllEL = 5
object DownloadManager {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val clients = ConcurrentHashMap<String, WsContext>()
@@ -169,6 +168,22 @@ object DownloadManager {
private val downloaderWatch = MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
init {
serverConfig.subscribeTo(serverConfig.maxSourcesInParallel, { maxSourcesInParallel ->
val runningDownloaders = downloaders.values.filter { it.isActive }
var downloadersToStop = runningDownloaders.size - maxSourcesInParallel
logger.debug { "Max sources in parallel changed to $maxSourcesInParallel (running downloaders ${runningDownloaders.size})" }
if (downloadersToStop > 0) {
runningDownloaders.takeWhile {
it.stop()
--downloadersToStop > 0
}
} else {
downloaderWatch.emit(Unit)
}
})
scope.launch {
downloaderWatch.sample(1.seconds).collect {
val runningDownloaders = downloaders.values.filter { it.isActive }
@@ -176,14 +191,14 @@ object DownloadManager {
logger.info { "Running: ${runningDownloaders.size}, Queued: ${availableDownloads.size}, Failed: ${downloadQueue.size - availableDownloads.size}" }
if (runningDownloaders.size < MAX_SOURCES_IN_PARAllEL) {
if (runningDownloaders.size < serverConfig.maxSourcesInParallel.value) {
availableDownloads.asSequence()
.map { it.manga.sourceId }
.distinct()
.minus(
runningDownloaders.map { it.sourceId }.toSet()
)
.take(MAX_SOURCES_IN_PARAllEL - runningDownloaders.size)
.take(serverConfig.maxSourcesInParallel.value - runningDownloaders.size)
.map { getDownloader(it) }
.forEach {
it.start()

View File

@@ -33,6 +33,7 @@ import suwayomi.tachidesk.util.HAScheduler
import java.util.Date
import java.util.concurrent.ConcurrentHashMap
import java.util.prefs.Preferences
import kotlin.math.absoluteValue
import kotlin.time.Duration.Companion.hours
class Updater : IUpdater {
@@ -45,13 +46,36 @@ class Updater : IUpdater {
private val tracker = ConcurrentHashMap<Int, UpdateJob>()
private val updateChannels = ConcurrentHashMap<String, Channel<UpdateJob>>()
private val semaphore = Semaphore(serverConfig.maxParallelUpdateRequests)
private var maxSourcesInParallel = 20 // max permits, necessary to be set to be able to release up to 20 permits
private val semaphore = Semaphore(maxSourcesInParallel)
private val lastAutomatedUpdateKey = "lastAutomatedUpdateKey"
private val preferences = Preferences.userNodeForPackage(Updater::class.java)
private var currentUpdateTaskId = ""
init {
serverConfig.subscribeTo(serverConfig.globalUpdateInterval, ::scheduleUpdateTask)
serverConfig.subscribeTo(
serverConfig.maxSourcesInParallel,
{ value ->
val newMaxPermits = value.coerceAtLeast(1).coerceAtMost(20)
val permitDifference = maxSourcesInParallel - newMaxPermits
maxSourcesInParallel = newMaxPermits
val addMorePermits = permitDifference < 0
for (i in 1..permitDifference.absoluteValue) {
if (addMorePermits) {
semaphore.release()
} else {
semaphore.acquire()
}
}
},
ignoreInitialValue = false
)
}
private fun autoUpdateTask() {
val lastAutomatedUpdate = preferences.getLong(lastAutomatedUpdateKey, 0)
preferences.putLong(lastAutomatedUpdateKey, System.currentTimeMillis())
@@ -61,19 +85,19 @@ class Updater : IUpdater {
return
}
logger.info { "Trigger global update (interval= ${serverConfig.globalUpdateInterval}h, lastAutomatedUpdate= ${Date(lastAutomatedUpdate)})" }
logger.info { "Trigger global update (interval= ${serverConfig.globalUpdateInterval.value}h, lastAutomatedUpdate= ${Date(lastAutomatedUpdate)})" }
addCategoriesToUpdateQueue(Category.getCategoryList(), clear = true, forceAll = false)
}
fun scheduleUpdateTask() {
HAScheduler.deschedule(currentUpdateTaskId)
val isAutoUpdateDisabled = serverConfig.globalUpdateInterval == 0.0
val isAutoUpdateDisabled = serverConfig.globalUpdateInterval.value == 0.0
if (isAutoUpdateDisabled) {
return
}
val updateInterval = serverConfig.globalUpdateInterval.hours.coerceAtLeast(6.hours).inWholeMilliseconds
val updateInterval = serverConfig.globalUpdateInterval.value.hours.coerceAtLeast(6.hours).inWholeMilliseconds
val lastAutomatedUpdate = preferences.getLong(lastAutomatedUpdateKey, 0)
val timeToNextExecution = (updateInterval - (System.currentTimeMillis() - lastAutomatedUpdate)).mod(updateInterval)
@@ -150,9 +174,9 @@ class Updater : IUpdater {
val mangasToUpdate = categoriesToUpdateMangas
.asSequence()
.filter { it.updateStrategy == UpdateStrategy.ALWAYS_UPDATE }
.filter { if (serverConfig.excludeUnreadChapters) { (it.unreadCount ?: 0L) == 0L } else true }
.filter { if (serverConfig.excludeNotStarted) { it.lastReadAt != null } else true }
.filter { if (serverConfig.excludeCompleted) { it.status != MangaStatus.COMPLETED.name } else true }
.filter { if (serverConfig.excludeUnreadChapters.value) { (it.unreadCount ?: 0L) == 0L } else true }
.filter { if (serverConfig.excludeNotStarted.value) { it.lastReadAt != null } else true }
.filter { if (serverConfig.excludeCompleted.value) { it.status != MangaStatus.COMPLETED.name } else true }
.filter { forceAll || !excludedCategories.any { category -> mangasToCategoriesMap[it.id]?.contains(category) == true } }
.toList()

View File

@@ -0,0 +1,29 @@
package suwayomi.tachidesk.server
interface ConfigAdapter<T> {
fun toType(configValue: String): T
}
object StringConfigAdapter : ConfigAdapter<String> {
override fun toType(configValue: String): String {
return configValue
}
}
object IntConfigAdapter : ConfigAdapter<Int> {
override fun toType(configValue: String): Int {
return configValue.toInt()
}
}
object BooleanConfigAdapter : ConfigAdapter<Boolean> {
override fun toType(configValue: String): Boolean {
return configValue.toBoolean()
}
}
object DoubleConfigAdapter : ConfigAdapter<Double> {
override fun toType(configValue: String): Double {
return configValue.toDouble()
}
}

View File

@@ -18,9 +18,12 @@ import io.swagger.v3.oas.models.info.Info
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.future.future
import kotlinx.coroutines.runBlocking
import mu.KotlinLogging
import org.eclipse.jetty.server.Server
import org.eclipse.jetty.server.ServerConnector
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
@@ -46,27 +49,48 @@ object JavalinSetup {
}
fun javalinSetup() {
val server = Server()
val connector = ServerConnector(server).apply {
host = serverConfig.ip.value
port = serverConfig.port.value
}
server.addConnector(connector)
serverConfig.subscribeTo(combine(serverConfig.ip, serverConfig.port) { ip, port -> Pair(ip, port) }, { (newIp, newPort) ->
val oldIp = connector.host
val oldPort = connector.port
connector.host = newIp
connector.port = newPort
connector.stop()
connector.start()
logger.info { "Server ip and/or port changed from $oldIp:$oldPort to $newIp:$newPort " }
})
val app = Javalin.create { config ->
if (serverConfig.webUIEnabled) {
if (serverConfig.webUIEnabled.value) {
runBlocking {
WebInterfaceManager.setupWebUI()
}
logger.info { "Serving web static files for ${serverConfig.webUIFlavor}" }
logger.info { "Serving web static files for ${serverConfig.webUIFlavor.value}" }
config.addStaticFiles(applicationDirs.webUIRoot, Location.EXTERNAL)
config.addSinglePageRoot("/", applicationDirs.webUIRoot + "/index.html", Location.EXTERNAL)
config.registerPlugin(OpenApiPlugin(getOpenApiOptions()))
}
config.server { server }
config.enableCorsForAllOrigins()
config.accessManager { handler, ctx, _ ->
fun credentialsValid(): Boolean {
val (username, password) = ctx.basicAuthCredentials()
return username == serverConfig.basicAuthUsername && password == serverConfig.basicAuthPassword
return username == serverConfig.basicAuthUsername.value && password == serverConfig.basicAuthPassword.value
}
if (serverConfig.basicAuthEnabled && !(ctx.basicAuthCredentialsExist() && credentialsValid())) {
if (serverConfig.basicAuthEnabled.value && !(ctx.basicAuthCredentialsExist() && credentialsValid())) {
ctx.header("WWW-Authenticate", "Basic")
ctx.status(401).json("Unauthorized")
} else {
@@ -75,11 +99,11 @@ object JavalinSetup {
}
}.events { event ->
event.serverStarted {
if (serverConfig.initialOpenInBrowserEnabled) {
if (serverConfig.initialOpenInBrowserEnabled.value) {
Browser.openInBrowser()
}
}
}.start(serverConfig.ip, serverConfig.port)
}.start()
// when JVM is prompted to shutdown, stop javalin gracefully
Runtime.getRuntime().addShutdownHook(

View File

@@ -8,58 +8,127 @@ package suwayomi.tachidesk.server
* 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.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.onEach
import xyz.nulldev.ts.config.GlobalConfigManager
import xyz.nulldev.ts.config.SystemPropertyOverridableConfigModule
import xyz.nulldev.ts.config.debugLogsEnabled
import kotlin.reflect.KProperty
val mutableConfigValueScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private const val MODULE_NAME = "server"
class ServerConfig(getConfig: () -> Config, moduleName: String = MODULE_NAME) : SystemPropertyOverridableConfigModule(getConfig, moduleName) {
var ip: String by overridableConfig
var port: Int by overridableConfig
class ServerConfig(getConfig: () -> Config, val moduleName: String = MODULE_NAME) : SystemPropertyOverridableConfigModule(getConfig, moduleName) {
inner class OverrideConfigValue<T>(private val configAdapter: ConfigAdapter<T>) {
private var flow: MutableStateFlow<T>? = null
operator fun getValue(thisRef: ServerConfig, property: KProperty<*>): MutableStateFlow<T> {
if (flow != null) {
return flow!!
}
val value = configAdapter.toType(overridableConfig.getValue<ServerConfig, String>(thisRef, property))
val stateFlow = MutableStateFlow(value)
flow = stateFlow
stateFlow.drop(1).distinctUntilChanged().onEach {
GlobalConfigManager.updateValue("$moduleName.${property.name}", it as Any)
}.launchIn(mutableConfigValueScope)
return stateFlow
}
}
val ip: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val port: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
// proxy
var socksProxyEnabled: Boolean by overridableConfig
var socksProxyHost: String by overridableConfig
var socksProxyPort: String by overridableConfig
val socksProxyEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val socksProxyHost: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val socksProxyPort: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
// webUI
var webUIEnabled: Boolean by overridableConfig
var webUIFlavor: String by overridableConfig
var initialOpenInBrowserEnabled: Boolean by overridableConfig
var webUIInterface: String by overridableConfig
var electronPath: String by overridableConfig
var webUIChannel: String by overridableConfig
var webUIUpdateCheckInterval: Double by overridableConfig
val webUIEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val webUIFlavor: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val initialOpenInBrowserEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val webUIInterface: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val electronPath: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val webUIChannel: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val webUIUpdateCheckInterval: MutableStateFlow<Double> by OverrideConfigValue(DoubleConfigAdapter)
// downloader
var downloadAsCbz: Boolean by overridableConfig
var downloadsPath: String by overridableConfig
var autoDownloadNewChapters: Boolean by overridableConfig
val downloadAsCbz: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val downloadsPath: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val autoDownloadNewChapters: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
// requests
val maxSourcesInParallel: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
// updater
var maxParallelUpdateRequests: Int by overridableConfig
var excludeUnreadChapters: Boolean by overridableConfig
var excludeNotStarted: Boolean by overridableConfig
var excludeCompleted: Boolean by overridableConfig
var globalUpdateInterval: Double by overridableConfig
val excludeUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val excludeNotStarted: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val excludeCompleted: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val globalUpdateInterval: MutableStateFlow<Double> by OverrideConfigValue(DoubleConfigAdapter)
// Authentication
var basicAuthEnabled: Boolean by overridableConfig
var basicAuthUsername: String by overridableConfig
var basicAuthPassword: String by overridableConfig
val basicAuthEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val basicAuthUsername: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val basicAuthPassword: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
// misc
var debugLogsEnabled: Boolean = debugLogsEnabled(GlobalConfigManager.config)
var systemTrayEnabled: Boolean by overridableConfig
val debugLogsEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val systemTrayEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
// backup
var backupPath: String by overridableConfig
var backupTime: String by overridableConfig
var backupInterval: Int by overridableConfig
var backupTTL: Int by overridableConfig
val backupPath: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val backupTime: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
val backupInterval: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
val backupTTL: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
// local source
var localSourcePath: String by overridableConfig
val localSourcePath: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
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().onEach { 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(MODULE_NAME) })

View File

@@ -7,11 +7,13 @@ package suwayomi.tachidesk.server
* 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 ch.qos.logback.classic.Level
import com.typesafe.config.ConfigRenderOptions
import eu.kanade.tachiyomi.App
import eu.kanade.tachiyomi.source.local.LocalSource
import io.javalin.plugin.json.JavalinJackson
import io.javalin.plugin.json.JsonMapper
import kotlinx.coroutines.flow.combine
import kotlinx.serialization.json.Json
import mu.KotlinLogging
import org.bouncycastle.jce.provider.BouncyCastleProvider
@@ -26,13 +28,14 @@ import suwayomi.tachidesk.manga.impl.update.Updater
import suwayomi.tachidesk.manga.impl.util.lang.renameTo
import suwayomi.tachidesk.server.database.databaseUp
import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex
import suwayomi.tachidesk.server.util.SystemTray.systemTray
import suwayomi.tachidesk.server.util.SystemTray
import xyz.nulldev.androidcompat.AndroidCompat
import xyz.nulldev.androidcompat.AndroidCompatInitializer
import xyz.nulldev.ts.config.ApplicationRootDir
import xyz.nulldev.ts.config.ConfigKodeinModule
import xyz.nulldev.ts.config.GlobalConfigManager
import xyz.nulldev.ts.config.initLoggerConfig
import xyz.nulldev.ts.config.setLogLevel
import java.io.File
import java.security.Security
import java.util.Locale
@@ -43,29 +46,25 @@ class ApplicationDirs(
val dataRoot: String = ApplicationRootDir,
val tempRoot: String = "${System.getProperty("java.io.tmpdir")}/Tachidesk"
) {
val cacheRoot = System.getProperty("java.io.tmpdir") + "/tachidesk"
val extensionsRoot = "$dataRoot/extensions"
val downloadsRoot = serverConfig.downloadsPath.ifBlank { "$dataRoot/downloads" }
val localMangaRoot = serverConfig.localSourcePath.ifBlank { "$dataRoot/local" }
val downloadsRoot get() = serverConfig.downloadsPath.value.ifBlank { "$dataRoot/downloads" }
val localMangaRoot get() = serverConfig.localSourcePath.value.ifBlank { "$dataRoot/local" }
val webUIRoot = "$dataRoot/webUI"
val automatedBackupRoot = serverConfig.backupPath.ifBlank { "$dataRoot/backups" }
val automatedBackupRoot get() = serverConfig.backupPath.value.ifBlank { "$dataRoot/backups" }
val tempThumbnailCacheRoot = "$tempRoot/thumbnails"
val tempMangaCacheRoot = "$tempRoot/manga-cache"
val thumbnailDownloadsRoot = "$downloadsRoot/thumbnails"
val mangaDownloadsRoot = "$downloadsRoot/mangas"
val thumbnailDownloadsRoot get() = "$downloadsRoot/thumbnails"
val mangaDownloadsRoot get() = "$downloadsRoot/mangas"
}
val serverConfig: ServerConfig by lazy { GlobalConfigManager.module() }
val systemTrayInstance by lazy { systemTray() }
val androidCompat by lazy { AndroidCompat() }
fun applicationSetup() {
Thread.setDefaultUncaughtExceptionHandler {
_, throwable ->
Thread.setDefaultUncaughtExceptionHandler { _, throwable ->
KotlinLogging.logger { }.error(throwable) { "unhandled exception" }
}
@@ -74,6 +73,14 @@ fun applicationSetup() {
ServerConfig.register { GlobalConfigManager.config }
)
serverConfig.subscribeTo(serverConfig.debugLogsEnabled, { debugLogsEnabled ->
if (debugLogsEnabled) {
setLogLevel(Level.DEBUG)
} else {
setLogLevel(Level.INFO)
}
})
// Application dirs
val applicationDirs = ApplicationDirs()
@@ -164,13 +171,17 @@ fun applicationSetup() {
LocalSource.register()
// create system tray
if (serverConfig.systemTrayEnabled) {
serverConfig.subscribeTo(serverConfig.systemTrayEnabled, { systemTrayEnabled ->
try {
systemTrayInstance
if (systemTrayEnabled) {
SystemTray.create()
} else {
SystemTray.remove()
}
} catch (e: Throwable) { // cover both java.lang.Exception and java.lang.Error
e.printStackTrace()
}
}
}, ignoreInitialValue = false)
// Disable jetty's logging
System.setProperty("org.eclipse.jetty.util.log.announce", "false")
@@ -178,11 +189,25 @@ fun applicationSetup() {
System.setProperty("org.eclipse.jetty.LEVEL", "OFF")
// socks proxy settings
if (serverConfig.socksProxyEnabled) {
System.getProperties()["socksProxyHost"] = serverConfig.socksProxyHost
System.getProperties()["socksProxyPort"] = serverConfig.socksProxyPort
logger.info("Socks Proxy is enabled to ${serverConfig.socksProxyHost}:${serverConfig.socksProxyPort}")
}
serverConfig.subscribeTo(
combine(
serverConfig.socksProxyEnabled,
serverConfig.socksProxyHost,
serverConfig.socksProxyPort
) { proxyEnabled, proxyHost, proxyPort ->
Triple(proxyEnabled, proxyHost, proxyPort)
},
{ (proxyEnabled, proxyHost, proxyPort) ->
logger.info("Socks Proxy changed - enabled= $proxyEnabled, proxy= $proxyHost:$proxyPort")
if (proxyEnabled) {
System.getProperties()["socksProxyHost"] = proxyHost
System.getProperties()["socksProxyPort"] = proxyPort
} else {
System.getProperties()["socksProxyHost"] = ""
System.getProperties()["socksProxyPort"] = ""
}
}
)
// AES/CBC/PKCS7Padding Cypher provider for zh.copymanga
Security.addProvider(BouncyCastleProvider())

View File

@@ -31,7 +31,7 @@ object AppMutex {
OtherApplicationRunning(2)
}
private val appIP = if (serverConfig.ip == "0.0.0.0") "127.0.0.1" else serverConfig.ip
private val appIP = if (serverConfig.ip.value == "0.0.0.0") "127.0.0.1" else serverConfig.ip.value
private val jsonMapper by DI.global.instance<JsonMapper>()
@@ -41,7 +41,7 @@ object AppMutex {
.build()
val request = Builder()
.url("http://$appIP:${serverConfig.port}/api/v1/settings/about/")
.url("http://$appIP:${serverConfig.port.value}/api/v1/settings/about/")
.build()
val response = try {
@@ -64,7 +64,7 @@ object AppMutex {
logger.info("Mutex status is clear, Resuming startup.")
}
AppMutexState.TachideskInstanceRunning -> {
logger.info("Another instance of Tachidesk is running on $appIP:${serverConfig.port}")
logger.info("Another instance of Tachidesk is running on $appIP:${serverConfig.port.value}")
logger.info("Probably user thought tachidesk is closed so, opening webUI in browser again.")
openInBrowser()
@@ -74,7 +74,7 @@ object AppMutex {
shutdownApp(MutexCheckFailedTachideskRunning)
}
AppMutexState.OtherApplicationRunning -> {
logger.error("A non Tachidesk application is running on $appIP:${serverConfig.port}, aborting startup.")
logger.error("A non Tachidesk application is running on $appIP:${serverConfig.port.value}, aborting startup.")
shutdownApp(MutexCheckFailedAnotherAppRunning)
}
}

View File

@@ -11,16 +11,20 @@ import dorkbox.desktop.Desktop
import suwayomi.tachidesk.server.serverConfig
object Browser {
private val appIP = if (serverConfig.ip == "0.0.0.0") "127.0.0.1" else serverConfig.ip
private val appBaseUrl = "http://$appIP:${serverConfig.port}"
private val electronInstances = mutableListOf<Any>()
private fun getAppBaseUrl(): String {
val appIP = if (serverConfig.ip.value == "0.0.0.0") "127.0.0.1" else serverConfig.ip.value
return "http://$appIP:${serverConfig.port.value}"
}
fun openInBrowser() {
if (serverConfig.webUIEnabled) {
if (serverConfig.webUIInterface == ("electron")) {
if (serverConfig.webUIEnabled.value) {
val appBaseUrl = getAppBaseUrl()
if (serverConfig.webUIInterface.value == ("electron")) {
try {
val electronPath = serverConfig.electronPath
val electronPath = serverConfig.electronPath.value
electronInstances.add(ProcessBuilder(electronPath, appBaseUrl).start())
} catch (e: Throwable) { // cover both java.lang.Exception and java.lang.Error
e.printStackTrace()

View File

@@ -17,10 +17,16 @@ import suwayomi.tachidesk.server.util.Browser.openInBrowser
import suwayomi.tachidesk.server.util.ExitCode.Success
object SystemTray {
fun systemTray(): SystemTray? {
try {
private var instance: SystemTray? = null
fun create() {
instance = try {
// ref: https://github.com/dorkbox/SystemTray/blob/master/test/dorkbox/TestTray.java
SystemTray.DEBUG = serverConfig.debugLogsEnabled
serverConfig.subscribeTo(
serverConfig.debugLogsEnabled,
{ debugLogsEnabled -> SystemTray.DEBUG = debugLogsEnabled },
ignoreInitialValue = false
)
CacheUtil.clear(BuildConfig.NAME)
@@ -28,7 +34,7 @@ object SystemTray {
SystemTray.FORCE_TRAY_TYPE = SystemTray.TrayType.Awt
}
val systemTray = SystemTray.get(BuildConfig.NAME) ?: return null
val systemTray = SystemTray.get(BuildConfig.NAME)
val mainMenu = systemTray.menu
mainMenu.add(
@@ -51,10 +57,15 @@ object SystemTray {
}
)
return systemTray
systemTray
} catch (e: Exception) {
e.printStackTrace()
return null
null
}
}
fun remove() {
instance?.remove()
instance = null
}
}

View File

@@ -16,11 +16,11 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
@@ -70,7 +70,7 @@ enum class WebUIChannel {
companion object {
fun doesConfigChannelEqual(channel: WebUIChannel): Boolean {
return serverConfig.webUIChannel.equals(channel.toString(), true)
return serverConfig.webUIChannel.value.equals(channel.toString(), true)
}
}
}
@@ -112,7 +112,7 @@ object WebInterfaceManager {
SharingStarted.Eagerly,
WebUIUpdateStatus(
info = WebUIUpdateInfo(
channel = serverConfig.webUIChannel,
channel = serverConfig.webUIChannel.value,
tag = "",
updateAvailable = false
),
@@ -122,27 +122,36 @@ object WebInterfaceManager {
)
init {
scheduleWebUIUpdateCheck()
serverConfig.subscribeTo(
combine(serverConfig.webUIUpdateCheckInterval, serverConfig.webUIFlavor) { interval, flavor ->
Pair(
interval,
flavor
)
},
::scheduleWebUIUpdateCheck,
ignoreInitialValue = false
)
}
private fun isAutoUpdateEnabled(): Boolean {
return serverConfig.webUIUpdateCheckInterval.toInt() != 0
return serverConfig.webUIUpdateCheckInterval.value.toInt() != 0
}
private fun scheduleWebUIUpdateCheck() {
HAScheduler.descheduleCron(currentUpdateTaskId)
val isAutoUpdateDisabled = !isAutoUpdateEnabled() || serverConfig.webUIFlavor == "Custom"
val isAutoUpdateDisabled = !isAutoUpdateEnabled() || serverConfig.webUIFlavor.value == "Custom"
if (isAutoUpdateDisabled) {
return
}
val updateInterval = serverConfig.webUIUpdateCheckInterval.hours.coerceAtLeast(1.hours).coerceAtMost(23.hours)
val updateInterval = serverConfig.webUIUpdateCheckInterval.value.hours.coerceAtLeast(1.hours).coerceAtMost(23.hours)
val lastAutomatedUpdate = preferences.getLong(lastWebUIUpdateCheckKey, System.currentTimeMillis())
val task = {
logger.debug {
"Checking for webUI update (channel= ${serverConfig.webUIChannel}, interval= ${serverConfig.webUIUpdateCheckInterval}h, lastAutomatedUpdate= ${
"Checking for webUI update (channel= ${serverConfig.webUIChannel.value}, interval= ${serverConfig.webUIUpdateCheckInterval.value}h, lastAutomatedUpdate= ${
Date(
lastAutomatedUpdate
)
@@ -165,14 +174,14 @@ object WebInterfaceManager {
}
suspend fun setupWebUI() {
if (serverConfig.webUIFlavor == "Custom") {
if (serverConfig.webUIFlavor.value == "Custom") {
return
}
if (doesLocalWebUIExist(applicationDirs.webUIRoot)) {
val currentVersion = getLocalVersion()
logger.info { "setupWebUI: found webUI files - flavor= ${serverConfig.webUIFlavor}, version= $currentVersion" }
logger.info { "setupWebUI: found webUI files - flavor= ${serverConfig.webUIFlavor.value}, version= $currentVersion" }
if (!isLocalWebUIValid(applicationDirs.webUIRoot)) {
doInitialSetup()
@@ -186,7 +195,7 @@ object WebInterfaceManager {
// check if the bundled webUI version is a newer version than the current used version
// this could be the case in case no compatible webUI version is available and a newer server version was installed
val shouldUpdateToBundledVersion =
serverConfig.webUIFlavor == DEFAULT_WEB_UI && extractVersion(getLocalVersion()) < extractVersion(
serverConfig.webUIFlavor.value == DEFAULT_WEB_UI && extractVersion(getLocalVersion()) < extractVersion(
BuildConfig.WEBUI_TAG
)
if (shouldUpdateToBundledVersion) {
@@ -232,10 +241,10 @@ object WebInterfaceManager {
return
}
if (serverConfig.webUIFlavor != DEFAULT_WEB_UI) {
if (serverConfig.webUIFlavor.value != DEFAULT_WEB_UI) {
logger.warn { "doInitialSetup: fallback to default webUI \"$DEFAULT_WEB_UI\"" }
serverConfig.webUIFlavor = DEFAULT_WEB_UI
serverConfig.webUIFlavor.value = DEFAULT_WEB_UI
val fallbackToBundledVersion = !doDownload() { getLatestCompatibleVersion() }
if (!fallbackToBundledVersion) {
@@ -287,11 +296,11 @@ object WebInterfaceManager {
val localVersion = getLocalVersion()
if (!isUpdateAvailable(localVersion).second) {
logger.debug { "checkForUpdate(${serverConfig.webUIFlavor}, $localVersion): local version is the latest one" }
logger.debug { "checkForUpdate(${serverConfig.webUIFlavor.value}, $localVersion): local version is the latest one" }
return
}
logger.info { "checkForUpdate(${serverConfig.webUIFlavor}, $localVersion): An update is available, starting download..." }
logger.info { "checkForUpdate(${serverConfig.webUIFlavor.value}, $localVersion): An update is available, starting download..." }
try {
downloadVersion(getLatestCompatibleVersion())
} catch (e: Exception) {
@@ -416,7 +425,7 @@ object WebInterfaceManager {
val currentServerVersionNumber = extractVersion(BuildConfig.REVISION)
val webUIToServerVersionMappings = fetchServerMappingFile()
logger.debug { "getLatestCompatibleVersion: webUIChannel= ${serverConfig.webUIChannel}, currentServerVersion= ${BuildConfig.REVISION}, mappingFile= $webUIToServerVersionMappings" }
logger.debug { "getLatestCompatibleVersion: webUIChannel= ${serverConfig.webUIChannel.value}, currentServerVersion= ${BuildConfig.REVISION}, mappingFile= $webUIToServerVersionMappings" }
for (i in 0 until webUIToServerVersionMappings.size) {
val webUIToServerVersionEntry = webUIToServerVersionMappings[i].jsonObject
@@ -446,7 +455,7 @@ object WebInterfaceManager {
notifyFlow.emit(
WebUIUpdateStatus(
info = WebUIUpdateInfo(
channel = serverConfig.webUIChannel,
channel = serverConfig.webUIChannel.value,
tag = version,
updateAvailable = true
),
@@ -472,7 +481,7 @@ object WebInterfaceManager {
val webUIZipURL = "${getDownloadUrlFor(version)}/$webUIZip"
val log =
KotlinLogging.logger("${logger.name} downloadVersion(version= $version, flavor= ${serverConfig.webUIFlavor})")
KotlinLogging.logger("${logger.name} downloadVersion(version= $version, flavor= ${serverConfig.webUIFlavor.value})")
log.info { "Downloading WebUI zip from the Internet..." }
executeWithRetry(log, {

View File

@@ -21,8 +21,10 @@ server.downloadAsCbz = false
server.downloadsPath = ""
server.autoDownloadNewChapters = false # if new chapters that have been retrieved should get automatically downloaded
# 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.maxParallelUpdateRequests = 10 # sets how many sources can be updated in parallel. updates are grouped by source and all mangas of a source are updated synchronously
server.excludeUnreadChapters = true
server.excludeNotStarted = true
server.excludeCompleted = true

View File

@@ -26,8 +26,8 @@ import suwayomi.tachidesk.server.ServerConfig
import suwayomi.tachidesk.server.androidCompat
import suwayomi.tachidesk.server.database.databaseUp
import suwayomi.tachidesk.server.serverConfig
import suwayomi.tachidesk.server.systemTrayInstance
import suwayomi.tachidesk.server.util.AppMutex
import suwayomi.tachidesk.server.util.SystemTray
import xyz.nulldev.androidcompat.AndroidCompatInitializer
import xyz.nulldev.ts.config.CONFIG_PREFIX
import xyz.nulldev.ts.config.ConfigKodeinModule
@@ -83,7 +83,7 @@ open class ApplicationTest {
// register Tachidesk's config which is dubbed "ServerConfig"
GlobalConfigManager.registerModule(
ServerConfig.register(GlobalConfigManager.config)
ServerConfig.register { GlobalConfigManager.config }
)
// Make sure only one instance of the app is running
@@ -125,9 +125,9 @@ open class ApplicationTest {
}
// create system tray
if (serverConfig.systemTrayEnabled) {
if (serverConfig.systemTrayEnabled.value) {
try {
systemTrayInstance
SystemTray.create()
} catch (e: Throwable) { // cover both java.lang.Exception and java.lang.Error
e.printStackTrace()
}
@@ -139,10 +139,10 @@ open class ApplicationTest {
System.setProperty("org.eclipse.jetty.LEVEL", "OFF")
// socks proxy settings
if (serverConfig.socksProxyEnabled) {
System.getProperties()["socksProxyHost"] = serverConfig.socksProxyHost
System.getProperties()["socksProxyPort"] = serverConfig.socksProxyPort
logger.info("Socks Proxy is enabled to ${serverConfig.socksProxyHost}:${serverConfig.socksProxyPort}")
if (serverConfig.socksProxyEnabled.value) {
System.getProperties()["socksProxyHost"] = serverConfig.socksProxyHost.value
System.getProperties()["socksProxyPort"] = serverConfig.socksProxyPort.value
logger.info("Socks Proxy is enabled to ${serverConfig.socksProxyHost.value}:${serverConfig.socksProxyPort.value}")
}
}

View File

@@ -11,8 +11,10 @@ server.socksProxyPort = ""
server.downloadAsCbz = false
server.autoDownloadNewChapters = false
# requests
server.maxSourcesInParallel = 10
# updater
server.maxParallelUpdateRequests = 10
server.excludeUnreadChapters = true
server.excludeNotStarted = true
server.excludeCompleted = true