Support EPUB and TXT downloads from Kavita and BookOasis
Build & Test / Build & Test App (push) Has been cancelled

This commit is contained in:
2026-09-21 11:02:48 +09:00
parent c0e0ef1575
commit 849148691a
9 changed files with 218 additions and 80 deletions
@@ -376,8 +376,10 @@ class DownloadCache(
it.name?.endsWith(Downloader.TMP_DIR_SUFFIX) == true -> null it.name?.endsWith(Downloader.TMP_DIR_SUFFIX) == true -> null
// Folder of images // Folder of images
it.isDirectory -> it.name it.isDirectory -> it.name
// CBZ files // Archived or original document files
it.isFile && it.extension == "cbz" -> it.nameWithoutExtension it.isFile && it.extension?.lowercase()?.let { extension ->
extension == "cbz" || extension in DOCUMENT_DOWNLOAD_EXTENSIONS
} == true -> it.nameWithoutExtension
// Anything else is irrelevant // Anything else is irrelevant
else -> null else -> null
} }
@@ -405,8 +405,12 @@ class DownloadManager(
.firstOrNull() ?: return .firstOrNull() ?: return
var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator, newChapter.url) var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator, newChapter.url)
if (oldDownload.isFile && oldDownload.extension == "cbz") { if (oldDownload.isFile) {
newName += ".cbz" oldDownload.extension?.lowercase()?.let { extension ->
if (extension == "cbz" || extension in DOCUMENT_DOWNLOAD_EXTENSIONS) {
newName += ".$extension"
}
}
} }
if (oldDownload.name == newName) return if (oldDownload.name == newName) return
@@ -19,6 +19,8 @@ import tachiyomi.domain.storage.service.StorageManager
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import java.io.IOException import java.io.IOException
internal val DOCUMENT_DOWNLOAD_EXTENSIONS = setOf("epub", "txt")
/** /**
* This class is used to provide the directories where the downloads should be saved. * This class is used to provide the directories where the downloads should be saved.
* It uses the following path scheme: /<root downloads dir>/<source name>/<manga>/<chapter> * It uses the following path scheme: /<root downloads dir>/<source name>/<manga>/<chapter>
@@ -250,11 +252,17 @@ class DownloadProvider(
add(chapterDirName) add(chapterDirName)
// Archived chapters // Archived chapters
add("$chapterDirName.cbz") add("$chapterDirName.cbz")
DOCUMENT_DOWNLOAD_EXTENSIONS.forEach { extension ->
add("$chapterDirName.$extension")
}
// any legacy names // any legacy names
legacyChapterDirNames.forEach { legacyChapterDirNames.forEach {
add(it) add(it)
add("$it.cbz") add("$it.cbz")
DOCUMENT_DOWNLOAD_EXTENSIONS.forEach { extension ->
add("$it.$extension")
}
} }
} }
} }
@@ -12,8 +12,16 @@ import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.library.LibraryUpdateNotifier import eu.kanade.tachiyomi.data.library.LibraryUpdateNotifier
import eu.kanade.tachiyomi.data.notification.NotificationHandler import eu.kanade.tachiyomi.data.notification.NotificationHandler
import eu.kanade.tachiyomi.network.HttpException import eu.kanade.tachiyomi.network.HttpException
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.UnmeteredSource import eu.kanade.tachiyomi.source.UnmeteredSource
import eu.kanade.tachiyomi.source.bookoasis.bookOasisDocumentDownloadRequest
import eu.kanade.tachiyomi.source.bookoasis.isBookOasisSource
import eu.kanade.tachiyomi.source.bookoasis.toBookOasisChapterRef
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.kavita.detectKavitaDocumentFormat
import eu.kanade.tachiyomi.source.kavita.isKavitaSource
import eu.kanade.tachiyomi.source.kavita.kavitaDocumentDownloadRequest
import eu.kanade.tachiyomi.source.kavita.toKavitaChapterId
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE
@@ -44,6 +52,7 @@ import kotlinx.coroutines.supervisorScope
import logcat.LogPriority import logcat.LogPriority
import mihon.core.archive.ZipWriter import mihon.core.archive.ZipWriter
import nl.adaptivity.xmlutil.serialization.XML import nl.adaptivity.xmlutil.serialization.XML
import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchIO
@@ -339,9 +348,15 @@ class Downloader(
download.chapter.scanlator, download.chapter.scanlator,
download.chapter.url, download.chapter.url,
) )
try {
val remoteDocument = findRemoteDocument(download)
if (remoteDocument != null) {
downloadDocument(download, mangaDir, chapterDirname, remoteDocument)
return
}
val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX)!! val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX)!!
try {
// If the page list already exists, start from the file // If the page list already exists, start from the file
val pageList = download.pages ?: run { val pageList = download.pages ?: run {
// Otherwise, pull page list from network and add them to download object // Otherwise, pull page list from network and add them to download object
@@ -415,6 +430,84 @@ class Downloader(
} }
} }
private suspend fun findRemoteDocument(download: Download): RemoteDocument? {
val source = download.source
return when {
source.isBookOasisSource() -> download.chapter.url
.toBookOasisChapterRef()
?.takeIf { it.format in DOCUMENT_DOWNLOAD_EXTENSIONS }
?.let { ref -> RemoteDocument(ref.format, source.bookOasisDocumentDownloadRequest(ref)) }
source.isKavitaSource() -> download.chapter.url
.toKavitaChapterId()
?.let { chapterId ->
source.detectKavitaDocumentFormat(chapterId)?.let { format ->
RemoteDocument(format, source.kavitaDocumentDownloadRequest(chapterId))
}
}
else -> null
}
}
private suspend fun downloadDocument(
download: Download,
mangaDir: UniFile,
chapterDirname: String,
document: RemoteDocument,
) {
val targetName = "$chapterDirname.${document.extension}"
mangaDir.findFile(targetName)?.delete()
mangaDir.findFile("$targetName.tmp")?.delete()
val temp = mangaDir.createFile("$targetName.tmp")
?: error("Failed to create temporary document file")
val page = Page(0).apply {
status = Page.State.DownloadImage
}
download.pages = listOf(page)
download.status = Download.State.DOWNLOADING
notifier.onProgressChange(download)
try {
download.source.client.newCall(document.request).await().use { response ->
if (!response.isSuccessful) {
throw IllegalStateException(
"Document download failed: HTTP ${response.code}",
)
}
temp.openOutputStream().use { output ->
response.body.byteStream().use { input -> input.copyTo(output) }
}
}
if (temp.length() <= 0L) {
throw IllegalStateException("Document download returned an empty file")
}
if (!temp.renameTo(targetName)) {
val target = mangaDir.createFile(targetName)
?: error("Failed to create downloaded document file")
temp.openInputStream().use { input ->
target.openOutputStream().use { output ->
input.copyTo(output)
}
}
temp.delete()
}
} catch (error: Throwable) {
temp.delete()
throw error
}
val target = mangaDir.findFile(targetName)
?: error("Downloaded document file is missing")
page.uri = target.uri
page.status = Page.State.Ready
page.progress = 100
notifier.onProgressChange(download)
cache.addChapter(chapterDirname, mangaDir, download.manga)
DiskUtil.createNoMediaFile(mangaDir, context)
download.status = Download.State.DOWNLOADED
}
/** /**
* Gets the image from the filesystem if it exists or downloads it otherwise. * Gets the image from the filesystem if it exists or downloads it otherwise.
* *
@@ -740,5 +833,10 @@ class Downloader(
} }
} }
private data class RemoteDocument(
val extension: String,
val request: Request,
)
// Arbitrary minimum required space to start a download: 200 MB // Arbitrary minimum required space to start a download: 200 MB
private const val MIN_DISK_SPACE = 200L * 1024 * 1024 private const val MIN_DISK_SPACE = 200L * 1024 * 1024
@@ -21,6 +21,22 @@ internal data class BookOasisChapterRef(
val format: String, val format: String,
) )
internal fun HttpSource.bookOasisDocumentDownloadRequest(ref: BookOasisChapterRef): Request {
val downloadUrl = buildString {
append(baseUrl.trimEnd('/'))
append("/api/media/books/")
append(ref.bookId)
append("/download?type=")
append(ref.dbType)
}
return Request.Builder()
.url(downloadUrl)
.headers(headers)
.get()
.build()
}
internal fun Source.isBookOasisSource(): Boolean { internal fun Source.isBookOasisSource(): Boolean {
return this::class.java.name.startsWith(BOOKOASIS_PACKAGE_PREFIX) return this::class.java.name.startsWith(BOOKOASIS_PACKAGE_PREFIX)
} }
@@ -1,12 +1,56 @@
package eu.kanade.tachiyomi.source.kavita package eu.kanade.tachiyomi.source.kavita
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.online.HttpSource
import okhttp3.Request
import org.json.JSONObject
import java.util.Locale
private const val KAVITA_PACKAGE_PREFIX = "eu.kanade.tachiyomi.extension.all.kavita." private const val KAVITA_PACKAGE_PREFIX = "eu.kanade.tachiyomi.extension.all.kavita."
internal fun Source.isKavitaSource(): Boolean = internal fun Source.isKavitaSource(): Boolean =
this::class.java.name.startsWith(KAVITA_PACKAGE_PREFIX) this::class.java.name.startsWith(KAVITA_PACKAGE_PREFIX)
internal suspend fun HttpSource.detectKavitaDocumentFormat(chapterId: Int): String? {
val apiBase = baseUrl.trimEnd('/') + "/api"
val request = Request.Builder()
.url("$apiBase/Chapter?chapterId=$chapterId")
.headers(headers)
.get()
.build()
client.newCall(request).await().use { response ->
if (!response.isSuccessful) return null
val body = response.body.string()
if (body.isBlank()) return null
val files = JSONObject(body).optJSONArray("files") ?: return null
for (i in 0 until files.length()) {
val file = files.optJSONObject(i) ?: continue
val extension = file.optString("extension")
.ifBlank { file.optString("filePath") }
.substringAfterLast('.')
.trim()
.trimStart('.')
.lowercase(Locale.ROOT)
if (extension == "epub" || extension == "txt") return extension
if (file.optInt("format", -1) == 3) return "epub"
}
}
return null
}
internal fun HttpSource.kavitaDocumentDownloadRequest(chapterId: Int): Request {
val apiBase = baseUrl.trimEnd('/') + "/api"
return Request.Builder()
.url("$apiBase/Download/chapter?chapterId=$chapterId")
.headers(headers)
.get()
.build()
}
/** /**
* Kavita extension chapter URLs are normally /Chapter/{id}. Keep compatibility * Kavita extension chapter URLs are normally /Chapter/{id}. Keep compatibility
* with older/alternate forms used by reading lists and previous releases. * with older/alternate forms used by reading lists and previous releases.
@@ -4,11 +4,11 @@ import android.content.Context
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.bookoasis.BookOasisChapterRef import eu.kanade.tachiyomi.source.bookoasis.BookOasisChapterRef
import eu.kanade.tachiyomi.source.bookoasis.bookOasisDocumentDownloadRequest
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
import mihon.core.archive.epubReader import mihon.core.archive.epubReader
import okhttp3.Request
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
@@ -68,19 +68,7 @@ internal class BookOasisFilePageLoader(
val temp = File(cacheDir, "${target.name}.part") val temp = File(cacheDir, "${target.name}.part")
if (temp.exists()) temp.delete() if (temp.exists()) temp.delete()
val downloadUrl = buildString { val request = source.bookOasisDocumentDownloadRequest(ref)
append(source.baseUrl.trimEnd('/'))
append("/api/media/books/")
append(ref.bookId)
append("/download?type=")
append(ref.dbType)
}
val request = Request.Builder()
.url(downloadUrl)
.headers(source.headers)
.get()
.build()
source.client.newCall(request).await().use { response -> source.client.newCall(request).await().use { response ->
if (!response.isSuccessful) { if (!response.isSuccessful) {
@@ -11,7 +11,10 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import mihon.core.archive.archiveReader import mihon.core.archive.archiveReader
import mihon.core.archive.epubReader
import tachiyomi.core.common.storage.extension
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
/** /**
@@ -27,9 +30,12 @@ internal class DownloadPageLoader(
private val context: Context by injectLazy() private val context: Context by injectLazy()
private var archivePageLoader: ArchivePageLoader? = null private var pageLoader: PageLoader? = null
private val readerPreferences: ReaderPreferences by injectLazy()
override var isLocal: Boolean = true override var isLocal: Boolean = true
override val usesTextReaderStyle: Boolean
get() = pageLoader?.usesTextReaderStyle == true
override suspend fun getPages(): List<ReaderPage> { override suspend fun getPages(): List<ReaderPage> {
val dbChapter = chapter.chapter val dbChapter = chapter.chapter
@@ -41,7 +47,11 @@ internal class DownloadPageLoader(
source, source,
) )
return if (chapterPath?.isFile == true) { return if (chapterPath?.isFile == true) {
getPagesFromArchive(chapterPath) when (chapterPath.extension?.lowercase()) {
"epub" -> getPagesFromEpub(chapterPath)
"txt" -> getPagesFromText(chapterPath)
else -> getPagesFromArchive(chapterPath)
}
} else { } else {
getPagesFromDirectory() getPagesFromDirectory()
} }
@@ -49,11 +59,30 @@ internal class DownloadPageLoader(
override fun recycle() { override fun recycle() {
super.recycle() super.recycle()
archivePageLoader?.recycle() pageLoader?.recycle()
pageLoader = null
} }
private suspend fun getPagesFromArchive(file: UniFile): List<ReaderPage> { private suspend fun getPagesFromArchive(file: UniFile): List<ReaderPage> {
val loader = ArchivePageLoader(file.archiveReader(context)).also { archivePageLoader = it } val loader = ArchivePageLoader(file.archiveReader(context)).also { pageLoader = it }
return loader.getPages()
}
private suspend fun getPagesFromEpub(file: UniFile): List<ReaderPage> {
val loader = EpubPageLoader(
file.epubReader(context),
readerPreferences.textReaderStyle(context),
context.cacheDir,
).also { pageLoader = it }
return loader.getPages()
}
private suspend fun getPagesFromText(file: UniFile): List<ReaderPage> {
val loader = TextPageLoader(
file,
readerPreferences.textReaderStyle(context),
context.cacheDir,
).also { pageLoader = it }
return loader.getPages() return loader.getPages()
} }
@@ -69,6 +98,6 @@ internal class DownloadPageLoader(
} }
override suspend fun loadPage(page: ReaderPage) { override suspend fun loadPage(page: ReaderPage) {
archivePageLoader?.loadPage(page) pageLoader?.loadPage(page)
} }
} }
@@ -4,16 +4,15 @@ import android.content.Context
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.kavita.detectKavitaDocumentFormat
import eu.kanade.tachiyomi.source.kavita.kavitaDocumentDownloadRequest
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
import mihon.core.archive.epubReader import mihon.core.archive.epubReader
import okhttp3.Request
import org.json.JSONObject
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.Locale
/** /**
* Compatibility loader for Kavita document chapters. * Compatibility loader for Kavita document chapters.
@@ -42,7 +41,7 @@ internal class KavitaCompatPageLoader(
override suspend fun getPages(): List<ReaderPage> { override suspend fun getPages(): List<ReaderPage> {
check(!isRecycled) check(!isRecycled)
val format = detectDocumentFormat() val format = source.detectKavitaDocumentFormat(chapterId)
val loader = when (format) { val loader = when (format) {
"epub", "txt" -> createDocumentLoader(format) "epub", "txt" -> createDocumentLoader(format)
else -> HttpPageLoader(chapter, source, chapterCache) else -> HttpPageLoader(chapter, source, chapterCache)
@@ -67,51 +66,6 @@ internal class KavitaCompatPageLoader(
super.recycle() super.recycle()
} }
private suspend fun detectDocumentFormat(): String? {
val apiBase = source.baseUrl.trimEnd('/') + "/api"
val request = Request.Builder()
.url("$apiBase/Chapter?chapterId=$chapterId")
.headers(source.headers)
.get()
.build()
source.client.newCall(request).await().use { response ->
if (!response.isSuccessful) {
// Preserve the extension's normal behavior when chapter metadata
// cannot be queried. It may still be readable as image content.
return null
}
val body = response.body.string()
if (body.isBlank()) return null
val chapterJson = JSONObject(body)
val files = chapterJson.optJSONArray("files") ?: return null
for (i in 0 until files.length()) {
val file = files.optJSONObject(i) ?: continue
val extension = normalizeExtension(
file.optString("extension").ifBlank { file.optString("filePath") },
)
if (extension == "epub" || extension == "txt") {
return extension
}
// Current Kavita format enum: EPUB == 3. TXT has no dedicated
// enum value in upstream Kavita, so it is detected by extension.
if (file.optInt("format", -1) == 3) {
return "epub"
}
}
}
return null
}
private fun normalizeExtension(value: String): String =
value.substringAfterLast('.', value)
.trim()
.trimStart('.')
.lowercase(Locale.ROOT)
private suspend fun createDocumentLoader(format: String): PageLoader { private suspend fun createDocumentLoader(format: String): PageLoader {
val file = getOrDownloadDocument(format) val file = getOrDownloadDocument(format)
val uniFile = UniFile.fromFile(file) val uniFile = UniFile.fromFile(file)
@@ -132,12 +86,7 @@ internal class KavitaCompatPageLoader(
val temp = File(cacheDir, "${target.name}.part") val temp = File(cacheDir, "${target.name}.part")
if (temp.exists()) temp.delete() if (temp.exists()) temp.delete()
val apiBase = source.baseUrl.trimEnd('/') + "/api" val request = source.kavitaDocumentDownloadRequest(chapterId)
val request = Request.Builder()
.url("$apiBase/Download/chapter?chapterId=$chapterId")
.headers(source.headers)
.get()
.build()
source.client.newCall(request).await().use { response -> source.client.newCall(request).await().use { response ->
if (!response.isSuccessful) { if (!response.isSuccessful) {