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

View File

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

View File

@@ -7,6 +7,7 @@
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.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.PageInfo
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 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(
@get:GraphQLIgnore
val chapterId: Int,
@get:GraphQLIgnore
val mangaId: Int,
var state: DownloadState = DownloadState.Queued,
var progress: Float = 0f,
var tries: Int = 0
val state: DownloadState,
val progress: Float,
val tries: Int
) : Node {
constructor(downloadChapter: DownloadChapter) : this(
downloadChapter.chapter.id,
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.tries
)
@@ -42,6 +65,18 @@ class DownloadType(
}
}
enum class DownloadState {
QUEUED,
DOWNLOADING,
FINISHED,
ERROR
}
enum class DownloaderState {
STARTED,
STOPPED
}
data class DownloadNodeList(
override val nodes: List<DownloadType>,
override val edges: List<DownloadEdge>,

View File

@@ -393,21 +393,7 @@ object Chapter {
private fun deleteChapters(input: MangaChapterBatchEditInput, mangaId: Int? = null) {
if (input.chapterIds != null) {
val chapterIds = 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
}
}
deleteChapters(input.chapterIds)
} else if (input.chapterIndexes != null && mangaId != null) {
transaction {
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> {
return paginatedFrom(pageNum) {
transaction {

View File

@@ -19,14 +19,16 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import mu.KotlinLogging
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.select
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.DownloadState.Downloading
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)
val status = notifyFlow.sample(1.seconds)
.map {
getStatus()
}
.stateIn(scope, SharingStarted.Eagerly, getStatus())
init {
scope.launch {
notifyFlow.sample(1.seconds).collect {
@@ -268,7 +276,6 @@ object DownloadManager {
)
downloadQueue.add(newDownloadChapter)
saveDownloadQueue()
downloadSubscriptionSource.publish(newDownloadChapter)
logger.debug { "Added chapter ${chapter.id} to download queue ($newDownloadChapter)" }
return newDownloadChapter
}
@@ -317,6 +324,15 @@ object DownloadManager {
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() {
logger.debug { "start" }