diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/DownloadMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/DownloadMutation.kt new file mode 100644 index 00000000..a7741bcc --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/DownloadMutation.kt @@ -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 + ) + data class DeleteDownloadedChaptersPayload( + val clientMutationId: String?, + val chapters: List + ) + + 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 + ) + data class EnqueueChapterDownloadsPayload( + val clientMutationId: String?, + val downloadStatus: DownloadStatus + ) + + fun enqueueChapterDownloads( + input: EnqueueChapterDownloadsInput + ): CompletableFuture { + 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 { + 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 + ) + data class DequeueChapterDownloadsPayload( + val clientMutationId: String?, + val downloadStatus: DownloadStatus + ) + + fun dequeueChapterDownloads( + input: DequeueChapterDownloadsInput + ): CompletableFuture { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 } + ) + } + ) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/DownloadQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/DownloadQuery.kt new file mode 100644 index 00000000..038e8765 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/DownloadQuery.kt @@ -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) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt index 036f2882..dc4b9c4b 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt @@ -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()), diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/subscriptions/DownloadSubscription.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/subscriptions/DownloadSubscription.kt index deb9bacc..15b01c1b 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/subscriptions/DownloadSubscription.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/subscriptions/DownloadSubscription.kt @@ -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() +import suwayomi.tachidesk.graphql.types.DownloadStatus +import suwayomi.tachidesk.manga.impl.download.DownloadManager class DownloadSubscription { - fun downloadChanged(dataFetchingEnvironment: DataFetchingEnvironment): Flow { - return downloadSubscriptionSource.emitter.map { downloadChapter -> - DownloadType(downloadChapter) + + fun downloadChanged(): Flow { + return DownloadManager.status.map { downloadStatus -> + DownloadStatus(downloadStatus) } } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/DownloadType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/DownloadType.kt index ab9a1bb1..9acddd37 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/DownloadType.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/DownloadType.kt @@ -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 +) { + 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, override val edges: List, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt index 9b621616..d4acd712 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt @@ -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) { + 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 { return paginatedFrom(pageNum) { transaction { diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/DownloadManager.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/DownloadManager.kt index 0173ed6e..d7a9c9e5 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/DownloadManager.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/DownloadManager.kt @@ -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(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" }