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

@@ -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,
)
}
}

View File

@@ -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<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) =
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)
}
}

View File

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

View File

@@ -125,7 +125,10 @@ abstract class ChaptersFilesProvider<Type : FileType>(
.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<Type : FileType>(
FileDownload3Args(::downloadImpl)
abstract override fun delete(): Boolean
abstract fun getAsArchiveStream(): Pair<InputStream, Long>
}

View File

@@ -88,6 +88,15 @@ class ArchiveProvider(
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(
cbzFile: 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.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<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()
}
}