From eaa7b15baec1759f34693d2dbaad2da870a89dd7 Mon Sep 17 00:00:00 2001 From: Alessandro Jean <14254807+alessandrojean@users.noreply.github.com> Date: Sat, 29 Oct 2022 22:59:22 -0300 Subject: [PATCH] Add option to attempt to use first volume cover on MangaDex (#14031) * Add option to attempt to use first volume cover on MangaDex. * Fix missing first volume covers and remove logical symbols. * Reinforce isOneShot check and reword preference. --- src/all/mangadex/build.gradle | 2 +- .../extension/all/mangadex/MDConstants.kt | 9 ++ .../extension/all/mangadex/MangaDex.kt | 102 +++++++++++++++--- .../extension/all/mangadex/MangaDexHelper.kt | 41 ++++--- .../extension/all/mangadex/MangaDexIntl.kt | 24 ++++- .../extension/all/mangadex/dto/CoverDto.kt | 22 ++++ .../extension/all/mangadex/dto/MangaDto.kt | 17 ++- 7 files changed, 172 insertions(+), 45 deletions(-) create mode 100644 src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/dto/CoverDto.kt diff --git a/src/all/mangadex/build.gradle b/src/all/mangadex/build.gradle index 2b99a01ca..54a5cc5d7 100644 --- a/src/all/mangadex/build.gradle +++ b/src/all/mangadex/build.gradle @@ -6,7 +6,7 @@ ext { extName = 'MangaDex' pkgNameSuffix = 'all.mangadex' extClass = '.MangaDexFactory' - extVersionCode = 170 + extVersionCode = 171 isNsfw = true } diff --git a/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MDConstants.kt b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MDConstants.kt index 0161eda47..a4fdc068d 100644 --- a/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MDConstants.kt +++ b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MDConstants.kt @@ -108,9 +108,18 @@ object MDConstants { return "${hasSanitizedUuidsPref}_$dexLang" } + private const val tryUsingFirstVolumeCoverPref = "tryUsingFirstVolumeCover" + const val tryUsingFirstVolumeCoverDefault = false + fun getTryUsingFirstVolumeCoverPrefKey(dexLang: String): String { + return "${tryUsingFirstVolumeCoverPref}_$dexLang" + } + private const val tagGroupContent = "content" private const val tagGroupFormat = "format" private const val tagGroupGenre = "genre" private const val tagGroupTheme = "theme" val tagGroupsOrder = arrayOf(tagGroupContent, tagGroupFormat, tagGroupGenre, tagGroupTheme) + + const val tagAnthologyUuid = "51d83883-4103-437c-b4b1-731cb73d786c" + const val tagOneShotUuid = "0234a31e-a729-4e28-9d6a-3f87c4966b9e" } diff --git a/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDex.kt b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDex.kt index 069a5c688..0c59e0640 100644 --- a/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDex.kt +++ b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDex.kt @@ -9,10 +9,13 @@ import androidx.preference.MultiSelectListPreference import androidx.preference.PreferenceScreen import androidx.preference.SwitchPreferenceCompat import eu.kanade.tachiyomi.extension.all.mangadex.dto.AggregateDto +import eu.kanade.tachiyomi.extension.all.mangadex.dto.AggregateVolume import eu.kanade.tachiyomi.extension.all.mangadex.dto.AtHomeDto import eu.kanade.tachiyomi.extension.all.mangadex.dto.ChapterDto import eu.kanade.tachiyomi.extension.all.mangadex.dto.ChapterListDto +import eu.kanade.tachiyomi.extension.all.mangadex.dto.CoverListDto import eu.kanade.tachiyomi.extension.all.mangadex.dto.ListDto +import eu.kanade.tachiyomi.extension.all.mangadex.dto.MangaDataDto import eu.kanade.tachiyomi.extension.all.mangadex.dto.MangaDto import eu.kanade.tachiyomi.extension.all.mangadex.dto.MangaListDto import eu.kanade.tachiyomi.extension.all.mangadex.dto.RelationshipDto @@ -26,7 +29,6 @@ import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.HttpSource -import kotlinx.serialization.SerializationException import kotlinx.serialization.decodeFromString import okhttp3.CacheControl import okhttp3.Headers @@ -97,9 +99,10 @@ abstract class MangaDex(final override val lang: String, private val dexLang: St val hasMoreResults = mangaListDto.limit + mangaListDto.offset < mangaListDto.total val coverSuffix = preferences.coverQuality + val firstVolumeCovers = fetchFirstVolumeCovers(mangaListDto.data).orEmpty() val mangaList = mangaListDto.data.map { mangaDataDto -> - val fileName = mangaDataDto.relationships + val fileName = firstVolumeCovers[mangaDataDto.id] ?: mangaDataDto.relationships .firstOrNull { it.type.equals(MDConstants.coverArt, true) } ?.attributes?.fileName helper.createBasicManga(mangaDataDto, fileName, coverSuffix, dexLang) @@ -129,13 +132,14 @@ abstract class MangaDex(final override val lang: String, private val dexLang: St val mangaRequest = GET(mangaUrl.build().toString(), headers, CacheControl.FORCE_NETWORK) val mangaResponse = client.newCall(mangaRequest).execute() val mangaListDto = mangaResponse.parseAs() + val firstVolumeCovers = fetchFirstVolumeCovers(mangaListDto.data).orEmpty() val mangaDtoMap = mangaListDto.data.associateBy({ it.id }, { it }) val coverSuffix = preferences.coverQuality val mangaList = mangaIds.mapNotNull { mangaDtoMap[it] }.map { mangaDataDto -> - val fileName = mangaDataDto.relationships + val fileName = firstVolumeCovers[mangaDataDto.id] ?: mangaDataDto.relationships .firstOrNull { it.type.equals(MDConstants.coverArt, true) } ?.attributes?.fileName helper.createBasicManga(mangaDataDto, fileName, coverSuffix, dexLang) @@ -182,7 +186,7 @@ abstract class MangaDex(final override val lang: String, private val dexLang: St .map { searchMangaListRequest(it, page) } else -> - return super.fetchSearchManga(page, query, filters) + return super.fetchSearchManga(page, query.trim(), filters) } } @@ -292,11 +296,12 @@ abstract class MangaDex(final override val lang: String, private val dexLang: St } val mangaListDto = response.parseAs() + val firstVolumeCovers = fetchFirstVolumeCovers(mangaListDto.data).orEmpty() val coverSuffix = preferences.coverQuality val mangaList = mangaListDto.data.map { mangaDataDto -> - val fileName = mangaDataDto.relationships + val fileName = firstVolumeCovers[mangaDataDto.id] ?: mangaDataDto.relationships .firstOrNull { it.type.equals(MDConstants.coverArt, true) } ?.attributes?.fileName helper.createBasicManga(mangaDataDto, fileName, coverSuffix, dexLang) @@ -365,33 +370,74 @@ abstract class MangaDex(final override val lang: String, private val dexLang: St return helper.createManga( manga.data, fetchSimpleChapterList(manga, dexLang), + fetchFirstVolumeCover(manga), dexLang, preferences.coverQuality ) } /** - * get a quick-n-dirty list of the chapters to be used in determining the manga status. - * uses the 'aggregate' endpoint + * Get a quick-n-dirty list of the chapters to be used in determining the manga status. + * Uses the 'aggregate' endpoint. + * * @see MangaDexHelper.getPublicationStatus * @see AggregateDto */ - private fun fetchSimpleChapterList(manga: MangaDto, langCode: String): List { + private fun fetchSimpleChapterList(manga: MangaDto, langCode: String): Map { val url = "${MDConstants.apiMangaUrl}/${manga.data.id}/aggregate?translatedLanguage[]=$langCode" val response = client.newCall(GET(url, headers)).execute() - val chapters: AggregateDto - try { - chapters = response.parseAs() - } catch (e: SerializationException) { - return emptyList() + return runCatching { response.parseAs() } + .getOrNull()?.volumes.orEmpty() + } + + /** + * Attempt to get the first volume cover if the setting is enabled. + * Uses the 'covers' endpoint. + * + * @see CoverListDto + */ + private fun fetchFirstVolumeCover(manga: MangaDto): String? { + return fetchFirstVolumeCovers(listOf(manga.data))?.get(manga.data.id) + } + + /** + * Attempt to get the first volume cover if the setting is enabled. + * Uses the 'covers' endpoint. + * + * @see CoverListDto + */ + private fun fetchFirstVolumeCovers(mangaList: List): Map? { + if (!preferences.tryUsingFirstVolumeCover) { + return null } - if (chapters.volumes.isNullOrEmpty()) return emptyList() + val mangaMap = mangaList.associate { it.id to it.attributes } + .filterValues { !it.originalLanguage.isNullOrEmpty() } + val locales = mangaList.mapNotNull { it.attributes.originalLanguage }.distinct() + val limit = (mangaMap.size * locales.size).coerceAtMost(100) - return chapters.volumes.values - .flatMap { it.chapters.values } - .map { it.chapter } + val apiUrl = "${MDConstants.apiUrl}/cover".toHttpUrl().newBuilder() + .addQueryParameter("order[volume]", "asc") + .addQueryParameter("manga[]", mangaMap.keys) + .addQueryParameter("locales[]", locales.toSet()) + .addQueryParameter("limit", limit.toString()) + .addQueryParameter("offset", "0") + .toString() + + val result = runCatching { + client.newCall(GET(apiUrl, headers)).execute().parseAs().data + } + + val covers = result.getOrNull() ?: return null + + return covers + .groupBy { it.relationships.find { r -> r.type == MDConstants.manga }!!.id } + .mapValues { + it.value.find { c -> c.attributes?.locale == mangaMap[it.key]?.originalLanguage } + } + .filterValues { !it?.attributes?.fileName.isNullOrEmpty() } + .mapValues { it.value!!.attributes!!.fileName!! } } // Chapter list section @@ -525,6 +571,21 @@ abstract class MangaDex(final override val lang: String, private val dexLang: St } } + val tryUsingFirstVolumeCoverPref = SwitchPreferenceCompat(screen.context).apply { + key = MDConstants.getTryUsingFirstVolumeCoverPrefKey(dexLang) + title = helper.intl.tryUsingFirstVolumeCover + summary = helper.intl.tryUsingFirstVolumeCoverSummary + setDefaultValue(MDConstants.tryUsingFirstVolumeCoverDefault) + + setOnPreferenceChangeListener { _, newValue -> + val checkValue = newValue as Boolean + + preferences.edit() + .putBoolean(MDConstants.getDataSaverPreferenceKey(dexLang), checkValue) + .commit() + } + } + val dataSaverPref = SwitchPreferenceCompat(screen.context).apply { key = MDConstants.getDataSaverPreferenceKey(dexLang) title = helper.intl.dataSaver @@ -636,6 +697,7 @@ abstract class MangaDex(final override val lang: String, private val dexLang: St } screen.addPreference(coverQualityPref) + screen.addPreference(tryUsingFirstVolumeCoverPref) screen.addPreference(dataSaverPref) screen.addPreference(standardHttpsPortPref) screen.addPreference(contentRatingPref) @@ -680,6 +742,12 @@ abstract class MangaDex(final override val lang: String, private val dexLang: St private val SharedPreferences.coverQuality get() = getString(MDConstants.getCoverQualityPreferenceKey(dexLang), "") + private val SharedPreferences.tryUsingFirstVolumeCover + get() = getBoolean( + MDConstants.getTryUsingFirstVolumeCoverPrefKey(dexLang), + MDConstants.tryUsingFirstVolumeCoverDefault + ) + private val SharedPreferences.blockedGroups get() = getString(MDConstants.getBlockedGroupsPrefKey(dexLang), "") ?.split(",") diff --git a/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDexHelper.kt b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDexHelper.kt index 681443916..0fc169e05 100644 --- a/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDexHelper.kt +++ b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDexHelper.kt @@ -5,18 +5,18 @@ import android.text.TextWatcher import android.util.Log import android.widget.Button import android.widget.EditText +import eu.kanade.tachiyomi.extension.all.mangadex.dto.AggregateVolume import eu.kanade.tachiyomi.extension.all.mangadex.dto.AtHomeDto import eu.kanade.tachiyomi.extension.all.mangadex.dto.ChapterDataDto import eu.kanade.tachiyomi.extension.all.mangadex.dto.MangaAttributesDto import eu.kanade.tachiyomi.extension.all.mangadex.dto.MangaDataDto -import eu.kanade.tachiyomi.extension.all.mangadex.dto.asMdMap +import eu.kanade.tachiyomi.extension.all.mangadex.dto.toLocalizedString import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonArray import okhttp3.CacheControl import okhttp3.Headers import okhttp3.HttpUrl.Companion.toHttpUrl @@ -27,7 +27,7 @@ import java.util.Date import java.util.Locale import java.util.concurrent.TimeUnit -class MangaDexHelper(private val lang: String) { +class MangaDexHelper(lang: String) { val mdFilters = MangaDexFilters() @@ -98,7 +98,11 @@ class MangaDexHelper(private val lang: String) { * Maps dex status to Tachi status. * Adapted from the MangaDex handler from TachiyomiSY. */ - fun getPublicationStatus(attr: MangaAttributesDto, chapters: List): Int { + fun getPublicationStatus(attr: MangaAttributesDto, volumes: Map): Int { + val chaptersList = volumes.values + .flatMap { it.chapters.values } + .map { it.chapter } + val tempStatus = when (attr.status) { "ongoing" -> SManga.ONGOING "cancelled" -> SManga.CANCELLED @@ -110,10 +114,13 @@ class MangaDexHelper(private val lang: String) { val publishedOrCancelled = tempStatus == SManga.PUBLISHING_FINISHED || tempStatus == SManga.CANCELLED - return if (chapters.contains(attr.lastChapter) && publishedOrCancelled) { - SManga.COMPLETED - } else { - tempStatus + val isOneShot = attr.tags.any { it.id == MDConstants.tagOneShotUuid } && + attr.tags.none { it.id == MDConstants.tagAnthologyUuid } + + return when { + chaptersList.contains(attr.lastChapter) && publishedOrCancelled -> SManga.COMPLETED + isOneShot && volumes["none"]?.chapters?.get("none") != null -> SManga.COMPLETED + else -> tempStatus } } @@ -210,15 +217,14 @@ class MangaDexHelper(private val lang: String) { ): SManga { return SManga.create().apply { url = "/manga/${mangaDataDto.id}" - val titleMap = mangaDataDto.attributes.title.asMdMap() + val titleMap = mangaDataDto.attributes.title.toLocalizedString() val dirtyTitle = titleMap[lang] ?: titleMap["en"] ?: titleMap["ja-ro"] - ?: mangaDataDto.attributes.altTitles.jsonArray - .find { - val altTitle = it.asMdMap() - (altTitle[lang] ?: altTitle["en"]) != null - }?.asMdMap()?.values?.singleOrNull() + ?: mangaDataDto.attributes.altTitles + .map { it.toLocalizedString() } + .find { (it[lang] ?: it["en"]) !== null } + ?.values?.singleOrNull() ?: titleMap["ja"] // romaji titles are sometimes ja (and are not altTitles) ?: titleMap.values.firstOrNull() // use literally anything from title as a last resort title = cleanString(dirtyTitle ?: "") @@ -237,7 +243,8 @@ class MangaDexHelper(private val lang: String) { */ fun createManga( mangaDataDto: MangaDataDto, - chapters: List, + chapters: Map, + firstVolumeCover: String?, lang: String, coverSuffix: String? ): SManga { @@ -275,7 +282,7 @@ class MangaDexHelper(private val lang: String) { .mapNotNull { it.attributes!!.name } .distinct() - val coverFileName = mangaDataDto.relationships + val coverFileName = firstVolumeCover ?: mangaDataDto.relationships .firstOrNull { it.type.equals(MDConstants.coverArt, true) } ?.attributes?.fileName @@ -293,7 +300,7 @@ class MangaDexHelper(private val lang: String) { val genreList = MDConstants.tagGroupsOrder.flatMap { genresMap[it].orEmpty() } + nonGenres.filterNotNull() - val desc = attr.description.asMdMap() + val desc = attr.description.toLocalizedString() return createBasicManga(mangaDataDto, coverFileName, coverSuffix, lang).apply { description = cleanString(desc[lang] ?: desc["en"] ?: "") diff --git a/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDexIntl.kt b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDexIntl.kt index 63f71122f..7f25aed19 100644 --- a/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDexIntl.kt +++ b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/MangaDexIntl.kt @@ -96,18 +96,18 @@ class MangaDexIntl(lang: String) { BRAZILIAN_PORTUGUESE, PORTUGUESE -> "Ative para fazer requisições em somente servidores de imagem que usem a porta 443. " + "Isso permite com que usuários com regras mais restritas de firewall possam acessar " + - "as imagens do MangaDex." + "as imagens do $MANGADEX_NAME." SPANISH_LATAM, SPANISH -> "Habilite esta opción solicitar las imágenes a los servidores que usan el puerto 443. " + "Esto permite a los usuarios con restricciones estrictas de firewall acceder " + - "a las imagenes en MangaDex" + "a las imagenes en $MANGADEX_NAME" RUSSIAN -> "Запрашивает изображения только с серверов которые используют порт 443. " + "Это позволяет пользователям со строгими правилами брандмауэра загружать " + - "изображения с Mangadex." + "изображения с $MANGADEX_NAME." else -> "Enable to only request image servers that use port 443. This allows users with " + - "stricter firewall restrictions to access MangaDex images" + "stricter firewall restrictions to access $MANGADEX_NAME images" } val contentRating: String = when (availableLang) { @@ -265,6 +265,20 @@ class MangaDexIntl(lang: String) { "Enter as a Comma-separated list of uploader UUIDs" } + val tryUsingFirstVolumeCover: String = when (availableLang) { + BRAZILIAN_PORTUGUESE, PORTUGUESE -> "Tentar usar a capa do primeiro volume como capa" + else -> "Attempt to use the first volume cover as cover" + } + + val tryUsingFirstVolumeCoverSummary: String = when (availableLang) { + BRAZILIAN_PORTUGUESE, PORTUGUESE -> + "Pode ser necessário atualizar os itens já adicionados na biblioteca. " + + "Alternativamente, limpe o banco de dados para as novas capas aparecerem." + else -> + "May need to manually refresh entries already in library. " + + "Otherwise, clear database to have new covers to show up." + } + val publicationDemographic: String = when (availableLang) { BRAZILIAN_PORTUGUESE, PORTUGUESE -> "Demografia da publicação" SPANISH_LATAM, SPANISH -> "Demografía" @@ -748,7 +762,7 @@ class MangaDexIntl(lang: String) { BRAZILIAN_PORTUGUESE, PORTUGUESE -> "Pós-apocalíptico" SPANISH_LATAM, SPANISH -> "Post-Apocalíptico" RUSSIAN -> "Постапокалиптика" - else -> "Post-Apocalypytic" + else -> "Post-Apocalyptic" } val themePsychological: String = when (availableLang) { diff --git a/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/dto/CoverDto.kt b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/dto/CoverDto.kt new file mode 100644 index 000000000..9f8c714a5 --- /dev/null +++ b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/dto/CoverDto.kt @@ -0,0 +1,22 @@ +package eu.kanade.tachiyomi.extension.all.mangadex.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class CoverListDto( + val data: List = emptyList() +) + +@Serializable +data class CoverDto( + val id: String, + val attributes: CoverAttributesDto? = null, + val relationships: List = emptyList() +) + +@Serializable +data class CoverAttributesDto( + val name: String? = null, + val fileName: String? = null, + val locale: String? = null +) diff --git a/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/dto/MangaDto.kt b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/dto/MangaDto.kt index 5637f7ff3..78ed3a718 100644 --- a/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/dto/MangaDto.kt +++ b/src/all/mangadex/src/eu/kanade/tachiyomi/extension/all/mangadex/dto/MangaDto.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.extension.all.mangadex.dto import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.contentOrNull @@ -45,7 +46,7 @@ data class MangaDataDto( @Serializable data class MangaAttributesDto( val title: JsonElement, - val altTitles: JsonElement, + val altTitles: JsonArray, val description: JsonElement, val originalLanguage: String?, val lastVolume: String?, @@ -67,8 +68,14 @@ data class TagAttributesDto( val group: String ) -fun JsonElement.asMdMap(): Map { - return runCatching { - (this as JsonObject).map { it.key to (it.value.jsonPrimitive.contentOrNull ?: "") }.toMap() - }.getOrElse { emptyMap() } +typealias LocalizedString = Map + +/** + * Temporary workaround while Dex API still returns arrays instead of objects + * in the places that uses [LocalizedString]. + */ +fun JsonElement.toLocalizedString(): LocalizedString { + return (this as? JsonObject)?.entries + ?.associate { (key, value) -> key to (value.jsonPrimitive.contentOrNull ?: "") } + .orEmpty() }