Remove gson use in localsource

And keeping up with how source class is from upstream

Co-Authored-By: arkon <4098258+ARKon@users.noreply.github.com>
This commit is contained in:
Jays2Kings 2022-04-20 22:39:43 -04:00
parent c87806465f
commit 4486665711
3 changed files with 140 additions and 127 deletions

View file

@ -2,40 +2,48 @@ package eu.kanade.tachiyomi.source
import android.content.Context import android.content.Context
import com.github.junrar.Archive import com.github.junrar.Archive
import com.google.gson.GsonBuilder
import com.google.gson.JsonParser
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
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
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga 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
import eu.kanade.tachiyomi.util.chapter.ChapterRecognition import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.EpubFile import eu.kanade.tachiyomi.util.storage.EpubFile
import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.ImageUtil
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import rx.Observable import rx.Observable
import tachiyomi.source.model.ChapterInfo
import tachiyomi.source.model.MangaInfo
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.InputStream import java.io.InputStream
import java.util.Locale
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 { class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSource {
companion object { companion object {
const val ID = 0L const val ID = 0L
const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/" const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/"
private const val COVER_NAME = "cover.jpg" private const val COVER_NAME = "cover.jpg"
private val SUPPORTED_ARCHIVE_TYPES = setOf("zip", "rar", "cbr", "cbz", "epub")
private val POPULAR_FILTERS = FilterList(OrderBy())
private val LATEST_FILTERS =
FilterList(OrderBy().apply { state = Filter.Sort.Selection(1, false) })
private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS) private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
fun updateCover(context: Context, manga: SManga, input: InputStream): File? { fun updateCover(context: Context, manga: SManga, input: InputStream): File? {
@ -44,17 +52,30 @@ class LocalSource(private val context: Context) : CatalogueSource {
input.close() input.close()
return null return null
} }
val cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME) 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() cover.parentFile?.mkdirs()
input.use { input.use {
cover.outputStream().use { cover.outputStream().use {
input.copyTo(it) input.copyTo(it)
} }
} }
manga.thumbnail_url = cover.absolutePath
return cover 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> { private fun getBaseDirectories(context: Context): List<File> {
val c = context.getString(R.string.app_name) + File.separator + "local" val c = context.getString(R.string.app_name) + File.separator + "local"
val oldLibrary = "Tachiyomi" + File.separator + "local" val oldLibrary = "Tachiyomi" + File.separator + "local"
@ -64,14 +85,16 @@ class LocalSource(private val context: Context) : CatalogueSource {
} }
} }
private val json: Json by injectLazy()
override val id = ID override val id = ID
override val name = context.getString(R.string.local_source) override val name = context.getString(R.string.local_source)
override val lang = "" override val lang = "other"
override val supportsLatest = true override val supportsLatest = true
override fun toString() = context.getString(R.string.local_source) override fun toString() = name
override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS) override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", popularFilters)
override fun fetchSearchManga( override fun fetchSearchManga(
page: Int, page: Int,
@ -81,7 +104,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
val baseDirs = getBaseDirectories(context) val baseDirs = getBaseDirectories(context)
val time = val time =
if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L if (filters === latestFilters) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
var mangaDirs = baseDirs var mangaDirs = baseDirs
.asSequence() .asSequence()
.mapNotNull { it.listFiles()?.toList() } .mapNotNull { it.listFiles()?.toList() }
@ -91,13 +114,13 @@ class LocalSource(private val context: Context) : CatalogueSource {
.filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time } .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 state = ((if (filters.isEmpty()) popularFilters else filters)[0] as OrderBy).state
when (state?.index) { when (state?.index) {
0 -> { 0 -> {
mangaDirs = if (state.ascending) { mangaDirs = if (state.ascending) {
mangaDirs.sortedBy { it.name.lowercase(Locale.ENGLISH) } mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
} else { } else {
mangaDirs.sortedByDescending { it.name.lowercase(Locale.ENGLISH) } mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name })
} }
} }
1 -> { 1 -> {
@ -116,30 +139,34 @@ class LocalSource(private val context: Context) : CatalogueSource {
// Try to find the cover // Try to find the cover
for (dir in baseDirs) { for (dir in baseDirs) {
val cover = File("${dir.absolutePath}/$url", COVER_NAME) val cover = getCoverFile(File("${dir.absolutePath}/$url"))
if (cover.exists()) { if (cover != null && cover.exists()) {
thumbnail_url = cover.absolutePath thumbnail_url = cover.absolutePath
break break
} }
} }
val chapters = fetchChapterList(this).toBlocking().first() val sManga = this
if (chapters.isNotEmpty()) { val mangaInfo = this.toMangaInfo()
val chapter = chapters.last() runBlocking {
val format = getFormat(chapter) val chapters = getChapterList(mangaInfo)
if (format is Format.Epub) { if (chapters.isNotEmpty()) {
EpubFile(format.file).use { epub -> val chapter = chapters.last().toSChapter()
epub.fillMangaMetadata(this) val format = getFormat(chapter)
if (format is Format.Epub) {
EpubFile(format.file).use { epub ->
epub.fillMangaMetadata(sManga)
}
} }
}
// Copy the cover from the first chapter found. // Copy the cover from the first chapter found.
if (thumbnail_url == null) { if (thumbnail_url == null) {
try { try {
val dest = updateCover(chapter, this) val dest = updateCover(chapter, sManga)
thumbnail_url = dest?.absolutePath thumbnail_url = dest?.absolutePath
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
}
} }
} }
} }
@ -149,44 +176,46 @@ class LocalSource(private val context: Context) : CatalogueSource {
return Observable.just(MangasPage(mangas.toList(), false)) return Observable.just(MangasPage(mangas.toList(), false))
} }
override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS) override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", latestFilters)
override fun fetchMangaDetails(manga: SManga): Observable<SManga> { override suspend fun getMangaDetails(manga: MangaInfo): MangaInfo {
getBaseDirectories(context) val localDetails = getBaseDirectories(context)
.asSequence() .asSequence()
.mapNotNull { File(it, manga.url).listFiles()?.toList() } .mapNotNull { File(it, manga.key).listFiles()?.toList() }
.flatten() .flatten()
.firstOrNull { it.extension == "json" } .firstOrNull { it.extension.equals("json", ignoreCase = true) }
?.apply {
val reader = this.inputStream().bufferedReader()
val json = JsonParser.parseReader(reader).asJsonObject
manga.title = json["title"]?.asString ?: manga.title return if (localDetails != null) {
manga.author = json["author"]?.asString ?: manga.author val obj = json.decodeFromStream<JsonObject>(localDetails.inputStream())
manga.artist = json["artist"]?.asString ?: manga.artist
manga.description = json["description"]?.asString ?: manga.description
manga.genre = json["genre"]?.asJsonArray?.joinToString(", ") { it.asString }
?: manga.genre
manga.status = json["status"]?.asInt ?: manga.status
}
return Observable.just(manga) manga.copy(
title = obj["title"]?.jsonPrimitive?.contentOrNull ?: manga.title,
author = obj["author"]?.jsonPrimitive?.contentOrNull ?: manga.author,
artist = obj["artist"]?.jsonPrimitive?.contentOrNull ?: manga.artist,
description = obj["description"]?.jsonPrimitive?.contentOrNull ?: manga.description,
genres = obj["genre"]?.jsonArray?.map { it.jsonPrimitive.content } ?: manga.genres,
status = obj["status"]?.jsonPrimitive?.intOrNull ?: manga.status,
)
} else {
manga
}
} }
fun updateMangaInfo(manga: SManga) { fun updateMangaInfo(manga: SManga) {
val directory = getBaseDirectories(context).map { File(it, manga.url) }.find { val directory = getBaseDirectories(context).map { File(it, manga.url) }.find {
it.exists() it.exists()
} ?: return } ?: return
val gson = GsonBuilder().setPrettyPrinting().create() val json = Json { prettyPrint = true }
val existingFileName = directory.listFiles()?.find { it.extension == "json" }?.name val existingFileName = directory.listFiles()?.find { it.extension == "json" }?.name
val file = File(directory, existingFileName ?: "info.json") val file = File(directory, existingFileName ?: "info.json")
file.writeText(gson.toJson(manga.toJson())) file.writeText(json.encodeToString(manga.toJson()))
} }
fun SManga.toJson(): MangaJson { private fun SManga.toJson(): MangaJson {
return MangaJson(title, author, artist, description, genre?.split(", ")?.toTypedArray(), status) return MangaJson(title, author, artist, description, genre?.split(", ")?.toTypedArray(), status)
} }
@Serializable
data class MangaJson( data class MangaJson(
val title: String, val title: String,
val author: String?, val author: String?,
@ -203,7 +232,6 @@ class LocalSource(private val context: Context) : CatalogueSource {
other as MangaJson other as MangaJson
if (title != other.title) return false if (title != other.title) return false
return true return true
} }
@ -212,15 +240,17 @@ class LocalSource(private val context: Context) : CatalogueSource {
} }
} }
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> { override suspend fun getChapterList(manga: MangaInfo): List<ChapterInfo> {
val sManga = manga.toSManga()
val chapters = getBaseDirectories(context) val chapters = getBaseDirectories(context)
.asSequence() .asSequence()
.mapNotNull { File(it, manga.url).listFiles()?.toList() } .mapNotNull { File(it, manga.key).listFiles()?.toList() }
.flatten() .flatten()
.filter { it.isDirectory || isSupportedFile(it.extension) } .filter { it.isDirectory || isSupportedFile(it.extension) }
.map { chapterFile -> .map { chapterFile ->
SChapter.create().apply { SChapter.create().apply {
url = "${manga.url}/${chapterFile.name}" url = "${manga.key}/${chapterFile.name}"
name = if (chapterFile.isDirectory) { name = if (chapterFile.isDirectory) {
chapterFile.name chapterFile.name
} else { } else {
@ -228,67 +258,30 @@ class LocalSource(private val context: Context) : CatalogueSource {
} }
date_upload = chapterFile.lastModified() date_upload = chapterFile.lastModified()
val format = getFormat(this) 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)
} }
} }
val chapNameCut = stripMangaTitle(name, manga.title) ChapterRecognition.parseChapterNumber(this, sManga)
if (chapNameCut.isNotEmpty()) name = chapNameCut
ChapterRecognition.parseChapterNumber(this, manga)
} }
} }
.map { it.toChapterInfo() }
.sortedWith { c1, c2 -> .sortedWith { c1, c2 ->
val c = c2.chapter_number.compareTo(c1.chapter_number) val c = c2.number.compareTo(c1.number)
if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c
} }
.toList() .toList()
return Observable.just(chapters) return chapters
} }
/** override suspend fun getPageList(chapter: ChapterInfo) = throw Exception("Unused")
* Strips the manga title from a chapter name, matching only based on alphanumeric and whitespace
* characters.
*/
private fun stripMangaTitle(chapterName: String, mangaTitle: String): String {
var chapterNameIndex = 0
var mangaTitleIndex = 0
while (chapterNameIndex < chapterName.length && mangaTitleIndex < mangaTitle.length) {
val chapterChar = chapterName[chapterNameIndex]
val mangaChar = mangaTitle[mangaTitleIndex]
if (!chapterChar.equals(mangaChar, true)) {
val invalidChapterChar = !chapterChar.isLetterOrDigit() && !chapterChar.isWhitespace()
val invalidMangaChar = !mangaChar.isLetterOrDigit() && !mangaChar.isWhitespace()
if (!invalidChapterChar && !invalidMangaChar) {
return chapterName
}
if (invalidChapterChar) {
chapterNameIndex++
}
if (invalidMangaChar) {
mangaTitleIndex++
}
} else {
chapterNameIndex++
mangaTitleIndex++
}
}
return chapterName.substring(chapterNameIndex).trimStart(' ', '-', '_', ',', ':')
}
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return Observable.error(Exception("Unused"))
}
private fun isSupportedFile(extension: String): Boolean { private fun isSupportedFile(extension: String): Boolean {
return extension.lowercase(Locale.getDefault()) in SUPPORTED_ARCHIVE_TYPES return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES
} }
fun getFormat(chapter: SChapter): Format { fun getFormat(chapter: SChapter): Format {
@ -300,21 +293,16 @@ class LocalSource(private val context: Context) : CatalogueSource {
return getFormat(chapFile) return getFormat(chapFile)
} }
throw Exception("Chapter not found") throw Exception(context.getString(R.string.chapter_not_found))
} }
private fun getFormat(file: File): Format { private fun getFormat(file: File) = with(file) {
val extension = file.extension when {
return if (file.isDirectory) { isDirectory -> Format.Directory(this)
Format.Directory(file) extension.equals("zip", true) || extension.equals("cbz", true) -> Format.Zip(this)
} else if (extension.equals("zip", true) || extension.equals("cbz", true)) { extension.equals("rar", true) || extension.equals("cbr", true) -> Format.Rar(this)
Format.Zip(file) extension.equals("epub", true) -> Format.Epub(this)
} else if (extension.equals("rar", true) || extension.equals("cbr", true)) { else -> throw Exception(context.getString(R.string.local_invalid_format))
Format.Rar(file)
} else if (extension.equals("epub", true)) {
Format.Epub(file)
} else {
throw Exception("Invalid chapter format")
} }
} }
@ -357,9 +345,16 @@ class LocalSource(private val context: Context) : CatalogueSource {
} }
} }
private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Selection(0, true)) override fun getFilterList() = popularFilters
override fun getFilterList() = FilterList(OrderBy()) private val popularFilters = FilterList(OrderBy(context))
private val latestFilters = FilterList(OrderBy(context).apply { state = Filter.Sort.Selection(1, false) })
private class OrderBy(context: Context) : Filter.Sort(
context.getString(R.string.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()
@ -368,3 +363,5 @@ class LocalSource(private val context: Context) : CatalogueSource {
data class Epub(val file: File) : Format() data class Epub(val file: File) : Format()
} }
} }
private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "epub")

