Downloader Queries and Mutations (#610)

* Add downloader GraphQL endpoints

* Fix names

* DeleteDownloadedChapter(s)

* DequeueChapterDownload(s)
This commit is contained in:
Mitchell Syer
2023-08-03 18:08:47 -04:00
committed by GitHub
parent c3fb08d634
commit cdb083ff48
7 changed files with 356 additions and 31 deletions

View File

@@ -0,0 +1,260 @@
package suwayomi.tachidesk.graphql.mutations
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeout
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.types.ChapterType
import suwayomi.tachidesk.graphql.types.DownloadStatus
import suwayomi.tachidesk.manga.impl.Chapter
import suwayomi.tachidesk.manga.impl.download.DownloadManager
import suwayomi.tachidesk.manga.impl.download.model.Status
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.server.JavalinSetup.future
import java.util.concurrent.CompletableFuture
import kotlin.time.Duration.Companion.seconds
class DownloadMutation {
data class DeleteDownloadedChaptersInput(
val clientMutationId: String? = null,
val ids: List<Int>
)
data class DeleteDownloadedChaptersPayload(
val clientMutationId: String?,
val chapters: List<ChapterType>
)
fun deleteDownloadedChapters(input: DeleteDownloadedChaptersInput): DeleteDownloadedChaptersPayload {
val (clientMutationId, chapters) = input
Chapter.deleteChapters(chapters)
return DeleteDownloadedChaptersPayload(
clientMutationId = clientMutationId,
chapters = transaction {
ChapterTable.select { ChapterTable.id inList chapters }
.map { ChapterType(it) }
}
)
}
data class DeleteDownloadedChapterInput(
val clientMutationId: String? = null,
val id: Int
)
data class DeleteDownloadedChapterPayload(
val clientMutationId: String?,
val chapters: ChapterType
)
fun deleteDownloadedChapter(input: DeleteDownloadedChapterInput): DeleteDownloadedChapterPayload {
val (clientMutationId, chapter) = input
Chapter.deleteChapters(listOf(chapter))
return DeleteDownloadedChapterPayload(
clientMutationId = clientMutationId,
chapters = transaction {
ChapterType(ChapterTable.select { ChapterTable.id eq chapter }.first())
}
)
}
data class EnqueueChapterDownloadsInput(
val clientMutationId: String? = null,
val ids: List<Int>
)
data class EnqueueChapterDownloadsPayload(
val clientMutationId: String?,
val downloadStatus: DownloadStatus
)
fun enqueueChapterDownloads(
input: EnqueueChapterDownloadsInput
): CompletableFuture<EnqueueChapterDownloadsPayload> {
val (clientMutationId, chapters) = input
DownloadManager.enqueue(DownloadManager.EnqueueInput(chapters))
return future {
EnqueueChapterDownloadsPayload(
clientMutationId = clientMutationId,
downloadStatus = withTimeout(30.seconds) {
DownloadStatus(DownloadManager.status.first { it.queue.any { it.chapter.id in chapters } })
}
)
}
}
data class EnqueueChapterDownloadInput(
val clientMutationId: String? = null,
val id: Int
)
data class EnqueueChapterDownloadPayload(
val clientMutationId: String?,
val downloadStatus: DownloadStatus
)
fun enqueueChapterDownload(
input: EnqueueChapterDownloadInput
): CompletableFuture<EnqueueChapterDownloadPayload> {
val (clientMutationId, chapter) = input
DownloadManager.enqueue(DownloadManager.EnqueueInput(listOf(chapter)))
return future {
EnqueueChapterDownloadPayload(
clientMutationId = clientMutationId,
downloadStatus = withTimeout(30.seconds) {
DownloadStatus(DownloadManager.status.first { it.queue.any { it.chapter.id == chapter } })
}
)
}
}
data class DequeueChapterDownloadsInput(
val clientMutationId: String? = null,
val ids: List<Int>
)
data class DequeueChapterDownloadsPayload(
val clientMutationId: String?,
val downloadStatus: DownloadStatus
)
fun dequeueChapterDownloads(
input: DequeueChapterDownloadsInput
): CompletableFuture<DequeueChapterDownloadsPayload> {
val (clientMutationId, chapters) = input
DownloadManager.dequeue(DownloadManager.EnqueueInput(chapters))
return future {
DequeueChapterDownloadsPayload(
clientMutationId = clientMutationId,
downloadStatus = withTimeout(30.seconds) {
DownloadStatus(DownloadManager.status.first { it.queue.none { it.chapter.id in chapters } })
}
)
}
}
data class DequeueChapterDownloadInput(
val clientMutationId: String? = null,
val id: Int
)
data class DequeueChapterDownloadPayload(
val clientMutationId: String?,
val downloadStatus: DownloadStatus
)
fun dequeueChapterDownload(
input: DequeueChapterDownloadInput
): CompletableFuture<DequeueChapterDownloadPayload> {
val (clientMutationId, chapter) = input
DownloadManager.dequeue(DownloadManager.EnqueueInput(listOf(chapter)))
return future {
DequeueChapterDownloadPayload(
clientMutationId = clientMutationId,
downloadStatus = withTimeout(30.seconds) {
DownloadStatus(DownloadManager.status.first { it.queue.none { it.chapter.id == chapter } })
}
)
}
}
data class StartDownloaderInput(
val clientMutationId: String? = null
)
data class StartDownloaderPayload(
val clientMutationId: String?,
val downloadStatus: DownloadStatus
)
fun startDownloader(input: StartDownloaderInput): CompletableFuture<StartDownloaderPayload> {
DownloadManager.start()
return future {
StartDownloaderPayload(
input.clientMutationId,
downloadStatus = withTimeout(30.seconds) {
DownloadStatus(
DownloadManager.status.first { it.status == Status.Started }
)
}
)
}
}
data class StopDownloaderInput(
val clientMutationId: String? = null
)
data class StopDownloaderPayload(
val clientMutationId: String?,
val downloadStatus: DownloadStatus
)
fun stopDownloader(input: StopDownloaderInput): CompletableFuture<StopDownloaderPayload> {
return future {
DownloadManager.stop()
StopDownloaderPayload(
input.clientMutationId,
downloadStatus = withTimeout(30.seconds) {
DownloadStatus(
DownloadManager.status.first { it.status == Status.Stopped }
)
}
)
}
}
data class ClearDownloaderInput(
val clientMutationId: String? = null
)
data class ClearDownloaderPayload(
val clientMutationId: String?,
val downloadStatus: DownloadStatus
)
fun clearDownloader(input: ClearDownloaderInput): CompletableFuture<ClearDownloaderPayload> {
return future {
DownloadManager.clear()
ClearDownloaderPayload(
input.clientMutationId,
downloadStatus = withTimeout(30.seconds) {
DownloadStatus(
DownloadManager.status.first { it.status == Status.Stopped && it.queue.isEmpty() }
)
}
)
}
}
data class ReorderChapterDownloadInput(
val clientMutationId: String? = null,
val chapterId: Int,
val to: Int
)
data class ReorderChapterDownloadPayload(
val clientMutationId: String?,
val downloadStatus: DownloadStatus
)
fun reorderChapterDownload(input: ReorderChapterDownloadInput): CompletableFuture<ReorderChapterDownloadPayload> {
val (clientMutationId, chapter, to) = input
DownloadManager.reorder(chapter, to)
return future {
ReorderChapterDownloadPayload(
clientMutationId,
downloadStatus = withTimeout(30.seconds) {
DownloadStatus(
DownloadManager.status.first { it.queue.indexOfFirst { it.chapter.id == chapter } <= to }
)
}
)
}
}
}

View File

@@ -0,0 +1,11 @@
package suwayomi.tachidesk.graphql.queries
import suwayomi.tachidesk.graphql.types.DownloadStatus
import suwayomi.tachidesk.manga.impl.download.DownloadManager
class DownloadQuery {
fun downloadStatus(): DownloadStatus {
return DownloadStatus(DownloadManager.status.value)
}
}

View File

@@ -16,6 +16,7 @@ import io.javalin.http.UploadedFile
import suwayomi.tachidesk.graphql.mutations.BackupMutation import suwayomi.tachidesk.graphql.mutations.BackupMutation
import suwayomi.tachidesk.graphql.mutations.CategoryMutation import suwayomi.tachidesk.graphql.mutations.CategoryMutation
import suwayomi.tachidesk.graphql.mutations.ChapterMutation import suwayomi.tachidesk.graphql.mutations.ChapterMutation
import suwayomi.tachidesk.graphql.mutations.DownloadMutation
import suwayomi.tachidesk.graphql.mutations.ExtensionMutation 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
@@ -24,6 +25,7 @@ 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
import suwayomi.tachidesk.graphql.queries.DownloadQuery
import suwayomi.tachidesk.graphql.queries.ExtensionQuery 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
@@ -57,6 +59,7 @@ val schema = toSchema(
TopLevelObject(BackupQuery()), TopLevelObject(BackupQuery()),
TopLevelObject(CategoryQuery()), TopLevelObject(CategoryQuery()),
TopLevelObject(ChapterQuery()), TopLevelObject(ChapterQuery()),
TopLevelObject(DownloadQuery()),
TopLevelObject(ExtensionQuery()), TopLevelObject(ExtensionQuery()),
TopLevelObject(MangaQuery()), TopLevelObject(MangaQuery()),
TopLevelObject(MetaQuery()), TopLevelObject(MetaQuery()),
@@ -67,6 +70,7 @@ val schema = toSchema(
TopLevelObject(BackupMutation()), TopLevelObject(BackupMutation()),
TopLevelObject(CategoryMutation()), TopLevelObject(CategoryMutation()),
TopLevelObject(ChapterMutation()), TopLevelObject(ChapterMutation()),
TopLevelObject(DownloadMutation()),
TopLevelObject(ExtensionMutation()), TopLevelObject(ExtensionMutation()),
TopLevelObject(MangaMutation()), TopLevelObject(MangaMutation()),
TopLevelObject(MetaMutation()), TopLevelObject(MetaMutation()),

View File

@@ -7,19 +7,16 @@
package suwayomi.tachidesk.graphql.subscriptions package suwayomi.tachidesk.graphql.subscriptions
import graphql.schema.DataFetchingEnvironment
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import suwayomi.tachidesk.graphql.server.subscriptions.FlowSubscriptionSource import suwayomi.tachidesk.graphql.types.DownloadStatus
import suwayomi.tachidesk.graphql.types.DownloadType import suwayomi.tachidesk.manga.impl.download.DownloadManager
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
val downloadSubscriptionSource = FlowSubscriptionSource<DownloadChapter>()
class DownloadSubscription { class DownloadSubscription {
fun downloadChanged(dataFetchingEnvironment: DataFetchingEnvironment): Flow<DownloadType> {
return downloadSubscriptionSource.emitter.map { downloadChapter -> fun downloadChanged(): Flow<DownloadStatus> {
DownloadType(downloadChapter) return DownloadManager.status.map { downloadStatus ->
DownloadStatus(downloadStatus)
} }
} }
} }

View File

@@ -7,6 +7,7 @@
package suwayomi.tachidesk.graphql.types package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.generator.annotations.GraphQLIgnore
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment import graphql.schema.DataFetchingEnvironment
import suwayomi.tachidesk.graphql.server.primitives.Cursor import suwayomi.tachidesk.graphql.server.primitives.Cursor
@@ -15,20 +16,42 @@ import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.graphql.server.primitives.NodeList import suwayomi.tachidesk.graphql.server.primitives.NodeList
import suwayomi.tachidesk.graphql.server.primitives.PageInfo import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
import suwayomi.tachidesk.manga.impl.download.model.DownloadState import suwayomi.tachidesk.manga.impl.download.model.DownloadStatus
import suwayomi.tachidesk.manga.impl.download.model.Status
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
import suwayomi.tachidesk.manga.impl.download.model.DownloadState as OtherDownloadState
data class DownloadStatus(
val state: DownloaderState,
val queue: List<DownloadType>
) {
constructor(downloadStatus: DownloadStatus) : this(
when (downloadStatus.status) {
Status.Stopped -> DownloaderState.STOPPED
Status.Started -> DownloaderState.STARTED
},
downloadStatus.queue.map { DownloadType(it) }
)
}
class DownloadType( class DownloadType(
@get:GraphQLIgnore
val chapterId: Int, val chapterId: Int,
@get:GraphQLIgnore
val mangaId: Int, val mangaId: Int,
var state: DownloadState = DownloadState.Queued, val state: DownloadState,
var progress: Float = 0f, val progress: Float,
var tries: Int = 0 val tries: Int
) : Node { ) : Node {
constructor(downloadChapter: DownloadChapter) : this( constructor(downloadChapter: DownloadChapter) : this(
downloadChapter.chapter.id, downloadChapter.chapter.id,
downloadChapter.mangaId, downloadChapter.mangaId,
downloadChapter.state, when (downloadChapter.state) {
OtherDownloadState.Queued -> DownloadState.QUEUED
OtherDownloadState.Downloading -> DownloadState.DOWNLOADING
OtherDownloadState.Finished -> DownloadState.FINISHED
OtherDownloadState.Error -> DownloadState.ERROR
},
downloadChapter.progress, downloadChapter.progress,
downloadChapter.tries downloadChapter.tries
) )
@@ -42,6 +65,18 @@ class DownloadType(
} }
} }
enum class DownloadState {
QUEUED,
DOWNLOADING,
FINISHED,
ERROR
}
enum class DownloaderState {
STARTED,
STOPPED
}
data class DownloadNodeList( data class DownloadNodeList(
override val nodes: List<DownloadType>, override val nodes: List<DownloadType>,
override val edges: List<DownloadEdge>, override val edges: List<DownloadEdge>,

View File

@@ -393,21 +393,7 @@ object Chapter {
private fun deleteChapters(input: MangaChapterBatchEditInput, mangaId: Int? = null) { private fun deleteChapters(input: MangaChapterBatchEditInput, mangaId: Int? = null) {
if (input.chapterIds != null) { if (input.chapterIds != null) {
val chapterIds = input.chapterIds deleteChapters(input.chapterIds)
transaction {
ChapterTable.slice(ChapterTable.manga, ChapterTable.id)
.select { ChapterTable.id inList chapterIds }
.forEach { row ->
val chapterMangaId = row[ChapterTable.manga].value
val chapterId = row[ChapterTable.id].value
ChapterDownloadHelper.delete(chapterMangaId, chapterId)
}
ChapterTable.update({ ChapterTable.id inList chapterIds }) {
it[isDownloaded] = false
}
}
} else if (input.chapterIndexes != null && mangaId != null) { } else if (input.chapterIndexes != null && mangaId != null) {
transaction { transaction {
val chapterIds = ChapterTable.slice(ChapterTable.manga, ChapterTable.id) val chapterIds = ChapterTable.slice(ChapterTable.manga, ChapterTable.id)
@@ -426,6 +412,22 @@ object Chapter {
} }
} }
fun deleteChapters(chapterIds: List<Int>) {
transaction {
ChapterTable.slice(ChapterTable.manga, ChapterTable.id)
.select { ChapterTable.id inList chapterIds }
.forEach { row ->
val chapterMangaId = row[ChapterTable.manga].value
val chapterId = row[ChapterTable.id].value
ChapterDownloadHelper.delete(chapterMangaId, chapterId)
}
ChapterTable.update({ ChapterTable.id inList chapterIds }) {
it[isDownloaded] = false
}
}
}
fun getRecentChapters(pageNum: Int): PaginatedList<MangaChapterDataClass> { fun getRecentChapters(pageNum: Int): PaginatedList<MangaChapterDataClass> {
return paginatedFrom(pageNum) { return paginatedFrom(pageNum) {
transaction { transaction {

View File

@@ -19,14 +19,16 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import mu.KotlinLogging import mu.KotlinLogging
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.subscriptions.downloadSubscriptionSource
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
import suwayomi.tachidesk.manga.impl.download.model.DownloadState.Downloading import suwayomi.tachidesk.manga.impl.download.model.DownloadState.Downloading
import suwayomi.tachidesk.manga.impl.download.model.DownloadState.Error import suwayomi.tachidesk.manga.impl.download.model.DownloadState.Error
@@ -108,6 +110,12 @@ object DownloadManager {
private val notifyFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) private val notifyFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val status = notifyFlow.sample(1.seconds)
.map {
getStatus()
}
.stateIn(scope, SharingStarted.Eagerly, getStatus())
init { init {
scope.launch { scope.launch {
notifyFlow.sample(1.seconds).collect { notifyFlow.sample(1.seconds).collect {
@@ -268,7 +276,6 @@ object DownloadManager {
) )
downloadQueue.add(newDownloadChapter) downloadQueue.add(newDownloadChapter)
saveDownloadQueue() saveDownloadQueue()
downloadSubscriptionSource.publish(newDownloadChapter)
logger.debug { "Added chapter ${chapter.id} to download queue ($newDownloadChapter)" } logger.debug { "Added chapter ${chapter.id} to download queue ($newDownloadChapter)" }
return newDownloadChapter return newDownloadChapter
} }
@@ -317,6 +324,15 @@ object DownloadManager {
saveDownloadQueue() saveDownloadQueue()
} }
fun reorder(chapterId: Int, to: Int) {
require(to >= 0) { "'to' must be over or equal to 0" }
val download = downloadQueue.find { it.chapter.id == chapterId }
?: return
downloadQueue -= download
downloadQueue.add(to, download)
saveDownloadQueue()
}
fun start() { fun start() {
logger.debug { "start" } logger.debug { "start" }