Changes to local source from upstream

Co-Authored-By: arkon <4098258+arkon@users.noreply.github.com>
This commit is contained in:
Jays2Kings 2021-04-29 01:00:51 -04:00
parent 0885f436e4
commit e1e8202192
2 changed files with 179 additions and 109 deletions

View File

@ -1,42 +1,34 @@
package eu.kanade.tachiyomi.source package eu.kanade.tachiyomi.source
import android.content.Context import android.content.Context
import com.google.gson.Gson import com.github.junrar.Archive
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.google.gson.JsonObject import com.google.gson.JsonParser
import eu.kanade.tachiyomi.R 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.Filter
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga 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.chapter.ChapterRecognition
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.EpubFile import eu.kanade.tachiyomi.util.storage.EpubFile
import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.ImageUtil
import junrar.Archive
import junrar.rarfile.FileHeader
import kotlinx.coroutines.runBlocking
import rx.Observable import rx.Observable
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.InputStream import java.io.InputStream
import java.util.Locale import java.util.Locale
import java.util.Scanner
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.zip.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
class LocalSource(private val context: Context) : CatalogueSource { class LocalSource(private val context: Context) : CatalogueSource {
companion object { companion object {
const val ID = 0L 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 const val COVER_NAME = "cover.jpg"
private val SUPPORTED_ARCHIVE_TYPES = setOf("zip", "rar", "cbr", "cbz", "epub") 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) private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
fun updateCover(context: Context, manga: SManga, input: InputStream): File? { fun updateCover(context: Context, manga: SManga, input: InputStream): File? {
val dir = getBaseDirectories(context).asSequence().firstOrNull() val dir = getBaseDirectories(context).firstOrNull()
if (dir == null) { if (dir == null) {
input.close() input.close()
return null return null
} }
val cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME) val cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME)
if (cover.exists()) cover.delete()
cover.parentFile?.mkdirs() cover.parentFile?.mkdirs()
input.use { input.use {
@ -93,22 +84,28 @@ class LocalSource(private val context: Context) : CatalogueSource {
if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
var mangaDirs = baseDirs var mangaDirs = baseDirs
.asSequence() .asSequence()
.mapNotNull { it.listFiles()?.toList() }.flatten() .mapNotNull { it.listFiles()?.toList() }
.flatten()
.filter { it.isDirectory } .filter { it.isDirectory }
.filterNot { it.name.startsWith('.') }
.filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time } .filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time }
.distinctBy { it.name } .distinctBy { it.name }
val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state
when (state?.index) { when (state?.index) {
0 -> { 0 -> {
if (state.ascending) mangaDirs = mangaDirs = if (state.ascending) {
mangaDirs.sortedBy { it.name.toLowerCase(Locale.ENGLISH) } mangaDirs.sortedBy { it.name.toLowerCase(Locale.ENGLISH) }
else mangaDirs = } else {
mangaDirs.sortedByDescending { it.name.toLowerCase(Locale.ENGLISH) } mangaDirs.sortedByDescending { it.name.toLowerCase(Locale.ENGLISH) }
} }
}
1 -> { 1 -> {
if (state.ascending) mangaDirs = mangaDirs.sortedBy(File::lastModified) mangaDirs = if (state.ascending) {
else mangaDirs = mangaDirs.sortedByDescending(File::lastModified) mangaDirs.sortedBy(File::lastModified)
} else {
mangaDirs.sortedByDescending(File::lastModified)
}
} }
} }
@ -126,12 +123,20 @@ class LocalSource(private val context: Context) : CatalogueSource {
} }
} }
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. // Copy the cover from the first chapter found.
if (thumbnail_url == null) { if (thumbnail_url == null) {
val chapters = runBlocking { getChapterList(toMangaInfo()).map { it.toSChapter() } }
if (chapters.isNotEmpty()) {
try { try {
val dest = updateCover(chapters.last(), this) val dest = updateCover(chapter, this)
thumbnail_url = dest?.absolutePath thumbnail_url = dest?.absolutePath
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
@ -140,52 +145,31 @@ class LocalSource(private val context: Context) : CatalogueSource {
} }
} }
} }
return Observable.just(MangasPage(mangas.toList(), false)) return Observable.just(MangasPage(mangas.toList(), false))
} }
override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS) override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
override fun fetchMangaDetails(manga: SManga): Observable<SManga> { override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
val baseDirs = getBaseDirectories(context) getBaseDirectories(context)
baseDirs .asSequence()
.mapNotNull { File(it, manga.url).listFiles()?.toList() } .mapNotNull { File(it, manga.url).listFiles()?.toList() }
.flatten() .flatten()
.filter { it.extension == "json" }.firstOrNull()?.apply { .firstOrNull { it.extension == "json" }
val json = Gson().fromJson( ?.apply {
Scanner(this).useDelimiter("\\Z").next(), val reader = this.inputStream().bufferedReader()
JsonObject::class.java val json = JsonParser.parseReader(reader).asJsonObject
)
manga.title = json["title"]?.asString ?: manga.title manga.title = json["title"]?.asString ?: manga.title
manga.author = json["author"]?.asString ?: manga.author manga.author = json["author"]?.asString ?: manga.author
manga.artist = json["artist"]?.asString ?: manga.artist manga.artist = json["artist"]?.asString ?: manga.artist
manga.description = json["description"]?.asString ?: manga.description 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.genre
manga.status = json["status"]?.asInt ?: manga.status 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) return Observable.just(manga)
} }
@ -229,33 +213,78 @@ class LocalSource(private val context: Context) : CatalogueSource {
} }
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> { override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
val chapters = val chapters = getBaseDirectories(context)
getBaseDirectories(context).mapNotNull { File(it, manga.url).listFiles()?.toList() } .asSequence()
.flatten().filter { it.isDirectory || isSupportedFile(it.extension) } .mapNotNull { File(it, manga.url).listFiles()?.toList() }
.flatten()
.filter { it.isDirectory || isSupportedFile(it.extension) }
.map { chapterFile -> .map { chapterFile ->
SChapter.create().apply { SChapter.create().apply {
url = "${manga.url}/${chapterFile.name}" url = "${manga.url}/${chapterFile.name}"
val chapName = if (chapterFile.isDirectory) { name = if (chapterFile.isDirectory) {
chapterFile.name chapterFile.name
} else { } else {
chapterFile.nameWithoutExtension chapterFile.nameWithoutExtension
} }
val chapNameCut =
chapName.replace(manga.title, "", true).trim(' ', '-', '_')
name = if (chapNameCut.isEmpty()) chapName else chapNameCut
date_upload = chapterFile.lastModified() date_upload = chapterFile.lastModified()
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
ChapterRecognition.parseChapterNumber(this, manga) ChapterRecognition.parseChapterNumber(this, manga)
} }
}.sortedWith( }
.sortedWith(
Comparator { c1, c2 -> Comparator { c1, c2 ->
val c = c2.chapter_number.compareTo(c1.chapter_number) val c = c2.chapter_number.compareTo(c1.chapter_number)
if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c
} }
) )
.toList()
return Observable.just(chapters) 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<List<Page>> { override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return Observable.error(Exception("Unused")) return Observable.error(Exception("Unused"))
} }
@ -292,52 +321,37 @@ class LocalSource(private val context: Context) : CatalogueSource {
} }
private fun updateCover(chapter: SChapter, manga: SManga): File? { private fun updateCover(chapter: SChapter, manga: SManga): File? {
val format = getFormat(chapter) return when (val format = getFormat(chapter)) {
return when (format) {
is Format.Directory -> { is Format.Directory -> {
val entry = format.file.listFiles() val entry = format.file.listFiles()
?.sortedWith( ?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
Comparator<File> { f1, f2 ->
f1.name.compareToCaseInsensitiveNaturalOrder(f2.name)
}
)
?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } } ?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
entry?.let { updateCover(context, manga, it.inputStream()) } entry?.let { updateCover(context, manga, it.inputStream()) }
} }
is Format.Zip -> { is Format.Zip -> {
ZipFile(format.file).use { zip -> ZipFile(format.file).use { zip ->
val entry = zip.entries().toList().sortedWith( val entry = zip.entries().toList()
Comparator<ZipEntry> { f1, f2 -> .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) .find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
}
).find {
!it.isDirectory && ImageUtil.isImage(it.name) {
zip.getInputStream(it)
}
}
entry?.let { updateCover(context, manga, zip.getInputStream(it)) } entry?.let { updateCover(context, manga, zip.getInputStream(it)) }
} }
} }
is Format.Rar -> { is Format.Rar -> {
Archive(format.file).use { archive -> Archive(format.file).use { archive ->
val entry = archive.fileHeaders.sortedWith( val entry = archive.fileHeaders
Comparator<FileHeader> { f1, f2 -> .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
f1.fileNameString.compareToCaseInsensitiveNaturalOrder(f2.fileNameString) .find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
}
).find {
!it.isDirectory && ImageUtil.isImage(it.fileNameString) {
archive.getInputStream(it)
}
}
entry?.let { updateCover(context, manga, archive.getInputStream(it)) } entry?.let { updateCover(context, manga, archive.getInputStream(it)) }
} }
} }
is Format.Epub -> { is Format.Epub -> {
EpubFile(format.file).use { 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)) } entry?.let { updateCover(context, manga, epub.getInputStream(it)) }
} }
@ -345,8 +359,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
} }
} }
private class OrderBy : private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Selection(0, true))
Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true))
override fun getFilterList() = FilterList(OrderBy()) override fun getFilterList() = FilterList(OrderBy())

