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 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 kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import okio.buffer import okio.source import rx.Observable import timber.log.Timber import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get 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 SUPPORTED_ARCHIVE_TYPES = setOf("zip", "rar", "cbr", "cbz", "epub") 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 } val cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME) // It might not exist if using the external SD card cover.parentFile?.mkdirs() input.use { cover.outputStream().use { input.copyTo(it) } } return cover } private fun getBaseDirectories(context: Context): List { val c = context.getString(R.string.app_name) + File.separator + "local" return DiskUtil.getExternalStorages(context).map { File(it.absolutePath, c) } } // SY --> val json = Json { prettyPrint = true } // SY <-- } override val id = ID override val name = context.getString(R.string.local_source) override val lang = "" 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 { val baseDirs = getBaseDirectories(context) // SY --> val allowLocalSourceHiddenFolders = Injekt.get().allowLocalSourceHiddenFolders().get() // SY <-- 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('.') /* SY --> */ && !allowLocalSourceHiddenFolders /* SY <-- */ } .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 = File("${dir.absolutePath}/$url", COVER_NAME) if (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) { Timber.e(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.writeText(json.encodeToString(manga.toJson())) } private fun SManga.toJson(): MangaJson { return MangaJson(title, author, artist, description, genre?.split(", "), status) } @Serializable data class MangaJson( val title: String? = null, val author: String? = null, val artist: String? = null, val description: String? = null, val genre: List? = null, val status: Int? = null ) // SY <-- override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS) override fun fetchMangaDetails(manga: SManga): Observable { getBaseDirectories(context) .asSequence() .mapNotNull { File(it, manga.url).listFiles()?.toList() } .flatten() .firstOrNull { it.extension == "json" } ?.apply { val json = json.decodeFromString( this.inputStream() .source() .buffer() .use { it.readUtf8() } ) 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 } return Observable.just(manga) } override fun fetchChapterList(manga: SManga): Observable> { 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> { 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): Format { val extension = file.extension return if (file.isDirectory) { Format.Directory(file) } else if (extension.equals("zip", true) || extension.equals("cbz", true)) { Format.Zip(file) } else if (extension.equals("rar", true) || extension.equals("cbr", true)) { Format.Rar(file) } else if (extension.equals("epub", true)) { Format.Epub(file) } 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() } }