komikku/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt

379 lines
14 KiB
Kotlin
Raw Normal View History

package eu.kanade.tachiyomi.source
import android.content.Context
import com.github.junrar.Archive
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
2020-02-27 00:12:44 +01:00
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
2020-02-03 04:22:54 +01:00
import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
2020-02-03 04:04:11 +01:00
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.EpubFile
2020-02-03 04:22:54 +01:00
import eu.kanade.tachiyomi.util.system.ImageUtil
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.encodeToStream
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 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.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()
2021-09-11 17:48:32 +02:00
// SY -->
private val preferences: PreferencesHelper by injectLazy()
// SY <--
override val id = ID
override val name = context.getString(R.string.local_source)
override val lang = "other"
override val supportsLatest = true
override fun toString() = name
override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS)
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
val baseDirs = getBaseDirectories(context)
// SY -->
val allowLocalSourceHiddenFolders = preferences.allowLocalSourceHiddenFolders().get()
// SY <--
val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
var mangaDirs = baseDirs
.asSequence()
.mapNotNull { it.listFiles()?.toList() }
2020-04-25 20:24:45 +02:00
.flatten()
.filter { it.isDirectory }
.filterNot { it.name.startsWith('.') /* SY --> */ && !allowLocalSourceHiddenFolders /* SY <-- */ }
.filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time }
2020-04-25 20:24:45 +02:00
.distinctBy { it.name }
val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state
when (state?.index) {
0 -> {
2020-04-25 20:24:45 +02:00
mangaDirs = if (state.ascending) {
mangaDirs.sortedBy { it.name.lowercase(Locale.ENGLISH) }
2020-04-25 20:24:45 +02:00
} else {
mangaDirs.sortedByDescending { it.name.lowercase(Locale.ENGLISH) }
2020-04-25 20:24:45 +02:00
}
}
1 -> {
2020-04-25 20:24:45 +02:00
mangaDirs = if (state.ascending) {
mangaDirs.sortedBy(File::lastModified)
2020-04-25 20:24:45 +02:00
} else {
mangaDirs.sortedByDescending(File::lastModified)
2020-04-25 20:24:45 +02:00
}
}
}
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))
}
// SY -->
fun updateMangaInfo(manga: SManga) {
val directory = getBaseDirectories(context).map { File(it, manga.url) }.find {
it.exists()
} ?: return
val existingFileName = directory.listFiles()?.find { it.extension == "json" }?.name
val file = File(directory, existingFileName ?: "info.json")
file.outputStream().use {
json.encodeToStream(manga.toJson(), it)
}
}
private fun SManga.toJson(): MangaJson {
return MangaJson(title, author, artist, description, genre?.split(", "), status)
}
@Serializable
data class MangaJson(
2021-07-04 23:14:07 +02:00
val title: String? = null,
val author: String? = null,
val artist: String? = null,
val description: String? = null,
val genre: List<String>? = null,
val status: Int? = null
)
// SY <--
override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
getBaseDirectories(context)
.asSequence()
2020-04-25 20:24:45 +02:00
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
.flatten()
.firstOrNull { it.extension.lowercase() == "json" }
2020-04-25 20:24:45 +02:00
?.apply {
val json = json.decodeFromStream<MangaJson>(inputStream())
2021-07-04 23:14:07 +02:00
manga.title = json.title ?: manga.title
manga.author = json.author ?: manga.author
manga.artist = json.artist ?: manga.artist
manga.description = json.description ?: manga.description
manga.genre = json.genre?.joinToString(", ") ?: manga.genre
manga.status = json.status ?: manga.status
2020-04-25 20:24:45 +02:00
}
return Observable.just(manga)
}
2017-02-05 12:01:58 +01:00
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
val chapters = getBaseDirectories(context)
2020-04-25 20:24:45 +02:00
.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) {
2020-04-25 20:24:45 +02:00
chapterFile.name
} else {
chapterFile.nameWithoutExtension
2017-02-05 12:01:58 +01:00
}
2020-04-25 20:24:45 +02:00
date_upload = chapterFile.lastModified()
val format = getFormat(chapterFile)
if (format is Format.Epub) {
EpubFile(format.file).use { epub ->
epub.fillChapterMetadata(this)
}
}
name = getCleanChapterTitle(name, manga.title)
2020-04-25 20:24:45 +02:00
ChapterRecognition.parseChapterNumber(this, manga)
2017-02-05 12:01:58 +01:00
}
2020-04-25 20:24:45 +02:00
}
.sortedWith { c1, c2 ->
val c = c2.chapter_number.compareTo(c1.chapter_number)
if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c
}
2020-04-25 20:24:45 +02:00
.toList()
2017-02-05 12:01:58 +01:00
return Observable.just(chapters)
}
/**
* Strips the manga title from a chapter name and trim whitespace/delimiter characters.
*/
private fun getCleanChapterTitle(chapterName: String, mangaTitle: String): String {
return chapterName
.replace(mangaTitle, "")
.trim(*WHITESPACE_CHARS.toCharArray(), '-', '_', ',', ':')
}
2017-02-05 12:01:58 +01:00
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 {
2017-02-05 12:01:58 +01:00
val baseDirs = getBaseDirectories(context)
for (dir in baseDirs) {
val chapFile = File(dir, chapter.url)
if (!chapFile.exists()) continue
return getFormat(chapFile)
2017-02-05 12:01:58 +01:00
}
throw Exception(context.getString(R.string.chapter_not_found))
2017-02-05 12:01:58 +01:00
}
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))
2017-02-05 12:01:58 +01:00
}
}
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) } }
2017-02-05 12:01:58 +01:00
entry?.let { updateCover(context, manga, it.inputStream()) }
2017-02-05 12:01:58 +01:00
}
is Format.Zip -> {
ZipFile(format.file).use { zip ->
val entry = zip.entries().toList()
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
2020-04-25 20:24:45 +02:00
.find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
2017-02-05 12:01:58 +01:00
entry?.let { updateCover(context, manga, zip.getInputStream(it)) }
}
2017-02-08 22:12:00 +01:00
}
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) } }
2017-02-08 22:12:00 +01:00
entry?.let { updateCover(context, manga, archive.getInputStream(it)) }
}
2017-02-05 12:01:58 +01:00
}
is Format.Epub -> {
EpubFile(format.file).use { epub ->
val entry = epub.getImagesFromPages()
2020-04-25 20:24:45 +02:00
.firstOrNull()
?.let { epub.getEntry(it) }
2017-02-05 12:01:58 +01:00
entry?.let { updateCover(context, manga, epub.getInputStream(it)) }
2017-02-05 12:01:58 +01:00
}
}
}
}
2017-02-05 12:01:58 +01:00
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) })
2017-02-05 12:01:58 +01:00
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)
)
2017-02-05 12:01:58 +01:00
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()
2017-02-05 12:01:58 +01:00
}
}
private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "epub")
private val WHITESPACE_CHARS = arrayOf(
' ',
'\u0009',
'\u000A',
'\u000B',
'\u000C',
'\u000D',
'\u0020',
'\u0085',
'\u00A0',
'\u1680',
'\u2000',
'\u2001',
'\u2002',
'\u2003',
'\u2004',
'\u2005',
'\u2006',
'\u2007',
'\u2008',
'\u2009',
'\u200A',
'\u2028',
'\u2029',
'\u202F',
'\u205F',
'\u3000',
)