mirror of
https://github.com/Suwayomi/Tachidesk.git
synced 2025-12-10 14:52:05 +01:00
Feature/global update trigger automatically (#593)
* Move "addCategoriesToUpdateQueue" to "Updater" * Automatically trigger the global update
This commit is contained in:
@@ -41,6 +41,7 @@ class SystemPropertyOverrideDelegate(val getConfig: () -> Config, val moduleName
|
|||||||
return when (T::class.simpleName) {
|
return when (T::class.simpleName) {
|
||||||
"Int" -> combined.toInt()
|
"Int" -> combined.toInt()
|
||||||
"Boolean" -> combined.toBoolean()
|
"Boolean" -> combined.toBoolean()
|
||||||
|
"Double" -> combined.toDouble()
|
||||||
// add more types as needed
|
// add more types as needed
|
||||||
else -> combined // covers String
|
else -> combined // covers String
|
||||||
} as T
|
} as T
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package suwayomi.tachidesk.manga.controller
|
package suwayomi.tachidesk.manga.controller
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
|
||||||
import io.javalin.http.HttpCode
|
import io.javalin.http.HttpCode
|
||||||
import io.javalin.websocket.WsConfig
|
import io.javalin.websocket.WsConfig
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
@@ -8,19 +7,13 @@ import org.kodein.di.DI
|
|||||||
import org.kodein.di.conf.global
|
import org.kodein.di.conf.global
|
||||||
import org.kodein.di.instance
|
import org.kodein.di.instance
|
||||||
import suwayomi.tachidesk.manga.impl.Category
|
import suwayomi.tachidesk.manga.impl.Category
|
||||||
import suwayomi.tachidesk.manga.impl.CategoryManga
|
|
||||||
import suwayomi.tachidesk.manga.impl.Chapter
|
import suwayomi.tachidesk.manga.impl.Chapter
|
||||||
import suwayomi.tachidesk.manga.impl.update.IUpdater
|
import suwayomi.tachidesk.manga.impl.update.IUpdater
|
||||||
import suwayomi.tachidesk.manga.impl.update.UpdateStatus
|
import suwayomi.tachidesk.manga.impl.update.UpdateStatus
|
||||||
import suwayomi.tachidesk.manga.impl.update.UpdaterSocket
|
import suwayomi.tachidesk.manga.impl.update.UpdaterSocket
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
|
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.IncludeInUpdate
|
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.PaginatedList
|
import suwayomi.tachidesk.manga.model.dataclass.PaginatedList
|
||||||
import suwayomi.tachidesk.manga.model.table.MangaStatus
|
|
||||||
import suwayomi.tachidesk.server.JavalinSetup.future
|
import suwayomi.tachidesk.server.JavalinSetup.future
|
||||||
import suwayomi.tachidesk.server.serverConfig
|
|
||||||
import suwayomi.tachidesk.server.util.formParam
|
import suwayomi.tachidesk.server.util.formParam
|
||||||
import suwayomi.tachidesk.server.util.handler
|
import suwayomi.tachidesk.server.util.handler
|
||||||
import suwayomi.tachidesk.server.util.pathParam
|
import suwayomi.tachidesk.server.util.pathParam
|
||||||
@@ -72,13 +65,14 @@ object UpdateController {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
behaviorOf = { ctx, categoryId ->
|
behaviorOf = { ctx, categoryId ->
|
||||||
|
val updater by DI.global.instance<IUpdater>()
|
||||||
if (categoryId == null) {
|
if (categoryId == null) {
|
||||||
logger.info { "Adding Library to Update Queue" }
|
logger.info { "Adding Library to Update Queue" }
|
||||||
addCategoriesToUpdateQueue(Category.getCategoryList(), true)
|
updater.addCategoriesToUpdateQueue(Category.getCategoryList(), true)
|
||||||
} else {
|
} else {
|
||||||
val category = Category.getCategoryById(categoryId)
|
val category = Category.getCategoryById(categoryId)
|
||||||
if (category != null) {
|
if (category != null) {
|
||||||
addCategoriesToUpdateQueue(listOf(category), true)
|
updater.addCategoriesToUpdateQueue(listOf(category), true)
|
||||||
} else {
|
} else {
|
||||||
logger.info { "No Category found" }
|
logger.info { "No Category found" }
|
||||||
ctx.status(HttpCode.BAD_REQUEST)
|
ctx.status(HttpCode.BAD_REQUEST)
|
||||||
@@ -91,45 +85,6 @@ object UpdateController {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun addCategoriesToUpdateQueue(categories: List<CategoryDataClass>, clear: Boolean = false) {
|
|
||||||
val updater by DI.global.instance<IUpdater>()
|
|
||||||
if (clear) {
|
|
||||||
updater.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
val includeInUpdateStatusToCategoryMap = categories.groupBy { it.includeInUpdate }
|
|
||||||
val excludedCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.EXCLUDE].orEmpty()
|
|
||||||
val includedCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.INCLUDE].orEmpty()
|
|
||||||
val unsetCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.UNSET].orEmpty()
|
|
||||||
val categoriesToUpdate = includedCategories.ifEmpty { unsetCategories }
|
|
||||||
|
|
||||||
logger.debug { "Updating categories: '${categoriesToUpdate.joinToString("', '") { it.name }}'" }
|
|
||||||
|
|
||||||
val categoriesToUpdateMangas = categoriesToUpdate
|
|
||||||
.flatMap { CategoryManga.getCategoryMangaList(it.id) }
|
|
||||||
.distinctBy { it.id }
|
|
||||||
val mangasToCategoriesMap = CategoryManga.getMangasCategories(categoriesToUpdateMangas.map { it.id })
|
|
||||||
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 { !excludedCategories.any { category -> mangasToCategoriesMap[it.id]?.contains(category) == true } }
|
|
||||||
.toList()
|
|
||||||
|
|
||||||
// In case no manga gets updated and no update job was running before, the client would never receive an info about its update request
|
|
||||||
if (mangasToUpdate.isEmpty()) {
|
|
||||||
UpdaterSocket.notifyAllClients(UpdateStatus())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updater.addMangasToQueue(
|
|
||||||
mangasToUpdate
|
|
||||||
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, MangaDataClass::title))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun categoryUpdateWS(ws: WsConfig) {
|
fun categoryUpdateWS(ws: WsConfig) {
|
||||||
ws.onConnect { ctx ->
|
ws.onConnect { ctx ->
|
||||||
UpdaterSocket.addClient(ctx)
|
UpdaterSocket.addClient(ctx)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package suwayomi.tachidesk.manga.impl.update
|
package suwayomi.tachidesk.manga.impl.update
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
|
||||||
|
|
||||||
interface IUpdater {
|
interface IUpdater {
|
||||||
fun addMangasToQueue(mangas: List<MangaDataClass>)
|
fun addCategoriesToUpdateQueue(categories: List<CategoryDataClass>, clear: Boolean?)
|
||||||
val status: StateFlow<UpdateStatus>
|
val status: StateFlow<UpdateStatus>
|
||||||
fun reset()
|
fun reset()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package suwayomi.tachidesk.manga.impl.update
|
package suwayomi.tachidesk.manga.impl.update
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -17,10 +18,23 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
|
import org.kodein.di.DI
|
||||||
|
import org.kodein.di.conf.global
|
||||||
|
import org.kodein.di.instance
|
||||||
|
import suwayomi.tachidesk.manga.impl.Category
|
||||||
|
import suwayomi.tachidesk.manga.impl.CategoryManga
|
||||||
import suwayomi.tachidesk.manga.impl.Chapter
|
import suwayomi.tachidesk.manga.impl.Chapter
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.IncludeInUpdate
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||||
|
import suwayomi.tachidesk.manga.model.table.MangaStatus
|
||||||
import suwayomi.tachidesk.server.serverConfig
|
import suwayomi.tachidesk.server.serverConfig
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Timer
|
||||||
|
import java.util.TimerTask
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.prefs.Preferences
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
|
||||||
class Updater : IUpdater {
|
class Updater : IUpdater {
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
@@ -34,6 +48,46 @@ class Updater : IUpdater {
|
|||||||
|
|
||||||
private val semaphore = Semaphore(serverConfig.maxParallelUpdateRequests)
|
private val semaphore = Semaphore(serverConfig.maxParallelUpdateRequests)
|
||||||
|
|
||||||
|
private val lastAutomatedUpdateKey = "lastAutomatedUpdateKey"
|
||||||
|
private val preferences = Preferences.userNodeForPackage(Updater::class.java)
|
||||||
|
|
||||||
|
private val updateTimer = Timer()
|
||||||
|
private var currentUpdateTask: TimerTask? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
scheduleUpdateTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleUpdateTask() {
|
||||||
|
if (!serverConfig.automaticallyTriggerGlobalUpdate) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val minInterval = 6.hours
|
||||||
|
val interval = serverConfig.globalUpdateInterval.hours
|
||||||
|
val updateInterval = interval.coerceAtLeast(minInterval).inWholeMilliseconds
|
||||||
|
|
||||||
|
val lastAutomatedUpdate = preferences.getLong(lastAutomatedUpdateKey, 0)
|
||||||
|
val initialDelay = updateInterval - (System.currentTimeMillis() - lastAutomatedUpdate) % updateInterval
|
||||||
|
|
||||||
|
currentUpdateTask?.cancel()
|
||||||
|
currentUpdateTask = object : TimerTask() {
|
||||||
|
override fun run() {
|
||||||
|
preferences.putLong(lastAutomatedUpdateKey, System.currentTimeMillis())
|
||||||
|
|
||||||
|
if (status.value.running) {
|
||||||
|
logger.debug { "Global update is already in progress, do not trigger global update" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info { "Trigger global update (interval= ${serverConfig.globalUpdateInterval}h, lastAutomatedUpdate= ${Date(lastAutomatedUpdate)})" }
|
||||||
|
addCategoriesToUpdateQueue(Category.getCategoryList(), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTimer.scheduleAtFixedRate(currentUpdateTask, initialDelay, updateInterval)
|
||||||
|
}
|
||||||
|
|
||||||
private fun getOrCreateUpdateChannelFor(source: String): Channel<UpdateJob> {
|
private fun getOrCreateUpdateChannelFor(source: String): Channel<UpdateJob> {
|
||||||
return updateChannels.getOrPut(source) {
|
return updateChannels.getOrPut(source) {
|
||||||
logger.debug { "getOrCreateUpdateChannelFor: created channel for $source - channels: ${updateChannels.size + 1}" }
|
logger.debug { "getOrCreateUpdateChannelFor: created channel for $source - channels: ${updateChannels.size + 1}" }
|
||||||
@@ -74,7 +128,46 @@ class Updater : IUpdater {
|
|||||||
return tracker.values.toList()
|
return tracker.values.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun addMangasToQueue(mangas: List<MangaDataClass>) {
|
override fun addCategoriesToUpdateQueue(categories: List<CategoryDataClass>, clear: Boolean?) {
|
||||||
|
val updater by DI.global.instance<IUpdater>()
|
||||||
|
if (clear == true) {
|
||||||
|
updater.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
val includeInUpdateStatusToCategoryMap = categories.groupBy { it.includeInUpdate }
|
||||||
|
val excludedCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.EXCLUDE].orEmpty()
|
||||||
|
val includedCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.INCLUDE].orEmpty()
|
||||||
|
val unsetCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.UNSET].orEmpty()
|
||||||
|
val categoriesToUpdate = includedCategories.ifEmpty { unsetCategories }
|
||||||
|
|
||||||
|
logger.debug { "Updating categories: '${categoriesToUpdate.joinToString("', '") { it.name }}'" }
|
||||||
|
|
||||||
|
val categoriesToUpdateMangas = categoriesToUpdate
|
||||||
|
.flatMap { CategoryManga.getCategoryMangaList(it.id) }
|
||||||
|
.distinctBy { it.id }
|
||||||
|
val mangasToCategoriesMap = CategoryManga.getMangasCategories(categoriesToUpdateMangas.map { it.id })
|
||||||
|
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 { !excludedCategories.any { category -> mangasToCategoriesMap[it.id]?.contains(category) == true } }
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
// In case no manga gets updated and no update job was running before, the client would never receive an info about its update request
|
||||||
|
if (mangasToUpdate.isEmpty()) {
|
||||||
|
UpdaterSocket.notifyAllClients(UpdateStatus())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addMangasToQueue(
|
||||||
|
mangasToUpdate
|
||||||
|
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, MangaDataClass::title))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addMangasToQueue(mangas: List<MangaDataClass>) {
|
||||||
mangas.forEach { tracker[it.id] = UpdateJob(it) }
|
mangas.forEach { tracker[it.id] = UpdateJob(it) }
|
||||||
_status.update { UpdateStatus(tracker.values.toList(), mangas.isNotEmpty()) }
|
_status.update { UpdateStatus(tracker.values.toList(), mangas.isNotEmpty()) }
|
||||||
mangas.forEach { addMangaToQueue(it) }
|
mangas.forEach { addMangaToQueue(it) }
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ class ServerConfig(getConfig: () -> Config, moduleName: String = MODULE_NAME) :
|
|||||||
var excludeUnreadChapters: Boolean by overridableConfig
|
var excludeUnreadChapters: Boolean by overridableConfig
|
||||||
var excludeNotStarted: Boolean by overridableConfig
|
var excludeNotStarted: Boolean by overridableConfig
|
||||||
var excludeCompleted: Boolean by overridableConfig
|
var excludeCompleted: Boolean by overridableConfig
|
||||||
|
var automaticallyTriggerGlobalUpdate: Boolean by overridableConfig
|
||||||
|
var globalUpdateInterval: Double by overridableConfig
|
||||||
|
|
||||||
// Authentication
|
// Authentication
|
||||||
var basicAuthEnabled: Boolean by overridableConfig
|
var basicAuthEnabled: Boolean by overridableConfig
|
||||||
|
|||||||
@@ -68,10 +68,12 @@ fun applicationSetup() {
|
|||||||
// Application dirs
|
// Application dirs
|
||||||
val applicationDirs = ApplicationDirs()
|
val applicationDirs = ApplicationDirs()
|
||||||
|
|
||||||
|
val updater = Updater()
|
||||||
|
|
||||||
DI.global.addImport(
|
DI.global.addImport(
|
||||||
DI.Module("Server") {
|
DI.Module("Server") {
|
||||||
bind<ApplicationDirs>() with singleton { applicationDirs }
|
bind<ApplicationDirs>() with singleton { applicationDirs }
|
||||||
bind<IUpdater>() with singleton { Updater() }
|
bind<IUpdater>() with singleton { updater }
|
||||||
bind<JsonMapper>() with singleton { JavalinJackson() }
|
bind<JsonMapper>() with singleton { JavalinJackson() }
|
||||||
bind<Json>() with singleton { Json { ignoreUnknownKeys = true } }
|
bind<Json>() with singleton { Json { ignoreUnknownKeys = true } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ server.maxParallelUpdateRequests = 10 # sets how many sources can be updated in
|
|||||||
server.excludeUnreadChapters = true
|
server.excludeUnreadChapters = true
|
||||||
server.excludeNotStarted = true
|
server.excludeNotStarted = true
|
||||||
server.excludeCompleted = true
|
server.excludeCompleted = true
|
||||||
|
server.automaticallyTriggerGlobalUpdate = false
|
||||||
|
server.globalUpdateInterval = 12 # time in hours (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
|
||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
server.basicAuthEnabled = false
|
server.basicAuthEnabled = false
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ server.maxParallelUpdateRequests = 10
|
|||||||
server.excludeUnreadChapters = true
|
server.excludeUnreadChapters = true
|
||||||
server.excludeNotStarted = true
|
server.excludeNotStarted = true
|
||||||
server.excludeCompleted = true
|
server.excludeCompleted = true
|
||||||
|
server.automaticallyTriggerGlobalUpdate = false
|
||||||
|
server.globalUpdateInterval = 12
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
server.debugLogsEnabled = true
|
server.debugLogsEnabled = true
|
||||||
|
|||||||
Reference in New Issue
Block a user