Library Update Queries and Mutations (#609)

* Add library update GraphQL endpoints

* No need for data classes

* UpdateLibraryManga
This commit is contained in:
Mitchell Syer
2023-08-03 18:08:35 -04:00
committed by GitHub
parent 78a167aacf
commit c3fb08d634
11 changed files with 199 additions and 52 deletions

View File

@@ -80,3 +80,20 @@ class MangaForSourceDataLoader : KotlinDataLoader<Long, MangaNodeList> {
} }
} }
} }
class MangaForIdsDataLoader : KotlinDataLoader<List<Int>, MangaNodeList> {
override val dataLoaderName = "MangaForIdsDataLoader"
override fun getDataLoader(): DataLoader<List<Int>, MangaNodeList> = DataLoaderFactory.newDataLoader { mangaIds ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val ids = mangaIds.flatten().distinct()
val manga = MangaTable.select { MangaTable.id inList ids }
.map { MangaType(it) }
mangaIds.map { mangaIds ->
manga.filter { it.id in mangaIds }.toNodeList()
}
}
}
}
}

View File

@@ -0,0 +1,69 @@
package suwayomi.tachidesk.graphql.mutations
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.graphql.types.UpdateStatus
import suwayomi.tachidesk.manga.impl.Category
import suwayomi.tachidesk.manga.impl.update.IUpdater
import suwayomi.tachidesk.manga.model.table.CategoryTable
import suwayomi.tachidesk.manga.model.table.toDataClass
class UpdateMutation {
private val updater by DI.global.instance<IUpdater>()
data class UpdateLibraryMangaInput(
val clientMutationId: String? = null
)
data class UpdateLibraryMangaPayload(
val clientMutationId: String?,
val updateStatus: UpdateStatus
)
fun updateLibraryManga(input: UpdateLibraryMangaInput): UpdateLibraryMangaPayload {
updater.addCategoriesToUpdateQueue(
Category.getCategoryList(),
clear = true,
forceAll = false
)
return UpdateLibraryMangaPayload(input.clientMutationId, UpdateStatus(updater.status.value))
}
data class UpdateCategoryMangaInput(
val clientMutationId: String? = null,
val categories: List<Int>
)
data class UpdateCategoryMangaPayload(
val clientMutationId: String?,
val updateStatus: UpdateStatus
)
fun updateCategoryManga(input: UpdateCategoryMangaInput): UpdateCategoryMangaPayload {
val categories = transaction {
CategoryTable.select { CategoryTable.id inList input.categories }.map {
CategoryTable.toDataClass(it)
}
}
updater.addCategoriesToUpdateQueue(categories, clear = true, forceAll = true)
return UpdateCategoryMangaPayload(
clientMutationId = input.clientMutationId,
updateStatus = UpdateStatus(updater.status.value)
)
}
data class UpdateStopInput(
val clientMutationId: String? = null
)
data class UpdateStopPayload(
val clientMutationId: String?
)
fun updateStop(input: UpdateStopInput): UpdateStopPayload {
updater.reset()
return UpdateStopPayload(input.clientMutationId)
}
}

View File

@@ -0,0 +1,24 @@
package suwayomi.tachidesk.graphql.queries
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.graphql.types.UpdateStatus
import suwayomi.tachidesk.graphql.types.UpdateStatusType
import suwayomi.tachidesk.manga.impl.update.IUpdater
import suwayomi.tachidesk.manga.impl.update.JobStatus
class UpdateQuery {
private val updater by DI.global.instance<IUpdater>()
fun updateStatus(): UpdateStatus {
val status = updater.status.value
return UpdateStatus(
isRunning = status.running,
pendingJobs = UpdateStatusType(status.statusMap[JobStatus.PENDING]?.map { it.id }.orEmpty()),
runningJobs = UpdateStatusType(status.statusMap[JobStatus.RUNNING]?.map { it.id }.orEmpty()),
completeJobs = UpdateStatusType(status.statusMap[JobStatus.COMPLETE]?.map { it.id }.orEmpty()),
failedJobs = UpdateStatusType(status.statusMap[JobStatus.FAILED]?.map { it.id }.orEmpty())
)
}
}

