Local Source - qol, cleanup and cover related fixes (#7166)

* Local Source - qol, cleanup and cover related fixes

* Review Changes

(cherry picked from commit ad17eb1386)
This commit is contained in:
FourTOne5 2022-05-25 04:02:02 +06:00 committed by arkon
parent 071dd88ef8
commit 940409a4c3
2 changed files with 209 additions and 146 deletions

View File

@ -1,8 +1,11 @@
package eu.kanade.tachiyomi.source package eu.kanade.tachiyomi.source
import android.content.Context import android.content.Context
import androidx.core.net.toUri
import com.github.junrar.Archive import com.github.junrar.Archive
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
@ -30,6 +33,8 @@ import logcat.LogPriority
import rx.Observable import rx.Observable
import tachiyomi.source.model.ChapterInfo import tachiyomi.source.model.ChapterInfo
import tachiyomi.source.model.MangaInfo import tachiyomi.source.model.MangaInfo
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
@ -37,129 +42,103 @@ import java.io.InputStream
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.zip.ZipFile import java.util.zip.ZipFile
class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSource { class LocalSource(
private val context: Context,
companion object { private val coverCache: CoverCache = Injekt.get(),
const val ID = 0L ) : CatalogueSource, UnmeteredSource {
const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/"
private const val COVER_NAME = "cover.jpg"
private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
fun updateCover(context: Context, manga: SManga, input: InputStream): File? {
val dir = getBaseDirectories(context).firstOrNull()
if (dir == null) {
input.close()
return null
}
var cover = getCoverFile(File("${dir.absolutePath}/${manga.url}"))
if (cover == null) {
cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME)
}
// It might not exist if using the external SD card
cover.parentFile?.mkdirs()
input.use {
cover.outputStream().use {
input.copyTo(it)
}
}
manga.thumbnail_url = cover.absolutePath
return cover
}
/**
* Returns valid cover file inside [parent] directory.
*/
private fun getCoverFile(parent: File): File? {
return parent.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf {
it.isFile && ImageUtil.isImage(it.name) { it.inputStream() }
}
}
private fun getBaseDirectories(context: Context): List<File> {
val c = context.getString(R.string.app_name) + File.separator + "local"
return DiskUtil.getExternalStorages(context).map { File(it.absolutePath, c) }
}
}
private val json: Json by injectLazy() private val json: Json by injectLazy()
override val id = ID override val name: String = context.getString(R.string.local_source)
override val name = context.getString(R.string.local_source)
override val lang = "other" override val id: Long = ID
override val supportsLatest = true
override val lang: String = "other"
override fun toString() = name override fun toString() = name
override val supportsLatest: Boolean = true
// Browse related
override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS) override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS)
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> { override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
val baseDirs = getBaseDirectories(context)
val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
var mangaDirs = baseDirs val baseDirsFiles = getBaseDirectoriesFiles(context)
.asSequence()
.mapNotNull { it.listFiles()?.toList() } var mangaDirs = baseDirsFiles
.flatten() // Filter out files that are hidden and is not a folder
.filter { it.isDirectory } .filter { it.isDirectory && !it.name.startsWith('.') }
.filterNot { it.name.startsWith('.') }
.filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time }
.distinctBy { it.name } .distinctBy { it.name }
val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state val lastModifiedLimit = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
when (state?.index) { // Filter by query or last modified
0 -> { mangaDirs = mangaDirs.filter {
mangaDirs = if (state.ascending) { if (lastModifiedLimit == 0L) {
mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, { it.name })) it.name.contains(query, ignoreCase = true)
} else { } else {
mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER, { it.name })) it.lastModified() >= lastModifiedLimit
}
}
filters.forEach { filter ->
when (filter) {
is OrderBy -> {
when (filter.state!!.index) {
0 -> {
mangaDirs = if (filter.state!!.ascending) {
mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
} else {
mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name })
} }
} }
1 -> { 1 -> {
mangaDirs = if (state.ascending) { mangaDirs = if (filter.state!!.ascending) {
mangaDirs.sortedBy(File::lastModified) mangaDirs.sortedBy(File::lastModified)
} else { } else {
mangaDirs.sortedByDescending(File::lastModified) mangaDirs.sortedByDescending(File::lastModified)
} }
} }
} }
}
else -> { /* Do nothing */ }
}
}
// Transform mangaDirs to list of SManga
val mangas = mangaDirs.map { mangaDir -> val mangas = mangaDirs.map { mangaDir ->
SManga.create().apply { SManga.create().apply {
title = mangaDir.name title = mangaDir.name
url = mangaDir.name url = mangaDir.name
// Try to find the cover // Try to find the cover
for (dir in baseDirs) { val cover = getCoverFile(mangaDir.name, baseDirsFiles)
val cover = getCoverFile(File("${dir.absolutePath}/$url"))
if (cover != null && cover.exists()) { if (cover != null && cover.exists()) {
thumbnail_url = cover.absolutePath thumbnail_url = cover.absolutePath
break }
} }
} }
val sManga = this // Fetch chapters of all the manga
val mangaInfo = this.toMangaInfo() mangas.forEach { manga ->
val mangaInfo = manga.toMangaInfo()
runBlocking { runBlocking {
val chapters = getChapterList(mangaInfo) val chapters = getChapterList(mangaInfo)
if (chapters.isNotEmpty()) { if (chapters.isNotEmpty()) {
val chapter = chapters.last().toSChapter() val chapter = chapters.last().toSChapter()
val format = getFormat(chapter) val format = getFormat(chapter)
if (format is Format.Epub) { if (format is Format.Epub) {
EpubFile(format.file).use { epub -> EpubFile(format.file).use { epub ->
epub.fillMangaMetadata(sManga) epub.fillMangaMetadata(manga)
} }
} }
// Copy the cover from the first chapter found. // Copy the cover from the first chapter found if not available
if (thumbnail_url == null) { if (manga.thumbnail_url == null) {
try { updateCover(chapter, manga)
val dest = updateCover(chapter, sManga)
thumbnail_url = dest?.absolutePath
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
}
}
} }
} }
} }
@ -168,38 +147,44 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
return Observable.just(MangasPage(mangas.toList(), false)) return Observable.just(MangasPage(mangas.toList(), false))
} }
override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS) // Manga details related
override suspend fun getMangaDetails(manga: MangaInfo): MangaInfo { override suspend fun getMangaDetails(manga: MangaInfo): MangaInfo {
val localDetails = getBaseDirectories(context) var mangaInfo = manga
.asSequence()
.mapNotNull { File(it, manga.key).listFiles()?.toList() } val baseDirsFile = getBaseDirectoriesFiles(context)
.flatten()
val coverFile = getCoverFile(manga.key, baseDirsFile)
coverFile?.let {
mangaInfo = mangaInfo.copy(cover = it.absolutePath)
}
val localDetails = getMangaDirsFiles(manga.key, baseDirsFile)
.firstOrNull { it.extension.equals("json", ignoreCase = true) } .firstOrNull { it.extension.equals("json", ignoreCase = true) }
return if (localDetails != null) { if (localDetails != null) {
val obj = json.decodeFromStream<JsonObject>(localDetails.inputStream()) val obj = json.decodeFromStream<JsonObject>(localDetails.inputStream())
manga.copy( mangaInfo = mangaInfo.copy(
title = obj["title"]?.jsonPrimitive?.contentOrNull ?: manga.title, title = obj["title"]?.jsonPrimitive?.contentOrNull ?: mangaInfo.title,
author = obj["author"]?.jsonPrimitive?.contentOrNull ?: manga.author, author = obj["author"]?.jsonPrimitive?.contentOrNull ?: mangaInfo.author,
artist = obj["artist"]?.jsonPrimitive?.contentOrNull ?: manga.artist, artist = obj["artist"]?.jsonPrimitive?.contentOrNull ?: mangaInfo.artist,
description = obj["description"]?.jsonPrimitive?.contentOrNull ?: manga.description, description = obj["description"]?.jsonPrimitive?.contentOrNull ?: mangaInfo.description,
genres = obj["genre"]?.jsonArray?.map { it.jsonPrimitive.content } ?: manga.genres, genres = obj["genre"]?.jsonArray?.map { it.jsonPrimitive.content } ?: mangaInfo.genres,
status = obj["status"]?.jsonPrimitive?.intOrNull ?: manga.status, status = obj["status"]?.jsonPrimitive?.intOrNull ?: mangaInfo.status,
) )
} else {
manga
}
} }
return mangaInfo
}
// Chapters
override suspend fun getChapterList(manga: MangaInfo): List<ChapterInfo> { override suspend fun getChapterList(manga: MangaInfo): List<ChapterInfo> {
val sManga = manga.toSManga() val sManga = manga.toSManga()
val chapters = getBaseDirectories(context) val baseDirsFile = getBaseDirectoriesFiles(context)
.asSequence() return getMangaDirsFiles(manga.key, baseDirsFile)
.mapNotNull { File(it, manga.key).listFiles()?.toList() } // Only keep supported formats
.flatten()
.filter { it.isDirectory || isSupportedFile(it.extension) } .filter { it.isDirectory || isSupportedFile(it.extension) }
.map { chapterFile -> .map { chapterFile ->
SChapter.create().apply { SChapter.create().apply {
@ -211,14 +196,14 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
} }
date_upload = chapterFile.lastModified() date_upload = chapterFile.lastModified()
ChapterRecognition.parseChapterNumber(this, sManga)
val format = getFormat(chapterFile) val format = getFormat(chapterFile)
if (format is Format.Epub) { if (format is Format.Epub) {
EpubFile(format.file).use { epub -> EpubFile(format.file).use { epub ->
epub.fillChapterMetadata(this) epub.fillChapterMetadata(this)
} }
} }
ChapterRecognition.parseChapterNumber(this, sManga)
} }
} }
.map { it.toChapterInfo() } .map { it.toChapterInfo() }
@ -227,12 +212,24 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c
} }
.toList() .toList()
return chapters
} }
override suspend fun getPageList(chapter: ChapterInfo) = throw Exception("Unused") // Filters
override fun getFilterList() = FilterList(OrderBy(context))
private val POPULAR_FILTERS = FilterList(OrderBy(context))
private val LATEST_FILTERS = FilterList(OrderBy(context).apply { state = Filter.Sort.Selection(1, false) })
private class OrderBy(context: Context) : Filter.Sort(
context.getString(R.string.local_filter_order_by),
arrayOf(context.getString(R.string.title), context.getString(R.string.date)),
Selection(0, true),
)
// Unused stuff
override suspend fun getPageList(chapter: ChapterInfo) = throw UnsupportedOperationException("Unused")
// Miscellaneous
private fun isSupportedFile(extension: String): Boolean { private fun isSupportedFile(extension: String): Boolean {
return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES
} }
@ -296,25 +293,90 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
} }
} }
} }
.also { coverCache.clearMemoryCache() }
} }
override fun getFilterList() = POPULAR_FILTERS
private val POPULAR_FILTERS = FilterList(OrderBy(context))
private val LATEST_FILTERS = FilterList(OrderBy(context).apply { state = Filter.Sort.Selection(1, false) })
private class OrderBy(context: Context) : Filter.Sort(
context.getString(R.string.local_filter_order_by),
arrayOf(context.getString(R.string.title), context.getString(R.string.date)),
Selection(0, true),
)
sealed class Format { sealed class Format {
data class Directory(val file: File) : Format() data class Directory(val file: File) : Format()
data class Zip(val file: File) : Format() data class Zip(val file: File) : Format()
data class Rar(val file: File) : Format() data class Rar(val file: File) : Format()
data class Epub(val file: File) : Format() data class Epub(val file: File) : Format()
} }
companion object {
const val ID = 0L
const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/"
private const val DEFAULT_COVER_NAME = "cover.jpg"
private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
private fun getBaseDirectories(context: Context): Sequence<File> {
val localFolder = context.getString(R.string.app_name) + File.separator + "local"
return DiskUtil.getExternalStorages(context)
.map { File(it.absolutePath, localFolder) }
.asSequence()
}
private fun getBaseDirectoriesFiles(context: Context): Sequence<File> {
return getBaseDirectories(context)
// Get all the files inside all baseDir
.flatMap { it.listFiles().orEmpty().toList() }
}
private fun getMangaDir(mangaUrl: String, baseDirsFile: Sequence<File>): File? {
return baseDirsFile
// Get the first mangaDir or null
.firstOrNull { it.isDirectory && it.name == mangaUrl }
}
private fun getMangaDirsFiles(mangaUrl: String, baseDirsFile: Sequence<File>): Sequence<File> {
return baseDirsFile
// Filter out ones that are not related to the manga and is not a directory
.filter { it.isDirectory && it.name == mangaUrl }
// Get all the files inside the filtered folders
.flatMap { it.listFiles().orEmpty().toList() }
}
private fun getCoverFile(mangaUrl: String, baseDirsFile: Sequence<File>): File? {
return getMangaDirsFiles(mangaUrl, baseDirsFile)
// 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.inputStream() }
}
}
fun updateCover(context: Context, manga: SManga, inputStream: InputStream): File? {
val baseDirsFiles = getBaseDirectoriesFiles(context)
val mangaDir = getMangaDir(manga.url, baseDirsFiles)
if (mangaDir == null) {
inputStream.close()
return null
}
var coverFile = getCoverFile(manga.url, baseDirsFiles)
if (coverFile == null) {
coverFile = File(mangaDir.absolutePath, DEFAULT_COVER_NAME)
}
// It might not exist at this point
coverFile.parentFile?.mkdirs()
inputStream.use { input ->
coverFile.outputStream().use { output ->
input.copyTo(output)
}
}
// Create a .nomedia file
DiskUtil.createNoMediaFile(UniFile.fromFile(mangaDir), context)
manga.thumbnail_url = coverFile.absolutePath
return coverFile
}
}
} }
private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "epub") private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "epub")

