Convert :source-api and :source-local to android library module (#3636)

- There's really no plan to support other platforms on this code base
- The "KMP" modules only had android target which is rather pointless
- `i18n` involves more work but given it has no actual code keeping it as is
This commit is contained in:
AntsyLich
2026-07-23 22:47:24 +06:00
committed by GitHub
parent 47d7c0f2ea
commit 9c3cc1ca5f
38 changed files with 54 additions and 128 deletions
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
@@ -0,0 +1,386 @@
package tachiyomi.source.local
import android.content.Context
import com.hippo.unifile.UniFile
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.source.model.SMangaUpdate
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.supervisorScope
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.core.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
class LocalSource(
private val context: Context,
private val fileSystem: LocalSourceFileSystem,
private val coverManager: LocalCoverManager,
) : Source, UnmeteredSource {
private val json: Json by injectLazy()
private val xml: XML by injectLazy()
@Suppress("PrivatePropertyName")
private val PopularFilters = FilterList(OrderBy.Popular(context))
@Suppress("PrivatePropertyName")
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)
}
override suspend fun getMangaUpdate(
manga: SManga,
chapters: List<SChapter>,
fetchDetails: Boolean,
fetchChapters: Boolean,
): SMangaUpdate = supervisorScope {
val asyncManga = if (fetchDetails) async { getMangaDetails(manga) } else null
val asyncChapters = if (fetchChapters) async { getChapterList(manga) } else null
SMangaUpdate(asyncManga?.await() ?: manga, asyncChapters?.await() ?: chapters)
}
// Manga details related
private 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<MangaDetails>(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 = copyComicInfoFileFromChapters(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 <T> getComicInfoForChapter(chapter: UniFile, block: (InputStream) -> T): T? {
return if (chapter.isDirectory) {
chapter.findFile(COMIC_INFO_FILE)?.openInputStream()?.use(block)
} else {
chapter.archiveReader(context).use { reader ->
reader.getInputStream(COMIC_INFO_FILE)?.use(block)
}
}
}
private fun copyComicInfoFileFromChapters(chapterArchives: List<UniFile>, folder: UniFile): UniFile? {
for (chapter in chapterArchives) {
val file = getComicInfoForChapter(chapter) f@{ stream ->
return@f copyComicInfoFile(stream, folder)
}
if (file != null) return file
}
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 parseComicInfo(stream: InputStream): ComicInfo {
return AndroidXmlReader(stream, StandardCharsets.UTF_8.name()).use {
xml.decodeFromReader<ComicInfo>(it)
}
}
private fun setMangaDetailsFromComicInfoFile(stream: InputStream, manga: SManga) {
manga.copyFromComicInfo(parseComicInfo(stream))
}
private fun setChapterDetailsFromComicInfoFile(stream: InputStream, chapter: SChapter) {
val comicInfo = parseComicInfo(stream)
comicInfo.title?.let { chapter.name = it.value }
comicInfo.number?.value?.toFloatOrNull()?.let { chapter.chapter_number = it }
comicInfo.translator?.let { chapter.scanlator = it.value }
}
// Chapters
private suspend fun getChapterList(manga: SManga): List<SChapter> = withIOContext {
val chapters = fileSystem.getFilesInMangaDirectory(manga.url)
// Only keep supported formats
.filterNot { it.name.orEmpty().startsWith('.') }
.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)
}
} else {
getComicInfoForChapter(chapterFile) { stream ->
setChapterDetailsFromComicInfoFile(stream, this)
}
}
}
}
.sortedWith { c1, c2 ->
c2.name.compareToCaseInsensitiveNaturalOrder(c1.name)
}
// 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<Page> = 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
@@ -0,0 +1,15 @@
package tachiyomi.source.local.filter
import android.content.Context
import eu.kanade.tachiyomi.source.model.Filter
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
sealed class OrderBy(context: Context, selection: Selection) : Filter.Sort(
context.stringResource(MR.strings.local_filter_order_by),
arrayOf(context.stringResource(MR.strings.title), context.stringResource(MR.strings.date)),
selection,
) {
class Popular(context: Context) : OrderBy(context, Selection(0, true))
class Latest(context: Context) : OrderBy(context, Selection(1, false))
}
@@ -0,0 +1,50 @@
package tachiyomi.source.local.image
import android.content.Context
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.storage.DiskUtil
import tachiyomi.core.common.storage.nameWithoutExtension
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.source.local.io.LocalSourceFileSystem
import java.io.InputStream
private const val DEFAULT_COVER_NAME = "cover.jpg"
class LocalCoverManager(
private val context: Context,
private val fileSystem: LocalSourceFileSystem,
) {
fun find(mangaUrl: String): UniFile? {
return fileSystem.getFilesInMangaDirectory(mangaUrl)
// Get all file whose names start with "cover"
.filter { it.isFile && it.nameWithoutExtension.equals("cover", ignoreCase = true) }
// Get the first actual image
.firstOrNull { ImageUtil.isImage(it.name) { it.openInputStream() } }
}
fun update(
manga: SManga,
inputStream: InputStream,
): UniFile? {
val directory = fileSystem.getMangaDirectory(manga.url)
if (directory == null) {
inputStream.close()
return null
}
val targetFile = find(manga.url) ?: directory.createFile(DEFAULT_COVER_NAME)!!
inputStream.use { input ->
targetFile.openOutputStream().use { output ->
input.copyTo(output)
}
}
DiskUtil.createNoMediaFile(directory, context)
manga.thumbnail_url = targetFile.uri.toString()
return targetFile
}
}
@@ -0,0 +1,13 @@
package tachiyomi.source.local.io
import com.hippo.unifile.UniFile
import tachiyomi.core.common.storage.extension
object Archive {
private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "7z", "cb7", "tar", "cbt")
fun isSupported(file: UniFile): Boolean {
return file.extension?.lowercase() in SUPPORTED_ARCHIVE_TYPES
}
}
@@ -0,0 +1,23 @@
package tachiyomi.source.local.io
import com.hippo.unifile.UniFile
import tachiyomi.core.common.storage.extension
import tachiyomi.source.local.io.Archive.isSupported as isArchiveSupported
sealed interface Format {
data class Directory(val file: UniFile) : Format
data class Archive(val file: UniFile) : Format
data class Epub(val file: UniFile) : Format
class UnknownFormatException : Exception()
companion object {
fun valueOf(file: UniFile) = when {
file.isDirectory -> Directory(file)
file.extension.equals("epub", true) -> Epub(file)
isArchiveSupported(file) -> Archive(file)
else -> throw UnknownFormatException()
}
}
}
@@ -0,0 +1,27 @@
package tachiyomi.source.local.io
import com.hippo.unifile.UniFile
import tachiyomi.domain.storage.service.StorageManager
class LocalSourceFileSystem(
private val storageManager: StorageManager,
) {
fun getBaseDirectory(): UniFile? {
return storageManager.getLocalSourceDirectory()
}
fun getFilesInBaseDirectory(): List<UniFile> {
return getBaseDirectory()?.listFiles().orEmpty().toList()
}
fun getMangaDirectory(name: String): UniFile? {
return getBaseDirectory()
?.findFile(name)
?.takeIf { it.isDirectory }
}
fun getFilesInMangaDirectory(name: String): List<UniFile> {
return getMangaDirectory(name)?.listFiles().orEmpty().toList()
}
}
@@ -0,0 +1,48 @@
package tachiyomi.source.local.metadata
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import mihon.core.archive.EpubReader
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale
/**
* Fills manga and chapter metadata using this epub file's metadata.
*/
fun EpubReader.fillMetadata(manga: SManga, chapter: SChapter) {
val ref = getPackageHref()
val doc = getPackageDocument(ref)
val title = doc.getElementsByTag("dc:title").first()
val publisher = doc.getElementsByTag("dc:publisher").first()
val creator = doc.getElementsByTag("dc:creator").first()
val description = doc.getElementsByTag("dc:description").first()
var date = doc.getElementsByTag("dc:date").first()
if (date == null) {
date = doc.select("meta[property=dcterms:modified]").first()
}
creator?.text()?.let { manga.author = it }
description?.text()?.let { manga.description = it }
title?.text()?.let { chapter.name = it }
if (publisher != null) {
chapter.scanlator = publisher.text()
} else if (creator != null) {
chapter.scanlator = creator.text()
}
if (date != null) {
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault())
try {
val parsedDate = dateFormat.parse(date.text())
if (parsedDate != null) {
chapter.date_upload = parsedDate.time
}
} catch (e: ParseException) {
// Empty
}
}
}