Files
Suwayomi-Server/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MangaMutation.kt
Mitchell Syer 2d535b44d8 Extension API 1.6 (#2120)
* Non-Extension Index changes for 1.6

* Changelog

* Minor fixes

* Implement extension store

* Test build fix

* Docs

* Simplify fetching manga and chapters

* Use EMPTY JsonObject

* Update docs/Configuring-Suwayomi‐Server.md

Co-authored-by: Constantin Piber <59023762+cpiber@users.noreply.github.com>

* Improve Fetch Extension Store

* Fixes

* Simplify deprecated isNsfw in SourceQuery

* Simplify ContentRating in Source.kt

* Simplify isNsfw in SourceType

* No magic numbers for ContentRating, improves safety for future versions of extension api

* Fix SearchTest

* Lint

* Lint

* Optimize imports and fix unchecked cast warning

* Proper extension store queries

* Optimize import fixes

* Add ContentRatingFilter

* Improve extension store sync

* fix: re-sync (#2121)

* Lint

* Add ExtenionStores to the fetchExtensions result since its possible for the stores to change.

* Use a single version of ContentRating

* Exclude ServerConfig.extensionStores from GraphQL

* Use syncDbToPrefs in ExtensionStoreMutation

* Optimize Imports

* Update server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt

Co-authored-by: Constantin Piber <59023762+cpiber@users.noreply.github.com>

* Remove replaceWith and add specific description for GQL APIs

* Include OkHttp ZSTD

* Update to latest Mihon extension lib

* Fix latest Mihon Extension Lib

* Lint

* Optimize imports

* Lint

* Review fixes

* Add a index to extesnion table store url

* Lint

---------

Co-authored-by: Constantin Piber <59023762+cpiber@users.noreply.github.com>
2026-06-27 13:39:28 -04:00

403 lines
13 KiB
Kotlin

@file:Suppress("RedundantNullableReturnType", "unused")
package suwayomi.tachidesk.graphql.mutations
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
import org.jetbrains.exposed.v1.core.LikePattern
import org.jetbrains.exposed.v1.core.Op
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import suwayomi.tachidesk.graphql.directives.RequireAuth
import suwayomi.tachidesk.graphql.types.ChapterType
import suwayomi.tachidesk.graphql.types.MangaMetaType
import suwayomi.tachidesk.graphql.types.MangaType
import suwayomi.tachidesk.graphql.types.MetaInput
import suwayomi.tachidesk.manga.impl.Library
import suwayomi.tachidesk.manga.impl.Manga
import suwayomi.tachidesk.manga.impl.update.IUpdater
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaMetaTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.toDataClass
import suwayomi.tachidesk.server.JavalinSetup.future
import uy.kohesive.injekt.injectLazy
import java.time.Instant
import java.util.concurrent.CompletableFuture
/**
* TODO Mutations
* - Download x(all = -1) chapters
* - Delete read/all downloaded chapters
*/
class MangaMutation {
private val updater: IUpdater by injectLazy()
data class UpdateMangaPatch(
val inLibrary: Boolean? = null,
)
data class UpdateMangaPayload(
val clientMutationId: String?,
val manga: MangaType,
)
data class UpdateMangaInput(
val clientMutationId: String? = null,
val id: Int,
val patch: UpdateMangaPatch,
)
data class UpdateMangasPayload(
val clientMutationId: String?,
val mangas: List<MangaType>,
)
data class UpdateMangasInput(
val clientMutationId: String? = null,
val ids: List<Int>,
val patch: UpdateMangaPatch,
)
private suspend fun updateMangas(
ids: List<Int>,
patch: UpdateMangaPatch,
) {
transaction {
if (patch.inLibrary != null) {
MangaTable.update({ MangaTable.id inList ids }) { update ->
patch.inLibrary.also {
update[inLibrary] = it
if (it) update[inLibraryAt] = Instant.now().epochSecond
}
}
}
}.apply {
if (patch.inLibrary != null) {
transaction {
// try to initialize uninitialized in library manga to ensure that the expected data is available (chapter list, metadata, ...)
val mangas =
transaction {
MangaTable
.selectAll()
.where { (MangaTable.id inList ids) and (MangaTable.initialized eq false) }
.map { MangaTable.toDataClass(it) }
}
updater.addMangasToQueue(mangas)
}
ids.forEach {
Library.handleMangaThumbnail(it, patch.inLibrary)
}
}
}
}
@RequireAuth
fun updateManga(input: UpdateMangaInput): CompletableFuture<UpdateMangaPayload?> {
val (clientMutationId, id, patch) = input
return future {
updateMangas(listOf(id), patch)
val manga =
transaction {
MangaType(MangaTable.selectAll().where { MangaTable.id eq id }.first())
}
UpdateMangaPayload(
clientMutationId = clientMutationId,
manga = manga,
)
}
}
@RequireAuth
fun updateMangas(input: UpdateMangasInput): CompletableFuture<UpdateMangasPayload?> {
val (clientMutationId, ids, patch) = input
return future {
updateMangas(ids, patch)
val mangas =
transaction {
MangaTable.selectAll().where { MangaTable.id inList ids }.map { MangaType(it) }
}
UpdateMangasPayload(
clientMutationId = clientMutationId,
mangas = mangas,
)
}
}
data class FetchMangaInput(
val clientMutationId: String? = null,
val id: Int,
)
data class FetchMangaPayload(
val clientMutationId: String?,
val manga: MangaType,
)
@RequireAuth
@GraphQLDeprecated("Deprecated in Tachiyomix 1.6", ReplaceWith("fetchMangaAndChapters"))
fun fetchManga(input: FetchMangaInput): CompletableFuture<FetchMangaPayload?> {
val (clientMutationId, id) = input
return future {
Manga.updateMangaAndChapters(id, updateChapters = false)
val manga =
transaction {
MangaTable.selectAll().where { MangaTable.id eq id }.first()
}
FetchMangaPayload(
clientMutationId = clientMutationId,
manga = MangaType(manga),
)
}
}
data class FetchMangaAndChaptersInput(
val clientMutationId: String? = null,
val id: Int,
val fetchManga: Boolean,
val fetchChapters: Boolean,
)
data class FetchMangaAndChaptersPayload(
val clientMutationId: String?,
val manga: MangaType,
val chapters: List<ChapterType>,
)
@RequireAuth
fun fetchMangaAndChapters(input: FetchMangaAndChaptersInput): CompletableFuture<FetchMangaAndChaptersPayload?> {
val (clientMutationId, id, fetchManga, fetchChapters) = input
return future {
Manga.updateMangaAndChapters(
mangaId = id,
updateManga = fetchManga,
updateChapters = fetchChapters,
)
val (manga, chapters) =
transaction {
Pair(
MangaTable.selectAll().where { MangaTable.id eq id }.first(),
ChapterTable
.selectAll()
.where { ChapterTable.manga eq id }
.orderBy(ChapterTable.sourceOrder)
.map { ChapterType(it) },
)
}
FetchMangaAndChaptersPayload(
clientMutationId = clientMutationId,
manga = MangaType(manga),
chapters = chapters,
)
}
}
data class SetMangaMetaInput(
val clientMutationId: String? = null,
val meta: MangaMetaType,
)
data class SetMangaMetaPayload(
val clientMutationId: String?,
val meta: MangaMetaType,
)
@RequireAuth
fun setMangaMeta(input: SetMangaMetaInput): SetMangaMetaPayload? {
val (clientMutationId, meta) = input
Manga.modifyMangaMeta(meta.mangaId, meta.key, meta.value)
return SetMangaMetaPayload(clientMutationId, meta)
}
data class DeleteMangaMetaInput(
val clientMutationId: String? = null,
val mangaId: Int,
val key: String,
)
data class DeleteMangaMetaPayload(
val clientMutationId: String?,
val meta: MangaMetaType?,
val manga: MangaType,
)
@RequireAuth
fun deleteMangaMeta(input: DeleteMangaMetaInput): DeleteMangaMetaPayload? {
val (clientMutationId, mangaId, key) = input
val (meta, manga) =
transaction {
val meta =
MangaMetaTable
.selectAll()
.where { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) }
.firstOrNull()
MangaMetaTable.deleteWhere { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) }
val manga =
transaction {
MangaType(MangaTable.selectAll().where { MangaTable.id eq mangaId }.first())
}
if (meta != null) {
MangaMetaType(meta)
} else {
null
} to manga
}
return DeleteMangaMetaPayload(clientMutationId, meta, manga)
}
data class SetMangaMetasItem(
val mangaIds: List<Int>,
val metas: List<MetaInput>,
)
data class SetMangaMetasInput(
val clientMutationId: String? = null,
val items: List<SetMangaMetasItem>,
)
data class SetMangaMetasPayload(
val clientMutationId: String?,
val metas: List<MangaMetaType>,
val mangas: List<MangaType>,
)
@RequireAuth
fun setMangaMetas(input: SetMangaMetasInput): SetMangaMetasPayload? {
val (clientMutationId, items) = input
val metaByMangaId =
items
.flatMap { item ->
val metaMap = item.metas.associate { it.key to it.value }
item.mangaIds.map { mangaId -> mangaId to metaMap }
}.groupBy({ it.first }, { it.second })
.mapValues { (_, maps) -> maps.reduce { acc, map -> acc + map } }
Manga.modifyMangasMetas(metaByMangaId)
val allMangaIds = metaByMangaId.keys
val allMetaKeys = metaByMangaId.values.flatMap { it.keys }.distinct()
val (updatedMetas, mangas) =
transaction {
val updatedMetas =
MangaMetaTable
.selectAll()
.where { (MangaMetaTable.ref inList allMangaIds) and (MangaMetaTable.key inList allMetaKeys) }
.map { MangaMetaType(it) }
val mangas =
MangaTable
.selectAll()
.where { MangaTable.id inList allMangaIds }
.map { MangaType(it) }
.distinctBy { it.id }
updatedMetas to mangas
}
return SetMangaMetasPayload(clientMutationId, updatedMetas, mangas)
}
data class DeleteMangaMetasItem(
val mangaIds: List<Int>,
val keys: List<String>? = null,
val prefixes: List<String>? = null,
)
data class DeleteMangaMetasInput(
val clientMutationId: String? = null,
val items: List<DeleteMangaMetasItem>,
)
data class DeleteMangaMetasPayload(
val clientMutationId: String?,
val metas: List<MangaMetaType>,
val mangas: List<MangaType>,
)
@RequireAuth
fun deleteMangaMetas(input: DeleteMangaMetasInput): DeleteMangaMetasPayload? {
val (clientMutationId, items) = input
items.forEach { item ->
require(!item.keys.isNullOrEmpty() || !item.prefixes.isNullOrEmpty()) {
"Either 'keys' or 'prefixes' must be provided for each item"
}
}
val (allDeletedMetas, allMangaIds) =
transaction {
val deletedMetas = mutableListOf<MangaMetaType>()
val mangaIds = mutableSetOf<Int>()
items.forEach { item ->
val keyCondition: Op<Boolean>? =
item.keys?.takeIf { it.isNotEmpty() }?.let { MangaMetaTable.key inList it }
val prefixCondition: Op<Boolean>? =
item.prefixes
?.filter { it.isNotEmpty() }
?.map { (MangaMetaTable.key like LikePattern("$it%")) as Op<Boolean> }
?.reduceOrNull { acc, op -> acc or op }
val metaKeyCondition =
if (keyCondition != null && prefixCondition != null) {
keyCondition or prefixCondition
} else {
keyCondition ?: prefixCondition!!
}
val condition = (MangaMetaTable.ref inList item.mangaIds) and metaKeyCondition
deletedMetas +=
MangaMetaTable
.selectAll()
.where { condition }
.map { MangaMetaType(it) }
MangaMetaTable.deleteWhere { condition }
mangaIds += item.mangaIds
}
deletedMetas to mangaIds
}
val mangas =
transaction {
MangaTable
.selectAll()
.where { MangaTable.id inList allMangaIds }
.map { MangaType(it) }
.distinctBy { it.id }
}
return DeleteMangaMetasPayload(clientMutationId, allDeletedMetas, mangas)
}
}