2017-01-29 20:48:55 +01:00
|
|
|
package eu.kanade.tachiyomi.source
|
|
|
|
|
|
|
|
import android.content.Context
|
2020-12-08 04:13:53 +01:00
|
|
|
import com.github.junrar.Archive
|
2017-01-29 20:48:55 +01:00
|
|
|
import eu.kanade.tachiyomi.R
|
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
|
2021-10-08 04:12:55 +02:00
|
|
|
import eu.kanade.tachiyomi.util.system.logcat
|
2021-09-06 17:54:00 +02:00
|
|
|
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
|
2021-10-08 04:12:55 +02:00
|
|
|
import logcat.LogPriority
|
2020-09-14 00:48:20 +02:00
|
|
|
import rx.Observable
|
2021-09-06 17:54:00 +02:00
|
|
|
import uy.kohesive.injekt.injectLazy
|
2017-01-29 20:48:55 +01:00
|
|
|
import java.io.File
|
|
|
|
import java.io.FileInputStream
|
|
|
|
import java.io.InputStream
|
2018-09-01 17:12:59 +02:00
|
|
|
import java.util.Locale
|
2017-01-29 20:48:55 +01:00
|
|
|
import java.util.concurrent.TimeUnit
|
|
|
|
import java.util.zip.ZipFile
|
|
|
|
|
|
|
|
class LocalSource(private val context: Context) : CatalogueSource {
|
|
|
|
companion object {
|
2020-07-25 20:20:47 +02:00
|
|
|
const val ID = 0L
|
2021-03-13 17:38:06 +01:00
|
|
|
const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/"
|
2020-04-25 17:56:24 +02:00
|
|
|
|
2021-10-30 18:16:29 +02:00
|
|
|
private const val COVER_NAME = "cover.jpg"
|
2017-01-29 20:48:55 +01:00
|
|
|
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
|
|
|
|
}
|
2021-10-30 18:16:29 +02:00
|
|
|
var cover = getCoverFile(File("${dir.absolutePath}/${manga.url}"))
|
|
|
|
if (cover == null) {
|
|
|
|
cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME)
|
|
|
|
}
|
2021-10-31 00:36:23 +02:00
|
|
|
if (!cover.exists()) {
|
2021-07-10 21:44:34 +02:00
|
|
|
// It might not exist if using the external SD card
|
|
|
|
cover.parentFile?.mkdirs()
|
|
|
|
input.use {
|
|
|
|
cover.outputStream().use {
|
|
|
|
input.copyTo(it)
|
|
|
|
}
|
2017-01-29 20:48:55 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
return cover
|
|
|
|
}
|
|
|
|
|
2021-07-10 21:44:34 +02:00
|
|
|
/**
|
|
|
|
* 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() }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-01-29 20:48:55 +01:00
|
|
|
private fun getBaseDirectories(context: Context): List<File> {
|
2017-01-29 20:51:11 +01:00
|
|
|
val c = context.getString(R.string.app_name) + File.separator + "local"
|
|
|
|
return DiskUtil.getExternalStorages(context).map { File(it.absolutePath, c) }
|
2017-01-29 20:48:55 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-09-06 17:54:00 +02:00
|
|
|
private val json: Json by injectLazy()
|
|
|
|
|
2017-01-29 20:48:55 +01:00
|
|
|
override val id = ID
|
2017-11-18 14:09:28 +01:00
|
|
|
override val name = context.getString(R.string.local_source)
|
2021-10-09 17:01:22 +02:00
|
|
|
override val lang = "other"
|
2017-01-29 20:48:55 +01:00
|
|
|
override val supportsLatest = true
|
|
|
|
|
2021-10-31 00:36:23 +02:00
|
|
|
override fun toString() = name
|
2017-01-29 20:48:55 +01:00
|
|
|
|
|
|
|
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
|
2020-07-25 20:20:47 +02:00
|
|
|
var mangaDirs = baseDirs
|
|
|
|
.asSequence()
|
|
|
|
.mapNotNull { it.listFiles()?.toList() }
|
2020-04-25 20:24:45 +02:00
|
|
|
.flatten()
|
2020-07-25 20:20:47 +02:00
|
|
|
.filter { it.isDirectory }
|
2020-08-22 23:33:04 +02:00
|
|
|
.filterNot { it.name.startsWith('.') }
|
2020-07-25 20:20:47 +02:00
|
|
|
.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 }
|
2017-01-29 20:48:55 +01:00
|
|
|
|
|
|
|
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) {
|
2021-06-01 23:53:51 +02:00
|
|
|
mangaDirs.sortedBy { it.name.lowercase(Locale.ENGLISH) }
|
2020-04-25 20:24:45 +02:00
|
|
|
} else {
|
2021-06-01 23:53:51 +02:00
|
|
|
mangaDirs.sortedByDescending { it.name.lowercase(Locale.ENGLISH) }
|
2020-04-25 20:24:45 +02:00
|
|
|
}
|
2017-01-29 20:48:55 +01:00
|
|
|
}
|
|
|
|
1 -> {
|
2020-04-25 20:24:45 +02:00
|
|
|
mangaDirs = if (state.ascending) {
|
2020-04-18 20:47:22 +02:00
|
|
|
mangaDirs.sortedBy(File::lastModified)
|
2020-04-25 20:24:45 +02:00
|
|
|
} else {
|
2020-04-18 20:47:22 +02:00
|
|
|
mangaDirs.sortedByDescending(File::lastModified)
|
2020-04-25 20:24:45 +02:00
|
|
|
}
|
2017-01-29 20:48:55 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
val mangas = mangaDirs.map { mangaDir ->
|
|
|
|
SManga.create().apply {
|
|
|
|
title = mangaDir.name
|
|
|
|
url = mangaDir.name
|
|
|
|
|
|
|
|
// Try to find the cover
|
|
|
|
for (dir in baseDirs) {
|
2021-07-10 21:44:34 +02:00
|
|
|
val cover = getCoverFile(File("${dir.absolutePath}/$url"))
|
|
|
|
if (cover != null && cover.exists()) {
|
2017-01-29 20:51:11 +01:00
|
|
|
thumbnail_url = cover.absolutePath
|
2017-01-29 20:48:55 +01:00
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-04-28 04:49:06 +02:00
|
|
|
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) {
|
2018-09-01 17:12:59 +02:00
|
|
|
try {
|
2020-04-28 04:49:06 +02:00
|
|
|
val dest = updateCover(chapter, this)
|
2018-09-01 17:12:59 +02:00
|
|
|
thumbnail_url = dest?.absolutePath
|
|
|
|
} catch (e: Exception) {
|
2021-10-08 04:12:55 +02:00
|
|
|
logcat(LogPriority.ERROR, e)
|
2017-01-29 20:48:55 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2020-07-25 20:20:47 +02:00
|
|
|
|
|
|
|
return Observable.just(MangasPage(mangas.toList(), false))
|
2017-01-29 20:48:55 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
|
|
|
|
|
2020-01-11 21:59:43 +01:00
|
|
|
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
|
|
|
getBaseDirectories(context)
|
2020-07-25 20:20:47 +02:00
|
|
|
.asSequence()
|
2020-04-25 20:24:45 +02:00
|
|
|
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
|
|
|
|
.flatten()
|
2021-10-31 00:36:23 +02:00
|
|
|
.firstOrNull { it.extension.lowercase() == "json" }
|
2020-04-25 20:24:45 +02:00
|
|
|
?.apply {
|
2021-09-06 17:54:00 +02:00
|
|
|
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 }
|
2020-04-25 20:24:45 +02:00
|
|
|
?: manga.genre
|
2021-09-06 17:54:00 +02:00
|
|
|
manga.status = obj["status"]?.jsonPrimitive?.intOrNull ?: manga.status
|
2020-04-25 20:24:45 +02:00
|
|
|
}
|
2020-07-25 20:20:47 +02:00
|
|
|
|
2020-01-11 21:59:43 +01: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}"
|
2020-04-28 04:49:06 +02:00
|
|
|
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()
|
2020-04-28 04:49:06 +02:00
|
|
|
|
2021-10-31 00:36:23 +02:00
|
|
|
val format = getFormat(chapterFile)
|
2020-04-28 04:49:06 +02:00
|
|
|
if (format is Format.Epub) {
|
|
|
|
EpubFile(format.file).use { epub ->
|
|
|
|
epub.fillChapterMetadata(this)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-10-31 00:36:23 +02:00
|
|
|
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
|
|
|
}
|
2021-05-24 22:50:07 +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)
|
|
|
|
}
|
|
|
|
|
2020-04-28 04:49:06 +02:00
|
|
|
/**
|
2021-10-31 00:36:23 +02:00
|
|
|
* Strips the manga title from a chapter name and trim whitespace/delimiter characters.
|
2020-04-28 04:49:06 +02:00
|
|
|
*/
|
2021-10-31 00:36:23 +02:00
|
|
|
private fun getCleanChapterTitle(chapterName: String, mangaTitle: String): String {
|
|
|
|
return chapterName
|
|
|
|
.replace(mangaTitle, "")
|
|
|
|
.trim(*WHITESPACE_CHARS.toCharArray(), '-', '_', ',', ':')
|
2020-04-28 04:49:06 +02:00
|
|
|
}
|
|
|
|
|
2017-02-05 12:01:58 +01:00
|
|
|
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
2018-09-01 17:12:59 +02:00
|
|
|
return Observable.error(Exception("Unused"))
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun isSupportedFile(extension: String): Boolean {
|
2021-06-01 23:53:51 +02:00
|
|
|
return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES
|
2018-09-01 17:12:59 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
|
2018-09-01 17:12:59 +02:00
|
|
|
return getFormat(chapFile)
|
2017-02-05 12:01:58 +01:00
|
|
|
}
|
2021-05-24 22:50:07 +02:00
|
|
|
throw Exception(context.getString(R.string.chapter_not_found))
|
2017-02-05 12:01:58 +01:00
|
|
|
}
|
|
|
|
|
2021-09-18 22:13:14 +02: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
|
|
|
}
|
2017-01-29 20:48:55 +01:00
|
|
|
}
|
|
|
|
|
2018-09-01 17:12:59 +02:00
|
|
|
private fun updateCover(chapter: SChapter, manga: SManga): File? {
|
2020-04-18 20:47:22 +02:00
|
|
|
return when (val format = getFormat(chapter)) {
|
2018-09-01 17:12:59 +02:00
|
|
|
is Format.Directory -> {
|
|
|
|
val entry = format.file.listFiles()
|
2020-12-08 04:13:53 +01:00
|
|
|
?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
|
2020-07-25 20:20:47 +02:00
|
|
|
?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
|
2017-02-05 12:01:58 +01:00
|
|
|
|
2020-02-17 23:23:37 +01:00
|
|
|
entry?.let { updateCover(context, manga, it.inputStream()) }
|
2017-02-05 12:01:58 +01:00
|
|
|
}
|
2018-09-01 17:12:59 +02:00
|
|
|
is Format.Zip -> {
|
|
|
|
ZipFile(format.file).use { zip ->
|
|
|
|
val entry = zip.entries().toList()
|
2020-12-08 04:13:53 +01:00
|
|
|
.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
|
|
|
|
2020-02-17 23:23:37 +01:00
|
|
|
entry?.let { updateCover(context, manga, zip.getInputStream(it)) }
|
2018-09-01 17:12:59 +02:00
|
|
|
}
|
2017-02-08 22:12:00 +01:00
|
|
|
}
|
2018-09-01 17:12:59 +02:00
|
|
|
is Format.Rar -> {
|
|
|
|
Archive(format.file).use { archive ->
|
|
|
|
val entry = archive.fileHeaders
|
2020-12-08 04:13:53 +01:00
|
|
|
.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
|
|
|
|
2020-02-17 23:23:37 +01:00
|
|
|
entry?.let { updateCover(context, manga, archive.getInputStream(it)) }
|
2018-09-01 17:12:59 +02:00
|
|
|
}
|
2017-02-05 12:01:58 +01:00
|
|
|
}
|
2018-09-01 17:12:59 +02: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
|
|
|
|
2018-09-01 17:12:59 +02:00
|
|
|
entry?.let { updateCover(context, manga, epub.getInputStream(it)) }
|
2017-02-05 12:01:58 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2018-09-01 17:12:59 +02:00
|
|
|
}
|
2017-02-05 12:01:58 +01:00
|
|
|
|
2021-05-24 22:50:07 +02: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
|
|
|
|
2021-05-24 22:50:07 +02: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
|
|
|
|
2018-09-01 17:12:59 +02:00
|
|
|
sealed class Format {
|
|
|
|
data class Directory(val file: File) : Format()
|
|
|
|
data class Zip(val file: File) : Format()
|
2020-02-17 23:23:37 +01:00
|
|
|
data class Rar(val file: File) : Format()
|
2018-09-01 17:12:59 +02:00
|
|
|
data class Epub(val file: File) : Format()
|
2017-02-05 12:01:58 +01:00
|
|
|
}
|
2018-09-01 17:12:59 +02:00
|
|
|
}
|
2021-10-31 00:36:23 +02: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',
|
|
|
|
)
|