瀏覽代碼

Read metadata from ComicInfo.xml files in Local source (#8025)

Co-authored-by: Shamicen <[email protected]>
Co-authored-by: Andreas <[email protected]>
Co-authored-by: jobobby04 <[email protected]>
arkon 2 年之前
父節點
當前提交
1395343f11

+ 60 - 0
app/src/main/java/eu/kanade/domain/manga/model/ComicInfo.kt

@@ -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 = "")

+ 9 - 0
app/src/main/java/eu/kanade/tachiyomi/AppModule.kt

@@ -30,6 +30,8 @@ import eu.kanade.tachiyomi.source.SourceManager
 import eu.kanade.tachiyomi.util.system.isDevFlavor
 import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
 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.InjektRegistrar
 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 { CoverCache(app) }

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/Migrations.kt

@@ -168,7 +168,7 @@ object Migrations {
                 }
             }
             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) {
                     AppUpdateJob.setupTask(context)
                 }

+ 126 - 15
app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt

@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.source
 import android.content.Context
 import com.github.junrar.Archive
 import com.hippo.unifile.UniFile
+import eu.kanade.domain.manga.model.ComicInfo
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.source.model.Filter
 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.util.chapter.ChapterRecognition
 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.EpubFile
 import eu.kanade.tachiyomi.util.system.ImageUtil
@@ -20,11 +22,14 @@ import kotlinx.serialization.Serializable
 import kotlinx.serialization.json.Json
 import kotlinx.serialization.json.decodeFromStream
 import logcat.LogPriority
+import nl.adaptivity.xmlutil.AndroidXmlReader
+import nl.adaptivity.xmlutil.serialization.XML
 import rx.Observable
 import uy.kohesive.injekt.injectLazy
 import java.io.File
 import java.io.FileInputStream
 import java.io.InputStream
+import java.nio.charset.StandardCharsets
 import java.util.concurrent.TimeUnit
 import java.util.zip.ZipFile
 
@@ -33,6 +38,7 @@ class LocalSource(
 ) : CatalogueSource, UnmeteredSource {
 
     private val json: Json by injectLazy()
+    private val xml: XML by injectLazy()
 
     override val name: String = context.getString(R.string.local_source)
 
@@ -134,27 +140,132 @@ class LocalSource(
     }
 
     // Manga details related
-    override suspend fun getMangaDetails(manga: SManga): SManga {
+    override suspend fun getMangaDetails(manga: SManga): SManga = withIOContext {
         val baseDirsFile = getBaseDirectoriesFiles(context)
 
         getCoverFile(manga.url, baseDirsFile)?.let {
             manga.thumbnail_url = it.absolutePath
         }
 
-        getMangaDirsFiles(manga.url, baseDirsFile)
-            .firstOrNull { it.extension.equals("json", ignoreCase = true) }
-            ?.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 }
+        // Augment manga details based on metadata files
+        try {
+            val mangaDirFiles = getMangaDirsFiles(manga.url, baseDirsFile).toList()
+            val comicInfoMetadata = mangaDirFiles
+                .firstOrNull { it.name == COMIC_INFO_FILE || it.name == ".noxml" }
+
+            when {
+                // Top level ComicInfo.xml
+                comicInfoMetadata?.name == COMIC_INFO_FILE -> {
+                    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
@@ -172,7 +283,7 @@ class LocalSource(
         val baseDirsFile = getBaseDirectoriesFiles(context)
         return getMangaDirsFiles(manga.url, baseDirsFile)
             // Only keep supported formats
-            .filter { it.isDirectory || isSupportedFile(it.extension) }
+            .filter { it.isDirectory || isSupportedArchiveFile(it.extension) }
             .map { chapterFile ->
                 SChapter.create().apply {
                     url = "${manga.url}/${chapterFile.name}"
@@ -182,7 +293,6 @@ class LocalSource(
                         chapterFile.nameWithoutExtension
                     }
                     date_upload = chapterFile.lastModified()
-
                     chapter_number = ChapterRecognition.parseChapterNumber(manga.title, this.name, this.chapter_number)
 
                     val format = getFormat(chapterFile)
@@ -216,7 +326,7 @@ class LocalSource(
     override suspend fun getPageList(chapter: SChapter) = throw UnsupportedOperationException("Unused")
 
     // Miscellaneous
-    private fun isSupportedFile(extension: String): Boolean {
+    private fun isSupportedArchiveFile(extension: String): Boolean {
         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 COMIC_INFO_FILE = "ComicInfo.xml"

+ 4 - 1
gradle/kotlinx.versions.toml

@@ -2,6 +2,7 @@
 kotlin_version = "1.7.10"
 coroutines_version = "1.6.4"
 serialization_version = "1.4.0"
+xml_serialization_version = "0.84.2"
 
 [libraries]
 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-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-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]
 coroutines = ["coroutines-core", "coroutines-android"]
-serialization = ["serialization-json", "serialization-protobuf"]
+serialization = ["serialization-json", "serialization-protobuf", "serialization-xml-core", "serialization-xml"]
 
 [plugins]
 android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin_version" }