View file

@ -40,24 +40,35 @@ interface Source : tachiyomi.source.Source {
* *
* @param manga the manga to update. * @param manga the manga to update.
*/ */
@Deprecated("Use getMangaDetails instead") @Deprecated(
fun fetchMangaDetails(manga: SManga): Observable<SManga> "Use the 1.x API instead",
ReplaceWith("getMangaDetails"),
)
fun fetchMangaDetails(manga: SManga): Observable<SManga> = throw IllegalStateException("Not used")
/** /**
* Returns an observable with all the available chapters for a manga. * Returns an observable with all the available chapters for a manga.
* *
* @param manga the manga to update. * @param manga the manga to update.
*/ */
@Deprecated("Use getChapterList instead") @Deprecated(
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> "Use the 1.x API instead",
ReplaceWith("getChapterList"),
)
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = throw IllegalStateException("Not used")
// TODO: remove direct usages on this method
/** /**
* Returns an observable with the list of pages a chapter has. * Returns an observable with the list of pages a chapter has.
* *
* @param chapter the chapter. * @param chapter the chapter.
*/ */
@Deprecated("Use getPageList instead") @Deprecated(
fun fetchPageList(chapter: SChapter): Observable<List<Page>> "Use the 1.x API instead",
ReplaceWith("getPageList"),
)
fun fetchPageList(chapter: SChapter): Observable<List<Page>> = Observable.empty()
/** /**
* [1.x API] Get the updated details for a manga. * [1.x API] Get the updated details for a manga.
@ -75,7 +86,8 @@ interface Source : tachiyomi.source.Source {
*/ */
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
override suspend fun getChapterList(manga: MangaInfo): List<ChapterInfo> { override suspend fun getChapterList(manga: MangaInfo): List<ChapterInfo> {
return fetchChapterList(manga.toSManga()).awaitSingle().map { it.toChapterInfo() } return fetchChapterList(manga.toSManga()).awaitSingle()
.map { it.toChapterInfo() }
} }
/** /**
@ -83,7 +95,8 @@ interface Source : tachiyomi.source.Source {
*/ */
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
override suspend fun getPageList(chapter: ChapterInfo): List<tachiyomi.source.model.Page> { override suspend fun getPageList(chapter: ChapterInfo): List<tachiyomi.source.model.Page> {
return fetchPageList(chapter.toSChapter()).awaitSingle().map { it.toPageUrl() } return fetchPageList(chapter.toSChapter()).awaitSingle()
.map { it.toPageUrl() }
} }
} }

