Add support for opds-pse for undownloaded chapters (#1278)

* Add OPDS page streaming for undownloaded chapters

* Add [D] in chapter title prefix when isDownloaded

* Removed Chapter.isDownloaded check in query for other opds endpoints

* Add chapter progression tracking for streaming and refactor code

* dd  Unicode for chapters with 0 pages [post pageRefresh]

* Add Library Updates feed and remove redundant metadata fetching for OPDS chapters and manga

* Address PR comments & add chapter markAsRead for cbzDownload

* Address PR comment/s

* Rem. markAsRead for chapter download

* Rem. markAsRead for chapter download

---------

Co-authored-by: ShowY <showypro@gmail.com>
This commit is contained in:
Shirish
2025-03-08 22:01:07 +05:30
committed by GitHub
parent 3be165a551
commit 95d9293fe0
13 changed files with 427 additions and 143 deletions

View File

@@ -401,6 +401,7 @@ object MangaController {
pathParam<Int>("mangaId"), pathParam<Int>("mangaId"),
pathParam<Int>("chapterIndex"), pathParam<Int>("chapterIndex"),
pathParam<Int>("index"), pathParam<Int>("index"),
queryParam<Boolean?>("updateProgress"),
documentWith = { documentWith = {
withOperation { withOperation {
summary("Get a chapter page") summary("Get a chapter page")
@@ -409,14 +410,18 @@ object MangaController {
) )
} }
}, },
behaviorOf = { ctx, mangaId, chapterIndex, index -> behaviorOf = { ctx, mangaId, chapterIndex, index, updateProgress ->
ctx.future { ctx.future {
future { Page.getPageImage(mangaId, chapterIndex, index) } future { Page.getPageImage(mangaId, chapterIndex, index, null) }
.thenApply { .thenApply {
ctx.header("content-type", it.second) ctx.header("content-type", it.second)
val httpCacheSeconds = 1.days.inWholeSeconds val httpCacheSeconds = 1.days.inWholeSeconds
ctx.header("cache-control", "max-age=$httpCacheSeconds") ctx.header("cache-control", "max-age=$httpCacheSeconds")
ctx.result(it.first) ctx.result(it.first)
if (updateProgress == true) {
Chapter.updateChapterProgress(mangaId, chapterIndex, pageNo = index)
}
} }
} }
}, },
@@ -437,10 +442,11 @@ object MangaController {
}, },
behaviorOf = { ctx, chapterId -> behaviorOf = { ctx, chapterId ->
ctx.future { ctx.future {
future { ChapterDownloadHelper.getCbzDownload(chapterId) } future { ChapterDownloadHelper.getCbzForDownload(chapterId) }
.thenApply { (inputStream, contentType, fileName) -> .thenApply { (inputStream, fileName, fileSize) ->
ctx.header("Content-Type", contentType) ctx.header("Content-Type", "application/vnd.comicbook+zip")
ctx.header("Content-Disposition", "attachment; filename=\"$fileName\"") ctx.header("Content-Disposition", "attachment; filename=\"$fileName\"")
ctx.header("Content-Length", fileSize.toString())
ctx.result(inputStream) ctx.result(inputStream)
} }
} }

View File

@@ -262,8 +262,8 @@ object Chapter {
// we got some clean up due // we got some clean up due
if (chaptersIdsToDelete.isNotEmpty()) { if (chaptersIdsToDelete.isNotEmpty()) {
transaction { transaction {
PageTable.deleteWhere { PageTable.chapter inList chaptersIdsToDelete } PageTable.deleteWhere { chapter inList chaptersIdsToDelete }
ChapterTable.deleteWhere { ChapterTable.id inList chaptersIdsToDelete } ChapterTable.deleteWhere { id inList chaptersIdsToDelete }
} }
} }
@@ -321,7 +321,7 @@ object Chapter {
} }
MangaTable.update({ MangaTable.id eq mangaId }) { 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 { lastPageRead?.also {
update[ChapterTable.lastPageRead] = it update[ChapterTable.lastPageRead] = it
update[ChapterTable.lastReadAt] = Instant.now().epochSecond update[lastReadAt] = Instant.now().epochSecond
} }
} }
} }
@@ -534,7 +534,7 @@ object Chapter {
} }
lastPageRead?.also { lastPageRead?.also {
update[ChapterTable.lastPageRead] = it update[ChapterTable.lastPageRead] = it
update[ChapterTable.lastReadAt] = now update[lastReadAt] = now
} }
} }
} }
@@ -603,7 +603,7 @@ object Chapter {
ChapterMetaTable.insert { ChapterMetaTable.insert {
it[ChapterMetaTable.key] = key it[ChapterMetaTable.key] = key
it[ChapterMetaTable.value] = value it[ChapterMetaTable.value] = value
it[ChapterMetaTable.ref] = chapterId it[ref] = chapterId
} }
} else { } else {
ChapterMetaTable.update({ (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) }) { 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,
)
}
} }

