mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-04 03:14:40 -05:00
add batch download api (#436)
* Add POST /downloads endpoint for creating multiple * Fix review notes * Add chapter id to API endpoints * Rewrite batch chapter download to use chapter id instead of mangaId+chapterIndex combination * Change EnqueueInput format to be more futureproof * Change endpoint path * Change endpoint path
This commit is contained in:
@@ -110,6 +110,7 @@ object MangaAPI {
|
|||||||
path("download") {
|
path("download") {
|
||||||
get("{mangaId}/chapter/{chapterIndex}", DownloadController.queueChapter)
|
get("{mangaId}/chapter/{chapterIndex}", DownloadController.queueChapter)
|
||||||
delete("{mangaId}/chapter/{chapterIndex}", DownloadController.unqueueChapter)
|
delete("{mangaId}/chapter/{chapterIndex}", DownloadController.unqueueChapter)
|
||||||
|
post("batch", DownloadController.queueChapters)
|
||||||
}
|
}
|
||||||
|
|
||||||
path("update") {
|
path("update") {
|
||||||
|
|||||||
@@ -9,13 +9,21 @@ package suwayomi.tachidesk.manga.controller
|
|||||||
|
|
||||||
import io.javalin.http.HttpCode
|
import io.javalin.http.HttpCode
|
||||||
import io.javalin.websocket.WsConfig
|
import io.javalin.websocket.WsConfig
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.kodein.di.DI
|
||||||
|
import org.kodein.di.conf.global
|
||||||
|
import org.kodein.di.instance
|
||||||
import suwayomi.tachidesk.manga.impl.download.DownloadManager
|
import suwayomi.tachidesk.manga.impl.download.DownloadManager
|
||||||
|
import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput
|
||||||
import suwayomi.tachidesk.server.JavalinSetup.future
|
import suwayomi.tachidesk.server.JavalinSetup.future
|
||||||
import suwayomi.tachidesk.server.util.handler
|
import suwayomi.tachidesk.server.util.handler
|
||||||
import suwayomi.tachidesk.server.util.pathParam
|
import suwayomi.tachidesk.server.util.pathParam
|
||||||
import suwayomi.tachidesk.server.util.withOperation
|
import suwayomi.tachidesk.server.util.withOperation
|
||||||
|
|
||||||
object DownloadController {
|
object DownloadController {
|
||||||
|
private val json by DI.global.instance<Json>()
|
||||||
|
|
||||||
/** Download queue stats */
|
/** Download queue stats */
|
||||||
fun downloadsWS(ws: WsConfig) {
|
fun downloadsWS(ws: WsConfig) {
|
||||||
ws.onConnect { ctx ->
|
ws.onConnect { ctx ->
|
||||||
@@ -84,20 +92,20 @@ object DownloadController {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Queue chapter for download */
|
/** Queue single chapter for download */
|
||||||
val queueChapter = handler(
|
val queueChapter = handler(
|
||||||
pathParam<Int>("chapterIndex"),
|
pathParam<Int>("chapterIndex"),
|
||||||
pathParam<Int>("mangaId"),
|
pathParam<Int>("mangaId"),
|
||||||
documentWith = {
|
documentWith = {
|
||||||
withOperation {
|
withOperation {
|
||||||
summary("Downloader add chapter")
|
summary("Downloader add single chapter")
|
||||||
description("Queue chapter for download")
|
description("Queue single chapter for download")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
behaviorOf = { ctx, chapterIndex, mangaId ->
|
behaviorOf = { ctx, chapterIndex, mangaId ->
|
||||||
ctx.future(
|
ctx.future(
|
||||||
future {
|
future {
|
||||||
DownloadManager.enqueue(chapterIndex, mangaId)
|
DownloadManager.enqueueWithChapterIndex(mangaId, chapterIndex)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -107,6 +115,27 @@ object DownloadController {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val queueChapters = handler(
|
||||||
|
documentWith = {
|
||||||
|
withOperation {
|
||||||
|
summary("Downloader add multiple chapters")
|
||||||
|
description("Queue multiple chapters for download")
|
||||||
|
}
|
||||||
|
body<EnqueueInput>()
|
||||||
|
},
|
||||||
|
behaviorOf = { ctx ->
|
||||||
|
val inputs = json.decodeFromString<EnqueueInput>(ctx.body())
|
||||||
|
ctx.future(
|
||||||
|
future {
|
||||||
|
DownloadManager.enqueue(inputs)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
withResults = {
|
||||||
|
httpCode(HttpCode.OK)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
/** delete chapter from download queue */
|
/** delete chapter from download queue */
|
||||||
val unqueueChapter = handler(
|
val unqueueChapter = handler(
|
||||||
pathParam<Int>("chapterIndex"),
|
pathParam<Int>("chapterIndex"),
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ object Chapter {
|
|||||||
val dbChapter = dbChapterMap.getValue(it.url)
|
val dbChapter = dbChapterMap.getValue(it.url)
|
||||||
|
|
||||||
ChapterDataClass(
|
ChapterDataClass(
|
||||||
|
dbChapter[ChapterTable.id].value,
|
||||||
it.url,
|
it.url,
|
||||||
it.name,
|
it.name,
|
||||||
it.date_upload,
|
it.date_upload,
|
||||||
|
|||||||
@@ -9,18 +9,24 @@ package suwayomi.tachidesk.manga.impl.download
|
|||||||
|
|
||||||
import io.javalin.websocket.WsContext
|
import io.javalin.websocket.WsContext
|
||||||
import io.javalin.websocket.WsMessageContext
|
import io.javalin.websocket.WsMessageContext
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
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.manga.impl.Manga.getManga
|
|
||||||
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.DownloadStatus
|
import suwayomi.tachidesk.manga.impl.download.model.DownloadStatus
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||||
|
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||||
import suwayomi.tachidesk.manga.model.table.toDataClass
|
import suwayomi.tachidesk.manga.model.table.toDataClass
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
object DownloadManager {
|
object DownloadManager {
|
||||||
private val clients = ConcurrentHashMap<String, WsContext>()
|
private val clients = ConcurrentHashMap<String, WsContext>()
|
||||||
private val downloadQueue = CopyOnWriteArrayList<DownloadChapter>()
|
private val downloadQueue = CopyOnWriteArrayList<DownloadChapter>()
|
||||||
@@ -75,24 +81,81 @@ object DownloadManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun enqueue(chapterIndex: Int, mangaId: Int) {
|
fun enqueueWithChapterIndex(mangaId: Int, chapterIndex: Int) {
|
||||||
if (downloadQueue.none { it.mangaId == mangaId && it.chapterIndex == chapterIndex }) {
|
val chapter = transaction {
|
||||||
downloadQueue.add(
|
ChapterTable
|
||||||
DownloadChapter(
|
.slice(ChapterTable.id)
|
||||||
chapterIndex,
|
.select { ChapterTable.manga.eq(mangaId) and ChapterTable.sourceOrder.eq(chapterIndex) }
|
||||||
mangaId,
|
.first()
|
||||||
chapter = ChapterTable.toDataClass(
|
|
||||||
transaction {
|
|
||||||
ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }
|
|
||||||
.first()
|
|
||||||
}
|
|
||||||
),
|
|
||||||
manga = getManga(mangaId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
start()
|
|
||||||
}
|
}
|
||||||
notifyAllClients()
|
enqueue(EnqueueInput(chapterIds = listOf(chapter[ChapterTable.id].value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
// Input might have additional formats in the future, such as "All for mangaID" or "Unread for categoryID"
|
||||||
|
// Having this input format is just future-proofing
|
||||||
|
data class EnqueueInput(
|
||||||
|
val chapterIds: List<Int>?
|
||||||
|
)
|
||||||
|
|
||||||
|
fun enqueue(input: EnqueueInput) {
|
||||||
|
if (input.chapterIds == null) return
|
||||||
|
|
||||||
|
val chapters = transaction {
|
||||||
|
(ChapterTable innerJoin MangaTable)
|
||||||
|
.select { ChapterTable.id inList input.chapterIds }
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val mangas = transaction {
|
||||||
|
chapters.distinctBy { chapter -> chapter[MangaTable.id] }
|
||||||
|
.map { MangaTable.toDataClass(it) }
|
||||||
|
.associateBy { it.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
val inputPairs = transaction {
|
||||||
|
chapters.map {
|
||||||
|
Pair(
|
||||||
|
// this should be safe because mangas is created above from chapters
|
||||||
|
mangas[it[ChapterTable.manga].value]!!,
|
||||||
|
ChapterTable.toDataClass(it)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addMultipleToQueue(inputPairs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries to add multiple inputs to queue
|
||||||
|
* If any of inputs was actually added to queue, starts the queue
|
||||||
|
*/
|
||||||
|
private fun addMultipleToQueue(inputs: List<Pair<MangaDataClass, ChapterDataClass>>) {
|
||||||
|
val addedChapters = inputs.mapNotNull { addToQueue(it.first, it.second) }
|
||||||
|
if (addedChapters.isNotEmpty()) {
|
||||||
|
start()
|
||||||
|
notifyAllClients()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries to add chapter to queue.
|
||||||
|
* If chapter is added, returns the created DownloadChapter, otherwise returns null
|
||||||
|
*/
|
||||||
|
private fun addToQueue(manga: MangaDataClass, chapter: ChapterDataClass): DownloadChapter? {
|
||||||
|
if (downloadQueue.none { it.mangaId == manga.id && it.chapterIndex == chapter.index }) {
|
||||||
|
val downloadChapter = DownloadChapter(
|
||||||
|
chapter.index,
|
||||||
|
manga.id,
|
||||||
|
chapter,
|
||||||
|
manga
|
||||||
|
)
|
||||||
|
downloadQueue.add(downloadChapter)
|
||||||
|
logger.debug { "Added chapter ${chapter.id} to download queue (${manga.title} | ${chapter.name})" }
|
||||||
|
return downloadChapter
|
||||||
|
}
|
||||||
|
logger.debug { "Chapter ${chapter.id} already present in queue (${manga.title} | ${chapter.name})" }
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unqueue(chapterIndex: Int, mangaId: Int) {
|
fun unqueue(chapterIndex: Int, mangaId: Int) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ package suwayomi.tachidesk.manga.model.dataclass
|
|||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
data class ChapterDataClass(
|
data class ChapterDataClass(
|
||||||
|
val id: Int,
|
||||||
val url: String,
|
val url: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val uploadDate: Long,
|
val uploadDate: Long,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ object ChapterTable : IntIdTable() {
|
|||||||
|
|
||||||
fun ChapterTable.toDataClass(chapterEntry: ResultRow) =
|
fun ChapterTable.toDataClass(chapterEntry: ResultRow) =
|
||||||
ChapterDataClass(
|
ChapterDataClass(
|
||||||
|
chapterEntry[id].value,
|
||||||
chapterEntry[url],
|
chapterEntry[url],
|
||||||
chapterEntry[name],
|
chapterEntry[name],
|
||||||
chapterEntry[date_upload],
|
chapterEntry[date_upload],
|
||||||
|
|||||||
Reference in New Issue
Block a user