package tachiyomi.source.local import android.content.Context import com.hippo.unifile.UniFile 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.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.i18n.stringResource import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE import tachiyomi.core.metadata.comicinfo.ComicInfo import tachiyomi.core.metadata.comicinfo.copyFromComicInfo import tachiyomi.core.metadata.comicinfo.getComicInfo import tachiyomi.core.metadata.tachiyomi.MangaDetails import tachiyomi.core.storage.extension import tachiyomi.core.storage.nameWithoutExtension import tachiyomi.core.storage.toFile 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.i18n.MR 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.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.stringResource(MR.strings.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 baseDirFiles = fileSystem.getFilesInBaseDirectory() val lastModifiedLimit by lazy { if (filters === LATEST_FILTERS) { System.currentTimeMillis() - LATEST_THRESHOLD } else { 0L } } var mangaDirs = baseDirFiles // Filter out files that are hidden and is not a folder .filter { it.isDirectory && !it.name.orEmpty().startsWith('.') } .distinctBy { it.name } .filter { // Filter by query or last modified if (lastModifiedLimit == 0L) { it.name.orEmpty().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.orEmpty() }) } else { mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name.orEmpty() }) } } is OrderBy.Latest -> { mangaDirs = if (filter.state!!.ascending) { mangaDirs.sortedBy(UniFile::lastModified) } else { mangaDirs.sortedByDescending(UniFile::lastModified) } } else -> { /* Do nothing */ } } } // Transform mangaDirs to list of SManga val mangas = mangaDirs.map { mangaDir -> SManga.create().apply { title = mangaDir.name.orEmpty() url = mangaDir.name.orEmpty() // Try to find the cover coverManager.find(mangaDir.name.orEmpty()) ?.takeIf(UniFile::exists) ?.let { thumbnail_url = it.uri.toString() } } } // Fetch chapters of all the manga mangas.forEach { manga -> 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.uri.toString() } // Augment manga details based on metadata files try { val mangaDir = fileSystem.getMangaDirectory(manga.url) 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.openInputStream(), manga) } // Old custom JSON format // TODO: remove support for this entirely after a while legacyJsonDetailsFile != null -> { json.decodeFromStream(legacyJsonDetailsFile.openInputStream()).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 } } // Replace with ComicInfo.xml file val comicInfo = manga.getComicInfo() mangaDir ?.createFile(COMIC_INFO_FILE) ?.openOutputStream() ?.use { val comicInfoString = xml.encodeToString(ComicInfo.serializer(), comicInfo) it.write(comicInfoString.toByteArray()) legacyJsonDetailsFile.delete() } } // Copy ComicInfo.xml from chapter archive to top level if found noXmlFile == null -> { val chapterArchives = mangaDirFiles .filter(Archive::isSupported) .toList() val folderPath = mangaDir?.filePath 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.toFile()).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.toFile()).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.orEmpty() } else { chapterFile.nameWithoutExtension.orEmpty() } 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 { val (mangaDirName, chapterName) = chapter.url.split(File.separator, limit = 2) return fileSystem.getBaseDirectory() ?.findFile(mangaDirName) ?.findFile(chapterName) ?.let(Format.Companion::valueOf) ?: throw Exception(context.stringResource(MR.strings.chapter_not_found)) } catch (e: Format.UnknownFormatException) { throw Exception(context.stringResource(MR.strings.local_invalid_format)) } catch (e: Exception) { throw e } } 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) { it.openInputStream() } } entry?.let { coverManager.update(manga, it.openInputStream()) } } is Format.Zip -> { ZipFile(format.file.toFile()).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.toFile()).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-source/" 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