diff --git a/app/src/main/java/eu/kanade/domain/manga/model/ComicInfo.kt b/app/src/main/java/eu/kanade/domain/manga/model/ComicInfo.kt new file mode 100644 index 0000000000..3d089debf2 --- /dev/null +++ b/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 = "") diff --git a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt index 7c4e831ecb..67eb0dd7ad 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt +++ b/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) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt index 0c6977e18a..5887d73997 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt +++ b/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) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt index f2fd8ab4ec..b821e6dca2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt +++ b/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(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(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, 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(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" diff --git a/gradle/kotlinx.versions.toml b/gradle/kotlinx.versions.toml index f5ba100884..1b1d61db0a 100644 --- a/gradle/kotlinx.versions.toml +++ b/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" } \ No newline at end of file