View file

@ -58,6 +58,8 @@
<string name="removed_bookmark">Removed bookmark</string> <string name="removed_bookmark">Removed bookmark</string>
<string name="chapters_removed">Chapters removed.</string> <string name="chapters_removed">Chapters removed.</string>
<string name="chapter_not_found">Chapter not found</string> <string name="chapter_not_found">Chapter not found</string>
<string name="local_invalid_format">Invalid chapter format</string>
<string name="order_by">Order by</string>
<string name="no_chapters_error">No chapters found</string> <string name="no_chapters_error">No chapters found</string>
<string name="no_pages_found">No pages found</string> <string name="no_pages_found">No pages found</string>
<string name="remove_all_downloads">Remove all downloads?</string> <string name="remove_all_downloads">Remove all downloads?</string>
@ -961,6 +963,7 @@
<string name="common">Common</string> <string name="common">Common</string>
<string name="cover_of_image">Cover of manga</string> <string name="cover_of_image">Cover of manga</string>
<string name="create">Create</string> <string name="create">Create</string>
<string name="date">Date</string>
<string name="default_value">Default</string> <string name="default_value">Default</string>
<string name="delete">Delete</string> <string name="delete">Delete</string>
<string name="deleted_">Deleted: %1$s</string> <string name="deleted_">Deleted: %1$s</string>