package tachiyomi.source.local import android.content.Context import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.UnmeteredSource 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.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.storage.EpubFile import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import logcat.LogPriority import nl.adaptivity.xmlutil.AndroidXmlReader import nl.adaptivity.xmlutil.serialization.XML import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE import tachiyomi.core.metadata.comicinfo.ComicInfo import tachiyomi.core.metadata.comicinfo.copyFromComicInfo import tachiyomi.core.metadata.tachiyomi.MangaDetails import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.system.ImageUtil import tachiyomi.core.util.system.logcat import tachiyomi.domain.chapter.service.ChapterRecognition import tachiyomi.domain.manga.model.Manga import tachiyomi.source.local.filter.OrderBy import tachiyomi.source.local.image.LocalCoverManager import tachiyomi.source.local.io.Archive import tachiyomi.source.local.io.Format import tachiyomi.source.local.io.LocalSourceFileSystem import tachiyomi.source.local.metadata.fillChapterMetadata import tachiyomi.source.local.metadata.fillMangaMetadata import uy.kohesive.injekt.injectLazy import java.io.File import java.io.FileInputStream import java.io.InputStream import java.nio.charset.StandardCharsets import java.util.zip.ZipFile import kotlin.time.Duration.Companion.days import com.github.junrar.Archive as JunrarArchive import tachiyomi.domain.source.model.Source as DomainSource actual class LocalSource( private val context: Context, private val fileSystem: LocalSourceFileSystem, private val coverManager: LocalCoverManager, ) : CatalogueSource, UnmeteredSource { private val json: Json by injectLazy() private val xml: XML by injectLazy() private val POPULAR_FILTERS = FilterList(OrderBy.Popular(context)) private val LATEST_FILTERS = FilterList(OrderBy.Latest(context)) override val name: String = context.getString(R.string.local_source) override val id: Long = ID override val lang: String = "other" override fun toString() = name override val supportsLatest: Boolean = true // Browse related override suspend fun getPopularManga(page: Int) = getSearchManga(page, "", POPULAR_FILTERS) override suspend fun getLatestUpdates(page: Int) = getSearchManga(page, "", LATEST_FILTERS) override suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage { val baseDirsFiles = fileSystem.getFilesInBaseDirectories() val lastModifiedLimit by lazy { if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L } var mangaDirs = baseDirsFiles // Filter out files that are hidden and is not a folder .filter { it.isDirectory && !it.name.startsWith('.') } .distinctBy { it.name } .filter { // Filter by query or last modified if (lastModifiedLimit == 0L) { it.name.contains(query, ignoreCase = true) } else { it.lastModified() >= lastModifiedLimit } } filters.forEach { filter -> when (filter) { is OrderBy.Popular -> { mangaDirs = if (filter.state!!.ascending) { mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) } else { mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name }) } } is OrderBy.Latest -> { mangaDirs = if (filter.state!!.ascending) { mangaDirs.sortedBy(File::lastModified) } else { mangaDirs.sortedByDescending(File::lastModified) } } else -> { /* Do nothing */ } } } // Transform mangaDirs to list of SManga val mangas = mangaDirs.map { mangaDir -> SManga.create().apply { title = mangaDir.name url = mangaDir.name // Try to find the cover coverManager.find(mangaDir.name) ?.takeIf(File::exists) ?.let { thumbnail_url = it.absolutePath } } } // Fetch chapters of all the manga mangas.forEach { manga -> runBlocking { val chapters = getChapterList(manga) if (chapters.isNotEmpty()) { val chapter = chapters.last() val format = getFormat(chapter) if (format is Format.Epub) { EpubFile(format.file).use { epub -> epub.fillMangaMetadata(manga) } } // Copy the cover from the first chapter found if not available if (manga.thumbnail_url == null) { updateCover(chapter, manga) } } } } return MangasPage(mangas.toList(), false) } // Manga details related override suspend fun getMangaDetails(manga: SManga): SManga = withIOContext { coverManager.find(manga.url)?.let { manga.thumbnail_url = it.absolutePath } // Augment manga details based on metadata files try { val mangaDirFiles = fileSystem.getFilesInMangaDirectory(manga.url).toList() val comicInfoFile = mangaDirFiles .firstOrNull { it.name == COMIC_INFO_FILE } val noXmlFile = mangaDirFiles .firstOrNull { it.name == ".noxml" } val legacyJsonDetailsFile = mangaDirFiles .firstOrNull { it.extension == "json" } when { // Top level ComicInfo.xml comicInfoFile != null -> { noXmlFile?.delete() setMangaDetailsFromComicInfoFile(comicInfoFile.inputStream(), manga) } // TODO: automatically convert these to ComicInfo.xml legacyJsonDetailsFile != null -> { json.decodeFromStream(legacyJsonDetailsFile.inputStream()).run { title?.let { manga.title = it } author?.let { manga.author = it } artist?.let { manga.artist = it } description?.let { manga.description = it } genre?.let { manga.genre = it.joinToString() } status?.let { manga.status = it } } } // Copy ComicInfo.xml from chapter archive to top level if found noXmlFile == null -> { val chapterArchives = mangaDirFiles .filter(Archive::isSupported) .toList() val mangaDir = fileSystem.getMangaDirectory(manga.url) val folderPath = mangaDir?.absolutePath val copiedFile = copyComicInfoFileFromArchive(chapterArchives, folderPath) if (copiedFile != null) { setMangaDetailsFromComicInfoFile(copiedFile.inputStream(), manga) } else { // Avoid re-scanning File("$folderPath/.noxml").createNewFile() } } } } catch (e: Throwable) { logcat(LogPriority.ERROR, e) { "Error setting manga details from local metadata for ${manga.title}" } } return@withIOContext manga } private fun copyComicInfoFileFromArchive(chapterArchives: List, folderPath: String?): File? { for (chapter in chapterArchives) { when (Format.valueOf(chapter)) { is Format.Zip -> { ZipFile(chapter).use { zip: ZipFile -> zip.getEntry(COMIC_INFO_FILE)?.let { comicInfoFile -> zip.getInputStream(comicInfoFile).buffered().use { stream -> return copyComicInfoFile(stream, folderPath) } } } } is Format.Rar -> { JunrarArchive(chapter).use { rar -> rar.fileHeaders.firstOrNull { it.fileName == COMIC_INFO_FILE }?.let { comicInfoFile -> rar.getInputStream(comicInfoFile).buffered().use { stream -> return copyComicInfoFile(stream, folderPath) } } } } else -> {} } } return null } private fun copyComicInfoFile(comicInfoFileStream: InputStream, folderPath: String?): File { return File("$folderPath/$COMIC_INFO_FILE").apply { outputStream().use { outputStream -> comicInfoFileStream.use { it.copyTo(outputStream) } } } } private fun setMangaDetailsFromComicInfoFile(stream: InputStream, manga: SManga) { val comicInfo = AndroidXmlReader(stream, StandardCharsets.UTF_8.name()).use { xml.decodeFromReader(it) } manga.copyFromComicInfo(comicInfo) } // Chapters override suspend fun getChapterList(manga: SManga): List { return fileSystem.getFilesInMangaDirectory(manga.url) // Only keep supported formats .filter { it.isDirectory || Archive.isSupported(it) } .map { chapterFile -> SChapter.create().apply { url = "${manga.url}/${chapterFile.name}" name = if (chapterFile.isDirectory) { chapterFile.name } else { chapterFile.nameWithoutExtension } date_upload = chapterFile.lastModified() chapter_number = ChapterRecognition .parseChapterNumber(manga.title, this.name, this.chapter_number.toDouble()) .toFloat() val format = Format.valueOf(chapterFile) if (format is Format.Epub) { EpubFile(format.file).use { epub -> epub.fillChapterMetadata(this) } } } } .sortedWith { c1, c2 -> val c = c2.chapter_number.compareTo(c1.chapter_number) if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c } .toList() } // Filters override fun getFilterList() = FilterList(OrderBy.Popular(context)) // Unused stuff override suspend fun getPageList(chapter: SChapter) = throw UnsupportedOperationException("Unused") fun getFormat(chapter: SChapter): Format { try { return fileSystem.getBaseDirectories() .map { dir -> File(dir, chapter.url) } .find { it.exists() } ?.let(Format.Companion::valueOf) ?: throw Exception(context.getString(R.string.chapter_not_found)) } catch (e: Format.UnknownFormatException) { throw Exception(context.getString(R.string.local_invalid_format)) } catch (e: Exception) { throw e } } private fun updateCover(chapter: SChapter, manga: SManga): File? { return try { 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 { coverManager.update(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 { coverManager.update(manga, zip.getInputStream(it)) } } } is Format.Rar -> { JunrarArchive(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 { coverManager.update(manga, archive.getInputStream(it)) } } } is Format.Epub -> { EpubFile(format.file).use { epub -> val entry = epub.getImagesFromPages() .firstOrNull() ?.let { epub.getEntry(it) } entry?.let { coverManager.update(manga, epub.getInputStream(it)) } } } } } catch (e: Throwable) { logcat(LogPriority.ERROR, e) { "Error updating cover for ${manga.title}" } null } } companion object { const val ID = 0L const val HELP_URL = "https://tachiyomi.org/docs/guides/local-series/" private val LATEST_THRESHOLD = 7.days.inWholeMilliseconds } } fun Manga.isLocal(): Boolean = source == LocalSource.ID fun Source.isLocal(): Boolean = id == LocalSource.ID fun DomainSource.isLocal(): Boolean = id == LocalSource.ID