View File

@@ -1,42 +0,0 @@
package suwayomi.tachidesk.graphql.queries
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.graphql.types.MangaType
import suwayomi.tachidesk.manga.impl.update.IUpdater
import suwayomi.tachidesk.manga.impl.update.JobStatus
import suwayomi.tachidesk.manga.impl.update.UpdaterSocket
import suwayomi.tachidesk.manga.model.table.MangaTable
class UpdaterQuery {
sealed interface UpdaterStatus {
data class UpdaterJob(val status: JobStatus, val manga: MangaType)
data class Running(val jobs: Map<JobStatus, List<MangaType>>) : UpdaterStatus
// data class Idle
}
private val updater by DI.global.instance<IUpdater>()
fun updaterStatus() {
val status = updater.status.value
if (status.running) {
val mangaIds = status.statusMap.values.flatMap { mangas -> mangas.map { it.id } }
val mangaMap = transaction {
MangaTable.select { MangaTable.id inList mangaIds }
.map { MangaType(it) }
.associateBy { it.id }
}
UpdaterStatus.Running(
status.statusMap.mapValues { (_, mangas) ->
mangas.mapNotNull { mangaMap[it.id] }
}
)
}
UpdaterSocket
}
}

View File

@@ -19,6 +19,7 @@ import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForSourceDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.GlobalMetaDataLoader import suwayomi.tachidesk.graphql.dataLoaders.GlobalMetaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.MangaDataLoader import suwayomi.tachidesk.graphql.dataLoaders.MangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.MangaForCategoryDataLoader import suwayomi.tachidesk.graphql.dataLoaders.MangaForCategoryDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.MangaForIdsDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.MangaForSourceDataLoader import suwayomi.tachidesk.graphql.dataLoaders.MangaForSourceDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.MangaMetaDataLoader import suwayomi.tachidesk.graphql.dataLoaders.MangaMetaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.SourceDataLoader import suwayomi.tachidesk.graphql.dataLoaders.SourceDataLoader
@@ -36,6 +37,7 @@ class TachideskDataLoaderRegistryFactory {
MangaMetaDataLoader(), MangaMetaDataLoader(),
MangaForCategoryDataLoader(), MangaForCategoryDataLoader(),
MangaForSourceDataLoader(), MangaForSourceDataLoader(),
MangaForIdsDataLoader(),
CategoryDataLoader(), CategoryDataLoader(),
CategoryMetaDataLoader(), CategoryMetaDataLoader(),
CategoriesForMangaDataLoader(), CategoriesForMangaDataLoader(),

View File

@@ -20,6 +20,7 @@ import suwayomi.tachidesk.graphql.mutations.ExtensionMutation
import suwayomi.tachidesk.graphql.mutations.MangaMutation import suwayomi.tachidesk.graphql.mutations.MangaMutation
import suwayomi.tachidesk.graphql.mutations.MetaMutation import suwayomi.tachidesk.graphql.mutations.MetaMutation
import suwayomi.tachidesk.graphql.mutations.SourceMutation import suwayomi.tachidesk.graphql.mutations.SourceMutation
import suwayomi.tachidesk.graphql.mutations.UpdateMutation
import suwayomi.tachidesk.graphql.queries.BackupQuery import suwayomi.tachidesk.graphql.queries.BackupQuery
import suwayomi.tachidesk.graphql.queries.CategoryQuery import suwayomi.tachidesk.graphql.queries.CategoryQuery
import suwayomi.tachidesk.graphql.queries.ChapterQuery import suwayomi.tachidesk.graphql.queries.ChapterQuery
@@ -27,11 +28,13 @@ import suwayomi.tachidesk.graphql.queries.ExtensionQuery
import suwayomi.tachidesk.graphql.queries.MangaQuery import suwayomi.tachidesk.graphql.queries.MangaQuery
import suwayomi.tachidesk.graphql.queries.MetaQuery import suwayomi.tachidesk.graphql.queries.MetaQuery
import suwayomi.tachidesk.graphql.queries.SourceQuery import suwayomi.tachidesk.graphql.queries.SourceQuery
import suwayomi.tachidesk.graphql.queries.UpdateQuery
import suwayomi.tachidesk.graphql.server.primitives.Cursor import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.GraphQLCursor import suwayomi.tachidesk.graphql.server.primitives.GraphQLCursor
import suwayomi.tachidesk.graphql.server.primitives.GraphQLLongAsString import suwayomi.tachidesk.graphql.server.primitives.GraphQLLongAsString
import suwayomi.tachidesk.graphql.server.primitives.GraphQLUpload import suwayomi.tachidesk.graphql.server.primitives.GraphQLUpload
import suwayomi.tachidesk.graphql.subscriptions.DownloadSubscription import suwayomi.tachidesk.graphql.subscriptions.DownloadSubscription
import suwayomi.tachidesk.graphql.subscriptions.UpdateSubscription
import kotlin.reflect.KClass import kotlin.reflect.KClass
import kotlin.reflect.KType import kotlin.reflect.KType
@@ -57,7 +60,8 @@ val schema = toSchema(
TopLevelObject(ExtensionQuery()), TopLevelObject(ExtensionQuery()),
TopLevelObject(MangaQuery()), TopLevelObject(MangaQuery()),
TopLevelObject(MetaQuery()), TopLevelObject(MetaQuery()),
TopLevelObject(SourceQuery()) TopLevelObject(SourceQuery()),
TopLevelObject(UpdateQuery())
), ),
mutations = listOf( mutations = listOf(
TopLevelObject(BackupMutation()), TopLevelObject(BackupMutation()),
@@ -66,9 +70,11 @@ val schema = toSchema(
TopLevelObject(ExtensionMutation()), TopLevelObject(ExtensionMutation()),
TopLevelObject(MangaMutation()), TopLevelObject(MangaMutation()),
TopLevelObject(MetaMutation()), TopLevelObject(MetaMutation()),
TopLevelObject(SourceMutation()) TopLevelObject(SourceMutation()),
TopLevelObject(UpdateMutation())
), ),
subscriptions = listOf( subscriptions = listOf(
TopLevelObject(DownloadSubscription()) TopLevelObject(DownloadSubscription()),
TopLevelObject(UpdateSubscription())
) )
) )