View File

@@ -13,7 +13,6 @@ import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.toDataClass import suwayomi.tachidesk.manga.model.table.toDataClass
import suwayomi.tachidesk.server.serverConfig import suwayomi.tachidesk.server.serverConfig
import java.io.File import java.io.File
import java.io.IOException
import java.io.InputStream import java.io.InputStream
object ChapterDownloadHelper { object ChapterDownloadHelper {
@@ -48,26 +47,28 @@ object ChapterDownloadHelper {
return FolderProvider(mangaId, chapterId) return FolderProvider(mangaId, chapterId)
} }
suspend fun getCbzDownload(chapterId: Int): Triple<InputStream, String, String> { fun getArchiveStreamWithSize(
mangaId: Int,
chapterId: Int,
): Pair<InputStream, Long> = provider(mangaId, chapterId).getAsArchiveStream()
fun getCbzForDownload(chapterId: Int): Triple<InputStream, String, Long> {
val (chapterData, mangaTitle) = val (chapterData, mangaTitle) =
transaction { transaction {
val row = val row =
(ChapterTable innerJoin MangaTable) (ChapterTable innerJoin MangaTable)
.select(ChapterTable.columns + MangaTable.columns) .select(ChapterTable.columns + MangaTable.columns)
.where { ChapterTable.id eq chapterId } .where { ChapterTable.id eq chapterId }
.firstOrNull() ?: throw Exception("Chapter not found") .firstOrNull() ?: throw IllegalArgumentException("ChapterId $chapterId not found")
val chapter = ChapterTable.toDataClass(row) val chapter = ChapterTable.toDataClass(row)
val title = row[MangaTable.title] val title = row[MangaTable.title]
Pair(chapter, title) Pair(chapter, title)
} }
val provider = provider(chapterData.mangaId, chapterData.id) val fileName = "$mangaTitle - [${chapterData.scanlator}] ${chapterData.name}.cbz"
return if (provider is ArchiveProvider) {
val cbzFile = File(getChapterCbzPath(chapterData.mangaId, chapterData.id)) val cbzFile = provider(chapterData.mangaId, chapterData.id).getAsArchiveStream()
val fileName = "$mangaTitle - [${chapterData.scanlator}] ${chapterData.name}.cbz"
Triple(cbzFile.inputStream(), "application/vnd.comicbook+zip", fileName) return Triple(cbzFile.first, fileName, cbzFile.second)
} else {
throw IOException("Chapter not available as CBZ")
}
} }
} }

View File

@@ -33,7 +33,6 @@ suspend fun getChapterDownloadReady(
mangaId: Int? = null, mangaId: Int? = null,
): ChapterDataClass { ): ChapterDataClass {
val chapter = ChapterForDownload(chapterId, chapterIndex, mangaId) val chapter = ChapterForDownload(chapterId, chapterIndex, mangaId)
return chapter.asDownloadReady() return chapter.asDownloadReady()
} }

View File

@@ -125,7 +125,10 @@ abstract class ChaptersFilesProvider<Type : FileType>(
.distinctUntilChanged() .distinctUntilChanged()
.onEach { .onEach {
download.progress = (pageNum.toFloat() + (it.toFloat() * 0.01f)) / pageCount 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) }.launchIn(scope)
}.first }.first
.close() .close()
@@ -159,4 +162,6 @@ abstract class ChaptersFilesProvider<Type : FileType>(
FileDownload3Args(::downloadImpl) FileDownload3Args(::downloadImpl)
abstract override fun delete(): Boolean abstract override fun delete(): Boolean
abstract fun getAsArchiveStream(): Pair<InputStream, Long>
} }

