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.Page import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import logcat.LogPriority import mihon.core.archive.archiveReader import mihon.core.archive.epubReader import nl.adaptivity.xmlutil.AndroidXmlReader import nl.adaptivity.xmlutil.serialization.XML import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.storage.extension import tachiyomi.core.common.storage.nameWithoutExtension import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.system.ImageUtil import tachiyomi.core.common.util.system.logcat 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.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.fillMetadata import uy.kohesive.injekt.injectLazy import java.io.InputStream import java.nio.charset.StandardCharsets import kotlin.time.Duration.Companion.days 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() @Suppress("PrivatePropertyName", "ktlint:standard:property-naming") private val PopularFilters = FilterList(OrderBy.Popular(context)) @Suppress("PrivatePropertyName", "ktlint:standard:property-naming") private val LatestFilters = 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, "", PopularFilters) override suspend fun getLatestUpdates(page: Int) = getSearchManga(page, "", LatestFilters) override suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage = withIOContext { val lastModifiedLimit = if (filters === LatestFilters) { System.currentTimeMillis() - LATEST_THRESHOLD } else { 0L } var mangaDirs = fileSystem.getFilesInBaseDirectory() // Filter out files that are hidden and is not a folder .filter { it.isDirectory && !it.name.orEmpty().startsWith('.') } .distinctBy { it.name } .filter { if (lastModifiedLimit == 0L && query.isBlank()) { true } else 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 */ } } } val mangas = mangaDirs .map { mangaDir -> async { SManga.create().apply { title = mangaDir.name.orEmpty() url = mangaDir.name.orEmpty() // Try to find the cover coverManager.find(mangaDir.name.orEmpty())?.let { thumbnail_url = it.uri.toString() } } } } .awaitAll() MangasPage(mangas, 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) ?: error("${manga.url} is not a valid directory") val mangaDirFiles = mangaDir.listFiles().orEmpty() 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) val copiedFile = copyComicInfoFileFromArchive(chapterArchives, mangaDir) if (copiedFile != null) { setMangaDetailsFromComicInfoFile(copiedFile.openInputStream(), manga) } else { // Avoid re-scanning mangaDir.createFile(".noxml") } } } } 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, folder: UniFile): UniFile? { for (chapter in chapterArchives) { chapter.archiveReader(context).use { reader -> reader.getInputStream(COMIC_INFO_FILE)?.use { stream -> return copyComicInfoFile(stream, folder) } } } return null } private fun copyComicInfoFile(comicInfoFileStream: InputStream, folder: UniFile): UniFile? { return folder.createFile(COMIC_INFO_FILE)?.apply { openOutputStream().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 = withIOContext { val chapters = fileSystem.getFilesInMangaDirectory(manga.url) // Only keep supported formats .filter { it.isDirectory || Archive.isSupported(it) || it.extension.equals("epub", true) } .map { chapterFile -> SChapter.create().apply { url = "${manga.url}/${chapterFile.name}" name = if (chapterFile.isDirectory) { chapterFile.name } 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) { format.file.epubReader(context).use { epub -> epub.fillMetadata(manga, this) } } } } .sortedWith { c1, c2 -> val c = c2.chapter_number.compareTo(c1.chapter_number) if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c } // Copy the cover from the first chapter found if not available if (manga.thumbnail_url.isNullOrBlank()) { chapters.lastOrNull()?.let { chapter -> updateCover(chapter, manga) } } chapters } // Filters override fun getFilterList() = FilterList(OrderBy.Popular(context)) // Unused stuff override suspend fun getPageList(chapter: SChapter): List = throw UnsupportedOperationException("Unused") fun getFormat(chapter: SChapter): Format { try { val (mangaDirName, chapterName) = chapter.url.split('/', 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.Archive -> { format.file.archiveReader(context).use { reader -> val entry = reader.useEntries { entries -> entries .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } .find { it.isFile && ImageUtil.isImage(it.name) { reader.getInputStream(it.name)!! } } } entry?.let { coverManager.update(manga, reader.getInputStream(it.name)!!) } } } is Format.Epub -> { format.file.epubReader(context).use { epub -> val entry = epub.getImagesFromPages().firstOrNull() 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://mihon.app/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