Support EPUB and TXT downloads from Kavita and BookOasis
Build & Test / Build & Test App (push) Has been cancelled
Build & Test / Build & Test App (push) Has been cancelled
This commit is contained in:
@@ -376,8 +376,10 @@ class DownloadCache(
|
||||
it.name?.endsWith(Downloader.TMP_DIR_SUFFIX) == true -> null
|
||||
// Folder of images
|
||||
it.isDirectory -> it.name
|
||||
// CBZ files
|
||||
it.isFile && it.extension == "cbz" -> it.nameWithoutExtension
|
||||
// Archived or original document files
|
||||
it.isFile && it.extension?.lowercase()?.let { extension ->
|
||||
extension == "cbz" || extension in DOCUMENT_DOWNLOAD_EXTENSIONS
|
||||
} == true -> it.nameWithoutExtension
|
||||
// Anything else is irrelevant
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -405,8 +405,12 @@ class DownloadManager(
|
||||
.firstOrNull() ?: return
|
||||
|
||||
var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator, newChapter.url)
|
||||
if (oldDownload.isFile && oldDownload.extension == "cbz") {
|
||||
newName += ".cbz"
|
||||
if (oldDownload.isFile) {
|
||||
oldDownload.extension?.lowercase()?.let { extension ->
|
||||
if (extension == "cbz" || extension in DOCUMENT_DOWNLOAD_EXTENSIONS) {
|
||||
newName += ".$extension"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (oldDownload.name == newName) return
|
||||
|
||||
@@ -19,6 +19,8 @@ import tachiyomi.domain.storage.service.StorageManager
|
||||
import tachiyomi.i18n.MR
|
||||
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.
|
||||
* It uses the following path scheme: /<root downloads dir>/<source name>/<manga>/<chapter>
|
||||
@@ -250,11 +252,17 @@ class DownloadProvider(
|
||||
add(chapterDirName)
|
||||
// Archived chapters
|
||||
add("$chapterDirName.cbz")
|
||||
DOCUMENT_DOWNLOAD_EXTENSIONS.forEach { extension ->
|
||||
add("$chapterDirName.$extension")
|
||||
}
|
||||
|
||||
// any legacy names
|
||||
legacyChapterDirNames.forEach {
|
||||
add(it)
|
||||
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.notification.NotificationHandler
|
||||
import eu.kanade.tachiyomi.network.HttpException
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
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.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.util.storage.DiskUtil
|
||||
import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE
|
||||
@@ -44,6 +52,7 @@ import kotlinx.coroutines.supervisorScope
|
||||
import logcat.LogPriority
|
||||
import mihon.core.archive.ZipWriter
|
||||
import nl.adaptivity.xmlutil.serialization.XML
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
@@ -339,9 +348,15 @@ class Downloader(
|
||||
download.chapter.scanlator,
|
||||
download.chapter.url,
|
||||
)
|
||||
val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX)!!
|
||||
|
||||
try {
|
||||
val remoteDocument = findRemoteDocument(download)
|
||||
if (remoteDocument != null) {
|
||||
downloadDocument(download, mangaDir, chapterDirname, remoteDocument)
|
||||
return
|
||||
}
|
||||
|
||||
val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX)!!
|
||||
|
||||
// If the page list already exists, start from the file
|
||||
val pageList = download.pages ?: run {
|
||||
// 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.
|
||||
*
|
||||
@@ -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
|
||||
private const val MIN_DISK_SPACE = 200L * 1024 * 1024
|
||||
|
||||
@@ -21,6 +21,22 @@ internal data class BookOasisChapterRef(
|
||||
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 {
|
||||
return this::class.java.name.startsWith(BOOKOASIS_PACKAGE_PREFIX)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,56 @@
|
||||
package eu.kanade.tachiyomi.source.kavita
|
||||
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
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."
|
||||
|
||||
internal fun Source.isKavitaSource(): Boolean =
|
||||
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
|
||||
* 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 eu.kanade.tachiyomi.network.await
|
||||
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.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
|
||||
import mihon.core.archive.epubReader
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
@@ -68,19 +68,7 @@ internal class BookOasisFilePageLoader(
|
||||
val temp = File(cacheDir, "${target.name}.part")
|
||||
if (temp.exists()) temp.delete()
|
||||
|
||||
val downloadUrl = buildString {
|
||||
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()
|
||||
val request = source.bookOasisDocumentDownloadRequest(ref)
|
||||
|
||||
source.client.newCall(request).await().use { response ->
|
||||
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.ReaderPage
|
||||
import mihon.core.archive.archiveReader
|
||||
import mihon.core.archive.epubReader
|
||||
import tachiyomi.core.common.storage.extension
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
@@ -27,9 +30,12 @@ internal class DownloadPageLoader(
|
||||
|
||||
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 val usesTextReaderStyle: Boolean
|
||||
get() = pageLoader?.usesTextReaderStyle == true
|
||||
|
||||
override suspend fun getPages(): List<ReaderPage> {
|
||||
val dbChapter = chapter.chapter
|
||||
@@ -41,7 +47,11 @@ internal class DownloadPageLoader(
|
||||
source,
|
||||
)
|
||||
return if (chapterPath?.isFile == true) {
|
||||
getPagesFromArchive(chapterPath)
|
||||
when (chapterPath.extension?.lowercase()) {
|
||||
"epub" -> getPagesFromEpub(chapterPath)
|
||||
"txt" -> getPagesFromText(chapterPath)
|
||||
else -> getPagesFromArchive(chapterPath)
|
||||
}
|
||||
} else {
|
||||
getPagesFromDirectory()
|
||||
}
|
||||
@@ -49,11 +59,30 @@ internal class DownloadPageLoader(
|
||||
|
||||
override fun recycle() {
|
||||
super.recycle()
|
||||
archivePageLoader?.recycle()
|
||||
pageLoader?.recycle()
|
||||
pageLoader = null
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -69,6 +98,6 @@ internal class DownloadPageLoader(
|
||||
}
|
||||
|
||||
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 eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||
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.ui.reader.model.ReaderChapter
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
|
||||
import mihon.core.archive.epubReader
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Compatibility loader for Kavita document chapters.
|
||||
@@ -42,7 +41,7 @@ internal class KavitaCompatPageLoader(
|
||||
override suspend fun getPages(): List<ReaderPage> {
|
||||
check(!isRecycled)
|
||||
|
||||
val format = detectDocumentFormat()
|
||||
val format = source.detectKavitaDocumentFormat(chapterId)
|
||||
val loader = when (format) {
|
||||
"epub", "txt" -> createDocumentLoader(format)
|
||||
else -> HttpPageLoader(chapter, source, chapterCache)
|
||||
@@ -67,51 +66,6 @@ internal class KavitaCompatPageLoader(
|
||||
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 {
|
||||
val file = getOrDownloadDocument(format)
|
||||
val uniFile = UniFile.fromFile(file)
|
||||
@@ -132,12 +86,7 @@ internal class KavitaCompatPageLoader(
|
||||
val temp = File(cacheDir, "${target.name}.part")
|
||||
if (temp.exists()) temp.delete()
|
||||
|
||||
val apiBase = source.baseUrl.trimEnd('/') + "/api"
|
||||
val request = Request.Builder()
|
||||
.url("$apiBase/Download/chapter?chapterId=$chapterId")
|
||||
.headers(source.headers)
|
||||
.get()
|
||||
.build()
|
||||
val request = source.kavitaDocumentDownloadRequest(chapterId)
|
||||
|
||||
source.client.newCall(request).await().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
|
||||
Reference in New Issue
Block a user