View File

@@ -88,6 +88,15 @@ class ArchiveProvider(
return cbzDeleted return cbzDeleted
} }
override fun getAsArchiveStream(): Pair<InputStream, Long> {
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( private fun extractCbzFile(
cbzFile: File, cbzFile: File,
chapterFolder: File, chapterFolder: File,

View File

@@ -7,8 +7,14 @@ import suwayomi.tachidesk.manga.impl.util.getChapterDownloadPath
import suwayomi.tachidesk.manga.impl.util.storage.FileDeletionHelper import suwayomi.tachidesk.manga.impl.util.storage.FileDeletionHelper
import suwayomi.tachidesk.server.ApplicationDirs import suwayomi.tachidesk.server.ApplicationDirs
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.BufferedOutputStream
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.InputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
private val applicationDirs: ApplicationDirs by injectLazy() private val applicationDirs: ApplicationDirs by injectLazy()
@@ -58,4 +64,31 @@ class FolderProvider(
FileDeletionHelper.cleanupParentFoldersFor(chapterDir, applicationDirs.mangaDownloadsRoot) FileDeletionHelper.cleanupParentFoldersFor(chapterDir, applicationDirs.mangaDownloadsRoot)
return chapterDirDeleted return chapterDirDeleted
} }
override fun getAsArchiveStream(): Pair<InputStream, Long> {
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()
}
} }

View File

