diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 371e8555..d7fb06db 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -53,7 +53,6 @@ dependencies { implementation("com.dorkbox:SystemTray:4.1") implementation("com.dorkbox:Utilities:1.9") // version locked by SystemTray - // dependencies of Tachiyomi extensions, some are duplicate, keeping it here for reference implementation("com.github.inorichi.injekt:injekt-core:65b0440") implementation("com.squareup.okhttp3:okhttp:4.9.1") @@ -68,8 +67,9 @@ dependencies { // asm for ByteCodeEditor(fixing SimpleDateFormat) (must match Dex2Jar version) implementation("org.ow2.asm:asm:9.2") - // extracting zip files + // Disk & File implementation("net.lingala.zip4j:zip4j:2.9.0") + implementation("com.github.junrar:junrar:7.4.0") // CloudflareInterceptor implementation("net.sourceforge.htmlunit:htmlunit:2.52.0") diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/LocalSource.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/local/LocalSource.kt similarity index 62% rename from server/src/main/kotlin/eu/kanade/tachiyomi/source/LocalSource.kt rename to server/src/main/kotlin/eu/kanade/tachiyomi/source/local/LocalSource.kt index 8f4e8738..7e452e93 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/source/LocalSource.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/local/LocalSource.kt @@ -1,8 +1,14 @@ -package eu.kanade.tachiyomi.source +package eu.kanade.tachiyomi.source.local -// import com.github.junrar.Archive -// import java.util.zip.ZipFile -import eu.kanade.tachiyomi.source.FileSystemInterceptor.fakeUrlFrom +import com.github.junrar.Archive +import eu.kanade.tachiyomi.source.local.FileSystemInterceptor.fakeUrlFrom +import eu.kanade.tachiyomi.source.local.LocalSource.Format.Directory +import eu.kanade.tachiyomi.source.local.LocalSource.Format.Epub +import eu.kanade.tachiyomi.source.local.LocalSource.Format.Rar +import eu.kanade.tachiyomi.source.local.LocalSource.Format.Zip +import eu.kanade.tachiyomi.source.local.loader.EpubPageLoader +import eu.kanade.tachiyomi.source.local.loader.RarPageLoader +import eu.kanade.tachiyomi.source.local.loader.ZipPageLoader import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.MangasPage @@ -11,6 +17,7 @@ import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder +import eu.kanade.tachiyomi.util.storage.EpubFile import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.contentOrNull @@ -18,6 +25,7 @@ import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive +import mu.KotlinLogging import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Protocol @@ -32,17 +40,21 @@ import org.kodein.di.DI import org.kodein.di.conf.global import org.kodein.di.instance import rx.Observable +import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil import suwayomi.tachidesk.manga.model.table.ExtensionTable import suwayomi.tachidesk.manga.model.table.SourceTable import suwayomi.tachidesk.server.ApplicationDirs import uy.kohesive.injekt.injectLazy import java.io.File +import java.io.FileInputStream import java.io.FileNotFoundException +import java.io.InputStream import java.net.URL import java.util.Locale import java.util.concurrent.TimeUnit +import java.util.zip.ZipFile -class LocalSource(override val baseUrl: String = "") : HttpSource() { +class LocalSource : HttpSource() { companion object { const val ID = 0L const val LANG = "localsourcelang" @@ -52,47 +64,39 @@ class LocalSource(override val baseUrl: String = "") : HttpSource() { const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/" - private val SUPPORTED_ARCHIVE_TYPES = setOf( -// "zip", -// "rar", -// "cbr", -// "cbz", -// "epub" - ) + private val SUPPORTED_ARCHIVE_TYPES = setOf("zip", "rar", "cbr", "cbz", "epub") private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS) - // fun updateCover(context: Context, manga: SManga, input: InputStream): File? { -// val dir = getBaseDirectories(context).firstOrNull() -// if (dir == null) { -// input.close() -// return null -// } -// val cover = getCoverFile(File("${dir.absolutePath}/${manga.url}")) -// -// if (cover != null && cover.exists()) { -// // It might not exist if using the external SD card -// cover.parentFile?.mkdirs() -// input.use { -// cover.outputStream().use { -// input.copyTo(it) -// } -// } -// } -// return cover -// } -// -// /** -// * Returns valid cover file inside [parent] directory. -// */ -// private fun getCoverFile(parent: File): File? { -// return parent.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf { -// it.isFile && ImageUtil.isImage(it.name) { it.inputStream() } -// } -// } -// + private val logger = KotlinLogging.logger {} + private val applicationDirs by DI.global.instance() + val pageCache: MutableMap InputStream>> = mutableMapOf() + + fun updateCover(manga: SManga, input: InputStream): File? { + val cover = getCoverFile(File("${applicationDirs.localMangaRoot}/${manga.url}")) + ?: File("${applicationDirs.localMangaRoot}/${manga.url}/cover.jpg") + + cover.parentFile?.mkdirs() + input.use { + cover.outputStream().use { + input.copyTo(it) + } + } + + return cover + } + + /** + * Returns valid cover file inside [parent] directory. + */ + private fun getCoverFile(parent: File): File? { + return parent.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf { + it.isFile && ImageUtil.isImage(it.name) { it.inputStream() } + } + } + fun addDbRecords() { transaction { val sourceRecord = SourceTable.select { SourceTable.id eq ID }.firstOrNull() @@ -102,7 +106,7 @@ class LocalSource(override val baseUrl: String = "") : HttpSource() { val extensionId = ExtensionTable.insertAndGetId { it[apkName] = "localSource" it[name] = EXTENSION_NAME - it[pkgName] = "eu.kanade.tachiyomi.source.LocalSource" + it[pkgName] = LocalSource::class.java.`package`.name it[versionName] = "1.2" it[versionCode] = 0 it[lang] = LANG @@ -125,6 +129,7 @@ class LocalSource(override val baseUrl: String = "") : HttpSource() { override val id = ID override val name = NAME override val lang = LANG + override val baseUrl: String = "" override val supportsLatest = true override val client: OkHttpClient = super.client.newBuilder() @@ -170,31 +175,31 @@ class LocalSource(override val baseUrl: String = "") : HttpSource() { url = mangaDir.name // Try to find the cover - val cover = File("${applicationDirs.localMangaRoot}/$url/cover.jpg") - if (cover.exists()) { + val cover = getCoverFile(File("${applicationDirs.localMangaRoot}/$url")) + if (cover != null && cover.exists()) { thumbnail_url = fakeUrlFrom(cover.absolutePath) } -// val chapters = fetchChapterList(this).toBlocking().first() -// if (chapters.isNotEmpty()) { -// val chapter = chapters.last() -// val format = getFormat(chapter) -// if (format is Format.Epub) { -// EpubFile(format.file).use { epub -> -// epub.fillMangaMetadata(this) -// } -// } -// -// // Copy the cover from the first chapter found. -// if (thumbnail_url == null) { -// try { -// val dest = updateCover(chapter, this) -// thumbnail_url = dest?.absolutePath -// } catch (e: Exception) { -// Timber.e(e) -// } -// } -// } + val chapters = fetchChapterList(this).toBlocking().first() + if (chapters.isNotEmpty()) { + val chapter = chapters.last() + val format = getFormat(chapter) + if (format is Format.Epub) { + EpubFile(format.file).use { epub -> + epub.fillMangaMetadata(this) + } + } + + // Copy the cover from the first chapter found. + if (thumbnail_url == null) { + try { + val dest = updateCover(chapter, this) + thumbnail_url = dest?.absolutePath?.let { fakeUrlFrom(it) } + } catch (e: Exception) { + logger.error { e } + } + } + } } } @@ -234,12 +239,12 @@ class LocalSource(override val baseUrl: String = "") : HttpSource() { } date_upload = chapterFile.lastModified() -// val format = getFormat(this) -// if (format is Format.Epub) { -// EpubFile(format.file).use { epub -> -// epub.fillChapterMetadata(this) -// } -// } + val format = getFormat(this) + if (format is Format.Epub) { + EpubFile(format.file).use { epub -> + epub.fillChapterMetadata(this) + } + } val chapNameCut = stripMangaTitle(name, manga.title) if (chapNameCut.isNotEmpty()) name = chapNameCut @@ -296,87 +301,100 @@ class LocalSource(override val baseUrl: String = "") : HttpSource() { override fun fetchPageList(chapter: SChapter): Observable> { val chapterFile = File(applicationDirs.localMangaRoot + "/" + chapter.url) - return Observable.just( - if (chapterFile.isDirectory) { - chapterFile.listFiles().sortedBy { it.name }.mapIndexed { index, page -> - Page( - index, - imageUrl = fakeUrlFrom(applicationDirs.localMangaRoot + "/" + chapter.url + "/" + page.name) - ) - } - } else { - throw Exception("Archive chapters are not supported.") + return when (getFormat(chapterFile)) { + is Directory -> { + Observable.just( + chapterFile.listFiles().orEmpty().sortedBy { it.name }.mapIndexed { index, page -> + Page( + index, + imageUrl = fakeUrlFrom(applicationDirs.localMangaRoot + "/" + chapter.url + "/" + page.name) + ) + } + ) } - ) + is Zip -> { + val pages = ZipPageLoader(chapterFile).getPages() + pageCache[chapter.url] = pages.map { it.stream!! } + + Observable.just(pages) + } + is Rar -> { + val pages = RarPageLoader(chapterFile).getPages() + pageCache[chapter.url] = pages.map { it.stream!! } + + Observable.just(pages) + } + is Epub -> { + val pages = EpubPageLoader(chapterFile).getPages() + pageCache[chapter.url] = pages.map { it.stream!! } + + Observable.just(pages) + } + } + } + + fun getFormat(chapter: SChapter): Format { + val chapFile = File(applicationDirs.localMangaRoot, chapter.url) + if (chapFile.exists()) + return getFormat(chapFile) + + throw Exception("Chapter not found") + } + + private fun getFormat(file: File): Format { + return with(file) { + when { + isDirectory -> Format.Directory(file) + extension.equals("zip", true) -> Format.Zip(file) + extension.equals("cbz", true) -> Format.Zip(file) + extension.equals("rar", true) -> Format.Rar(file) + extension.equals("cbr", true) -> Format.Rar(file) + extension.equals("epub", true) -> Format.Epub(file) + + else -> throw Exception("Invalid chapter format") + } + } + } + + private fun updateCover(chapter: SChapter, manga: SManga): File? { + return when (val format = getFormat(chapter)) { + is Format.Directory -> { + val entry = format.file.listFiles() + ?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } + ?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } } + + entry?.let { updateCover(manga, it.inputStream()) } + } + is Format.Zip -> { + ZipFile(format.file).use { zip -> + val entry = zip.entries().toList() + .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } + .find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } + + entry?.let { updateCover(manga, zip.getInputStream(it)) } + } + } + is Format.Rar -> { + Archive(format.file).use { archive -> + val entry = archive.fileHeaders + .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } + .find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } } + + entry?.let { updateCover(manga, archive.getInputStream(it)) } + } + } + is Format.Epub -> { + EpubFile(format.file).use { epub -> + val entry = epub.getImagesFromPages() + .firstOrNull() + ?.let { epub.getEntry(it) } + + entry?.let { updateCover(manga, epub.getInputStream(it)) } + } + } + } } - // -// fun getFormat(chapter: SChapter): Format { -// val baseDirs = getBaseDirectories(context) -// -// for (dir in baseDirs) { -// val chapFile = File(dir, chapter.url) -// if (!chapFile.exists()) continue -// -// return getFormat(chapFile) -// } -// throw Exception(context.getString(R.string.chapter_not_found)) -// } -// -// private fun getFormat(file: File): Format { -// val extension = file.extension -// return if (file.isDirectory) { -// Format.Directory(file) -// } else if (extension.equals("zip", true) || extension.equals("cbz", true)) { -// Format.Zip(file) -// } else if (extension.equals("rar", true) || extension.equals("cbr", true)) { -// Format.Rar(file) -// } else if (extension.equals("epub", true)) { -// Format.Epub(file) -// } else { -// throw Exception(context.getString(R.string.local_invalid_format)) -// } -// } -// -// private fun updateCover(chapter: SChapter, manga: SManga): File? { -// return when (val format = getFormat(chapter)) { -// is Format.Directory -> { -// val entry = format.file.listFiles() -// ?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } -// ?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } } -// -// entry?.let { updateCover(context, manga, it.inputStream()) } -// } -// is Format.Zip -> { -// ZipFile(format.file).use { zip -> -// val entry = zip.entries().toList() -// .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } -// .find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } -// -// entry?.let { updateCover(context, manga, zip.getInputStream(it)) } -// } -// } -// is Format.Rar -> { -// Archive(format.file).use { archive -> -// val entry = archive.fileHeaders -// .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } -// .find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } } -// -// entry?.let { updateCover(context, manga, archive.getInputStream(it)) } -// } -// } -// is Format.Epub -> { -// EpubFile(format.file).use { epub -> -// val entry = epub.getImagesFromPages() -// .firstOrNull() -// ?.let { epub.getEntry(it) } -// -// entry?.let { updateCover(context, manga, epub.getInputStream(it)) } -// } -// } -// } -// } -// override fun getFilterList() = POPULAR_FILTERS private val POPULAR_FILTERS = FilterList(OrderBy()) diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/local/loader/EpubPageLoader.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/local/loader/EpubPageLoader.kt new file mode 100644 index 00000000..55c23311 --- /dev/null +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/local/loader/EpubPageLoader.kt @@ -0,0 +1,31 @@ +package eu.kanade.tachiyomi.source.local.loader + +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.util.storage.EpubFile +import java.io.File + +/** + * Loader used to load a chapter from a .epub file. + */ +class EpubPageLoader(file: File) : PageLoader { + + /** + * The epub file. + */ + private val epub = EpubFile(file) + + /** + * Returns an observable containing the pages found on this zip archive ordered with a natural + * comparator. + */ + override fun getPages(): List { + return epub.getImagesFromPages() + .mapIndexed { i, path -> + val streamFn = { epub.getInputStream(epub.getEntry(path)!!) } + ReaderPage(i).apply { + stream = streamFn + status = Page.READY + } + } + } +} diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/local/loader/PageLoader.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/local/loader/PageLoader.kt new file mode 100644 index 00000000..c06be987 --- /dev/null +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/local/loader/PageLoader.kt @@ -0,0 +1,10 @@ +package eu.kanade.tachiyomi.source.local.loader + +// adapted from eu.kanade.tachiyomi.ui.reader.loader.PageLoader +interface PageLoader { + /** + * Returns an observable containing the list of pages of a chapter. Only the first emission + * will be used. + */ + fun getPages(): List +} diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/local/loader/RarPageLoader.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/local/loader/RarPageLoader.kt new file mode 100644 index 00000000..411ace0c --- /dev/null +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/local/loader/RarPageLoader.kt @@ -0,0 +1,63 @@ +package eu.kanade.tachiyomi.source.local.loader + +import com.github.junrar.Archive +import com.github.junrar.rarfile.FileHeader +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder +import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil +import java.io.File +import java.io.InputStream +import java.io.PipedInputStream +import java.io.PipedOutputStream +import java.util.concurrent.Executors + +/** + * Loader used to load a chapter from a .rar or .cbr file. + */ +class RarPageLoader(file: File) : PageLoader { + + /** + * The rar archive to load pages from. + */ + private val archive = Archive(file) + + /** + * Pool for copying compressed files to an input stream. + */ + private val pool = Executors.newFixedThreadPool(1) + + /** + * Returns an observable containing the pages found on this rar archive ordered with a natural + * comparator. + */ + override fun getPages(): List { + return archive.fileHeaders + .filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } } + .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } + .mapIndexed { i, header -> + val streamFn = { getStream(header) } + + ReaderPage(i).apply { + stream = streamFn + status = Page.READY + } + } + } + + /** + * Returns an input stream for the given [header]. + */ + private fun getStream(header: FileHeader): InputStream { + val pipeIn = PipedInputStream() + val pipeOut = PipedOutputStream(pipeIn) + pool.execute { + try { + pipeOut.use { + archive.extractFile(header, it) + } + } catch (e: Exception) { + } + } + return pipeIn + } +} diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/local/loader/ReaderPage.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/local/loader/ReaderPage.kt new file mode 100644 index 00000000..8a39befb --- /dev/null +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/local/loader/ReaderPage.kt @@ -0,0 +1,11 @@ +package eu.kanade.tachiyomi.source.local.loader + +import eu.kanade.tachiyomi.source.model.Page +import java.io.InputStream + +class ReaderPage( + index: Int, + url: String = "", + imageUrl: String? = null, + var stream: (() -> InputStream)? = null +) : Page(index, url, imageUrl, null) diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/local/loader/ZipPageLoader.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/local/loader/ZipPageLoader.kt new file mode 100644 index 00000000..6aa0a026 --- /dev/null +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/local/loader/ZipPageLoader.kt @@ -0,0 +1,31 @@ +package eu.kanade.tachiyomi.source.local.loader + +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder +import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil +import java.io.File +import java.util.zip.ZipFile + +class ZipPageLoader(file: File) : PageLoader { + /** + * The zip file to load pages from. + */ + private val zip = ZipFile(file) + + /** + * Returns an observable containing the pages found on this zip archive ordered with a natural + * comparator. + */ + override fun getPages(): List { + return zip.entries().toList() + .filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } + .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } + .mapIndexed { i, entry -> + val streamFn = { zip.getInputStream(entry) } + ReaderPage(i).apply { + stream = streamFn + status = Page.READY + } + } + } +} diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/util/storage/EpubFile.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/util/storage/EpubFile.kt new file mode 100644 index 00000000..9a8b24be --- /dev/null +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/util/storage/EpubFile.kt @@ -0,0 +1,215 @@ +package eu.kanade.tachiyomi.util.storage + +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import java.io.Closeable +import java.io.File +import java.io.InputStream +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.zip.ZipEntry +import java.util.zip.ZipFile + +/** + * Wrapper over ZipFile to load files in epub format. + */ +class EpubFile(file: File) : Closeable { + + /** + * Zip file of this epub. + */ + private val zip = ZipFile(file) + + /** + * Path separator used by this epub. + */ + private val pathSeparator = getPathSeparator() + + /** + * Closes the underlying zip file. + */ + override fun close() { + zip.close() + } + + /** + * Returns an input stream for reading the contents of the specified zip file entry. + */ + fun getInputStream(entry: ZipEntry): InputStream { + return zip.getInputStream(entry) + } + + /** + * Returns the zip file entry for the specified name, or null if not found. + */ + fun getEntry(name: String): ZipEntry? { + return zip.getEntry(name) + } + + /** + * Fills manga metadata using this epub file's metadata. + */ + fun fillMangaMetadata(manga: SManga) { + val ref = getPackageHref() + val doc = getPackageDocument(ref) + + val creator = doc.getElementsByTag("dc:creator").first() + val description = doc.getElementsByTag("dc:description").first() + + manga.author = creator?.text() + manga.description = description?.text() + } + + /** + * Fills chapter metadata using this epub file's metadata. + */ + fun fillChapterMetadata(chapter: SChapter) { + val ref = getPackageHref() + val doc = getPackageDocument(ref) + + val title = doc.getElementsByTag("dc:title").first() + val publisher = doc.getElementsByTag("dc:publisher").first() + val creator = doc.getElementsByTag("dc:creator").first() + var date = doc.getElementsByTag("dc:date").first() + if (date == null) { + date = doc.select("meta[property=dcterms:modified]").first() + } + + if (title != null) { + chapter.name = title.text() + } + + if (publisher != null) { + chapter.scanlator = publisher.text() + } else if (creator != null) { + chapter.scanlator = creator.text() + } + + if (date != null) { + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()) + try { + val parsedDate = dateFormat.parse(date.text()) + if (parsedDate != null) { + chapter.date_upload = parsedDate.time + } + } catch (e: ParseException) { + // Empty + } + } + } + + /** + * Returns the path of all the images found in the epub file. + */ + fun getImagesFromPages(): List { + val ref = getPackageHref() + val doc = getPackageDocument(ref) + val pages = getPagesFromDocument(doc) + return getImagesFromPages(pages, ref) + } + + /** + * Returns the path to the package document. + */ + private fun getPackageHref(): String { + val meta = zip.getEntry(resolveZipPath("META-INF", "container.xml")) + if (meta != null) { + val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") } + val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path") + if (path != null) { + return path + } + } + return resolveZipPath("OEBPS", "content.opf") + } + + /** + * Returns the package document where all the files are listed. + */ + private fun getPackageDocument(ref: String): Document { + val entry = zip.getEntry(ref) + return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") } + } + + /** + * Returns all the pages from the epub. + */ + private fun getPagesFromDocument(document: Document): List { + val pages = document.select("manifest > item") + .filter { "application/xhtml+xml" == it.attr("media-type") } + .associateBy { it.attr("id") } + + val spine = document.select("spine > itemref").map { it.attr("idref") } + return spine.mapNotNull { pages[it] }.map { it.attr("href") } + } + + /** + * Returns all the images contained in every page from the epub. + */ + private fun getImagesFromPages(pages: List, packageHref: String): List { + val result = mutableListOf() + val basePath = getParentDirectory(packageHref) + pages.forEach { page -> + val entryPath = resolveZipPath(basePath, page) + val entry = zip.getEntry(entryPath) + val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") } + val imageBasePath = getParentDirectory(entryPath) + + document.allElements.forEach { + if (it.tagName() == "img") { + result.add(resolveZipPath(imageBasePath, it.attr("src"))) + } else if (it.tagName() == "image") { + result.add(resolveZipPath(imageBasePath, it.attr("xlink:href"))) + } + } + } + + return result + } + + /** + * Returns the path separator used by the epub file. + */ + private fun getPathSeparator(): String { + val meta = zip.getEntry("META-INF\\container.xml") + return if (meta != null) { + "\\" + } else { + "/" + } + } + + /** + * Resolves a zip path from base and relative components and a path separator. + */ + private fun resolveZipPath(basePath: String, relativePath: String): String { + if (relativePath.startsWith(pathSeparator)) { + // Path is absolute, so return as-is. + return relativePath + } + + var fixedBasePath = basePath.replace(pathSeparator, File.separator) + if (!fixedBasePath.startsWith(File.separator)) { + fixedBasePath = "${File.separator}$fixedBasePath" + } + + val fixedRelativePath = relativePath.replace(pathSeparator, File.separator) + val resolvedPath = File(fixedBasePath, fixedRelativePath).canonicalPath + return resolvedPath.replace(File.separator, pathSeparator).substring(1) + } + + /** + * Gets the parent directory of a path. + */ + private fun getParentDirectory(path: String): String { + val separatorIndex = path.lastIndexOf(pathSeparator) + return if (separatorIndex >= 0) { + path.substring(0, separatorIndex) + } else { + "" + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Page.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Page.kt index ff25f0f9..4c07caec 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Page.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Page.kt @@ -7,7 +7,7 @@ package suwayomi.tachidesk.manga.impl * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.local.LocalSource import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.online.HttpSource import org.jetbrains.exposed.sql.and @@ -60,6 +60,20 @@ object Page { pageEntry[PageTable.imageUrl] ) + // we treat Local source differently + if (mangaEntry[MangaTable.sourceReference] == LocalSource.ID) { + // is of archive format + if (LocalSource.pageCache.containsKey(chapterEntry[ChapterTable.url])) { + val pageStream = LocalSource.pageCache[chapterEntry[ChapterTable.url]]!![index]() + return pageStream to "image/jpeg" + } + + // is of directory format + return CachedImageResponse.getImageResponse { + source.fetchImage(tachiyomiPage).awaitSingle() + } + } + if (pageEntry[PageTable.imageUrl] == null) { val trueImageUrl = getTrueImageUrl(tachiyomiPage, source) transaction { @@ -69,13 +83,6 @@ object Page { } } - // don't cache images for Local Source - if (mangaEntry[MangaTable.sourceReference] == LocalSource.ID) { - return CachedImageResponse.getImageResponse { - source.fetchImage(tachiyomiPage).awaitSingle() - } - } - val chapterDir = getChapterDir(mangaId, chapterId) File(chapterDir).mkdirs() val fileName = getPageName(index, chapterDir) // e.g. 001 diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Source.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Source.kt index 440f3540..8a6c5841 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Source.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Source.kt @@ -11,8 +11,8 @@ import android.app.Application import android.content.Context import androidx.preference.PreferenceScreen import eu.kanade.tachiyomi.source.ConfigurableSource -import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.getPreferenceKey +import eu.kanade.tachiyomi.source.local.LocalSource import mu.KotlinLogging import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.selectAll diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/ExtensionsList.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/ExtensionsList.kt index 019c9d2f..e493dce5 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/ExtensionsList.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/ExtensionsList.kt @@ -7,7 +7,7 @@ package suwayomi.tachidesk.manga.impl.extension * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.local.LocalSource import mu.KotlinLogging import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.insert diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/GetHttpSource.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/GetHttpSource.kt index ec1ca167..1ef99262 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/GetHttpSource.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/GetHttpSource.kt @@ -7,9 +7,9 @@ package suwayomi.tachidesk.manga.impl.util * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceFactory +import eu.kanade.tachiyomi.source.local.LocalSource import eu.kanade.tachiyomi.source.online.HttpSource import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.transactions.transaction diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/storage/ImageUtil.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/storage/ImageUtil.kt index 85c9949b..c5a855fd 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/storage/ImageUtil.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/storage/ImageUtil.kt @@ -1,11 +1,5 @@ package suwayomi.tachidesk.manga.impl.util.storage -import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.GIF -import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.JPG -import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.PNG -import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.WEBP -import java.io.InputStream - /* * Copyright (C) Contributors to the Suwayomi project * @@ -13,8 +7,23 @@ import java.io.InputStream * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -// adopted from: https://github.com/tachiyomiorg/tachiyomi/blob/ff369010074b058bb734ce24c66508300e6e9ac6/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt +import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.GIF +import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.JPG +import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.PNG +import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.WEBP +import java.io.InputStream +import java.net.URLConnection + +// adopted from: eu.kanade.tachiyomi.util.system.ImageUtil object ImageUtil { + fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean { + val contentType = try { + URLConnection.guessContentTypeFromName(name) + } catch (e: Exception) { + null + } ?: openStream?.let { findImageType(it)?.mime } + return contentType?.startsWith("image/") ?: false + } fun findImageType(openStream: () -> InputStream): ImageType? { return openStream().use { findImageType(it) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt index 0dd49289..64fc866b 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt @@ -8,7 +8,7 @@ package suwayomi.tachidesk.server * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import eu.kanade.tachiyomi.App -import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.local.LocalSource import mu.KotlinLogging import org.kodein.di.DI import org.kodein.di.bind