komikku/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt

412 lines
16 KiB
Kotlin
Raw Normal View History

package eu.kanade.tachiyomi.source
import android.content.Context
import com.github.junrar.Archive
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
2020-02-27 00:12:44 +01:00
import eu.kanade.tachiyomi.source.model.Filter
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.source.model.toChapterInfo
import eu.kanade.tachiyomi.source.model.toMangaInfo
import eu.kanade.tachiyomi.source.model.toSChapter
import eu.kanade.tachiyomi.source.model.toSManga
2020-02-03 04:22:54 +01:00
import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
2020-02-03 04:04:11 +01:00
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.EpubFile
2020-02-03 04:22:54 +01:00
import eu.kanade.tachiyomi.util.system.ImageUtil
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.encodeToStream
import logcat.LogPriority
import rx.Observable
import tachiyomi.source.model.ChapterInfo
import tachiyomi.source.model.MangaInfo
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.util.concurrent.TimeUnit
import java.util.zip.ZipFile
class LocalSource(
private val context: Context,
) : CatalogueSource, UnmeteredSource {
private val json: Json by injectLazy()
2021-09-11 17:48:32 +02:00
// SY -->
private val preferences: PreferencesHelper by injectLazy()
// SY <--
override val name: String = context.getString(R.string.local_source)
override val id: Long = ID
override val lang: String = "other"
override fun toString() = name
override val supportsLatest: Boolean = true
// Browse related
override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS)
override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
val baseDirsFiles = getBaseDirectoriesFiles(context)
// SY -->
val allowLocalSourceHiddenFolders = preferences.allowLocalSourceHiddenFolders().get()
// SY <--
var mangaDirs = baseDirsFiles
// Filter out files that are hidden and is not a folder
.filter { it.isDirectory && /* SY --> */ (!it.name.startsWith('.') || allowLocalSourceHiddenFolders) /* SY <-- */ }
2020-04-25 20:24:45 +02:00
.distinctBy { it.name }
val lastModifiedLimit = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
// Filter by query or last modified
mangaDirs = mangaDirs.filter {
if (lastModifiedLimit == 0L) {
it.name.contains(query, ignoreCase = true)
} else {
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 -> {
mangaDirs = if (filter.state!!.ascending) {
mangaDirs.sortedBy(File::lastModified)
} else {
mangaDirs.sortedByDescending(File::lastModified)
}
}
}
2020-04-25 20:24:45 +02:00
}
else -> { /* Do nothing */ }
}
}
// Transform mangaDirs to list of SManga
val mangas = mangaDirs.map { mangaDir ->
SManga.create().apply {
title = mangaDir.name
url = mangaDir.name
// Try to find the cover
val cover = getCoverFile(mangaDir.name, baseDirsFiles)
if (cover != null && cover.exists()) {
thumbnail_url = cover.absolutePath
}
}
}
// Fetch chapters of all the manga
mangas.forEach { manga ->
val mangaInfo = manga.toMangaInfo()
runBlocking {
val chapters = getChapterList(mangaInfo)
if (chapters.isNotEmpty()) {
val chapter = chapters.last().toSChapter()
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 Observable.just(MangasPage(mangas.toList(), false))
}
// SY -->
fun updateMangaInfo(manga: SManga) {
val directory = getBaseDirectories(context).map { File(it, manga.url) }.find {
it.exists()
} ?: return
val existingFileName = directory.listFiles()?.find { it.extension == "json" }?.name
val file = File(directory, existingFileName ?: "info.json")
file.outputStream().use {
json.encodeToStream(manga.toJson(), it)
}
}
private fun SManga.toJson(): MangaJson {
return MangaJson(title, author, artist, description, genre?.split(", "), status)
}
@Serializable
data class MangaJson(
2021-07-04 23:14:07 +02:00
val title: String? = null,
val author: String? = null,
val artist: String? = null,
val description: String? = null,
val genre: List<String>? = null,
Update linter (cherry picked from commit f0eb42e72d1e267049777a303bd97d96517a9a1f) # Conflicts: # app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestore.kt # app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/Backup.kt # app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupManga.kt # app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/Backup.kt # app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt # app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt # app/src/main/java/eu/kanade/tachiyomi/ui/base/changehandler/OneWayFadeChangeHandler.kt # app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchController.kt # app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchPresenter.kt # app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/SelectionHeader.kt # app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceItem.kt # app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcePresenter.kt # app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceFilterSheet.kt # app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchController.kt # app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt # app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHolder.kt # app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListHolder.kt # app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt # app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt # app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPageSheet.kt # app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt # app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt # app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt # app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt # app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt
2022-04-08 21:30:30 +02:00
val status: Int? = null,
2021-07-04 23:14:07 +02:00
)
// SY <--
// Manga details related
override suspend fun getMangaDetails(manga: MangaInfo): MangaInfo {
var mangaInfo = manga
val baseDirsFile = getBaseDirectoriesFiles(context)
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) }
if (localDetails != null) {
val mangaJson = json.decodeFromStream<MangaJson>(localDetails.inputStream())
mangaInfo = mangaInfo.copy(
title = mangaJson.title ?: mangaInfo.title,
author = mangaJson.author ?: mangaInfo.author,
artist = mangaJson.artist ?: mangaInfo.artist,
description = mangaJson.description ?: mangaInfo.description,
genres = mangaJson.genre ?: mangaInfo.genres,
status = mangaJson.status ?: mangaInfo.status,
)
}
return mangaInfo
}
2017-02-05 12:01:58 +01:00
// Chapters
override suspend fun getChapterList(manga: MangaInfo): List<ChapterInfo> {
val sManga = manga.toSManga()
val baseDirsFile = getBaseDirectoriesFiles(context)
return getMangaDirsFiles(manga.key, baseDirsFile)
// Only keep supported formats
2020-04-25 20:24:45 +02:00
.filter { it.isDirectory || isSupportedFile(it.extension) }
.map { chapterFile ->
SChapter.create().apply {
url = "${manga.key}/${chapterFile.name}"
name = if (chapterFile.isDirectory) {
2020-04-25 20:24:45 +02:00
chapterFile.name
} else {
chapterFile.nameWithoutExtension
2017-02-05 12:01:58 +01:00
}
2020-04-25 20:24:45 +02:00
date_upload = chapterFile.lastModified()
chapter_number = ChapterRecognition.parseChapterNumber(sManga.title, this.name, this.chapter_number)
val format = getFormat(chapterFile)
if (format is Format.Epub) {
EpubFile(format.file).use { epub ->
epub.fillChapterMetadata(this)
}
}
2017-02-05 12:01:58 +01:00
}
2020-04-25 20:24:45 +02:00
}
.map { it.toChapterInfo() }
.sortedWith { c1, c2 ->
val c = c2.number.compareTo(c1.number)
if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c
}
2020-04-25 20:24:45 +02:00
.toList()
2017-02-05 12:01:58 +01:00
}
// 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 {
return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES
}
fun getFormat(chapter: SChapter): Format {
2017-02-05 12:01:58 +01:00
val baseDirs = getBaseDirectories(context)
for (dir in baseDirs) {
val chapFile = File(dir, chapter.url)
if (!chapFile.exists()) continue
return getFormat(chapFile)
2017-02-05 12:01:58 +01:00
}
throw Exception(context.getString(R.string.chapter_not_found))
2017-02-05 12:01:58 +01:00
}
private fun getFormat(file: File) = with(file) {
when {
isDirectory -> Format.Directory(this)
extension.equals("zip", true) || extension.equals("cbz", true) -> Format.Zip(this)
extension.equals("rar", true) || extension.equals("cbr", true) -> Format.Rar(this)
extension.equals("epub", true) -> Format.Epub(this)
else -> throw Exception(context.getString(R.string.local_invalid_format))
2017-02-05 12:01:58 +01:00
}
}
private fun updateCover(chapter: SChapter, manga: SManga): File? {
return try {
when (val format = getFormat(chapter)) {
is Format.Directory -> {
val entry = format.file.listFiles()
?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
entry?.let { updateCover(context, manga, it.inputStream()) }
}
is Format.Zip -> {
ZipFile(format.file).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) } }
2017-02-05 12:01:58 +01:00
entry?.let { updateCover(context, manga, zip.getInputStream(it)) }
}
}
is Format.Rar -> {
Archive(format.file).use { archive ->
val entry = archive.fileHeaders
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
2017-02-08 22:12:00 +01:00
entry?.let { updateCover(context, manga, archive.getInputStream(it)) }
}
}
is Format.Epub -> {
EpubFile(format.file).use { epub ->
val entry = epub.getImagesFromPages()
.firstOrNull()
?.let { epub.getEntry(it) }
2017-02-05 12:01:58 +01:00
entry?.let { updateCover(context, manga, epub.getInputStream(it)) }
}
2017-02-05 12:01:58 +01:00
}
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Error updating cover for ${manga.title}" }
null
2017-02-05 12:01:58 +01:00
}
}
2017-02-05 12:01:58 +01:00
sealed class Format {
data class Directory(val file: File) : Format()
data class Zip(val file: File) : Format()
data class Rar(val file: File) : Format()
data class Epub(val file: File) : Format()
2017-02-05 12:01:58 +01:00
}
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)
}
}
DiskUtil.createNoMediaFile(UniFile.fromFile(mangaDir), context)
manga.thumbnail_url = coverFile.absolutePath
return coverFile
}
}
}
private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "epub")