View File

@@ -0,0 +1,26 @@
/*
* 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.subscriptions
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.graphql.types.UpdateStatus
import suwayomi.tachidesk.manga.impl.update.IUpdater
class UpdateSubscription {
private val updater by DI.global.instance<IUpdater>()
fun updateStatusChanged(): Flow<UpdateStatus> {
return updater.status.map { updateStatus ->
UpdateStatus(updateStatus)
}
}
}

View File

@@ -0,0 +1,33 @@
package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.generator.annotations.GraphQLIgnore
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import suwayomi.tachidesk.manga.impl.update.JobStatus
import suwayomi.tachidesk.manga.impl.update.UpdateStatus
import java.util.concurrent.CompletableFuture
class UpdateStatus(
val isRunning: Boolean,
val pendingJobs: UpdateStatusType,
val runningJobs: UpdateStatusType,
val completeJobs: UpdateStatusType,
val failedJobs: UpdateStatusType
) {
constructor(status: UpdateStatus) : this(
isRunning = status.running,
pendingJobs = UpdateStatusType(status.statusMap[JobStatus.PENDING]?.map { it.id }.orEmpty()),
runningJobs = UpdateStatusType(status.statusMap[JobStatus.RUNNING]?.map { it.id }.orEmpty()),
completeJobs = UpdateStatusType(status.statusMap[JobStatus.COMPLETE]?.map { it.id }.orEmpty()),
failedJobs = UpdateStatusType(status.statusMap[JobStatus.FAILED]?.map { it.id }.orEmpty())
)
}
class UpdateStatusType(
@get:GraphQLIgnore
val mangaIds: List<Int>
) {
fun mangas(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<MangaNodeList> {
return dataFetchingEnvironment.getValueFromDataLoader<List<Int>, MangaNodeList>("MangaForIdsDataLoader", mangaIds)
}
}

View File

@@ -68,11 +68,19 @@ object UpdateController {
val updater by DI.global.instance<IUpdater>() 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" }
updater.addCategoriesToUpdateQueue(Category.getCategoryList(), true) updater.addCategoriesToUpdateQueue(
Category.getCategoryList(),
clear = true,
forceAll = false
)
} else { } else {
val category = Category.getCategoryById(categoryId) val category = Category.getCategoryById(categoryId)
if (category != null) { if (category != null) {
updater.addCategoriesToUpdateQueue(listOf(category), true) updater.addCategoriesToUpdateQueue(
listOf(category),
clear = true,
forceAll = true
)
} else { } else {
logger.info { "No Category found" } logger.info { "No Category found" }
ctx.status(HttpCode.BAD_REQUEST) ctx.status(HttpCode.BAD_REQUEST)

View File

@@ -4,7 +4,7 @@ import kotlinx.coroutines.flow.StateFlow
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
interface IUpdater { interface IUpdater {
fun addCategoriesToUpdateQueue(categories: List<CategoryDataClass>, clear: Boolean?) fun addCategoriesToUpdateQueue(categories: List<CategoryDataClass>, clear: Boolean?, forceAll: Boolean)
val status: StateFlow<UpdateStatus> val status: StateFlow<UpdateStatus>
fun reset() fun reset()
} }

View File

@@ -62,7 +62,7 @@ class Updater : IUpdater {
} }
logger.info { "Trigger global update (interval= ${serverConfig.globalUpdateInterval}h, lastAutomatedUpdate= ${Date(lastAutomatedUpdate)})" } logger.info { "Trigger global update (interval= ${serverConfig.globalUpdateInterval}h, lastAutomatedUpdate= ${Date(lastAutomatedUpdate)})" }
addCategoriesToUpdateQueue(Category.getCategoryList(), true) addCategoriesToUpdateQueue(Category.getCategoryList(), clear = true, forceAll = false)
} }
fun scheduleUpdateTask() { fun scheduleUpdateTask() {
@@ -125,7 +125,7 @@ class Updater : IUpdater {
return tracker.values.toList() return tracker.values.toList()
} }
override fun addCategoriesToUpdateQueue(categories: List<CategoryDataClass>, clear: Boolean?) { override fun addCategoriesToUpdateQueue(categories: List<CategoryDataClass>, clear: Boolean?, forceAll: Boolean) {
val updater by DI.global.instance<IUpdater>() val updater by DI.global.instance<IUpdater>()
if (clear == true) { if (clear == true) {
updater.reset() updater.reset()
@@ -135,7 +135,11 @@ class Updater : IUpdater {
val excludedCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.EXCLUDE].orEmpty() val excludedCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.EXCLUDE].orEmpty()
val includedCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.INCLUDE].orEmpty() val includedCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.INCLUDE].orEmpty()
val unsetCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.UNSET].orEmpty() val unsetCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.UNSET].orEmpty()
val categoriesToUpdate = includedCategories.ifEmpty { unsetCategories } val categoriesToUpdate = if (forceAll) {
categories
} else {
includedCategories.ifEmpty { unsetCategories }
}
logger.debug { "Updating categories: '${categoriesToUpdate.joinToString("', '") { it.name }}'" } logger.debug { "Updating categories: '${categoriesToUpdate.joinToString("', '") { it.name }}'" }
@@ -149,7 +153,7 @@ class Updater : IUpdater {
.filter { if (serverConfig.excludeUnreadChapters) { (it.unreadCount ?: 0L) == 0L } else true } .filter { if (serverConfig.excludeUnreadChapters) { (it.unreadCount ?: 0L) == 0L } else true }
.filter { if (serverConfig.excludeNotStarted) { it.lastReadAt != null } 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.excludeCompleted) { it.status != MangaStatus.COMPLETED.name } else true }
.filter { !excludedCategories.any { category -> mangasToCategoriesMap[it.id]?.contains(category) == true } } .filter { forceAll || !excludedCategories.any { category -> mangasToCategoriesMap[it.id]?.contains(category) == true } }
.toList() .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 // In case no manga gets updated and no update job was running before, the client would never receive an info about its update request