View File

@ -1,10 +1,15 @@
package eu.kanade.tachiyomi.util.storage 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.Jsoup
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import java.io.Closeable import java.io.Closeable
import java.io.File import java.io.File
import java.io.InputStream 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.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
@ -44,6 +49,58 @@ class EpubFile(file: File) : Closeable {
return zip.getEntry(name) 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. * 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. * Returns all the images contained in every page from the epub.
*/ */
private fun getImagesFromPages(pages: List<String>, packageHref: String): List<String> { private fun getImagesFromPages(pages: List<String>, packageHref: String): List<String> {
val result = ArrayList<String>() val result = mutableListOf<String>()
val basePath = getParentDirectory(packageHref) val basePath = getParentDirectory(packageHref)
pages.forEach { page -> pages.forEach { page ->
val entryPath = resolveZipPath(basePath, page) val entryPath = resolveZipPath(basePath, page)
@ -118,10 +175,10 @@ class EpubFile(file: File) : Closeable {
*/ */
private fun getPathSeparator(): String { private fun getPathSeparator(): String {
val meta = zip.getEntry("META-INF\\container.xml") val meta = zip.getEntry("META-INF\\container.xml")
if (meta != null) { return if (meta != null) {
return "\\" "\\"
} else { } else {
return "/" "/"
} }
} }
@ -149,10 +206,10 @@ class EpubFile(file: File) : Closeable {
*/ */
private fun getParentDirectory(path: String): String { private fun getParentDirectory(path: String): String {
val separatorIndex = path.lastIndexOf(pathSeparator) val separatorIndex = path.lastIndexOf(pathSeparator)
if (separatorIndex >= 0) { return if (separatorIndex >= 0) {
return path.substring(0, separatorIndex) path.substring(0, separatorIndex)
} else { } else {
return "" ""
} }
} }
} }