diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt index 42bcd986a..018a8306b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt @@ -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 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt index 3ba77c7d1..a445ace9a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt index e4d2ec18c..1d14df4b1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt @@ -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: //// @@ -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") + } } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt index 4cbd55981..da0384082 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/bookoasis/BookOasisCompat.kt b/app/src/main/java/eu/kanade/tachiyomi/source/bookoasis/BookOasisCompat.kt index 726d99079..9925dd1a3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/bookoasis/BookOasisCompat.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/bookoasis/BookOasisCompat.kt @@ -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) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/kavita/KavitaCompat.kt b/app/src/main/java/eu/kanade/tachiyomi/source/kavita/KavitaCompat.kt index a2616bcc6..6306377cc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/kavita/KavitaCompat.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/kavita/KavitaCompat.kt @@ -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. diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/BookOasisFilePageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/BookOasisFilePageLoader.kt index 2a928c5e8..cf6c199dd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/BookOasisFilePageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/BookOasisFilePageLoader.kt @@ -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) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt index e948c86af..73d8ea52d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt @@ -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 { 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 { - 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 { + val loader = EpubPageLoader( + file.epubReader(context), + readerPreferences.textReaderStyle(context), + context.cacheDir, + ).also { pageLoader = it } + return loader.getPages() + } + + private suspend fun getPagesFromText(file: UniFile): List { + 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) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/KavitaCompatPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/KavitaCompatPageLoader.kt index 588659212..377f68608 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/KavitaCompatPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/KavitaCompatPageLoader.kt @@ -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 { 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) {