@@ -14,7 +14,6 @@ import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.manga.impl.Chapter.getChapterMetaMap import suwayomi.tachidesk.manga.impl.Chapter.getChapterMetaMap
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.table.MangaTable.nullable
object ChapterTable : IntIdTable() { object ChapterTable : IntIdTable() {
val url = varchar("url", 2048) val url = varchar("url", 2048)
@@ -41,31 +40,43 @@ object ChapterTable : IntIdTable() {
val manga = reference("manga", MangaTable, ReferenceOption.CASCADE) val manga = reference("manga", MangaTable, ReferenceOption.CASCADE)
} }
fun ChapterTable.toDataClass(chapterEntry: ResultRow) = fun ChapterTable.toDataClass(
ChapterDataClass( chapterEntry: ResultRow,
id = chapterEntry[id].value, includeChapterCount: Boolean = true,
url = chapterEntry[url], includeChapterMeta: Boolean = true,
name = chapterEntry[name], ) = ChapterDataClass(
uploadDate = chapterEntry[date_upload], id = chapterEntry[id].value,
chapterNumber = chapterEntry[chapter_number], url = chapterEntry[url],
scanlator = chapterEntry[scanlator], name = chapterEntry[name],
mangaId = chapterEntry[manga].value, uploadDate = chapterEntry[date_upload],
read = chapterEntry[isRead], chapterNumber = chapterEntry[chapter_number],
bookmarked = chapterEntry[isBookmarked], scanlator = chapterEntry[scanlator],
lastPageRead = chapterEntry[lastPageRead], mangaId = chapterEntry[manga].value,
lastReadAt = chapterEntry[lastReadAt], read = chapterEntry[isRead],
index = chapterEntry[sourceOrder], bookmarked = chapterEntry[isBookmarked],
fetchedAt = chapterEntry[fetchedAt], lastPageRead = chapterEntry[lastPageRead],
realUrl = chapterEntry[realUrl], lastReadAt = chapterEntry[lastReadAt],
downloaded = chapterEntry[isDownloaded], index = chapterEntry[sourceOrder],
pageCount = chapterEntry[pageCount], fetchedAt = chapterEntry[fetchedAt],
chapterCount = realUrl = chapterEntry[realUrl],
downloaded = chapterEntry[isDownloaded],
pageCount = chapterEntry[pageCount],
chapterCount =
if (includeChapterCount) {
transaction { transaction {
ChapterTable ChapterTable
.selectAll() .selectAll()
.where { manga eq chapterEntry[manga].value } .where { manga eq chapterEntry[manga].value }
.count() .count()
.toInt() .toInt()
}, }
meta = getChapterMetaMap(chapterEntry[id]), } else {
) null
},
meta =
if (includeChapterMeta) {
getChapterMetaMap(chapterEntry[id])
} else {
emptyMap()
},
)

View File

@@ -46,28 +46,35 @@ object MangaTable : IntIdTable() {
val updateStrategy = varchar("update_strategy", 256).default(UpdateStrategy.ALWAYS_UPDATE.name) val updateStrategy = varchar("update_strategy", 256).default(UpdateStrategy.ALWAYS_UPDATE.name)
} }
fun MangaTable.toDataClass(mangaEntry: ResultRow) = fun MangaTable.toDataClass(
MangaDataClass( mangaEntry: ResultRow,
id = mangaEntry[this.id].value, includeMangaMeta: Boolean = true,
sourceId = mangaEntry[sourceReference].toString(), ) = MangaDataClass(
url = mangaEntry[url], id = mangaEntry[this.id].value,
title = mangaEntry[title], sourceId = mangaEntry[sourceReference].toString(),
thumbnailUrl = proxyThumbnailUrl(mangaEntry[this.id].value), url = mangaEntry[url],
thumbnailUrlLastFetched = mangaEntry[thumbnailUrlLastFetched], title = mangaEntry[title],
initialized = mangaEntry[initialized], thumbnailUrl = proxyThumbnailUrl(mangaEntry[this.id].value),
artist = mangaEntry[artist], thumbnailUrlLastFetched = mangaEntry[thumbnailUrlLastFetched],
author = mangaEntry[author], initialized = mangaEntry[initialized],
description = mangaEntry[description], artist = mangaEntry[artist],
genre = mangaEntry[genre].toGenreList(), author = mangaEntry[author],
status = Companion.valueOf(mangaEntry[status]).name, description = mangaEntry[description],
inLibrary = mangaEntry[inLibrary], genre = mangaEntry[genre].toGenreList(),
inLibraryAt = mangaEntry[inLibraryAt], status = Companion.valueOf(mangaEntry[status]).name,
meta = getMangaMetaMap(mangaEntry[id].value), inLibrary = mangaEntry[inLibrary],
realUrl = mangaEntry[realUrl], inLibraryAt = mangaEntry[inLibraryAt],
lastFetchedAt = mangaEntry[lastFetchedAt], meta =
chaptersLastFetchedAt = mangaEntry[chaptersLastFetchedAt], if (includeMangaMeta) {
updateStrategy = UpdateStrategy.valueOf(mangaEntry[updateStrategy]), getMangaMetaMap(mangaEntry[id].value)
) } else {
emptyMap()
},
realUrl = mangaEntry[realUrl],
lastFetchedAt = mangaEntry[lastFetchedAt],
chaptersLastFetchedAt = mangaEntry[chaptersLastFetchedAt],
updateStrategy = UpdateStrategy.valueOf(mangaEntry[updateStrategy]),
)
enum class MangaStatus( enum class MangaStatus(
val value: Int, val value: Int,

View File

@@ -23,12 +23,17 @@ object OpdsAPI {
get("genres", OpdsV1Controller.genresFeed) get("genres", OpdsV1Controller.genresFeed)
get("status", OpdsV1Controller.statusFeed) get("status", OpdsV1Controller.statusFeed)
get("languages", OpdsV1Controller.languagesFeed) get("languages", OpdsV1Controller.languagesFeed)
get("library-updates", OpdsV1Controller.libraryUpdatesFeed)
// Faceted feeds (Acquisition Feeds) // Faceted feeds (Acquisition Feeds)
path("manga/{mangaId}") { path("manga/{mangaId}") {
get(OpdsV1Controller.mangaFeed) get(OpdsV1Controller.mangaFeed)
} }
path("manga/{mangaId}/chapter/{chapterId}/fetch") {
get(OpdsV1Controller.chapterMetadataFeed)
}
path("source/{sourceId}") { path("source/{sourceId}") {
get(OpdsV1Controller.sourceFeed) get(OpdsV1Controller.sourceFeed)
} }

View File

@@ -278,6 +278,31 @@ object OpdsV1Controller {
}, },
) )
var chapterMetadataFeed =
handler(
pathParam<Int>("mangaId"),
pathParam<Int>("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 // Specific Source Feed
val sourceFeed = val sourceFeed =
handler( handler(
@@ -407,4 +432,28 @@ object OpdsV1Controller {
httpCode(HttpStatus.NOT_FOUND) httpCode(HttpStatus.NOT_FOUND)
}, },
) )
// Main Library Updates Feed
val libraryUpdatesFeed =
handler(
queryParam<Int?>("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)
},
)
} }

