diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt index 21f9f9d369..8d19a78c08 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt @@ -1,42 +1,34 @@ package eu.kanade.tachiyomi.source import android.content.Context -import com.google.gson.Gson +import com.github.junrar.Archive import com.google.gson.GsonBuilder -import com.google.gson.JsonObject +import com.google.gson.JsonParser import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.toMangaInfo import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga -import eu.kanade.tachiyomi.source.model.toMangaInfo -import eu.kanade.tachiyomi.source.model.toSChapter import eu.kanade.tachiyomi.util.chapter.ChapterRecognition import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.EpubFile import eu.kanade.tachiyomi.util.system.ImageUtil -import junrar.Archive -import junrar.rarfile.FileHeader -import kotlinx.coroutines.runBlocking import rx.Observable import timber.log.Timber import java.io.File import java.io.FileInputStream import java.io.InputStream import java.util.Locale -import java.util.Scanner import java.util.concurrent.TimeUnit -import java.util.zip.ZipEntry import java.util.zip.ZipFile class LocalSource(private val context: Context) : CatalogueSource { companion object { const val ID = 0L - const val HELP_URL = "https://tachiyomi.org/help/guides/reading-local-manga/" + const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/" private const val COVER_NAME = "cover.jpg" private val SUPPORTED_ARCHIVE_TYPES = setOf("zip", "rar", "cbr", "cbz", "epub") @@ -47,13 +39,12 @@ class LocalSource(private val context: Context) : CatalogueSource { private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS) fun updateCover(context: Context, manga: SManga, input: InputStream): File? { - val dir = getBaseDirectories(context).asSequence().firstOrNull() + val dir = getBaseDirectories(context).firstOrNull() if (dir == null) { input.close() return null } val cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME) - if (cover.exists()) cover.delete() cover.parentFile?.mkdirs() input.use { @@ -93,22 +84,28 @@ class LocalSource(private val context: Context) : CatalogueSource { if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L var mangaDirs = baseDirs .asSequence() - .mapNotNull { it.listFiles()?.toList() }.flatten() + .mapNotNull { it.listFiles()?.toList() } + .flatten() .filter { it.isDirectory } + .filterNot { it.name.startsWith('.') } .filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time } .distinctBy { it.name } val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state when (state?.index) { 0 -> { - if (state.ascending) mangaDirs = + mangaDirs = if (state.ascending) { mangaDirs.sortedBy { it.name.toLowerCase(Locale.ENGLISH) } - else mangaDirs = + } else { mangaDirs.sortedByDescending { it.name.toLowerCase(Locale.ENGLISH) } + } } 1 -> { - if (state.ascending) mangaDirs = mangaDirs.sortedBy(File::lastModified) - else mangaDirs = mangaDirs.sortedByDescending(File::lastModified) + mangaDirs = if (state.ascending) { + mangaDirs.sortedBy(File::lastModified) + } else { + mangaDirs.sortedByDescending(File::lastModified) + } } } @@ -126,12 +123,20 @@ class LocalSource(private val context: Context) : CatalogueSource { } } - // Copy the cover from the first chapter found. - if (thumbnail_url == null) { - val chapters = runBlocking { getChapterList(toMangaInfo()).map { it.toSChapter() } } - if (chapters.isNotEmpty()) { + 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(chapters.last(), this) + val dest = updateCover(chapter, this) thumbnail_url = dest?.absolutePath } catch (e: Exception) { Timber.e(e) @@ -140,52 +145,31 @@ class LocalSource(private val context: Context) : CatalogueSource { } } } + return Observable.just(MangasPage(mangas.toList(), false)) } override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS) override fun fetchMangaDetails(manga: SManga): Observable { - val baseDirs = getBaseDirectories(context) - baseDirs + getBaseDirectories(context) + .asSequence() .mapNotNull { File(it, manga.url).listFiles()?.toList() } .flatten() - .filter { it.extension == "json" }.firstOrNull()?.apply { - val json = Gson().fromJson( - Scanner(this).useDelimiter("\\Z").next(), - JsonObject::class.java - ) + .firstOrNull { it.extension == "json" } + ?.apply { + val reader = this.inputStream().bufferedReader() + val json = JsonParser.parseReader(reader).asJsonObject + manga.title = json["title"]?.asString ?: manga.title manga.author = json["author"]?.asString ?: manga.author manga.artist = json["artist"]?.asString ?: manga.artist manga.description = json["description"]?.asString ?: manga.description - manga.genre = json["genre"]?.asJsonArray?.map { it.asString }?.joinToString(", ") + manga.genre = json["genre"]?.asJsonArray?.joinToString(", ") { it.asString } ?: manga.genre manga.status = json["status"]?.asInt ?: manga.status } - val url = manga.url - // Try to find the cover - for (dir in baseDirs) { - val cover = File("${dir.absolutePath}/$url", COVER_NAME) - if (cover.exists()) { - manga.thumbnail_url = cover.absolutePath - break - } - } - - // Copy the cover from the first chapter found. - if (manga.thumbnail_url == null) { - val chapters = runBlocking { getChapterList(manga.toMangaInfo()).map { it.toSChapter() } } - if (chapters.isNotEmpty()) { - try { - val dest = updateCover(chapters.last(), manga) - manga.thumbnail_url = dest?.absolutePath - } catch (e: Exception) { - Timber.e(e) - } - } - } return Observable.just(manga) } @@ -229,33 +213,78 @@ class LocalSource(private val context: Context) : CatalogueSource { } override fun fetchChapterList(manga: SManga): Observable> { - val chapters = - getBaseDirectories(context).mapNotNull { File(it, manga.url).listFiles()?.toList() } - .flatten().filter { it.isDirectory || isSupportedFile(it.extension) } - .map { chapterFile -> - SChapter.create().apply { - url = "${manga.url}/${chapterFile.name}" - val chapName = if (chapterFile.isDirectory) { - chapterFile.name - } else { - chapterFile.nameWithoutExtension + val chapters = getBaseDirectories(context) + .asSequence() + .mapNotNull { File(it, manga.url).listFiles()?.toList() } + .flatten() + .filter { it.isDirectory || isSupportedFile(it.extension) } + .map { chapterFile -> + SChapter.create().apply { + url = "${manga.url}/${chapterFile.name}" + name = if (chapterFile.isDirectory) { + chapterFile.name + } else { + chapterFile.nameWithoutExtension + } + date_upload = chapterFile.lastModified() + + val format = getFormat(this) + if (format is Format.Epub) { + EpubFile(format.file).use { epub -> + epub.fillChapterMetadata(this) } - val chapNameCut = - chapName.replace(manga.title, "", true).trim(' ', '-', '_') - name = if (chapNameCut.isEmpty()) chapName else chapNameCut - date_upload = chapterFile.lastModified() - ChapterRecognition.parseChapterNumber(this, manga) } - }.sortedWith( - Comparator { c1, c2 -> - val c = c2.chapter_number.compareTo(c1.chapter_number) - if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c - } - ) + + val chapNameCut = stripMangaTitle(name, manga.title) + if (chapNameCut.isNotEmpty()) name = chapNameCut + ChapterRecognition.parseChapterNumber(this, manga) + } + } + .sortedWith( + Comparator { c1, c2 -> + val c = c2.chapter_number.compareTo(c1.chapter_number) + if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c + } + ) + .toList() return Observable.just(chapters) } + /** + * Strips the manga title from a chapter name, matching only based on alphanumeric and whitespace + * characters. + */ + private fun stripMangaTitle(chapterName: String, mangaTitle: String): String { + var chapterNameIndex = 0 + var mangaTitleIndex = 0 + while (chapterNameIndex < chapterName.length && mangaTitleIndex < mangaTitle.length) { + val chapterChar = chapterName[chapterNameIndex] + val mangaChar = mangaTitle[mangaTitleIndex] + if (!chapterChar.equals(mangaChar, true)) { + val invalidChapterChar = !chapterChar.isLetterOrDigit() && !chapterChar.isWhitespace() + val invalidMangaChar = !mangaChar.isLetterOrDigit() && !mangaChar.isWhitespace() + + if (!invalidChapterChar && !invalidMangaChar) { + return chapterName + } + + if (invalidChapterChar) { + chapterNameIndex++ + } + + if (invalidMangaChar) { + mangaTitleIndex++ + } + } else { + chapterNameIndex++ + mangaTitleIndex++ + } + } + + return chapterName.substring(chapterNameIndex).trimStart(' ', '-', '_', ',', ':') + } + override fun fetchPageList(chapter: SChapter): Observable> { return Observable.error(Exception("Unused")) } @@ -292,52 +321,37 @@ class LocalSource(private val context: Context) : CatalogueSource { } private fun updateCover(chapter: SChapter, manga: SManga): File? { - val format = getFormat(chapter) - return when (format) { + return when (val format = getFormat(chapter)) { is Format.Directory -> { val entry = format.file.listFiles() - ?.sortedWith( - Comparator { f1, f2 -> - f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) - } - ) + ?.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( - Comparator { f1, f2 -> - f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) - } - ).find { - !it.isDirectory && ImageUtil.isImage(it.name) { - zip.getInputStream(it) - } - } + 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( - Comparator { f1, f2 -> - f1.fileNameString.compareToCaseInsensitiveNaturalOrder(f2.fileNameString) - } - ).find { - !it.isDirectory && ImageUtil.isImage(it.fileNameString) { - archive.getInputStream(it) - } - } + 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) } + val entry = epub.getImagesFromPages() + .firstOrNull() + ?.let { epub.getEntry(it) } entry?.let { updateCover(context, manga, epub.getInputStream(it)) } } @@ -345,8 +359,7 @@ class LocalSource(private val context: Context) : CatalogueSource { } } - private class OrderBy : - Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true)) + private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Selection(0, true)) override fun getFilterList() = FilterList(OrderBy()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt b/app/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt index 7a7fb0681b..9a8b24be8e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt @@ -1,10 +1,15 @@ 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 @@ -44,6 +49,58 @@ class EpubFile(file: File) : Closeable { 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. */ @@ -93,7 +150,7 @@ class EpubFile(file: File) : Closeable { * Returns all the images contained in every page from the epub. */ private fun getImagesFromPages(pages: List, packageHref: String): List { - val result = ArrayList() + val result = mutableListOf() val basePath = getParentDirectory(packageHref) pages.forEach { page -> val entryPath = resolveZipPath(basePath, page) @@ -118,10 +175,10 @@ class EpubFile(file: File) : Closeable { */ private fun getPathSeparator(): String { val meta = zip.getEntry("META-INF\\container.xml") - if (meta != null) { - return "\\" + return if (meta != null) { + "\\" } else { - return "/" + "/" } } @@ -149,10 +206,10 @@ class EpubFile(file: File) : Closeable { */ private fun getParentDirectory(path: String): String { val separatorIndex = path.lastIndexOf(pathSeparator) - if (separatorIndex >= 0) { - return path.substring(0, separatorIndex) + return if (separatorIndex >= 0) { + path.substring(0, separatorIndex) } else { - return "" + "" } } }