Files
Suwayomi-Server/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt
T
renovate[bot] c117d380a3 Update exposed to v1 (major) (#1868)
* Update exposed to v1

* Update Exposed

* Add Kotlinx.DateTime extensions

* Update H2

* Review comments

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Syer10 <syer10@users.noreply.github.com>
2026-05-12 12:53:41 -04:00

550 lines
21 KiB
Kotlin

package suwayomi.tachidesk.manga.controller
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import io.javalin.http.HandlerType
import io.javalin.http.HttpStatus
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import suwayomi.tachidesk.manga.impl.CategoryManga
import suwayomi.tachidesk.manga.impl.Chapter
import suwayomi.tachidesk.manga.impl.ChapterDownloadHelper
import suwayomi.tachidesk.manga.impl.Library
import suwayomi.tachidesk.manga.impl.Manga
import suwayomi.tachidesk.manga.impl.Page
import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReadyByIndex
import suwayomi.tachidesk.manga.impl.sync.KoreaderSyncService
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.server.JavalinSetup.Attribute
import suwayomi.tachidesk.server.JavalinSetup.future
import suwayomi.tachidesk.server.JavalinSetup.getAttribute
import suwayomi.tachidesk.server.serverConfig
import suwayomi.tachidesk.server.user.requireUser
import suwayomi.tachidesk.server.user.requireUserWithBasicFallback
import suwayomi.tachidesk.server.util.formParam
import suwayomi.tachidesk.server.util.handler
import suwayomi.tachidesk.server.util.pathParam
import suwayomi.tachidesk.server.util.queryParam
import suwayomi.tachidesk.server.util.withOperation
import uy.kohesive.injekt.injectLazy
import kotlin.time.Duration.Companion.days
object MangaController {
private val json: Json by injectLazy()
val retrieve =
handler(
pathParam<Int>("mangaId"),
queryParam("onlineFetch", false),
documentWith = {
withOperation {
summary("Get manga info")
description("Get a manga from the database using a specific id.")
}
},
behaviorOf = { ctx, mangaId, onlineFetch ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.future {
future {
Manga.getManga(mangaId, onlineFetch)
}.thenApply { ctx.json(it) }
}
},
withResults = {
json<MangaDataClass>(HttpStatus.OK)
httpCode(HttpStatus.NOT_FOUND)
},
)
/** get manga info with all data filled in */
val retrieveFull =
handler(
pathParam<Int>("mangaId"),
queryParam("onlineFetch", false),
documentWith = {
withOperation {
summary("Get manga info with all data filled in")
description("Get a manga from the database using a specific id.")
}
},
behaviorOf = { ctx, mangaId, onlineFetch ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.future {
future {
Manga.getMangaFull(mangaId, onlineFetch)
}.thenApply { ctx.json(it) }
}
},
withResults = {
json<MangaDataClass>(HttpStatus.OK)
httpCode(HttpStatus.NOT_FOUND)
},
)
/** manga thumbnail */
val thumbnail =
handler(
pathParam<Int>("mangaId"),
documentWith = {
withOperation {
summary("Get a manga thumbnail")
description("Get a manga thumbnail from the source or the cache.")
}
},
behaviorOf = { ctx, mangaId ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.future {
future { Manga.getMangaThumbnail(mangaId) }
.thenApply {
ctx.header("content-type", it.second)
val httpCacheSeconds = 1.days.inWholeSeconds
ctx.header("cache-control", "max-age=$httpCacheSeconds")
ctx.result(it.first)
}
}
},
withResults = {
image(HttpStatus.OK)
httpCode(HttpStatus.NOT_FOUND)
},
)
/** adds the manga to library */
val addToLibrary =
handler(
pathParam<Int>("mangaId"),
documentWith = {
withOperation {
summary("Add manga to library")
description("Use a manga id to add the manga to your library.\nWill do nothing if manga is already in your library.")
}
},
behaviorOf = { ctx, mangaId ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.future {
future { Library.addMangaToLibrary(mangaId) }
.thenApply { ctx.status(HttpStatus.OK) }
}
},
withResults = {
httpCode(HttpStatus.OK)
httpCode(HttpStatus.NOT_FOUND)
},
)
/** removes the manga from the library */
val removeFromLibrary =
handler(
pathParam<Int>("mangaId"),
documentWith = {
withOperation {
summary("Remove manga to library")
description("Use a manga id to remove the manga to your library.\nWill do nothing if manga not in your library.")
}
},
behaviorOf = { ctx, mangaId ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.future {
future { Library.removeMangaFromLibrary(mangaId) }
.thenApply { ctx.status(HttpStatus.OK) }
}
},
withResults = {
httpCode(HttpStatus.OK)
httpCode(HttpStatus.NOT_FOUND)
},
)
/** list manga's categories */
val categoryList =
handler(
pathParam<Int>("mangaId"),
documentWith = {
withOperation {
summary("Get a manga's categories")
description("Get the list of categories for this manga")
}
},
behaviorOf = { ctx, mangaId ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.json(CategoryManga.getMangaCategories(mangaId))
},
withResults = {
json<Array<CategoryDataClass>>(HttpStatus.OK)
},
)
/** adds the manga to category */
val addToCategory =
handler(
pathParam<Int>("mangaId"),
pathParam<Int>("categoryId"),
documentWith = {
withOperation {
summary("Add manga to category")
description("Add a manga to a category using their ids.")
}
},
behaviorOf = { ctx, mangaId, categoryId ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
CategoryManga.addMangaToCategory(mangaId, categoryId)
ctx.status(200)
},
withResults = {
httpCode(HttpStatus.OK)
},
)
/** removes the manga from the category */
val removeFromCategory =
handler(
pathParam<Int>("mangaId"),
pathParam<Int>("categoryId"),
documentWith = {
withOperation {
summary("Remove manga from category")
description("Remove a manga from a category using their ids.")
}
},
behaviorOf = { ctx, mangaId, categoryId ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
CategoryManga.removeMangaFromCategory(mangaId, categoryId)
ctx.status(200)
},
withResults = {
httpCode(HttpStatus.OK)
},
)
/** used to modify a manga's meta parameters */
val meta =
handler(
pathParam<Int>("mangaId"),
formParam<String>("key"),
formParam<String>("value"),
documentWith = {
withOperation {
summary("Add data to manga")
description("A simple Key-Value storage in the manga object, you can set values for whatever you want inside it.")
}
},
behaviorOf = { ctx, mangaId, key, value ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
Manga.modifyMangaMeta(mangaId, key, value)
ctx.status(200)
},
withResults = {
httpCode(HttpStatus.OK)
httpCode(HttpStatus.NOT_FOUND)
},
)
/** get chapter list when showing a manga */
val chapterList =
handler(
pathParam<Int>("mangaId"),
queryParam("onlineFetch", false),
documentWith = {
withOperation {
summary("Get manga chapter list")
description(
"Get the manga chapter list from the database or online. " +
"If there is no chapters in the database it fetches the chapters online. " +
"Use onlineFetch to update chapter list.",
)
}
},
behaviorOf = { ctx, mangaId, onlineFetch ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.future {
future { Chapter.getChapterList(mangaId, onlineFetch) }
.thenApply { ctx.json(it) }
}
},
withResults = {
json<Array<ChapterDataClass>>(HttpStatus.OK)
httpCode(HttpStatus.NOT_FOUND)
},
)
/** batch edit chapters of single manga */
val chapterBatch =
handler(
pathParam<Int>("mangaId"),
documentWith = {
withOperation {
summary("Chapters update multiple")
description("Update multiple chapters of single manga. For batch marking as read, or bookmarking")
}
body<Chapter.MangaChapterBatchEditInput>()
},
behaviorOf = { ctx, mangaId ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
val input = json.decodeFromString<Chapter.MangaChapterBatchEditInput>(ctx.body())
Chapter.modifyChapters(input, mangaId)
},
withResults = {
httpCode(HttpStatus.OK)
},
)
/** batch edit chapters from multiple manga */
val anyChapterBatch =
handler(
documentWith = {
withOperation {
summary("Chapters update multiple")
description("Update multiple chapters on any manga. For batch marking as read, or bookmarking")
}
body<Chapter.ChapterBatchEditInput>()
},
behaviorOf = { ctx ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
val input = json.decodeFromString<Chapter.ChapterBatchEditInput>(ctx.body())
Chapter.modifyChapters(
Chapter.MangaChapterBatchEditInput(
input.chapterIds,
null,
input.change,
),
)
},
withResults = {
httpCode(HttpStatus.OK)
},
)
/** used to display a chapter, get a chapter in order to show its pages */
val chapterRetrieve =
handler(
pathParam<Int>("mangaId"),
pathParam<Int>("chapterIndex"),
documentWith = {
withOperation {
summary("Get a chapter")
description("Get the chapter from the manga id and chapter index. It will also retrieve the pages for this chapter.")
}
},
behaviorOf = { ctx, mangaId, chapterIndex ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.future {
future {
var chapter = getChapterDownloadReadyByIndex(chapterIndex, mangaId)
val syncResult = KoreaderSyncService.checkAndPullProgress(chapter.id)
if (syncResult != null) {
if (syncResult.shouldUpdate) {
// Update DB for SILENT and RECEIVE
transaction {
ChapterTable.update({ ChapterTable.id eq chapter.id }) {
it[lastPageRead] = syncResult.pageRead
it[lastReadAt] = syncResult.timestamp
}
}
}
// For PROMPT, SILENT, and RECEIVE, return the remote progress
chapter =
chapter.copy(
lastPageRead = syncResult.pageRead,
lastReadAt = syncResult.timestamp,
)
}
chapter
}.thenApply { ctx.json(it) }
}
},
withResults = {
json<ChapterDataClass>(HttpStatus.OK)
httpCode(HttpStatus.NOT_FOUND)
},
)
/** used to modify a chapter's parameters */
val chapterModify =
handler(
pathParam<Int>("mangaId"),
pathParam<Int>("chapterIndex"),
formParam<Boolean?>("read"),
formParam<Boolean?>("bookmarked"),
formParam<Boolean?>("markPrevRead"),
formParam<Int?>("lastPageRead"),
documentWith = {
withOperation {
summary("Modify a chapter")
description("Update user info for a given chapter, such as read status, bookmarked, and more.")
}
},
behaviorOf = { ctx, mangaId, chapterIndex, read, bookmarked, markPrevRead, lastPageRead ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
val chapterId = Chapter.modifyChapter(mangaId, chapterIndex, read, bookmarked, markPrevRead, lastPageRead)
// Sync with KoreaderSync when progress is updated
if (lastPageRead != null || read == true) {
GlobalScope.launch { KoreaderSyncService.pushProgress(chapterId) }
}
ctx.status(200)
},
withResults = {
httpCode(HttpStatus.OK)
},
)
/** delete a downloaded chapter */
val chapterDelete =
handler(
pathParam<Int>("mangaId"),
pathParam<Int>("chapterIndex"),
documentWith = {
withOperation {
summary("Delete a chapter download")
description("Delete the downloaded chapter and its files.")
}
},
behaviorOf = { ctx, mangaId, chapterIndex ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
Chapter.deleteChapter(mangaId, chapterIndex)
ctx.status(200)
},
withResults = {
httpCode(HttpStatus.OK)
httpCode(HttpStatus.NOT_FOUND)
},
)
/** used to modify a chapter's meta parameters */
val chapterMeta =
handler(
pathParam<Int>("mangaId"),
pathParam<Int>("chapterIndex"),
formParam<String>("key"),
formParam<String>("value"),
documentWith = {
withOperation {
summary("Add data to chapter")
description("A simple Key-Value storage in the chapter object, you can set values for whatever you want inside it.")
}
},
behaviorOf = { ctx, mangaId, chapterIndex, key, value ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
Chapter.modifyChapterMeta(mangaId, chapterIndex, key, value)
ctx.status(200)
},
withResults = {
httpCode(HttpStatus.OK)
httpCode(HttpStatus.NOT_FOUND)
},
)
/** get page at index "index" */
val pageRetrieve =
handler(
pathParam<Int>("mangaId"),
pathParam<Int>("chapterIndex"),
pathParam<Int>("index"),
queryParam<Boolean?>("updateProgress"),
queryParam<String?>("format"),
queryParam<Boolean?>("opds"),
documentWith = {
withOperation {
summary("Get a chapter page")
description(
"Get a chapter page for a given index. Cache use can be disabled so it only retrieves it directly from the source.",
)
}
},
behaviorOf = { ctx, mangaId, chapterIndex, index, updateProgress, format, opds ->
if (opds == true) {
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
} else {
ctx.getAttribute(Attribute.TachideskUser).requireUser()
}
ctx.future {
future {
Page.getPageImageServe(
mangaId = mangaId,
chapterIndex = chapterIndex,
index = index,
format = format,
)
}.thenApply {
ctx.header("content-type", it.second)
val httpCacheSeconds = 1.days.inWholeSeconds
ctx.header("cache-control", "max-age=$httpCacheSeconds")
ctx.result(it.first)
if (updateProgress == true) {
val chapterId = Chapter.updateChapterProgress(mangaId, chapterIndex, pageNo = index)
// Sync progress with KoreaderSync if chapter update was successful
if (chapterId != -1) {
GlobalScope.launch { KoreaderSyncService.pushProgress(chapterId) }
}
}
}
}
},
withResults = {
image(HttpStatus.OK)
httpCode(HttpStatus.NOT_FOUND)
},
)
val downloadChapter =
handler(
pathParam<Int>("chapterId"),
queryParam<Boolean?>("markAsRead"),
documentWith = {
withOperation {
summary("Download chapter as CBZ")
description("Get the CBZ file of the specified chapter, or its metadata via a HEAD request.")
}
},
behaviorOf = { ctx, chapterId, markAsRead ->
ctx.getAttribute(Attribute.TachideskUser).requireUser()
ctx.disableCompression()
val contentType = serverConfig.opdsCbzMimetype.value.mediaType
if (ctx.method() == HandlerType.HEAD) {
ctx.future {
future { ChapterDownloadHelper.getCbzMetadataForDownload(chapterId) }
.thenApply { (fileName, fileSize) ->
ctx.header("Content-Type", contentType)
ctx.header("Content-Disposition", "attachment; filename=\"$fileName\"")
ctx.header("Content-Length", fileSize.toString())
ctx.status(HttpStatus.OK)
}
}
} else {
val shouldMarkAsRead = markAsRead ?: false
ctx.future {
future { ChapterDownloadHelper.getCbzForDownload(chapterId, shouldMarkAsRead) }
.thenApply { (inputStream, fileName, fileSize) ->
ctx.header("Content-Type", contentType)
ctx.header("Content-Disposition", "attachment; filename=\"$fileName\"")
ctx.header("Content-Length", fileSize.toString())
ctx.result(inputStream)
}
}
}
},
withResults = {
httpCode(HttpStatus.OK)
httpCode(HttpStatus.NOT_FOUND)
},
)
}