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("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(HttpStatus.OK) httpCode(HttpStatus.NOT_FOUND) }, ) /** get manga info with all data filled in */ val retrieveFull = handler( pathParam("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(HttpStatus.OK) httpCode(HttpStatus.NOT_FOUND) }, ) /** manga thumbnail */ val thumbnail = handler( pathParam("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("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("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("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>(HttpStatus.OK) }, ) /** adds the manga to category */ val addToCategory = handler( pathParam("mangaId"), pathParam("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("mangaId"), pathParam("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("mangaId"), formParam("key"), formParam("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("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>(HttpStatus.OK) httpCode(HttpStatus.NOT_FOUND) }, ) /** batch edit chapters of single manga */ val chapterBatch = handler( pathParam("mangaId"), documentWith = { withOperation { summary("Chapters update multiple") description("Update multiple chapters of single manga. For batch marking as read, or bookmarking") } body() }, behaviorOf = { ctx, mangaId -> ctx.getAttribute(Attribute.TachideskUser).requireUser() val input = json.decodeFromString(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() }, behaviorOf = { ctx -> ctx.getAttribute(Attribute.TachideskUser).requireUser() val input = json.decodeFromString(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("mangaId"), pathParam("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(HttpStatus.OK) httpCode(HttpStatus.NOT_FOUND) }, ) /** used to modify a chapter's parameters */ val chapterModify = handler( pathParam("mangaId"), pathParam("chapterIndex"), formParam("read"), formParam("bookmarked"), formParam("markPrevRead"), formParam("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("mangaId"), pathParam("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("mangaId"), pathParam("chapterIndex"), formParam("key"), formParam("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("mangaId"), pathParam("chapterIndex"), pathParam("index"), queryParam("updateProgress"), queryParam("format"), queryParam("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("chapterId"), queryParam("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) }, ) }