diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt index d454ff0f..89bab491 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt @@ -401,6 +401,7 @@ object MangaController { pathParam("mangaId"), pathParam("chapterIndex"), pathParam("index"), + queryParam("updateProgress"), documentWith = { withOperation { summary("Get a chapter page") @@ -409,14 +410,18 @@ object MangaController { ) } }, - behaviorOf = { ctx, mangaId, chapterIndex, index -> + behaviorOf = { ctx, mangaId, chapterIndex, index, updateProgress -> ctx.future { - future { Page.getPageImage(mangaId, chapterIndex, index) } + future { Page.getPageImage(mangaId, chapterIndex, index, null) } .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) { + Chapter.updateChapterProgress(mangaId, chapterIndex, pageNo = index) + } } } }, @@ -437,10 +442,11 @@ object MangaController { }, behaviorOf = { ctx, chapterId -> ctx.future { - future { ChapterDownloadHelper.getCbzDownload(chapterId) } - .thenApply { (inputStream, contentType, fileName) -> - ctx.header("Content-Type", contentType) + future { ChapterDownloadHelper.getCbzForDownload(chapterId) } + .thenApply { (inputStream, fileName, fileSize) -> + ctx.header("Content-Type", "application/vnd.comicbook+zip") ctx.header("Content-Disposition", "attachment; filename=\"$fileName\"") + ctx.header("Content-Length", fileSize.toString()) ctx.result(inputStream) } } 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 c69949b0..6b92949b 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt @@ -262,8 +262,8 @@ object Chapter { // we got some clean up due if (chaptersIdsToDelete.isNotEmpty()) { transaction { - PageTable.deleteWhere { PageTable.chapter inList chaptersIdsToDelete } - ChapterTable.deleteWhere { ChapterTable.id inList chaptersIdsToDelete } + PageTable.deleteWhere { chapter inList chaptersIdsToDelete } + ChapterTable.deleteWhere { id inList chaptersIdsToDelete } } } @@ -321,7 +321,7 @@ object Chapter { } MangaTable.update({ MangaTable.id eq mangaId }) { - it[MangaTable.chaptersLastFetchedAt] = Instant.now().epochSecond + it[chaptersLastFetchedAt] = Instant.now().epochSecond } } @@ -443,7 +443,7 @@ object Chapter { } lastPageRead?.also { update[ChapterTable.lastPageRead] = it - update[ChapterTable.lastReadAt] = Instant.now().epochSecond + update[lastReadAt] = Instant.now().epochSecond } } } @@ -534,7 +534,7 @@ object Chapter { } lastPageRead?.also { update[ChapterTable.lastPageRead] = it - update[ChapterTable.lastReadAt] = now + update[lastReadAt] = now } } } @@ -603,7 +603,7 @@ object Chapter { ChapterMetaTable.insert { it[ChapterMetaTable.key] = key it[ChapterMetaTable.value] = value - it[ChapterMetaTable.ref] = chapterId + it[ref] = chapterId } } else { ChapterMetaTable.update({ (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) }) { @@ -693,4 +693,33 @@ object Chapter { } } } + + fun updateChapterProgress( + mangaId: Int, + chapterIndex: Int, + pageNo: Int, + ) { + val chapterData = + transaction { + ChapterTable + .selectAll() + .where { + (ChapterTable.sourceOrder eq chapterIndex) and + (ChapterTable.manga eq mangaId) + }.first() + .let { ChapterTable.toDataClass(it) } + } + + val oneIndexedPageNo = pageNo.inc() + val isRead = chapterData.pageCount.takeIf { it == oneIndexedPageNo }?.let { true } + + modifyChapter( + mangaId, + chapterIndex, + isRead = isRead, + lastPageRead = pageNo, + isBookmarked = null, + markPrevRead = null, + ) + } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/ChapterDownloadHelper.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/ChapterDownloadHelper.kt index 64742975..619a65fc 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/ChapterDownloadHelper.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/ChapterDownloadHelper.kt @@ -13,7 +13,6 @@ import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.toDataClass import suwayomi.tachidesk.server.serverConfig import java.io.File -import java.io.IOException import java.io.InputStream object ChapterDownloadHelper { @@ -48,26 +47,28 @@ object ChapterDownloadHelper { return FolderProvider(mangaId, chapterId) } - suspend fun getCbzDownload(chapterId: Int): Triple { + fun getArchiveStreamWithSize( + mangaId: Int, + chapterId: Int, + ): Pair = provider(mangaId, chapterId).getAsArchiveStream() + + fun getCbzForDownload(chapterId: Int): Triple { val (chapterData, mangaTitle) = transaction { val row = (ChapterTable innerJoin MangaTable) .select(ChapterTable.columns + MangaTable.columns) .where { ChapterTable.id eq chapterId } - .firstOrNull() ?: throw Exception("Chapter not found") + .firstOrNull() ?: throw IllegalArgumentException("ChapterId $chapterId not found") val chapter = ChapterTable.toDataClass(row) val title = row[MangaTable.title] Pair(chapter, title) } - val provider = provider(chapterData.mangaId, chapterData.id) - return if (provider is ArchiveProvider) { - val cbzFile = File(getChapterCbzPath(chapterData.mangaId, chapterData.id)) - val fileName = "$mangaTitle - [${chapterData.scanlator}] ${chapterData.name}.cbz" - Triple(cbzFile.inputStream(), "application/vnd.comicbook+zip", fileName) - } else { - throw IOException("Chapter not available as CBZ") - } + val fileName = "$mangaTitle - [${chapterData.scanlator}] ${chapterData.name}.cbz" + + val cbzFile = provider(chapterData.mangaId, chapterData.id).getAsArchiveStream() + + return Triple(cbzFile.first, fileName, cbzFile.second) } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/chapter/ChapterForDownload.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/chapter/ChapterForDownload.kt index e6616466..6b5fe8f0 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/chapter/ChapterForDownload.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/chapter/ChapterForDownload.kt @@ -33,7 +33,6 @@ suspend fun getChapterDownloadReady( mangaId: Int? = null, ): ChapterDataClass { val chapter = ChapterForDownload(chapterId, chapterIndex, mangaId) - return chapter.asDownloadReady() } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/ChaptersFilesProvider.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/ChaptersFilesProvider.kt index 18c23456..aa891f0d 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/ChaptersFilesProvider.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/ChaptersFilesProvider.kt @@ -125,7 +125,10 @@ abstract class ChaptersFilesProvider( .distinctUntilChanged() .onEach { download.progress = (pageNum.toFloat() + (it.toFloat() * 0.01f)) / pageCount - step(null, false) // don't throw on canceled download here since we can't do anything + step( + null, + false, + ) // don't throw on canceled download here since we can't do anything }.launchIn(scope) }.first .close() @@ -159,4 +162,6 @@ abstract class ChaptersFilesProvider( FileDownload3Args(::downloadImpl) abstract override fun delete(): Boolean + + abstract fun getAsArchiveStream(): Pair } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/impl/ArchiveProvider.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/impl/ArchiveProvider.kt index 211bdbba..45c73eb9 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/impl/ArchiveProvider.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/impl/ArchiveProvider.kt @@ -88,6 +88,15 @@ class ArchiveProvider( return cbzDeleted } + override fun getAsArchiveStream(): Pair { + val cbzFile = + File(getChapterCbzPath(mangaId, chapterId)) + .takeIf { it.exists() } + ?: throw IllegalArgumentException("CBZ file not found for chapter ID: $chapterId (Manga ID: $mangaId)") + + return cbzFile.inputStream() to cbzFile.length() + } + private fun extractCbzFile( cbzFile: File, chapterFolder: File, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/impl/FolderProvider.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/impl/FolderProvider.kt index 963b2202..5f84c7b7 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/impl/FolderProvider.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/fileProvider/impl/FolderProvider.kt @@ -7,8 +7,14 @@ import suwayomi.tachidesk.manga.impl.util.getChapterDownloadPath import suwayomi.tachidesk.manga.impl.util.storage.FileDeletionHelper import suwayomi.tachidesk.server.ApplicationDirs import uy.kohesive.injekt.injectLazy +import java.io.BufferedOutputStream +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream import java.io.File import java.io.FileInputStream +import java.io.InputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream private val applicationDirs: ApplicationDirs by injectLazy() @@ -58,4 +64,31 @@ class FolderProvider( FileDeletionHelper.cleanupParentFoldersFor(chapterDir, applicationDirs.mangaDownloadsRoot) return chapterDirDeleted } + + override fun getAsArchiveStream(): Pair { + val chapterDir = File(getChapterDownloadPath(mangaId, chapterId)) + + if (!chapterDir.exists() || !chapterDir.isDirectory || chapterDir.listFiles().isNullOrEmpty()) { + throw IllegalArgumentException("Invalid folder to create CBZ for chapter ID: $chapterId") + } + + val byteArrayOutputStream = ByteArrayOutputStream() + ZipOutputStream(BufferedOutputStream(byteArrayOutputStream)).use { zipOutputStream -> + chapterDir + .listFiles() + ?.filter { it.isFile } + ?.sortedBy { it.name } + ?.forEach { imageFile -> + FileInputStream(imageFile).use { fileInputStream -> + val zipEntry = ZipEntry(imageFile.name) + zipOutputStream.putNextEntry(zipEntry) + fileInputStream.copyTo(zipOutputStream) + zipOutputStream.closeEntry() + } + } + } + + val zipData = byteArrayOutputStream.toByteArray() + return ByteArrayInputStream(zipData) to zipData.size.toLong() + } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ChapterTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ChapterTable.kt index 03d443ef..6d7f8f65 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ChapterTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ChapterTable.kt @@ -14,7 +14,6 @@ import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import suwayomi.tachidesk.manga.impl.Chapter.getChapterMetaMap import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass -import suwayomi.tachidesk.manga.model.table.MangaTable.nullable object ChapterTable : IntIdTable() { val url = varchar("url", 2048) @@ -41,31 +40,43 @@ object ChapterTable : IntIdTable() { val manga = reference("manga", MangaTable, ReferenceOption.CASCADE) } -fun ChapterTable.toDataClass(chapterEntry: ResultRow) = - ChapterDataClass( - id = chapterEntry[id].value, - url = chapterEntry[url], - name = chapterEntry[name], - uploadDate = chapterEntry[date_upload], - chapterNumber = chapterEntry[chapter_number], - scanlator = chapterEntry[scanlator], - mangaId = chapterEntry[manga].value, - read = chapterEntry[isRead], - bookmarked = chapterEntry[isBookmarked], - lastPageRead = chapterEntry[lastPageRead], - lastReadAt = chapterEntry[lastReadAt], - index = chapterEntry[sourceOrder], - fetchedAt = chapterEntry[fetchedAt], - realUrl = chapterEntry[realUrl], - downloaded = chapterEntry[isDownloaded], - pageCount = chapterEntry[pageCount], - chapterCount = +fun ChapterTable.toDataClass( + chapterEntry: ResultRow, + includeChapterCount: Boolean = true, + includeChapterMeta: Boolean = true, +) = ChapterDataClass( + id = chapterEntry[id].value, + url = chapterEntry[url], + name = chapterEntry[name], + uploadDate = chapterEntry[date_upload], + chapterNumber = chapterEntry[chapter_number], + scanlator = chapterEntry[scanlator], + mangaId = chapterEntry[manga].value, + read = chapterEntry[isRead], + bookmarked = chapterEntry[isBookmarked], + lastPageRead = chapterEntry[lastPageRead], + lastReadAt = chapterEntry[lastReadAt], + index = chapterEntry[sourceOrder], + fetchedAt = chapterEntry[fetchedAt], + realUrl = chapterEntry[realUrl], + downloaded = chapterEntry[isDownloaded], + pageCount = chapterEntry[pageCount], + chapterCount = + if (includeChapterCount) { transaction { ChapterTable .selectAll() .where { manga eq chapterEntry[manga].value } .count() .toInt() - }, - meta = getChapterMetaMap(chapterEntry[id]), - ) + } + } else { + null + }, + meta = + if (includeChapterMeta) { + getChapterMetaMap(chapterEntry[id]) + } else { + emptyMap() + }, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/MangaTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/MangaTable.kt index b3bd8ec8..aede63da 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/MangaTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/MangaTable.kt @@ -46,28 +46,35 @@ object MangaTable : IntIdTable() { val updateStrategy = varchar("update_strategy", 256).default(UpdateStrategy.ALWAYS_UPDATE.name) } -fun MangaTable.toDataClass(mangaEntry: ResultRow) = - MangaDataClass( - id = mangaEntry[this.id].value, - sourceId = mangaEntry[sourceReference].toString(), - url = mangaEntry[url], - title = mangaEntry[title], - thumbnailUrl = proxyThumbnailUrl(mangaEntry[this.id].value), - thumbnailUrlLastFetched = mangaEntry[thumbnailUrlLastFetched], - initialized = mangaEntry[initialized], - artist = mangaEntry[artist], - author = mangaEntry[author], - description = mangaEntry[description], - genre = mangaEntry[genre].toGenreList(), - status = Companion.valueOf(mangaEntry[status]).name, - inLibrary = mangaEntry[inLibrary], - inLibraryAt = mangaEntry[inLibraryAt], - meta = getMangaMetaMap(mangaEntry[id].value), - realUrl = mangaEntry[realUrl], - lastFetchedAt = mangaEntry[lastFetchedAt], - chaptersLastFetchedAt = mangaEntry[chaptersLastFetchedAt], - updateStrategy = UpdateStrategy.valueOf(mangaEntry[updateStrategy]), - ) +fun MangaTable.toDataClass( + mangaEntry: ResultRow, + includeMangaMeta: Boolean = true, +) = MangaDataClass( + id = mangaEntry[this.id].value, + sourceId = mangaEntry[sourceReference].toString(), + url = mangaEntry[url], + title = mangaEntry[title], + thumbnailUrl = proxyThumbnailUrl(mangaEntry[this.id].value), + thumbnailUrlLastFetched = mangaEntry[thumbnailUrlLastFetched], + initialized = mangaEntry[initialized], + artist = mangaEntry[artist], + author = mangaEntry[author], + description = mangaEntry[description], + genre = mangaEntry[genre].toGenreList(), + status = Companion.valueOf(mangaEntry[status]).name, + inLibrary = mangaEntry[inLibrary], + inLibraryAt = mangaEntry[inLibraryAt], + meta = + if (includeMangaMeta) { + getMangaMetaMap(mangaEntry[id].value) + } else { + emptyMap() + }, + realUrl = mangaEntry[realUrl], + lastFetchedAt = mangaEntry[lastFetchedAt], + chaptersLastFetchedAt = mangaEntry[chaptersLastFetchedAt], + updateStrategy = UpdateStrategy.valueOf(mangaEntry[updateStrategy]), +) enum class MangaStatus( val value: Int, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt index b02b04e4..76c97c02 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/OpdsAPI.kt @@ -23,12 +23,17 @@ object OpdsAPI { get("genres", OpdsV1Controller.genresFeed) get("status", OpdsV1Controller.statusFeed) get("languages", OpdsV1Controller.languagesFeed) + get("library-updates", OpdsV1Controller.libraryUpdatesFeed) // Faceted feeds (Acquisition Feeds) path("manga/{mangaId}") { get(OpdsV1Controller.mangaFeed) } + path("manga/{mangaId}/chapter/{chapterId}/fetch") { + get(OpdsV1Controller.chapterMetadataFeed) + } + path("source/{sourceId}") { get(OpdsV1Controller.sourceFeed) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt index 101e326b..9e1082dd 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt @@ -278,6 +278,31 @@ object OpdsV1Controller { }, ) + var chapterMetadataFeed = + handler( + pathParam("mangaId"), + pathParam("chapterId"), + documentWith = { + withOperation { + summary("OPDS Chapter Details Feed") + description("OPDS feed for a specific undownloaded chapter of a manga") + } + }, + behaviorOf = { ctx, mangaId, chapterId -> + ctx.future { + future { + Opds.getChapterMetadataFeed(mangaId, chapterId, BASE_URL) + }.thenApply { xml -> + ctx.contentType(OPDS_MIME).result(xml) + } + } + }, + withResults = { + httpCode(HttpStatus.OK) + httpCode(HttpStatus.NOT_FOUND) + }, + ) + // Specific Source Feed val sourceFeed = handler( @@ -407,4 +432,28 @@ object OpdsV1Controller { httpCode(HttpStatus.NOT_FOUND) }, ) + + // Main Library Updates Feed + val libraryUpdatesFeed = + handler( + queryParam("pageNumber"), + documentWith = { + withOperation { + summary("OPDS Library Updates Feed") + description("OPDS feed listing recent manga chapter updates") + } + }, + behaviorOf = { ctx, pageNumber -> + ctx.future { + future { + Opds.getLibraryUpdatesFeed(BASE_URL, pageNumber ?: 1) + }.thenApply { xml -> + ctx.contentType(OPDS_MIME).result(xml) + } + } + }, + withResults = { + httpCode(HttpStatus.OK) + }, + ) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/Opds.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/Opds.kt index 535f1ed8..a3e400ea 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/Opds.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/Opds.kt @@ -1,6 +1,8 @@ package suwayomi.tachidesk.opds.impl import SearchCriteria +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import nl.adaptivity.xmlutil.XmlDeclMode import nl.adaptivity.xmlutil.core.XmlVersion import nl.adaptivity.xmlutil.serialization.XML @@ -8,12 +10,14 @@ import org.jetbrains.exposed.sql.JoinType import org.jetbrains.exposed.sql.Op import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.lowerCase import org.jetbrains.exposed.sql.or import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction +import suwayomi.tachidesk.manga.impl.ChapterDownloadHelper.getArchiveStreamWithSize import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl +import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReady import suwayomi.tachidesk.manga.impl.extension.Extension.getExtensionIconUrl -import suwayomi.tachidesk.manga.impl.util.getChapterCbzPath import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass import suwayomi.tachidesk.manga.model.dataclass.SourceDataClass @@ -26,7 +30,6 @@ import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.SourceTable import suwayomi.tachidesk.manga.model.table.toDataClass import suwayomi.tachidesk.opds.model.OpdsXmlModels -import java.io.File import java.net.URLEncoder import java.nio.charset.StandardCharsets import java.time.Instant @@ -48,6 +51,7 @@ object Opds { "genres" to "Genres", "status" to "Status", "languages" to "Languages", + "library-updates" to "Library Update History", ).map { (id, title) -> OpdsXmlModels.Entry( id = id, @@ -79,26 +83,26 @@ object Opds { .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) .select(MangaTable.columns) .where { - val baseCondition = ChapterTable.isDownloaded eq true - if (criteria == null) { - baseCondition - } else { - val conditions = mutableListOf>() - criteria.query?.takeIf { it.isNotBlank() }?.let { q -> - conditions += ( - (MangaTable.title like "%$q%") or - (MangaTable.author like "%$q%") or - (MangaTable.genre like "%$q%") - ) - } - criteria.author?.takeIf { it.isNotBlank() }?.let { author -> - conditions += (MangaTable.author like "%$author%") - } - criteria.title?.takeIf { it.isNotBlank() }?.let { title -> - conditions += (MangaTable.title like "%$title%") - } - baseCondition and (if (conditions.isEmpty()) Op.TRUE else conditions.reduce { acc, op -> acc and op }) + val conditions = mutableListOf>() + + criteria?.query?.takeIf { it.isNotBlank() }?.let { q -> + val lowerQ = q.lowercase() + conditions += ( + (MangaTable.title.lowerCase() like "%$lowerQ%") or + (MangaTable.author.lowerCase() like "%$lowerQ%") or + (MangaTable.genre.lowerCase() like "%$lowerQ%") + ) } + + criteria?.author?.takeIf { it.isNotBlank() }?.let { author -> + conditions += (MangaTable.author.lowerCase() like "%${author.lowercase()}%") + } + + criteria?.title?.takeIf { it.isNotBlank() }?.let { title -> + conditions += (MangaTable.title.lowerCase() like "%${title.lowercase()}%") + } + + if (conditions.isEmpty()) (MangaTable.inLibrary eq true) else conditions.reduce { acc, op -> acc and op } }.groupBy(MangaTable.id) .orderBy(MangaTable.title to SortOrder.ASC) val totalCount = query.count() @@ -106,7 +110,7 @@ object Opds { query .limit(ITEMS_PER_PAGE) .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) - .map { MangaTable.toDataClass(it) } + .map { MangaTable.toDataClass(it, includeMangaMeta = false) } Pair(mangas, totalCount) } @@ -136,7 +140,6 @@ object Opds { }.join(ChapterTable, JoinType.INNER) { ChapterTable.manga eq MangaTable.id }.select(SourceTable.columns) - .where { ChapterTable.isDownloaded eq true } .groupBy(SourceTable.id) .orderBy(SourceTable.name to SortOrder.ASC) @@ -195,7 +198,6 @@ object Opds { .join(MangaTable, JoinType.INNER, onColumn = CategoryMangaTable.manga, otherColumn = MangaTable.id) .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) .select(CategoryTable.id, CategoryTable.name) - .where { ChapterTable.isDownloaded eq true } .groupBy(CategoryTable.id) .orderBy(CategoryTable.order to SortOrder.ASC) .map { row -> @@ -241,7 +243,6 @@ object Opds { MangaTable .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) .select(MangaTable.genre) - .where { ChapterTable.isDownloaded eq true } .map { it[MangaTable.genre] } .flatMap { it?.split(", ")?.filterNot { g -> g.isBlank() } ?: emptyList() } .groupingBy { it } @@ -344,7 +345,6 @@ object Opds { .join(MangaTable, JoinType.INNER, onColumn = SourceTable.id, otherColumn = MangaTable.sourceReference) .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) .select(SourceTable.lang) - .where { ChapterTable.isDownloaded eq true } .groupBy(SourceTable.lang) .orderBy(SourceTable.lang to SortOrder.ASC) .map { row -> row[SourceTable.lang] } @@ -378,7 +378,6 @@ object Opds { baseUrl: String, pageNum: Int, ): String { - val formattedNow = opdsDateFormatter.format(Instant.now()) val (manga, chapters, totalCount) = transaction { val mangaEntry = @@ -386,21 +385,20 @@ object Opds { .selectAll() .where { MangaTable.id eq mangaId } .first() - val mangaData = MangaTable.toDataClass(mangaEntry) + val mangaData = MangaTable.toDataClass(mangaEntry, includeMangaMeta = false) val chaptersQuery = ChapterTable .selectAll() .where { - (ChapterTable.manga eq mangaId) and - (ChapterTable.isDownloaded eq true) and - (ChapterTable.pageCount greater 0) + (ChapterTable.manga eq mangaId) }.orderBy(ChapterTable.sourceOrder to SortOrder.DESC) + val total = chaptersQuery.count() val chaptersData = chaptersQuery .limit(ITEMS_PER_PAGE) .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) - .map { ChapterTable.toDataClass(it) } + .map { ChapterTable.toDataClass(it, includeChapterCount = false, includeChapterMeta = false) } Triple(mangaData, chaptersData, total) } @@ -422,56 +420,140 @@ object Opds { type = "image/jpeg", ) } - entries += chapters.map { createChapterEntry(it, manga) } + entries += chapters.map { createChapterEntry(it, manga, baseUrl, isMetaDataEntry = false) } }.build() .let(::serialize) } + suspend fun getChapterMetadataFeed( + mangaId: Int, + chapterIndex: Int, + baseUrl: String, + ): String { + val mangaData = + withContext(Dispatchers.IO) { + transaction { + val mangaEntry = + MangaTable + .selectAll() + .where { MangaTable.id eq mangaId } + .first() + MangaTable.toDataClass(mangaEntry, includeMangaMeta = false) + } + } + + val updatedChapterData = getChapterDownloadReady(chapterIndex = chapterIndex, mangaId = mangaId) + val updatedEntry = createChapterEntry(updatedChapterData, mangaData, baseUrl, isMetaDataEntry = true) + + return FeedBuilder( + baseUrl = baseUrl, + pageNum = 1, + id = "manga/$mangaId/chapter/$chapterIndex", + title = "${mangaData.title} | ${updatedChapterData.name} | Details", + ).apply { + totalResults = 1 + icon = mangaData.thumbnailUrl + mangaData.thumbnailUrl?.let { url -> + links += + OpdsXmlModels.Link( + rel = "http://opds-spec.org/image", + href = url, + type = "image/jpeg", + ) + links += + OpdsXmlModels.Link( + rel = "http://opds-spec.org/image/thumbnail", + href = url, + type = "image/jpeg", + ) + } + entries += listOf(updatedEntry) + }.build() + .let(::serialize) + } + private fun createChapterEntry( chapter: ChapterDataClass, manga: MangaDataClass, + baseUrl: String, + isMetaDataEntry: Boolean, + addMangaTitleInEntry: Boolean = false, ): OpdsXmlModels.Entry { - val cbzFile = File(getChapterCbzPath(manga.id, chapter.id)) - val isCbzAvailable = cbzFile.exists() + val chapterDetails = + buildString { + append("${manga.title} | ${chapter.name} | By ${chapter.scanlator}") + if (isMetaDataEntry) { + append(" | Progress (${chapter.lastPageRead} / ${chapter.pageCount})") + } + } - return OpdsXmlModels.Entry( - id = "chapter/${chapter.id}", - title = chapter.name, - updated = opdsDateFormatter.format(Instant.ofEpochMilli(chapter.uploadDate)), - content = OpdsXmlModels.Content(value = "${chapter.scanlator}"), - summary = manga.description?.let { OpdsXmlModels.Summary(value = it) }, - extent = - cbzFile.takeIf { it.exists() }?.let { - formatFileSize(it.length()) - }, - format = cbzFile.takeIf { it.exists() }?.let { "CBZ" }, - authors = - listOfNotNull( - manga.author?.let { OpdsXmlModels.Author(name = it) }, - manga.artist?.takeIf { it != manga.author }?.let { OpdsXmlModels.Author(name = it) }, - ), - link = - listOfNotNull( - if (isCbzAvailable) { + val entryTitle = + when { + chapter.read -> "✅" + chapter.lastPageRead > 0 -> "⌛" + chapter.pageCount == 0 -> "❌" + else -> "⭕" + } + (if (addMangaTitleInEntry) " ${manga.title} :" else "") + " ${chapter.name}" + + val cbzInputStreamPair = + runCatching { + if (isMetaDataEntry && chapter.downloaded) getArchiveStreamWithSize(manga.id, chapter.id) else null + }.getOrNull() + + val links = + mutableListOf().apply { + if (cbzInputStreamPair != null) { + add( OpdsXmlModels.Link( rel = "http://opds-spec.org/acquisition/open-access", href = "/api/v1/chapter/${chapter.id}/download", type = "application/vnd.comicbook+zip", - ) - } else { + ), + ) + } + if (isMetaDataEntry) { + add( OpdsXmlModels.Link( rel = "http://vaemendis.net/opds-pse/stream", - href = "/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/{pageNumber}", + href = "/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/{pageNumber}?updateProgress=true", type = "image/jpeg", pseCount = chapter.pageCount, - ) - }, - OpdsXmlModels.Link( - rel = "http://opds-spec.org/image", - href = "/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/0", - type = "image/jpeg", - ), + pseLastRead = chapter.lastPageRead.takeIf { it != 0 }, + ), + ) + add( + OpdsXmlModels.Link( + rel = "http://opds-spec.org/image", + href = "/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/0", + type = "image/jpeg", + ), + ) + } else { + add( + OpdsXmlModels.Link( + rel = "subsection", + href = "$baseUrl/manga/${manga.id}/chapter/${chapter.index}/fetch", + type = "application/atom+xml;profile=opds-catalog;kind=acquisition", + ), + ) + } + } + + return OpdsXmlModels.Entry( + id = "chapter/${chapter.id}", + title = entryTitle, + updated = opdsDateFormatter.format(Instant.ofEpochMilli(chapter.uploadDate)), + content = OpdsXmlModels.Content(value = chapterDetails), + summary = OpdsXmlModels.Summary(value = chapterDetails), + extent = cbzInputStreamPair?.second?.let { formatFileSize(it) }, + format = cbzInputStreamPair?.second?.let { "CBZ" }, + authors = + listOfNotNull( + manga.author?.let { OpdsXmlModels.Author(name = it) }, + manga.artist?.takeIf { it != manga.author }?.let { OpdsXmlModels.Author(name = it) }, + chapter.scanlator?.let { OpdsXmlModels.Author(name = it) }, ), + link = links, ) } @@ -495,7 +577,7 @@ object Opds { .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) .select(MangaTable.columns) .where { - (MangaTable.sourceReference eq sourceId) and (ChapterTable.isDownloaded eq true) + (MangaTable.sourceReference eq sourceId) }.groupBy(MangaTable.id) .orderBy(MangaTable.title to SortOrder.ASC) @@ -504,7 +586,7 @@ object Opds { query .limit(ITEMS_PER_PAGE) .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) - .map { MangaTable.toDataClass(it) } + .map { MangaTable.toDataClass(it, includeMangaMeta = false) } Triple(paginatedResults, totalCount, sourceRow) } @@ -536,7 +618,7 @@ object Opds { .join(MangaTable, JoinType.INNER, onColumn = CategoryMangaTable.manga, otherColumn = MangaTable.id) .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) .select(MangaTable.columns) - .where { (CategoryMangaTable.category eq categoryId) and (ChapterTable.isDownloaded eq true) } + .where { (CategoryMangaTable.category eq categoryId) } .groupBy(MangaTable.id) .orderBy(MangaTable.title to SortOrder.ASC) val totalCount = query.count() @@ -544,7 +626,7 @@ object Opds { query .limit(ITEMS_PER_PAGE) .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) - .map { MangaTable.toDataClass(it) } + .map { MangaTable.toDataClass(it, includeMangaMeta = false) } Triple(mangas, totalCount, categoryName) } return FeedBuilder(baseUrl, pageNum, "category/$categoryId", "Category: $categoryName") @@ -567,7 +649,7 @@ object Opds { MangaTable .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) .select(MangaTable.columns) - .where { (MangaTable.genre like "%$genre%") and (ChapterTable.isDownloaded eq true) } + .where { (MangaTable.genre like "%$genre%") } .groupBy(MangaTable.id) .orderBy(MangaTable.title to SortOrder.ASC) val totalCount = query.count() @@ -575,7 +657,7 @@ object Opds { query .limit(ITEMS_PER_PAGE) .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) - .map { MangaTable.toDataClass(it) } + .map { MangaTable.toDataClass(it, includeMangaMeta = false) } Pair(mangas, totalCount) } return FeedBuilder(baseUrl, pageNum, "genre/${genre.encodeURL()}", "Genre: $genre") @@ -604,7 +686,7 @@ object Opds { MangaTable .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) .select(MangaTable.columns) - .where { (MangaTable.status eq statusId.toInt()) and (ChapterTable.isDownloaded eq true) } + .where { (MangaTable.status eq statusId.toInt()) } .groupBy(MangaTable.id) .orderBy(MangaTable.title to SortOrder.ASC) val totalCount = query.count() @@ -612,7 +694,7 @@ object Opds { query .limit(ITEMS_PER_PAGE) .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) - .map { MangaTable.toDataClass(it) } + .map { MangaTable.toDataClass(it, includeMangaMeta = false) } Pair(mangas, totalCount) } return FeedBuilder(baseUrl, pageNum, "status/$statusId", "Status: $statusName") @@ -636,7 +718,7 @@ object Opds { .join(MangaTable, JoinType.INNER, onColumn = SourceTable.id, otherColumn = MangaTable.sourceReference) .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) .select(MangaTable.columns) - .where { (SourceTable.lang eq langCode) and (ChapterTable.isDownloaded eq true) } + .where { (SourceTable.lang eq langCode) } .groupBy(MangaTable.id) .orderBy(MangaTable.title to SortOrder.ASC) val totalCount = query.count() @@ -644,7 +726,7 @@ object Opds { query .limit(ITEMS_PER_PAGE) .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) - .map { MangaTable.toDataClass(it) } + .map { MangaTable.toDataClass(it, includeMangaMeta = false) } Pair(mangas, totalCount) } return FeedBuilder(baseUrl, pageNum, "language/$langCode", "Language: $langCode") @@ -655,6 +737,52 @@ object Opds { .let(::serialize) } + fun getLibraryUpdatesFeed( + baseUrl: String, + pageNum: Int, + ): String { + val (chapterToMangaMap, total) = + transaction { + val query = + ChapterTable + .join(MangaTable, JoinType.INNER, onColumn = ChapterTable.manga, otherColumn = MangaTable.id) + .selectAll() + .where { (MangaTable.inLibrary eq true) } + .orderBy(ChapterTable.fetchedAt to SortOrder.DESC, ChapterTable.sourceOrder to SortOrder.DESC) + + val totalCount = query.count() + val chapters = + query + .limit(ITEMS_PER_PAGE) + .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) + .map { + ChapterTable.toDataClass( + it, + includeChapterCount = false, + includeChapterMeta = false, + ) to MangaTable.toDataClass(it, includeMangaMeta = false) + } + + Pair(chapters, totalCount) + } + + return FeedBuilder(baseUrl, pageNum, "library-updates", "Library Updates") + .apply { + totalResults = total + entries += + chapterToMangaMap.map { + createChapterEntry( + it.first, + it.second, + baseUrl, + isMetaDataEntry = false, + addMangaTitleInEntry = true, + ) + } + }.build() + .let(::serialize) + } + private class FeedBuilder( val baseUrl: String, val pageNum: Int, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsXmlModels.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsXmlModels.kt index 9e33b1b4..cb14efb8 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsXmlModels.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/model/OpdsXmlModels.kt @@ -64,6 +64,8 @@ data class OpdsXmlModels( val title: String? = null, @XmlSerialName("pse:count", "", "") val pseCount: Int? = null, + @XmlSerialName("pse:lastRead", "", "") + val pseLastRead: Int? = null, @XmlSerialName("opds:facetGroup", "", "") val facetGroup: String? = null, @XmlSerialName("opds:activeFacet", "", "")