mirror of
https://github.com/null2264/yokai.git
synced 2025-06-21 10:44:42 +00:00
feat(source/local): Scan external storage for entries (GH-197)
An experimental feature that allow user to store local entries on `/storage/sdcard/Android/data/<yokai>/local/`
This commit is contained in:
parent
f9bfb0b423
commit
8f9194c4a9
6 changed files with 90 additions and 39 deletions
|
@ -1,5 +1,6 @@
|
|||
package eu.kanade.tachiyomi.source
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.core.net.toFile
|
||||
import co.touchlab.kermit.Logger
|
||||
|
@ -10,18 +11,19 @@ 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.DiskUtil
|
||||
import eu.kanade.tachiyomi.util.storage.EpubFile
|
||||
import eu.kanade.tachiyomi.util.storage.fillMetadata
|
||||
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.withIOContext
|
||||
import eu.kanade.tachiyomi.util.system.writeText
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.InputStream
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.serialization.Serializable
|
||||
|
@ -38,6 +40,7 @@ import yokai.core.metadata.ComicInfo
|
|||
import yokai.core.metadata.copyFromComicInfo
|
||||
import yokai.core.metadata.toComicInfo
|
||||
import yokai.domain.chapter.services.ChapterRecognition
|
||||
import yokai.domain.source.SourcePreferences
|
||||
import yokai.domain.storage.StorageManager
|
||||
import yokai.i18n.MR
|
||||
import yokai.util.lang.getString
|
||||
|
@ -57,9 +60,14 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
}
|
||||
}
|
||||
|
||||
fun getMangaLang(manga: SManga): String {
|
||||
fun getMangaLang(manga: SManga, context: Context = Injekt.get<Application>()): String {
|
||||
return langMap.getOrPut(manga.url) {
|
||||
val localDetails = getBaseDirectory()?.findFile(manga.url)?.listFiles().orEmpty()
|
||||
val dir = getBaseDirectories(context).asSequence()
|
||||
.mapNotNull { it?.findFile(manga.url)?.listFiles() }
|
||||
.firstOrNull()
|
||||
.orEmpty()
|
||||
|
||||
val localDetails = dir.orEmpty()
|
||||
.filter { !it.isDirectory }
|
||||
.firstOrNull { it.name == COMIC_INFO_FILE }
|
||||
|
||||
|
@ -71,15 +79,19 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
}
|
||||
}
|
||||
|
||||
fun invalidateCover(manga: SManga) {
|
||||
val dir = getBaseDirectory()?.findFile(manga.url) ?: return
|
||||
fun invalidateCover(manga: SManga, context: Context = Injekt.get<Application>()) {
|
||||
val dir = getBaseDirectories(context).asSequence()
|
||||
.mapNotNull { it?.findFile(manga.url) }
|
||||
.firstOrNull() ?: return
|
||||
val cover = getCoverFile(dir) ?: return
|
||||
|
||||
manga.thumbnail_url = cover.uri.toString()
|
||||
}
|
||||
|
||||
fun updateCover(manga: SManga, input: InputStream): UniFile? {
|
||||
val dir = getBaseDirectory()?.findFile(manga.url)
|
||||
fun updateCover(manga: SManga, input: InputStream, context: Context = Injekt.get<Application>()): UniFile? {
|
||||
val dir = getBaseDirectories(context).asSequence()
|
||||
.mapNotNull { it?.findFile(manga.url) }
|
||||
.firstOrNull()
|
||||
if (dir == null) {
|
||||
input.close()
|
||||
return null
|
||||
|
@ -117,9 +129,19 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
}
|
||||
|
||||
private fun getBaseDirectory(): UniFile? {
|
||||
val storageManager: StorageManager by injectLazy()
|
||||
val storageManager: StorageManager = Injekt.get()
|
||||
return storageManager.getLocalSourceDirectory()
|
||||
}
|
||||
|
||||
private fun getBaseDirectories(context: Context): List<UniFile?> {
|
||||
val sourcePreferences: SourcePreferences = Injekt.get()
|
||||
val base = listOf(getBaseDirectory())
|
||||
if (!sourcePreferences.externalLocalSource().get()) return base
|
||||
|
||||
return base + DiskUtil.getExternalStorages(context, false).map {
|
||||
UniFile.fromFile(File(it, StorageManager.LOCAL_SOURCE_PATH))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
@ -145,7 +167,9 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
0L
|
||||
}
|
||||
|
||||
var mangaDirs = getBaseDirectory()?.listFiles().orEmpty()
|
||||
var mangaDirs = getBaseDirectories(context).asSequence()
|
||||
.mapNotNull { it?.listFiles()?.toList() }
|
||||
.flatten()
|
||||
.filter { it.isDirectory && !it.name.orEmpty().startsWith('.') }
|
||||
.distinctBy { it.name }
|
||||
.filter {
|
||||
|
@ -175,7 +199,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
}
|
||||
}
|
||||
|
||||
val mangas = mangaDirs.map { mangaDir ->
|
||||
val mangas = mangaDirs.toList().map { mangaDir ->
|
||||
async {
|
||||
SManga.create().apply {
|
||||
title = mangaDir.name.orEmpty()
|
||||
|
@ -190,17 +214,19 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
}
|
||||
}.awaitAll()
|
||||
|
||||
MangasPage(mangas.toList(), false)
|
||||
MangasPage(mangas, false)
|
||||
}
|
||||
|
||||
override suspend fun getLatestUpdates(page: Int) = getSearchManga(page, "", latestFilters)
|
||||
|
||||
override suspend fun getMangaDetails(manga: SManga): SManga = withIOContext {
|
||||
// Making sure that we have the latest cover file path, in case user use different file format
|
||||
invalidateCover(manga)
|
||||
invalidateCover(manga, context)
|
||||
|
||||
try {
|
||||
val localMangaDir = getBaseDirectory()?.findFile(manga.url) ?: throw Exception("${manga.url} is not a valid directory")
|
||||
val localMangaDir = getBaseDirectories(context).asSequence()
|
||||
.mapNotNull { it?.findFile(manga.url) }
|
||||
.firstOrNull() ?: 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) }
|
||||
|
@ -249,7 +275,9 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
}
|
||||
|
||||
fun updateMangaInfo(manga: SManga, lang: String?) {
|
||||
val directory = getBaseDirectory()?.findFile(manga.url) ?: return
|
||||
val directory = getBaseDirectories(context).asSequence()
|
||||
.mapNotNull { it?.findFile(manga.url) }
|
||||
.firstOrNull() ?: return
|
||||
if (!directory.exists()) return
|
||||
|
||||
lang?.let { langMap[manga.url] = it }
|
||||
|
@ -274,8 +302,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
|
||||
other as MangaJson
|
||||
|
||||
if (title != other.title) return false
|
||||
return true
|
||||
return title == other.title
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
|
@ -284,7 +311,12 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
}
|
||||
|
||||
override suspend fun getChapterList(manga: SManga): List<SChapter> = withIOContext {
|
||||
val chapters = getBaseDirectory()?.findFile(manga.url)?.listFiles().orEmpty()
|
||||
val dir = getBaseDirectories(context).asSequence()
|
||||
.mapNotNull { it?.findFile(manga.url)?.listFiles() }
|
||||
.firstOrNull()
|
||||
.orEmpty()
|
||||
|
||||
val chapters = dir
|
||||
.filter { it.isDirectory || isSupportedArchive(it.extension.orEmpty()) || it.extension.equals("epub", true) }
|
||||
.map { chapterFile ->
|
||||
SChapter.create().apply {
|
||||
|
@ -331,12 +363,10 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
}
|
||||
|
||||
fun getFormat(chapter: SChapter): Format {
|
||||
val dir = getBaseDirectory()
|
||||
|
||||
val (mangaDirName, chapterName) = chapter.url.split('/', limit = 2)
|
||||
val chapFile = dir
|
||||
?.findFile(mangaDirName)
|
||||
?.findFile(chapterName)
|
||||
val chapFile = getBaseDirectories(context).asSequence()
|
||||
.mapNotNull { it?.findFile(mangaDirName)?.findFile(chapterName) }
|
||||
.firstOrNull()
|
||||
if (chapFile == null || !chapFile.exists())
|
||||
throw Exception(context.getString(MR.strings.chapter_not_found))
|
||||
|
||||
|
@ -360,7 +390,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
?.sortedWith { f1, f2 -> f1.name.orEmpty().compareToCaseInsensitiveNaturalOrder(f2.name.orEmpty()) }
|
||||
?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it.uri.toFile()) } }
|
||||
|
||||
entry?.let { updateCover(manga, it.openInputStream()) }
|
||||
entry?.let { updateCover(manga, it.openInputStream(), context) }
|
||||
}
|
||||
is Format.Archive -> {
|
||||
format.file.archiveReader(context).use { reader ->
|
||||
|
@ -370,14 +400,14 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
.find { it.isFile && ImageUtil.isImage(it.name) { reader.getInputStream(it.name)!! } }
|
||||
}
|
||||
|
||||
entry?.let { updateCover(manga, reader.getInputStream(it.name)!!) }
|
||||
entry?.let { updateCover(manga, reader.getInputStream(it.name)!!, context) }
|
||||
}
|
||||
}
|
||||
is Format.Epub -> {
|
||||
EpubFile(format.file.archiveReader(context)).use { epub ->
|
||||
val entry = epub.getImagesFromPages().firstOrNull()
|
||||
|
||||
entry?.let { updateCover(manga, epub.getInputStream(it)!!) }
|
||||
entry?.let { updateCover(manga, epub.getInputStream(it)!!, context) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -57,6 +57,7 @@ import eu.kanade.tachiyomi.ui.setting.preference
|
|||
import eu.kanade.tachiyomi.ui.setting.preferenceCategory
|
||||
import eu.kanade.tachiyomi.ui.setting.switchPreference
|
||||
import eu.kanade.tachiyomi.util.CrashLogUtil
|
||||
import eu.kanade.tachiyomi.util.lang.addBetaTag
|
||||
import eu.kanade.tachiyomi.util.system.GLUtil
|
||||
import eu.kanade.tachiyomi.util.system.disableItems
|
||||
import eu.kanade.tachiyomi.util.system.isPackageInstalled
|
||||
|
@ -88,6 +89,7 @@ import uy.kohesive.injekt.injectLazy
|
|||
import yokai.domain.base.BasePreferences.ExtensionInstaller
|
||||
import yokai.domain.extension.interactor.TrustExtension
|
||||
import yokai.domain.manga.interactor.GetManga
|
||||
import yokai.domain.source.SourcePreferences
|
||||
import yokai.domain.ui.settings.ReaderPreferences
|
||||
import yokai.i18n.MR
|
||||
import yokai.util.lang.getString
|
||||
|
@ -100,6 +102,7 @@ class SettingsAdvancedController : SettingsLegacyController() {
|
|||
private val network: NetworkHelper by injectLazy()
|
||||
private val networkPreferences: NetworkPreferences by injectLazy()
|
||||
private val readerPreferences: ReaderPreferences by injectLazy()
|
||||
private val sourcePreferences: SourcePreferences by injectLazy()
|
||||
|
||||
private val db: DatabaseHelper by injectLazy()
|
||||
|
||||
|
@ -440,6 +443,15 @@ class SettingsAdvancedController : SettingsLegacyController() {
|
|||
}
|
||||
}
|
||||
|
||||
preferenceCategory {
|
||||
titleRes = MR.strings.local_source
|
||||
|
||||
switchPreference {
|
||||
bindTo(sourcePreferences.externalLocalSource())
|
||||
title = context.getString(MR.strings.pref_external_local_source).addBetaTag(context)
|
||||
}
|
||||
}
|
||||
|
||||
preferenceCategory {
|
||||
title = "Danger zone!"
|
||||
|
||||
|
|
|
@ -58,17 +58,21 @@ object DiskUtil {
|
|||
}
|
||||
}
|
||||
|
||||
fun File.isMounted(): Boolean {
|
||||
val state = EnvironmentCompat.getStorageState(this)
|
||||
return state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the root folders of all the available external storages.
|
||||
* Returns the folders of all the available external storages.
|
||||
*/
|
||||
fun getExternalStorages(context: Context): List<File> {
|
||||
fun getExternalStorages(context: Context, root: Boolean = true): List<File> {
|
||||
return context.getExternalFilesDirs(null)
|
||||
.filterNotNull()
|
||||
.mapNotNull {
|
||||
val file = File(it.absolutePath.substringBefore("/Android/"))
|
||||
val state = EnvironmentCompat.getStorageState(file)
|
||||
if (state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY) {
|
||||
file
|
||||
if (file.isMounted()) {
|
||||
if (root) file else it
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
|
|
@ -4,4 +4,6 @@ import eu.kanade.tachiyomi.core.preference.PreferenceStore
|
|||
|
||||
class SourcePreferences(private val preferenceStore: PreferenceStore) {
|
||||
fun trustedExtensions() = preferenceStore.getStringSet("trusted_extensions", emptySet())
|
||||
|
||||
fun externalLocalSource() = preferenceStore.getBoolean("pref_external_local_source", false)
|
||||
}
|
||||
|
|
|
@ -93,12 +93,14 @@ class StorageManager(
|
|||
fun getLogsDirectory(): UniFile? {
|
||||
return baseDir?.createDirectory(LOGS_PATH)
|
||||
}
|
||||
}
|
||||
|
||||
private const val BACKUPS_PATH = "backup"
|
||||
private const val AUTOMATIC_BACKUPS_PATH = "autobackup"
|
||||
private const val DOWNLOADS_PATH = "downloads"
|
||||
private const val LOCAL_SOURCE_PATH = "local"
|
||||
private const val COVERS_PATH = "covers"
|
||||
private const val PAGES_PATH = "pages"
|
||||
private const val LOGS_PATH = "logs"
|
||||
companion object {
|
||||
private const val BACKUPS_PATH = "backup"
|
||||
private const val AUTOMATIC_BACKUPS_PATH = "autobackup"
|
||||
private const val DOWNLOADS_PATH = "downloads"
|
||||
const val LOCAL_SOURCE_PATH = "local"
|
||||
private const val COVERS_PATH = "covers"
|
||||
private const val PAGES_PATH = "pages"
|
||||
private const val LOGS_PATH = "logs"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -807,6 +807,7 @@
|
|||
<string name="pref_verbose_logging">Verbose logging</string>
|
||||
<string name="pref_verbose_logging_summary">Print verbose logs to system log (may reduces app performance)</string>
|
||||
<string name="pref_reader_debug_mode">Debug mode</string>
|
||||
<string name="pref_external_local_source">Scan external storages for entries</string>
|
||||
<string name="network">Network</string>
|
||||
<string name="doh">DNS over HTTPS</string>
|
||||
<string name="user_agent_string">Default user agent string</string>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue