Read metadata from ComicInfo.xml files in Local source (#8025)
Co-authored-by: Shamicen <84282253+Shamicen@users.noreply.github.com> Co-authored-by: Andreas <andreas.everos@gmail.com> Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com>
This commit is contained in:
parent
30b3b2d3ff
commit
1395343f11
5 changed files with 200 additions and 17 deletions
60
app/src/main/java/eu/kanade/domain/manga/model/ComicInfo.kt
Normal file
60
app/src/main/java/eu/kanade/domain/manga/model/ComicInfo.kt
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
package eu.kanade.domain.manga.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import nl.adaptivity.xmlutil.serialization.XmlSerialName
|
||||||
|
import nl.adaptivity.xmlutil.serialization.XmlValue
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@XmlSerialName("ComicInfo", "", "")
|
||||||
|
data class ComicInfo(
|
||||||
|
val series: ComicInfoSeries?,
|
||||||
|
val summary: ComicInfoSummary?,
|
||||||
|
val writer: ComicInfoWriter?,
|
||||||
|
val penciller: ComicInfoPenciller?,
|
||||||
|
val inker: ComicInfoInker?,
|
||||||
|
val colorist: ComicInfoColorist?,
|
||||||
|
val letterer: ComicInfoLetterer?,
|
||||||
|
val coverArtist: ComicInfoCoverArtist?,
|
||||||
|
val genre: ComicInfoGenre?,
|
||||||
|
val tags: ComicInfoTags?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@XmlSerialName("Series", "", "")
|
||||||
|
data class ComicInfoSeries(@XmlValue(true) val value: String = "")
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@XmlSerialName("Summary", "", "")
|
||||||
|
data class ComicInfoSummary(@XmlValue(true) val value: String = "")
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@XmlSerialName("Writer", "", "")
|
||||||
|
data class ComicInfoWriter(@XmlValue(true) val value: String = "")
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@XmlSerialName("Penciller", "", "")
|
||||||
|
data class ComicInfoPenciller(@XmlValue(true) val value: String = "")
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@XmlSerialName("Inker", "", "")
|
||||||
|
data class ComicInfoInker(@XmlValue(true) val value: String = "")
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@XmlSerialName("Colorist", "", "")
|
||||||
|
data class ComicInfoColorist(@XmlValue(true) val value: String = "")
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@XmlSerialName("Letterer", "", "")
|
||||||
|
data class ComicInfoLetterer(@XmlValue(true) val value: String = "")
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@XmlSerialName("CoverArtist", "", "")
|
||||||
|
data class ComicInfoCoverArtist(@XmlValue(true) val value: String = "")
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@XmlSerialName("Genre", "", "")
|
||||||
|
data class ComicInfoGenre(@XmlValue(true) val value: String = "")
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@XmlSerialName("Tags", "", "")
|
||||||
|
data class ComicInfoTags(@XmlValue(true) val value: String = "")
|
|
@ -30,6 +30,8 @@ import eu.kanade.tachiyomi.source.SourceManager
|
||||||
import eu.kanade.tachiyomi.util.system.isDevFlavor
|
import eu.kanade.tachiyomi.util.system.isDevFlavor
|
||||||
import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
|
import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import nl.adaptivity.xmlutil.serialization.UnknownChildHandler
|
||||||
|
import nl.adaptivity.xmlutil.serialization.XML
|
||||||
import uy.kohesive.injekt.api.InjektModule
|
import uy.kohesive.injekt.api.InjektModule
|
||||||
import uy.kohesive.injekt.api.InjektRegistrar
|
import uy.kohesive.injekt.api.InjektRegistrar
|
||||||
import uy.kohesive.injekt.api.addSingleton
|
import uy.kohesive.injekt.api.addSingleton
|
||||||
|
@ -89,6 +91,13 @@ class AppModule(val app: Application) : InjektModule {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addSingletonFactory {
|
||||||
|
XML {
|
||||||
|
unknownChildHandler = UnknownChildHandler { _, _, _, _, _ -> emptyList() }
|
||||||
|
autoPolymorphic = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
addSingletonFactory { ChapterCache(app) }
|
addSingletonFactory { ChapterCache(app) }
|
||||||
|
|
||||||
addSingletonFactory { CoverCache(app) }
|
addSingletonFactory { CoverCache(app) }
|
||||||
|
|
|
@ -168,7 +168,7 @@ object Migrations {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (oldVersion < 60) {
|
if (oldVersion < 60) {
|
||||||
// Re-enable update check that was prevously accidentally disabled for M
|
// Re-enable update check that was previously accidentally disabled for M
|
||||||
if (BuildConfig.INCLUDE_UPDATER && Build.VERSION.SDK_INT == Build.VERSION_CODES.M) {
|
if (BuildConfig.INCLUDE_UPDATER && Build.VERSION.SDK_INT == Build.VERSION_CODES.M) {
|
||||||
AppUpdateJob.setupTask(context)
|
AppUpdateJob.setupTask(context)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ 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.hippo.unifile.UniFile
|
import com.hippo.unifile.UniFile
|
||||||
|
import eu.kanade.domain.manga.model.ComicInfo
|
||||||
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
|
||||||
|
@ -11,6 +12,7 @@ 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.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.lang.withIOContext
|
||||||
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
|
||||||
|
@ -20,11 +22,14 @@ import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.decodeFromStream
|
import kotlinx.serialization.json.decodeFromStream
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
|
import nl.adaptivity.xmlutil.AndroidXmlReader
|
||||||
|
import nl.adaptivity.xmlutil.serialization.XML
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
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
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.zip.ZipFile
|
import java.util.zip.ZipFile
|
||||||
|
|
||||||
|
@ -33,6 +38,7 @@ class LocalSource(
|
||||||
) : CatalogueSource, UnmeteredSource {
|
) : CatalogueSource, UnmeteredSource {
|
||||||
|
|
||||||
private val json: Json by injectLazy()
|
private val json: Json by injectLazy()
|
||||||
|
private val xml: XML by injectLazy()
|
||||||
|
|
||||||
override val name: String = context.getString(R.string.local_source)
|
override val name: String = context.getString(R.string.local_source)
|
||||||
|
|
||||||
|
@ -134,27 +140,132 @@ class LocalSource(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manga details related
|
// Manga details related
|
||||||
override suspend fun getMangaDetails(manga: SManga): SManga {
|
override suspend fun getMangaDetails(manga: SManga): SManga = withIOContext {
|
||||||
val baseDirsFile = getBaseDirectoriesFiles(context)
|
val baseDirsFile = getBaseDirectoriesFiles(context)
|
||||||
|
|
||||||
getCoverFile(manga.url, baseDirsFile)?.let {
|
getCoverFile(manga.url, baseDirsFile)?.let {
|
||||||
manga.thumbnail_url = it.absolutePath
|
manga.thumbnail_url = it.absolutePath
|
||||||
}
|
}
|
||||||
|
|
||||||
getMangaDirsFiles(manga.url, baseDirsFile)
|
// Augment manga details based on metadata files
|
||||||
.firstOrNull { it.extension.equals("json", ignoreCase = true) }
|
try {
|
||||||
?.let { file ->
|
val mangaDirFiles = getMangaDirsFiles(manga.url, baseDirsFile).toList()
|
||||||
json.decodeFromStream<MangaDetails>(file.inputStream()).run {
|
val comicInfoMetadata = mangaDirFiles
|
||||||
title?.let { manga.title = it }
|
.firstOrNull { it.name == COMIC_INFO_FILE || it.name == ".noxml" }
|
||||||
author?.let { manga.author = it }
|
|
||||||
artist?.let { manga.artist = it }
|
when {
|
||||||
description?.let { manga.description = it }
|
// Top level ComicInfo.xml
|
||||||
genre?.let { manga.genre = it.joinToString() }
|
comicInfoMetadata?.name == COMIC_INFO_FILE -> {
|
||||||
status?.let { manga.status = it }
|
setMangaDetailsFromComicInfoFile(comicInfoMetadata.inputStream(), manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy ComicInfo.xml from chapter archive to top level if found
|
||||||
|
comicInfoMetadata == null -> {
|
||||||
|
val chapterArchives = mangaDirFiles
|
||||||
|
.filter { isSupportedArchiveFile(it.extension) }
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
val mangaDir = getMangaDir(manga.url, baseDirsFile)
|
||||||
|
val folderPath = mangaDir?.absolutePath
|
||||||
|
|
||||||
|
val copiedFile = copyComicInfoFileFromArchive(chapterArchives, folderPath)
|
||||||
|
if (copiedFile != null) {
|
||||||
|
setMangaDetailsFromComicInfoFile(copiedFile.inputStream(), manga)
|
||||||
|
} else {
|
||||||
|
// Avoid re-scanning
|
||||||
|
File("$folderPath/.noxml").createNewFile()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to legacy JSON details format
|
||||||
|
else -> {
|
||||||
|
mangaDirFiles
|
||||||
|
.firstOrNull { it.extension == "json" }
|
||||||
|
?.let { file ->
|
||||||
|
json.decodeFromStream<MangaDetails>(file.inputStream()).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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
logcat(LogPriority.ERROR, e) { "Error setting manga details from local metadata for ${manga.title}" }
|
||||||
|
}
|
||||||
|
|
||||||
return manga
|
return@withIOContext manga
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyComicInfoFileFromArchive(chapterArchives: List<File>, folderPath: String?): File? {
|
||||||
|
for (chapter in chapterArchives) {
|
||||||
|
when (getFormat(chapter)) {
|
||||||
|
is Format.Zip -> {
|
||||||
|
ZipFile(chapter).use { zip: ZipFile ->
|
||||||
|
zip.getEntry(COMIC_INFO_FILE)?.let { comicInfoFile ->
|
||||||
|
zip.getInputStream(comicInfoFile).buffered().use { stream ->
|
||||||
|
return copyComicInfoFile(stream, folderPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Format.Rar -> {
|
||||||
|
Archive(chapter).use { rar: Archive ->
|
||||||
|
rar.fileHeaders.firstOrNull { it.fileName == COMIC_INFO_FILE }?.let { comicInfoFile ->
|
||||||
|
rar.getInputStream(comicInfoFile).buffered().use { stream ->
|
||||||
|
return copyComicInfoFile(stream, folderPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyComicInfoFile(comicInfoFileStream: InputStream, folderPath: String?): File {
|
||||||
|
return File("$folderPath/$COMIC_INFO_FILE").apply {
|
||||||
|
outputStream().use { outputStream ->
|
||||||
|
comicInfoFileStream.use { it.copyTo(outputStream) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setMangaDetailsFromComicInfoFile(stream: InputStream, manga: SManga) {
|
||||||
|
val comicInfo = AndroidXmlReader(stream, StandardCharsets.UTF_8.name()).use {
|
||||||
|
xml.decodeFromReader<ComicInfo>(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
comicInfo.series?.let { manga.title = it.value }
|
||||||
|
comicInfo.writer?.let { manga.author = it.value }
|
||||||
|
comicInfo.summary?.let { manga.description = it.value }
|
||||||
|
|
||||||
|
listOfNotNull(
|
||||||
|
comicInfo.genre?.value,
|
||||||
|
comicInfo.tags?.value,
|
||||||
|
)
|
||||||
|
.flatMap { it.split(", ") }
|
||||||
|
.distinct()
|
||||||
|
.joinToString(", ") { it.trim() }
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { manga.genre = it }
|
||||||
|
|
||||||
|
listOfNotNull(
|
||||||
|
comicInfo.penciller?.value,
|
||||||
|
comicInfo.inker?.value,
|
||||||
|
comicInfo.colorist?.value,
|
||||||
|
comicInfo.letterer?.value,
|
||||||
|
comicInfo.coverArtist?.value,
|
||||||
|
)
|
||||||
|
.flatMap { it.split(", ") }
|
||||||
|
.distinct()
|
||||||
|
.joinToString(", ") { it.trim() }
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { manga.artist = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@ -172,7 +283,7 @@ class LocalSource(
|
||||||
val baseDirsFile = getBaseDirectoriesFiles(context)
|
val baseDirsFile = getBaseDirectoriesFiles(context)
|
||||||
return getMangaDirsFiles(manga.url, baseDirsFile)
|
return getMangaDirsFiles(manga.url, baseDirsFile)
|
||||||
// Only keep supported formats
|
// Only keep supported formats
|
||||||
.filter { it.isDirectory || isSupportedFile(it.extension) }
|
.filter { it.isDirectory || isSupportedArchiveFile(it.extension) }
|
||||||
.map { chapterFile ->
|
.map { chapterFile ->
|
||||||
SChapter.create().apply {
|
SChapter.create().apply {
|
||||||
url = "${manga.url}/${chapterFile.name}"
|
url = "${manga.url}/${chapterFile.name}"
|
||||||
|
@ -182,7 +293,6 @@ class LocalSource(
|
||||||
chapterFile.nameWithoutExtension
|
chapterFile.nameWithoutExtension
|
||||||
}
|
}
|
||||||
date_upload = chapterFile.lastModified()
|
date_upload = chapterFile.lastModified()
|
||||||
|
|
||||||
chapter_number = ChapterRecognition.parseChapterNumber(manga.title, this.name, this.chapter_number)
|
chapter_number = ChapterRecognition.parseChapterNumber(manga.title, this.name, this.chapter_number)
|
||||||
|
|
||||||
val format = getFormat(chapterFile)
|
val format = getFormat(chapterFile)
|
||||||
|
@ -216,7 +326,7 @@ class LocalSource(
|
||||||
override suspend fun getPageList(chapter: SChapter) = throw UnsupportedOperationException("Unused")
|
override suspend fun getPageList(chapter: SChapter) = throw UnsupportedOperationException("Unused")
|
||||||
|
|
||||||
// Miscellaneous
|
// Miscellaneous
|
||||||
private fun isSupportedFile(extension: String): Boolean {
|
private fun isSupportedArchiveFile(extension: String): Boolean {
|
||||||
return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES
|
return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -369,3 +479,4 @@ class LocalSource(
|
||||||
}
|
}
|
||||||
|
|
||||||
private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "epub")
|
private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "epub")
|
||||||
|
private val COMIC_INFO_FILE = "ComicInfo.xml"
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
kotlin_version = "1.7.10"
|
kotlin_version = "1.7.10"
|
||||||
coroutines_version = "1.6.4"
|
coroutines_version = "1.6.4"
|
||||||
serialization_version = "1.4.0"
|
serialization_version = "1.4.0"
|
||||||
|
xml_serialization_version = "0.84.2"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin_version" }
|
reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin_version" }
|
||||||
|
@ -13,10 +14,12 @@ coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-androi
|
||||||
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_version" }
|
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_version" }
|
||||||
serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "serialization_version" }
|
serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "serialization_version" }
|
||||||
serialization-gradle = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin_version" }
|
serialization-gradle = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin_version" }
|
||||||
|
serialization-xml-core = { module = "io.github.pdvrieze.xmlutil:core-android", version.ref = "xml_serialization_version" }
|
||||||
|
serialization-xml = { module = "io.github.pdvrieze.xmlutil:serialization-android", version.ref = "xml_serialization_version" }
|
||||||
|
|
||||||
[bundles]
|
[bundles]
|
||||||
coroutines = ["coroutines-core", "coroutines-android"]
|
coroutines = ["coroutines-core", "coroutines-android"]
|
||||||
serialization = ["serialization-json", "serialization-protobuf"]
|
serialization = ["serialization-json", "serialization-protobuf", "serialization-xml-core", "serialization-xml"]
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin_version" }
|
android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin_version" }
|
Reference in a new issue