mirror of
https://github.com/mihonapp/mihon.git
synced 2024-09-22 13:42:39 +02:00
f0a5557e60
getCoverFile only returns a cover if it already exists, meaning the block of code to write it from the inputstream never executes. If getCoverFile returns null, then use previous behaviour of setting it to cover.jpg so that if the file doesn't exist it's created
345 lines
14 KiB
Kotlin
345 lines
14 KiB
Kotlin
package eu.kanade.tachiyomi.source
|
|
|
|
import android.content.Context
|
|
import com.github.junrar.Archive
|
|
import eu.kanade.tachiyomi.R
|
|
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.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 eu.kanade.tachiyomi.util.system.logcat
|
|
import kotlinx.serialization.json.Json
|
|
import kotlinx.serialization.json.JsonObject
|
|
import kotlinx.serialization.json.contentOrNull
|
|
import kotlinx.serialization.json.decodeFromStream
|
|
import kotlinx.serialization.json.intOrNull
|
|
import kotlinx.serialization.json.jsonArray
|
|
import kotlinx.serialization.json.jsonPrimitive
|
|
import logcat.LogPriority
|
|
import rx.Observable
|
|
import uy.kohesive.injekt.injectLazy
|
|
import java.io.File
|
|
import java.io.FileInputStream
|
|
import java.io.InputStream
|
|
import java.util.Locale
|
|
import java.util.concurrent.TimeUnit
|
|
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/local-manga/"
|
|
|
|
private val SUPPORTED_ARCHIVE_TYPES = setOf("zip", "rar", "cbr", "cbz", "epub")
|
|
|
|
private const val COVER_NAME = "cover.jpg"
|
|
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
|
|
}
|
|
var cover = getCoverFile(File("${dir.absolutePath}/${manga.url}"))
|
|
if (cover == null) {
|
|
cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME)
|
|
}
|
|
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 fun getBaseDirectories(context: Context): List<File> {
|
|
val c = context.getString(R.string.app_name) + File.separator + "local"
|
|
return DiskUtil.getExternalStorages(context).map { File(it.absolutePath, c) }
|
|
}
|
|
}
|
|
|
|
private val json: Json by injectLazy()
|
|
|
|
override val id = ID
|
|
override val name = context.getString(R.string.local_source)
|
|
override val lang = "other"
|
|
override val supportsLatest = true
|
|
|
|
override fun toString() = context.getString(R.string.local_source)
|
|
|
|
override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS)
|
|
|
|
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
|
val baseDirs = getBaseDirectories(context)
|
|
|
|
val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
|
|
var mangaDirs = baseDirs
|
|
.asSequence()
|
|
.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 -> {
|
|
mangaDirs = if (state.ascending) {
|
|
mangaDirs.sortedBy { it.name.lowercase(Locale.ENGLISH) }
|
|
} else {
|
|
mangaDirs.sortedByDescending { it.name.lowercase(Locale.ENGLISH) }
|
|
}
|
|
}
|
|
1 -> {
|
|
mangaDirs = if (state.ascending) {
|
|
mangaDirs.sortedBy(File::lastModified)
|
|
} else {
|
|
mangaDirs.sortedByDescending(File::lastModified)
|
|
}
|
|
}
|
|
}
|
|
|
|
val mangas = mangaDirs.map { mangaDir ->
|
|
SManga.create().apply {
|
|
title = mangaDir.name
|
|
url = mangaDir.name
|
|
|
|
// Try to find the cover
|
|
for (dir in baseDirs) {
|
|
val cover = getCoverFile(File("${dir.absolutePath}/$url"))
|
|
if (cover != null && cover.exists()) {
|
|
thumbnail_url = cover.absolutePath
|
|
break
|
|
}
|
|
}
|
|
|
|
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) {
|
|
logcat(LogPriority.ERROR, e)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return Observable.just(MangasPage(mangas.toList(), false))
|
|
}
|
|
|
|
override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
|
|
|
|
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
|
getBaseDirectories(context)
|
|
.asSequence()
|
|
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
|
|
.flatten()
|
|
.firstOrNull { it.extension == "json" }
|
|
?.apply {
|
|
val obj = json.decodeFromStream<JsonObject>(inputStream())
|
|
|
|
manga.title = obj["title"]?.jsonPrimitive?.contentOrNull ?: manga.title
|
|
manga.author = obj["author"]?.jsonPrimitive?.contentOrNull ?: manga.author
|
|
manga.artist = obj["artist"]?.jsonPrimitive?.contentOrNull ?: manga.artist
|
|
manga.description = obj["description"]?.jsonPrimitive?.contentOrNull ?: manga.description
|
|
manga.genre = obj["genre"]?.jsonArray?.joinToString(", ") { it.jsonPrimitive.content }
|
|
?: manga.genre
|
|
manga.status = obj["status"]?.jsonPrimitive?.intOrNull ?: manga.status
|
|
}
|
|
|
|
return Observable.just(manga)
|
|
}
|
|
|
|
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
|
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 = stripMangaTitle(name, manga.title)
|
|
if (chapNameCut.isNotEmpty()) name = chapNameCut
|
|
ChapterRecognition.parseChapterNumber(this, manga)
|
|
}
|
|
}
|
|
.sortedWith { 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<List<Page>> {
|
|
return Observable.error(Exception("Unused"))
|
|
}
|
|
|
|
private fun isSupportedFile(extension: String): Boolean {
|
|
return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES
|
|
}
|
|
|
|
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) = with(file) {
|
|
when {
|
|
isDirectory -> Format.Directory(this)
|
|
extension.equals("zip", true) || extension.equals("cbz", true) -> Format.Zip(this)
|
|
extension.equals("rar", true) || extension.equals("cbr", true) -> Format.Rar(this)
|
|
extension.equals("epub", true) -> Format.Epub(this)
|
|
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(context))
|
|
private val LATEST_FILTERS = FilterList(OrderBy(context).apply { state = Filter.Sort.Selection(1, false) })
|
|
|
|
private class OrderBy(context: Context) : Filter.Sort(
|
|
context.getString(R.string.local_filter_order_by),
|
|
arrayOf(context.getString(R.string.title), context.getString(R.string.date)),
|
|
Selection(0, true)
|
|
)
|
|
|
|
sealed class Format {
|
|
data class Directory(val file: File) : Format()
|
|
data class Zip(val file: File) : Format()
|
|
data class Rar(val file: File) : Format()
|
|
data class Epub(val file: File) : Format()
|
|
}
|
|
}
|