View File

@@ -1,6 +1,8 @@
package suwayomi.tachidesk.opds.impl package suwayomi.tachidesk.opds.impl
import SearchCriteria import SearchCriteria
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import nl.adaptivity.xmlutil.XmlDeclMode import nl.adaptivity.xmlutil.XmlDeclMode
import nl.adaptivity.xmlutil.core.XmlVersion import nl.adaptivity.xmlutil.core.XmlVersion
import nl.adaptivity.xmlutil.serialization.XML 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.Op
import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.lowerCase
import org.jetbrains.exposed.sql.or import org.jetbrains.exposed.sql.or
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction 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.MangaList.proxyThumbnailUrl
import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReady
import suwayomi.tachidesk.manga.impl.extension.Extension.getExtensionIconUrl 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.ChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.dataclass.SourceDataClass 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.SourceTable
import suwayomi.tachidesk.manga.model.table.toDataClass import suwayomi.tachidesk.manga.model.table.toDataClass
import suwayomi.tachidesk.opds.model.OpdsXmlModels import suwayomi.tachidesk.opds.model.OpdsXmlModels
import java.io.File
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.time.Instant import java.time.Instant
@@ -48,6 +51,7 @@ object Opds {
"genres" to "Genres", "genres" to "Genres",
"status" to "Status", "status" to "Status",
"languages" to "Languages", "languages" to "Languages",
"library-updates" to "Library Update History",
).map { (id, title) -> ).map { (id, title) ->
OpdsXmlModels.Entry( OpdsXmlModels.Entry(
id = id, id = id,
@@ -79,26 +83,26 @@ object Opds {
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
.select(MangaTable.columns) .select(MangaTable.columns)
.where { .where {
val baseCondition = ChapterTable.isDownloaded eq true val conditions = mutableListOf<Op<Boolean>>()
if (criteria == null) {
baseCondition criteria?.query?.takeIf { it.isNotBlank() }?.let { q ->
} else { val lowerQ = q.lowercase()
val conditions = mutableListOf<Op<Boolean>>() conditions += (
criteria.query?.takeIf { it.isNotBlank() }?.let { q -> (MangaTable.title.lowerCase() like "%$lowerQ%") or
conditions += ( (MangaTable.author.lowerCase() like "%$lowerQ%") or
(MangaTable.title like "%$q%") or (MangaTable.genre.lowerCase() like "%$lowerQ%")
(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 })
} }
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) }.groupBy(MangaTable.id)
.orderBy(MangaTable.title to SortOrder.ASC) .orderBy(MangaTable.title to SortOrder.ASC)
val totalCount = query.count() val totalCount = query.count()
@@ -106,7 +110,7 @@ object Opds {
query query
.limit(ITEMS_PER_PAGE) .limit(ITEMS_PER_PAGE)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
.map { MangaTable.toDataClass(it) } .map { MangaTable.toDataClass(it, includeMangaMeta = false) }
Pair(mangas, totalCount) Pair(mangas, totalCount)
} }
@@ -136,7 +140,6 @@ object Opds {
}.join(ChapterTable, JoinType.INNER) { }.join(ChapterTable, JoinType.INNER) {
ChapterTable.manga eq MangaTable.id ChapterTable.manga eq MangaTable.id
}.select(SourceTable.columns) }.select(SourceTable.columns)
.where { ChapterTable.isDownloaded eq true }
.groupBy(SourceTable.id) .groupBy(SourceTable.id)
.orderBy(SourceTable.name to SortOrder.ASC) .orderBy(SourceTable.name to SortOrder.ASC)
@@ -195,7 +198,6 @@ object Opds {
.join(MangaTable, JoinType.INNER, onColumn = CategoryMangaTable.manga, otherColumn = MangaTable.id) .join(MangaTable, JoinType.INNER, onColumn = CategoryMangaTable.manga, otherColumn = MangaTable.id)
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
.select(CategoryTable.id, CategoryTable.name) .select(CategoryTable.id, CategoryTable.name)
.where { ChapterTable.isDownloaded eq true }
.groupBy(CategoryTable.id) .groupBy(CategoryTable.id)
.orderBy(CategoryTable.order to SortOrder.ASC) .orderBy(CategoryTable.order to SortOrder.ASC)
.map { row -> .map { row ->
@@ -241,7 +243,6 @@ object Opds {
MangaTable MangaTable
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
.select(MangaTable.genre) .select(MangaTable.genre)
.where { ChapterTable.isDownloaded eq true }
.map { it[MangaTable.genre] } .map { it[MangaTable.genre] }
.flatMap { it?.split(", ")?.filterNot { g -> g.isBlank() } ?: emptyList() } .flatMap { it?.split(", ")?.filterNot { g -> g.isBlank() } ?: emptyList() }
.groupingBy { it } .groupingBy { it }
@@ -344,7 +345,6 @@ object Opds {
.join(MangaTable, JoinType.INNER, onColumn = SourceTable.id, otherColumn = MangaTable.sourceReference) .join(MangaTable, JoinType.INNER, onColumn = SourceTable.id, otherColumn = MangaTable.sourceReference)
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
.select(SourceTable.lang) .select(SourceTable.lang)
.where { ChapterTable.isDownloaded eq true }
.groupBy(SourceTable.lang) .groupBy(SourceTable.lang)
.orderBy(SourceTable.lang to SortOrder.ASC) .orderBy(SourceTable.lang to SortOrder.ASC)
.map { row -> row[SourceTable.lang] } .map { row -> row[SourceTable.lang] }
@@ -378,7 +378,6 @@ object Opds {
baseUrl: String, baseUrl: String,
pageNum: Int, pageNum: Int,
): String { ): String {
val formattedNow = opdsDateFormatter.format(Instant.now())
val (manga, chapters, totalCount) = val (manga, chapters, totalCount) =
transaction { transaction {
val mangaEntry = val mangaEntry =
@@ -386,21 +385,20 @@ object Opds {
.selectAll() .selectAll()
.where { MangaTable.id eq mangaId } .where { MangaTable.id eq mangaId }
.first() .first()
val mangaData = MangaTable.toDataClass(mangaEntry) val mangaData = MangaTable.toDataClass(mangaEntry, includeMangaMeta = false)
val chaptersQuery = val chaptersQuery =
ChapterTable ChapterTable
.selectAll() .selectAll()
.where { .where {
(ChapterTable.manga eq mangaId) and (ChapterTable.manga eq mangaId)
(ChapterTable.isDownloaded eq true) and
(ChapterTable.pageCount greater 0)
}.orderBy(ChapterTable.sourceOrder to SortOrder.DESC) }.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
val total = chaptersQuery.count() val total = chaptersQuery.count()
val chaptersData = val chaptersData =
chaptersQuery chaptersQuery
.limit(ITEMS_PER_PAGE) .limit(ITEMS_PER_PAGE)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
.map { ChapterTable.toDataClass(it) } .map { ChapterTable.toDataClass(it, includeChapterCount = false, includeChapterMeta = false) }
Triple(mangaData, chaptersData, total) Triple(mangaData, chaptersData, total)
} }
@@ -422,56 +420,140 @@ object Opds {
type = "image/jpeg", type = "image/jpeg",
) )
} }
entries += chapters.map { createChapterEntry(it, manga) } entries += chapters.map { createChapterEntry(it, manga, baseUrl, isMetaDataEntry = false) }
}.build() }.build()
.let(::serialize) .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( private fun createChapterEntry(
chapter: ChapterDataClass, chapter: ChapterDataClass,
manga: MangaDataClass, manga: MangaDataClass,
baseUrl: String,
isMetaDataEntry: Boolean,
addMangaTitleInEntry: Boolean = false,
): OpdsXmlModels.Entry { ): OpdsXmlModels.Entry {
val cbzFile = File(getChapterCbzPath(manga.id, chapter.id)) val chapterDetails =
val isCbzAvailable = cbzFile.exists() buildString {
append("${manga.title} | ${chapter.name} | By ${chapter.scanlator}")
if (isMetaDataEntry) {
append(" | Progress (${chapter.lastPageRead} / ${chapter.pageCount})")
}
}
return OpdsXmlModels.Entry( val entryTitle =
id = "chapter/${chapter.id}", when {
title = chapter.name, chapter.read -> ""
updated = opdsDateFormatter.format(Instant.ofEpochMilli(chapter.uploadDate)), chapter.lastPageRead > 0 -> ""
content = OpdsXmlModels.Content(value = "${chapter.scanlator}"), chapter.pageCount == 0 -> ""
summary = manga.description?.let { OpdsXmlModels.Summary(value = it) }, else -> ""
extent = } + (if (addMangaTitleInEntry) " ${manga.title} :" else "") + " ${chapter.name}"
cbzFile.takeIf { it.exists() }?.let {
formatFileSize(it.length()) val cbzInputStreamPair =
}, runCatching {
format = cbzFile.takeIf { it.exists() }?.let { "CBZ" }, if (isMetaDataEntry && chapter.downloaded) getArchiveStreamWithSize(manga.id, chapter.id) else null
authors = }.getOrNull()
listOfNotNull(
manga.author?.let { OpdsXmlModels.Author(name = it) }, val links =
manga.artist?.takeIf { it != manga.author }?.let { OpdsXmlModels.Author(name = it) }, mutableListOf<OpdsXmlModels.Link>().apply {
), if (cbzInputStreamPair != null) {
link = add(
listOfNotNull(
if (isCbzAvailable) {
OpdsXmlModels.Link( OpdsXmlModels.Link(
rel = "http://opds-spec.org/acquisition/open-access", rel = "http://opds-spec.org/acquisition/open-access",
href = "/api/v1/chapter/${chapter.id}/download", href = "/api/v1/chapter/${chapter.id}/download",
type = "application/vnd.comicbook+zip", type = "application/vnd.comicbook+zip",
) ),
} else { )
}
if (isMetaDataEntry) {
add(
OpdsXmlModels.Link( OpdsXmlModels.Link(
rel = "http://vaemendis.net/opds-pse/stream", 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", type = "image/jpeg",
pseCount = chapter.pageCount, pseCount = chapter.pageCount,
) pseLastRead = chapter.lastPageRead.takeIf { it != 0 },
}, ),
OpdsXmlModels.Link( )
rel = "http://opds-spec.org/image", add(
href = "/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/0", OpdsXmlModels.Link(
type = "image/jpeg", 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) .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
.select(MangaTable.columns) .select(MangaTable.columns)
.where { .where {
(MangaTable.sourceReference eq sourceId) and (ChapterTable.isDownloaded eq true) (MangaTable.sourceReference eq sourceId)
}.groupBy(MangaTable.id) }.groupBy(MangaTable.id)
.orderBy(MangaTable.title to SortOrder.ASC) .orderBy(MangaTable.title to SortOrder.ASC)
@@ -504,7 +586,7 @@ object Opds {
query query
.limit(ITEMS_PER_PAGE) .limit(ITEMS_PER_PAGE)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
.map { MangaTable.toDataClass(it) } .map { MangaTable.toDataClass(it, includeMangaMeta = false) }
Triple(paginatedResults, totalCount, sourceRow) Triple(paginatedResults, totalCount, sourceRow)
} }
@@ -536,7 +618,7 @@ object Opds {
.join(MangaTable, JoinType.INNER, onColumn = CategoryMangaTable.manga, otherColumn = MangaTable.id) .join(MangaTable, JoinType.INNER, onColumn = CategoryMangaTable.manga, otherColumn = MangaTable.id)
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
.select(MangaTable.columns) .select(MangaTable.columns)
.where { (CategoryMangaTable.category eq categoryId) and (ChapterTable.isDownloaded eq true) } .where { (CategoryMangaTable.category eq categoryId) }
.groupBy(MangaTable.id) .groupBy(MangaTable.id)
.orderBy(MangaTable.title to SortOrder.ASC) .orderBy(MangaTable.title to SortOrder.ASC)
val totalCount = query.count() val totalCount = query.count()
@@ -544,7 +626,7 @@ object Opds {
query query
.limit(ITEMS_PER_PAGE) .limit(ITEMS_PER_PAGE)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
.map { MangaTable.toDataClass(it) } .map { MangaTable.toDataClass(it, includeMangaMeta = false) }
Triple(mangas, totalCount, categoryName) Triple(mangas, totalCount, categoryName)
} }
return FeedBuilder(baseUrl, pageNum, "category/$categoryId", "Category: $categoryName") return FeedBuilder(baseUrl, pageNum, "category/$categoryId", "Category: $categoryName")
@@ -567,7 +649,7 @@ object Opds {
MangaTable MangaTable
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
.select(MangaTable.columns) .select(MangaTable.columns)
.where { (MangaTable.genre like "%$genre%") and (ChapterTable.isDownloaded eq true) } .where { (MangaTable.genre like "%$genre%") }
.groupBy(MangaTable.id) .groupBy(MangaTable.id)
.orderBy(MangaTable.title to SortOrder.ASC) .orderBy(MangaTable.title to SortOrder.ASC)
val totalCount = query.count() val totalCount = query.count()
@@ -575,7 +657,7 @@ object Opds {
query query
.limit(ITEMS_PER_PAGE) .limit(ITEMS_PER_PAGE)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
.map { MangaTable.toDataClass(it) } .map { MangaTable.toDataClass(it, includeMangaMeta = false) }
Pair(mangas, totalCount) Pair(mangas, totalCount)
} }
return FeedBuilder(baseUrl, pageNum, "genre/${genre.encodeURL()}", "Genre: $genre") return FeedBuilder(baseUrl, pageNum, "genre/${genre.encodeURL()}", "Genre: $genre")
@@ -604,7 +686,7 @@ object Opds {
MangaTable MangaTable
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
.select(MangaTable.columns) .select(MangaTable.columns)
.where { (MangaTable.status eq statusId.toInt()) and (ChapterTable.isDownloaded eq true) } .where { (MangaTable.status eq statusId.toInt()) }
.groupBy(MangaTable.id) .groupBy(MangaTable.id)
.orderBy(MangaTable.title to SortOrder.ASC) .orderBy(MangaTable.title to SortOrder.ASC)
val totalCount = query.count() val totalCount = query.count()
@@ -612,7 +694,7 @@ object Opds {
query query
.limit(ITEMS_PER_PAGE) .limit(ITEMS_PER_PAGE)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
.map { MangaTable.toDataClass(it) } .map { MangaTable.toDataClass(it, includeMangaMeta = false) }
Pair(mangas, totalCount) Pair(mangas, totalCount)
} }
return FeedBuilder(baseUrl, pageNum, "status/$statusId", "Status: $statusName") 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(MangaTable, JoinType.INNER, onColumn = SourceTable.id, otherColumn = MangaTable.sourceReference)
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
.select(MangaTable.columns) .select(MangaTable.columns)
.where { (SourceTable.lang eq langCode) and (ChapterTable.isDownloaded eq true) } .where { (SourceTable.lang eq langCode) }
.groupBy(MangaTable.id) .groupBy(MangaTable.id)
.orderBy(MangaTable.title to SortOrder.ASC) .orderBy(MangaTable.title to SortOrder.ASC)
val totalCount = query.count() val totalCount = query.count()
@@ -644,7 +726,7 @@ object Opds {
query query
.limit(ITEMS_PER_PAGE) .limit(ITEMS_PER_PAGE)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) .offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
.map { MangaTable.toDataClass(it) } .map { MangaTable.toDataClass(it, includeMangaMeta = false) }
Pair(mangas, totalCount) Pair(mangas, totalCount)
} }
return FeedBuilder(baseUrl, pageNum, "language/$langCode", "Language: $langCode") return FeedBuilder(baseUrl, pageNum, "language/$langCode", "Language: $langCode")
@@ -655,6 +737,52 @@ object Opds {
.let(::serialize) .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( private class FeedBuilder(
val baseUrl: String, val baseUrl: String,
val pageNum: Int, val pageNum: Int,

View File

@@ -64,6 +64,8 @@ data class OpdsXmlModels(
val title: String? = null, val title: String? = null,
@XmlSerialName("pse:count", "", "") @XmlSerialName("pse:count", "", "")
val pseCount: Int? = null, val pseCount: Int? = null,
@XmlSerialName("pse:lastRead", "", "")
val pseLastRead: Int? = null,
@XmlSerialName("opds:facetGroup", "", "") @XmlSerialName("opds:facetGroup", "", "")
val facetGroup: String? = null, val facetGroup: String? = null,
@XmlSerialName("opds:activeFacet", "", "") @XmlSerialName("opds:activeFacet", "", "")