package eu.kanade.tachiyomi.source import android.content.Context import androidx.core.net.toFile import co.touchlab.kermit.Logger import com.github.junrar.Archive import com.hippo.unifile.UniFile 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.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.EpubFile import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.e import eu.kanade.tachiyomi.util.system.extension import eu.kanade.tachiyomi.util.system.nameWithoutExtension import eu.kanade.tachiyomi.util.system.openReadOnlyChannel import eu.kanade.tachiyomi.util.system.toZipFile import eu.kanade.tachiyomi.util.system.withIOContext import eu.kanade.tachiyomi.util.system.writeText import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import nl.adaptivity.xmlutil.AndroidXmlReader import nl.adaptivity.xmlutil.serialization.XML import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy import yokai.core.metadata.COMIC_INFO_FILE import yokai.core.metadata.ComicInfo import yokai.core.metadata.copyFromComicInfo import yokai.core.metadata.toComicInfo import yokai.domain.storage.StorageManager import java.io.FileInputStream import java.io.InputStream import java.nio.charset.StandardCharsets import java.util.concurrent.* class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSource { companion object { const val ID = 0L const val HELP_URL = "https://mihon.app/docs/guides/local-source/" private const val COVER_NAME = "cover.jpg" private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS) private val langMap = hashMapOf() fun decodeComicInfo(stream: InputStream, xml: XML = Injekt.get()): ComicInfo { return AndroidXmlReader(stream, StandardCharsets.UTF_8.name()).use { reader -> xml.decodeFromReader(reader) } } fun getMangaLang(manga: SManga): String { return langMap.getOrPut(manga.url) { val localDetails = getBaseDirectory()?.findFile(manga.url)?.listFiles().orEmpty() .filter { !it.isDirectory } .firstOrNull { it.name == COMIC_INFO_FILE } return if (localDetails != null) { decodeComicInfo(localDetails.openInputStream()).language?.value ?: "other" } else { "other" } } } fun updateCover(manga: SManga, input: InputStream): UniFile? { val dir = getBaseDirectory() ?: return null var cover = getCoverFile(dir.findFile(manga.url)) if (cover == null) { cover = dir.findFile(manga.url)?.createFile(COVER_NAME)!! } // It might not exist if using the external SD card cover.parentFile?.parentFile?.createDirectory(cover.parentFile?.name) input.use { cover.openOutputStream().use { input.copyTo(it) } } manga.thumbnail_url = cover.uri.toString() return cover } private fun updateMetadata(chapter: SChapter, manga: SManga, stream: InputStream) { val comicInfo = decodeComicInfo(stream) comicInfo.title?.let { chapter.name = it.value } comicInfo.number?.value?.toFloatOrNull()?.let { chapter.chapter_number = it } ?: ChapterRecognition.parseChapterNumber(chapter, manga) comicInfo.translator?.let { chapter.scanlator = it.value } } /** * Returns valid cover file inside [parent] directory. */ private fun getCoverFile(parent: UniFile?): UniFile? { return parent?.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf { it.isFile && ImageUtil.isImage(it.name.orEmpty()) { it.openInputStream() } } } private fun getBaseDirectory(): UniFile? { val storageManager: StorageManager by injectLazy() return storageManager.getLocalSourceDirectory() } } private val json: Json by injectLazy() private val xml: XML 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() = name override suspend fun getPopularManga(page: Int) = getSearchManga(page, "", popularFilters) override suspend fun getSearchManga( page: Int, query: String, filters: FilterList, ): MangasPage = withIOContext { val time = if (filters === latestFilters) { System.currentTimeMillis() - LATEST_THRESHOLD } else { 0L } var mangaDirs = getBaseDirectory()?.listFiles().orEmpty() .filter { it.isDirectory || !it.name.orEmpty().startsWith('.') } .distinctBy { it.name } .filter { if (time == 0L && query.isBlank()) true else if (time == 0L) it.name.orEmpty().contains(query, ignoreCase = true) else it.lastModified() >= time } val state = ((if (filters.isEmpty()) popularFilters else filters)[0] as OrderBy).state when (state?.index) { 0 -> { mangaDirs = if (state.ascending) { mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name.orEmpty() }) } else { mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name.orEmpty()}) } } 1 -> { mangaDirs = if (state.ascending) { mangaDirs.sortedBy(UniFile::lastModified) } else { mangaDirs.sortedByDescending(UniFile::lastModified) } } } val mangas = mangaDirs.map { mangaDir -> async { SManga.create().apply { title = mangaDir.name.orEmpty() url = mangaDir.name.orEmpty() // Try to find the cover val cover = getCoverFile(mangaDir) if (cover != null && cover.exists()) { thumbnail_url = cover.uri.toString() } val manga = this val chapters = getChapterList(manga) if (chapters.isNotEmpty()) { val chapter = chapters.last() val format = getFormat(chapter) if (format is Format.Epub) { EpubFile(format.file.openReadOnlyChannel(context)).use { epub -> epub.fillMangaMetadata(manga) } } // Copy the cover from the first chapter found. if (thumbnail_url == null) { try { val dest = updateCover(chapter, manga) thumbnail_url = dest?.filePath } catch (e: Exception) { Logger.e(e) } } } } } }.awaitAll() MangasPage(mangas.toList(), false) } override suspend fun getLatestUpdates(page: Int) = getSearchManga(page, "", latestFilters) override suspend fun getMangaDetails(manga: SManga): SManga = withIOContext { try { val localMangaDir = getBaseDirectory()?.findFile(manga.url) ?: throw Exception("${manga.url} is not a valid directory") val localMangaFiles = localMangaDir.listFiles().orEmpty().filter { !it.isDirectory } val comicInfoFile = localMangaFiles.firstOrNull { it.name.orEmpty() == COMIC_INFO_FILE } val legacyJsonFile = localMangaFiles.firstOrNull { it.extension.orEmpty().equals("json", true) } if (comicInfoFile != null) return@withIOContext manga.copy().apply { setMangaDetailsFromComicInfoFile(comicInfoFile.openInputStream(), this) } // TODO: Remove after awhile if (legacyJsonFile != null) { val rt = manga.copy().apply { setMangaDetailsFromLegacyJsonFile(legacyJsonFile.openInputStream(), this) } val comicInfo = rt.toComicInfo() localMangaDir.createFile(COMIC_INFO_FILE) ?.writeText(xml.encodeToString(ComicInfo.serializer(), comicInfo)) { legacyJsonFile.delete() } return@withIOContext rt } } catch (e: Exception) { Logger.e(e) } return@withIOContext manga } private fun setMangaDetailsFromComicInfoFile(stream: InputStream, manga: SManga) { val comicInfo = decodeComicInfo(stream, xml) comicInfo.language?.let { langMap[manga.url] = it.value } manga.copyFromComicInfo(comicInfo) } private fun setMangaDetailsFromLegacyJsonFile(stream: InputStream, manga: SManga) { val obj = json.decodeFromStream(stream) obj.lang?.let { langMap[manga.url] = it } manga.apply { title = obj.title ?: manga.title author = obj.author ?: manga.author artist = obj.artist ?: manga.artist description = obj.description ?: manga.description genre = obj.genre?.joinToString(", ") ?: manga.genre status = obj.status ?: manga.status } } fun updateMangaInfo(manga: SManga, lang: String?) { val directory = getBaseDirectory()?.findFile(manga.url) ?: return if (!directory.exists()) return lang?.let { langMap[manga.url] = it } val file = directory.createFile(COMIC_INFO_FILE)!! file.writeText(xml.encodeToString(ComicInfo.serializer(), manga.toComicInfo(lang = lang))) } @Serializable data class MangaJson( val title: String? = null, val author: String? = null, val artist: String? = null, val description: String? = null, val genre: Array? = null, val status: Int? = null, val lang: String? = null, ) { override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as MangaJson if (title != other.title) return false return true } override fun hashCode(): Int { return title.hashCode() } } override suspend fun getChapterList(manga: SManga): List = withIOContext { val chapters = getBaseDirectory()?.findFile(manga.url)?.listFiles().orEmpty() .filter { it.isDirectory || isSupportedFile(it.extension.orEmpty()) } .map { chapterFile -> SChapter.create().apply { url = "${manga.url}/${chapterFile.name}" name = if (chapterFile.isDirectory) { chapterFile.name.orEmpty() } else { chapterFile.nameWithoutExtension.orEmpty() } date_upload = chapterFile.lastModified() val success = updateMetadata(this, manga, chapterFile) if (!success) 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() chapters } override suspend fun getPageList(chapter: SChapter) = throw Exception("Unused") private fun isSupportedFile(extension: String): Boolean { return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES } fun getFormat(chapter: SChapter): Format { val dir = getBaseDirectory() val (mangaDirName, chapterName) = chapter.url.split('/', limit = 2) val chapFile = dir ?.findFile(mangaDirName) ?.findFile(chapterName) if (chapFile == null || !chapFile.exists()) throw Exception(context.getString(R.string.chapter_not_found)) return getFormat(chapFile) } private fun getFormat(file: UniFile) = 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): UniFile? { return try { when (val format = getFormat(chapter)) { is Format.Directory -> { val entry = format.file.listFiles() ?.sortedWith { f1, f2 -> f1.name.orEmpty().compareToCaseInsensitiveNaturalOrder(f2.name.orEmpty()) } ?.find { !it.isDirectory && ImageUtil.isImage(it.name.orEmpty()) { FileInputStream(it.uri.toFile()) } } entry?.let { updateCover(manga, it.openInputStream()) } } is Format.Zip -> { format.file.openReadOnlyChannel(context).toZipFile().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(manga, zip.getInputStream(it)) } } } is Format.Rar -> { Archive(format.file.openInputStream()).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(manga, archive.getInputStream(it)) } } } is Format.Epub -> { EpubFile(format.file.openReadOnlyChannel(context)).use { epub -> val entry = epub.getImagesFromPages() .firstOrNull() ?.let { epub.getEntry(it) } entry?.let { updateCover(manga, epub.getInputStream(it)) } } } } } catch (e: Throwable) { Logger.e(e) { "Error updating cover for ${manga.title}" } null } } private fun updateMetadata(chapter: SChapter, manga: SManga, chapterFile: UniFile? = null): Boolean { return try { when (val format = if (chapterFile != null) getFormat(chapterFile) else getFormat(chapter)) { is Format.Directory -> { val entry = format.file.findFile(COMIC_INFO_FILE) ?: return false updateMetadata(chapter, manga, entry.openInputStream()) true } is Format.Epub -> { EpubFile(format.file.openReadOnlyChannel(context)).use { epub -> epub.fillChapterMetadata(chapter) } true } is Format.Rar -> Archive(format.file.openInputStream()).use { archive -> val entry = archive.fileHeaders .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } .find { !it.isDirectory && it.fileName == COMIC_INFO_FILE } ?: return false updateMetadata(chapter, manga, archive.getInputStream(entry)) true } is Format.Zip -> format.file.openReadOnlyChannel(context).toZipFile().use { zip -> val entry = zip.entries.toList() .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } .find { !it.isDirectory && it.name == COMIC_INFO_FILE } ?: return false updateMetadata(chapter, manga, zip.getInputStream(entry)) true } } } catch (e: Throwable) { Logger.e(e) { "Error updating a metadata" } false } } override fun getFilterList() = popularFilters private val popularFilters = FilterList(OrderBy(context)) private val latestFilters = FilterList(OrderBy(context).apply { state = Filter.Sort.Selection(1, false) }) private class OrderBy(context: Context) : Filter.Sort( context.getString(R.string.order_by), arrayOf(context.getString(R.string.title), context.getString(R.string.date)), Selection(0, true), ) sealed class Format { data class Directory(val file: UniFile) : Format() data class Zip(val file: UniFile) : Format() data class Rar(val file: UniFile) : Format() data class Epub(val file: UniFile) : Format() } } private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "epub")