mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 14:27:57 +01:00 
			
		
		
		
	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:
		
							
								
								
									
										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 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) } | ||||
|   | ||||
| @@ -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) | ||||
|                 } | ||||
|   | ||||
| @@ -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" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user