mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-03 19:04:39 -05:00
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:
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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", "", "")
|
||||||
|
|||||||
Reference in New Issue
Block a user