diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/DownloadMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/DownloadMutation.kt index a7741bcc..dfc94b51 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/DownloadMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/DownloadMutation.kt @@ -7,6 +7,7 @@ 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.Manga import suwayomi.tachidesk.manga.impl.download.DownloadManager import suwayomi.tachidesk.manga.impl.download.model.Status import suwayomi.tachidesk.manga.model.table.ChapterTable @@ -257,4 +258,20 @@ class DownloadMutation { ) } } + + data class DownloadAheadInput( + val clientMutationId: String? = null, + val mangaIds: List = emptyList(), + val latestReadChapterIds: List? = null + ) + + data class DownloadAheadPayload(val clientMutationId: String?) + + fun downloadAhead(input: DownloadAheadInput): DownloadAheadPayload { + val (clientMutationId, mangaIds, latestReadChapterIds) = input + + Manga.downloadAhead(mangaIds, latestReadChapterIds ?: emptyList()) + + return DownloadAheadPayload(clientMutationId) + } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt index 13296f4c..5c837cc7 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt @@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.source.local.LocalSource import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.UpdateStrategy import eu.kanade.tachiyomi.source.online.HttpSource +import mu.KotlinLogging import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.and @@ -25,6 +26,8 @@ import org.kodein.di.conf.global import org.kodein.di.instance import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl import suwayomi.tachidesk.manga.impl.Source.getSource +import suwayomi.tachidesk.manga.impl.download.DownloadManager +import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput import suwayomi.tachidesk.manga.impl.download.fileProvider.impl.MissingThumbnailException import suwayomi.tachidesk.manga.impl.util.network.await import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull @@ -42,11 +45,17 @@ import suwayomi.tachidesk.manga.model.table.MangaStatus import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.toDataClass import suwayomi.tachidesk.server.ApplicationDirs +import suwayomi.tachidesk.server.serverConfig import uy.kohesive.injekt.injectLazy import java.io.File import java.io.IOException import java.io.InputStream import java.time.Instant +import java.util.Timer +import java.util.TimerTask +import java.util.concurrent.ConcurrentHashMap + +private val logger = KotlinLogging.logger { } object Manga { private fun truncate(text: String?, maxLength: Int): String? { @@ -307,4 +316,87 @@ object Manga { clearCachedImage(applicationDirs.tempThumbnailCacheRoot, fileName) clearCachedImage(applicationDirs.thumbnailDownloadsRoot, fileName) } + + private val downloadAheadQueue = ConcurrentHashMap>() + private var downloadAheadTimer: Timer? = null + fun downloadAhead(mangaIds: List, latestReadChapterIds: List = emptyList()) { + if (serverConfig.autoDownloadAheadLimit.value == 0) { + return + } + + val MANGAS_KEY = "mangaIds" + val CHAPTERS_KEY = "chapterIds" + + val updateDownloadAheadQueue = { key: String, ids: List -> + val idSet = downloadAheadQueue[key] ?: ConcurrentHashMap.newKeySet() + idSet.addAll(ids) + downloadAheadQueue[key] = idSet + } + + updateDownloadAheadQueue(MANGAS_KEY, mangaIds) + updateDownloadAheadQueue(CHAPTERS_KEY, latestReadChapterIds) + + // handle cases where this function gets called multiple times in quick succession. + // this could happen in case e.g. multiple chapters get marked as read without batching the operation + downloadAheadTimer?.cancel() + downloadAheadTimer = Timer().apply { + schedule( + object : TimerTask() { + override fun run() { + downloadAheadChapters(downloadAheadQueue[MANGAS_KEY]!!.toList(), downloadAheadQueue[CHAPTERS_KEY]!!.toList()) + downloadAheadQueue.clear() + } + }, + 5000 + ) + } + } + + /** + * Downloads the latest unread and not downloaded chapters for each passed manga id. + * + * To pass a specific chapter as the latest read chapter for a manga, it can be provided in the "latestReadChapterIds" list. + * This makes it possible to handle cases, where the actual latest read chapter isn't marked as read yet. + * E.g. the client marks a chapter as read and at the same time sends the "downloadAhead" mutation. + * In this case, the latest read chapter could potentially be the one, that just got send to get marked as read by the client. + * Without providing it in "latestReadChapterIds" it could be incorrectly included in the chapters, that will get downloaded. + * + * The latest read chapter will be considered the starting point. + * E.g.: + * - 20 chapters + * - chapter 15 marked as read + * - 16 - 20 marked as unread + * - 10 - 14 marked as unread + * + * will download the unread chapters starting from chapter 15 + */ + private fun downloadAheadChapters(mangaIds: List, latestReadChapterIds: List) { + val mangaToLatestReadChapterIndex = transaction { + ChapterTable.select { (ChapterTable.manga inList mangaIds) and (ChapterTable.isRead eq true) } + .orderBy(ChapterTable.sourceOrder to SortOrder.DESC).groupBy { it[ChapterTable.manga].value } + }.mapValues { (_, chapters) -> chapters.firstOrNull()?.let { it[ChapterTable.sourceOrder] } ?: 0 } + + val mangaToUnreadChaptersMap = transaction { + ChapterTable.select { (ChapterTable.manga inList mangaIds) and (ChapterTable.isRead eq false) } + .orderBy(ChapterTable.sourceOrder to SortOrder.DESC) + .groupBy { it[ChapterTable.manga].value } + } + + val chapterIdsToDownload = mangaToUnreadChaptersMap.map { (mangaId, unreadChapters) -> + val latestReadChapterIndex = mangaToLatestReadChapterIndex[mangaId] ?: 0 + val lastChapterToDownloadIndex = + unreadChapters.indexOfLast { it[ChapterTable.sourceOrder] > latestReadChapterIndex && it[ChapterTable.id].value !in latestReadChapterIds } + val unreadChaptersToConsider = unreadChapters.subList(0, lastChapterToDownloadIndex + 1) + val firstChapterToDownloadIndex = + (unreadChaptersToConsider.size - serverConfig.autoDownloadAheadLimit.value).coerceAtLeast(0) + unreadChaptersToConsider.subList(firstChapterToDownloadIndex, lastChapterToDownloadIndex + 1) + .filter { !it[ChapterTable.isDownloaded] } + .map { it[ChapterTable.id].value } + }.flatten() + + logger.info { "downloadAheadChapters: download chapters [${chapterIdsToDownload.joinToString(", ")}]" } + + DownloadManager.dequeue(mangaIds, chapterIdsToDownload) + DownloadManager.enqueue(EnqueueInput(chapterIdsToDownload)) + } } 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 c630b430..d59e782d 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 @@ -329,6 +329,10 @@ object DownloadManager { dequeue(downloadQueue.filter { it.mangaId == mangaId && it.chapterIndex == chapterIndex }.toSet()) } + fun dequeue(mangaIds: List, chaptersToIgnore: List = emptyList()) { + dequeue(downloadQueue.filter { it.mangaId in mangaIds && it.chapter.id !in chaptersToIgnore }.toSet()) + } + private fun dequeue(chapterDownloads: Set) { logger.debug { "dequeue ${chapterDownloads.size} chapters [${chapterDownloads.joinToString(separator = ", ") { "$it" }}]" }