View File

@ -4,7 +4,6 @@ import android.app.Application
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import com.jakewharton.rxrelay.BehaviorRelay import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.History import eu.kanade.tachiyomi.data.database.models.History
@ -675,15 +674,16 @@ class ReaderPresenter(
Observable Observable
.fromCallable { .fromCallable {
stream().use {
if (manga.isLocal()) { if (manga.isLocal()) {
val context = Injekt.get<Application>() val context = Injekt.get<Application>()
LocalSource.updateCover(context, manga, stream()) LocalSource.updateCover(context, manga, it)
manga.updateCoverLastModified(db) manga.updateCoverLastModified(db)
R.string.cover_updated coverCache.clearMemoryCache()
SetAsCoverResult.Success SetAsCoverResult.Success
} else { } else {
if (manga.favorite) { if (manga.favorite) {
coverCache.setCustomCoverToCache(manga, stream()) coverCache.setCustomCoverToCache(manga, it)
manga.updateCoverLastModified(db) manga.updateCoverLastModified(db)
coverCache.clearMemoryCache() coverCache.clearMemoryCache()
SetAsCoverResult.Success SetAsCoverResult.Success
@ -692,6 +692,7 @@ class ReaderPresenter(
} }
} }
} }
}
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribeFirst( .subscribeFirst(