diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt index 15874f7a..99bca41b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt @@ -38,6 +38,7 @@ data class MetaDetails( val trailers: List = emptyList(), val links: List = emptyList(), val videos: List = emptyList(), + val imdbId: String? = null, ) enum class MoreLikeThisSource { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt index 21e8b4aa..bb014e09 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt @@ -55,6 +55,7 @@ internal object MetaDetailsParser { trailers = meta.trailers(), links = links, videos = meta.videos(), + imdbId = meta.string("imdb_id") ?: meta.string("imdbId"), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 44911c48..5e0c5495 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -261,11 +261,17 @@ fun MetaDetailsScreen( return@LaunchedEffect } - val imdbId = extractImdbId(metaForRatings.id) ?: extractImdbId(id) + val imdbId = metaForRatings.imdbId?.takeIf { it.startsWith("tt", ignoreCase = true) } + ?: extractImdbId(metaForRatings.id) + ?: extractImdbId(id) val tmdbId = extractTmdbId(metaForRatings.id) ?: extractTmdbId(id) - ?: TmdbService.ensureTmdbId(metaForRatings.id, metaForRatings.type)?.toIntOrNull() - ?: TmdbService.ensureTmdbId(id, type)?.toIntOrNull() + ?: TmdbService.ensureTmdbId( + metaForRatings.id, + metaForRatings.type, + metaForRatings.imdbId, + )?.toIntOrNull() + ?: TmdbService.ensureTmdbId(id, type, metaForRatings.imdbId)?.toIntOrNull() if (imdbId == null && tmdbId == null) { episodeImdbRatings = emptyMap() @@ -1565,6 +1571,7 @@ private fun MetaDetails.toMetaPreview(): MetaPreview = releaseInfo = releaseInfo, imdbRating = imdbRating, genres = genres, + imdbId = imdbId, ) private fun LazyListScope.configuredMetaSectionItems( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt index 611b9109..246a2bd2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt @@ -57,6 +57,7 @@ internal object HomeCatalogParser { genres = meta.array("genres").mapNotNull { genre -> genre.jsonPrimitive.contentOrNull?.takeIf { it.isNotBlank() } }, + imdbId = meta.string("imdb_id") ?: meta.string("imdbId"), ) if (seenKeys.add(item.stableKey())) { add(item) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt index 0b2c774e..aa97a9a3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt @@ -18,6 +18,7 @@ data class MetaPreview( val voteCount: Int? = null, val imdbRating: String? = null, val genres: List = emptyList(), + val imdbId: String? = null, ) fun MetaPreview.stableKey(): String = "$type:$id" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListMetadataService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListMetadataService.kt index 1316e372..04e36b50 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListMetadataService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListMetadataService.kt @@ -49,7 +49,9 @@ object MdbListMetadataService { if (!settings.enabled) return false if (settings.apiKey.trim().isBlank()) return false if (settings.enabledProvidersInPriorityOrder().isEmpty()) return false - return extractImdbId(meta.id) != null || extractImdbId(fallbackItemId) != null + return validImdbId(meta.imdbId) != null || + extractImdbId(meta.id) != null || + extractImdbId(fallbackItemId) != null } suspend fun enrichMeta( @@ -62,7 +64,8 @@ object MdbListMetadataService { } val apiKey = settings.apiKey.trim() - val imdbId = extractImdbId(meta.id) + val imdbId = validImdbId(meta.imdbId) + ?: extractImdbId(meta.id) ?: extractImdbId(fallbackItemId) ?: return meta.copy(externalRatings = emptyList()) val mediaType = toMdbListMediaType(meta.type) @@ -108,6 +111,11 @@ object MdbListMetadataService { ratings } + private fun validImdbId(value: String?): String? = + value + ?.trim() + ?.takeIf { imdbRegex.matches(it) } + private suspend fun fetchProviderRating( imdbId: String, mediaType: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt index b8763562..72cf99f4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt @@ -524,8 +524,8 @@ object TmdbMetadataService { if (!settings.enabled || !settings.hasApiKey) return meta val tmdbType = normalizeMetaType(meta.type) - val tmdbId = TmdbService.ensureTmdbId(meta.id, tmdbType) - ?: TmdbService.ensureTmdbId(fallbackItemId, tmdbType) + val tmdbId = TmdbService.ensureTmdbId(meta.id, tmdbType, meta.imdbId) + ?: TmdbService.ensureTmdbId(fallbackItemId, tmdbType, meta.imdbId) ?: return meta val needsEpisodes = ( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbService.kt index 4e7e8848..2d06d4ed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbService.kt @@ -15,22 +15,26 @@ object TmdbService { private val tmdbToImdbCache = linkedMapOf() private val cacheMutex = Mutex() - suspend fun ensureTmdbId(videoId: String, mediaType: String): String? { + suspend fun ensureTmdbId( + videoId: String, + mediaType: String, + fallbackImdbId: String? = null, + ): String? { val apiKey = currentApiKey() ?: return null + val normalizedType = normalizeMediaType(mediaType) - val normalized = videoId - .removePrefix("tmdb:") - .removePrefix("movie:") - .removePrefix("series:") - .substringBefore(':') - .substringBefore('/') - .trim() + tmdbLookupCandidates(videoId, fallbackImdbId).forEach { candidate -> + if (candidate.all(Char::isDigit)) return candidate + if (candidate.startsWith("tt", ignoreCase = true)) { + imdbToTmdb( + imdbId = candidate, + mediaType = normalizedType, + apiKey = apiKey, + )?.let { return it } + } + } - if (normalized.isBlank()) return null - if (normalized.all(Char::isDigit)) return normalized - if (!normalized.startsWith("tt", ignoreCase = true)) return null - - return imdbToTmdb(imdbId = normalized, mediaType = mediaType, apiKey = apiKey) + return null } suspend fun tmdbToImdb(tmdbId: Int, mediaType: String): String? { @@ -110,6 +114,24 @@ object TmdbService { } } +internal fun tmdbLookupCandidates( + videoId: String, + fallbackImdbId: String?, +): List = + listOfNotNull(videoId, fallbackImdbId) + .mapNotNull { rawId -> + rawId + .trim() + .removePrefix("tmdb:") + .removePrefix("movie:") + .removePrefix("series:") + .substringBefore(':') + .substringBefore('/') + .trim() + .takeIf(String::isNotBlank) + } + .distinct() + internal fun buildTmdbUrl( endpoint: String, apiKey: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktRelatedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktRelatedRepository.kt index c0b61bef..ed35c049 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktRelatedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktRelatedRepository.kt @@ -106,12 +106,19 @@ object TraktRelatedRepository { ): ResolvedRelatedTarget? { val type = resolveRelatedType(meta = meta, fallbackItemType = fallbackItemType) ?: return null resolveDirectPathId(meta.id)?.let { return ResolvedRelatedTarget(type, it) } + resolveDirectPathId(meta.imdbId)?.let { return ResolvedRelatedTarget(type, it) } resolveDirectPathId(fallbackItemId)?.let { return ResolvedRelatedTarget(type, it) } val tmdbId = resolveTmdbCandidate(meta.id) ?: resolveTmdbCandidate(fallbackItemId) - ?: TmdbService.ensureTmdbId(meta.id, meta.type)?.toIntOrNull() - ?: fallbackItemId?.let { TmdbService.ensureTmdbId(it, fallbackItemType ?: meta.type) }?.toIntOrNull() + ?: TmdbService.ensureTmdbId(meta.id, meta.type, meta.imdbId)?.toIntOrNull() + ?: fallbackItemId?.let { + TmdbService.ensureTmdbId( + it, + fallbackItemType ?: meta.type, + meta.imdbId, + ) + }?.toIntOrNull() ?: return null return resolveViaTraktSearch(type = type, tmdbId = tmdbId, headers = headers) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt index bfcbaafa..470d852d 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt @@ -22,7 +22,8 @@ class MetaDetailsParserTest { { "id": "mal:62516", "type": "series", - "name": "The Fragrant Flower Blooms with Dignity" + "name": "The Fragrant Flower Blooms with Dignity", + "imdb_id": "tt30217403" } """.trimIndent(), ) @@ -30,6 +31,7 @@ class MetaDetailsParserTest { assertEquals("mal:62516", result.id) assertEquals("series", result.type) assertEquals("The Fragrant Flower Blooms with Dignity", result.name) + assertEquals("tt30217403", result.imdbId) } @Test diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt index 65c94d8a..b5d67631 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt @@ -9,12 +9,17 @@ class HomeCatalogParserTest { fun `parse catalog response de-duplicates repeated metas but preserves raw count`() { val result = HomeCatalogParser.parseCatalogResponse( """ - { - "metas": [ - { "id": "mal:62516", "type": "series", "name": "A" }, - { "id": "mal:62516", "type": "series", "name": "A duplicate" }, - { "id": "mal:1", "type": "movie", "name": "B" } - ] + { + "metas": [ + { + "id": "mal:62516", + "type": "series", + "name": "A", + "imdb_id": "tt30217403" + }, + { "id": "mal:62516", "type": "series", "name": "A duplicate" }, + { "id": "mal:1", "type": "movie", "name": "B" } + ] } """.trimIndent(), ) @@ -25,6 +30,7 @@ class HomeCatalogParserTest { result.items.map { it.stableKey() }, ) assertEquals("A", result.items.first().name) + assertEquals("tt30217403", result.items.first().imdbId) } @Test diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/mdblist/MdbListMetadataServiceTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/mdblist/MdbListMetadataServiceTest.kt new file mode 100644 index 00000000..850010e4 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/mdblist/MdbListMetadataServiceTest.kt @@ -0,0 +1,29 @@ +package com.nuvio.app.features.mdblist + +import com.nuvio.app.features.details.MetaDetails +import kotlin.test.Test +import kotlin.test.assertTrue + +class MdbListMetadataServiceTest { + + @Test + fun `addon imdb alias enables ratings for anime id`() { + val meta = MetaDetails( + id = "mal:49894", + type = "series", + name = "Hero Classroom", + imdbId = "tt28254942", + ) + + assertTrue( + MdbListMetadataService.shouldFetchForMeta( + meta = meta, + fallbackItemId = meta.id, + settings = MdbListSettings( + enabled = true, + apiKey = "test", + ), + ), + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbServiceTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbServiceTest.kt new file mode 100644 index 00000000..a9f77159 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbServiceTest.kt @@ -0,0 +1,40 @@ +package com.nuvio.app.features.tmdb + +import kotlin.test.Test +import kotlin.test.assertEquals + +class TmdbServiceTest { + + @Test + fun `anime id keeps addon imdb alias as fallback candidate`() { + assertEquals( + listOf("mal", "tt28254942"), + tmdbLookupCandidates( + videoId = "mal:49894", + fallbackImdbId = "tt28254942", + ), + ) + } + + @Test + fun `numeric tmdb id remains ahead of fallback imdb alias`() { + assertEquals( + listOf("228234", "tt28254942"), + tmdbLookupCandidates( + videoId = "tmdb:228234", + fallbackImdbId = "tt28254942", + ), + ) + } + + @Test + fun `duplicate primary and fallback ids are resolved once`() { + assertEquals( + listOf("tt0133093"), + tmdbLookupCandidates( + videoId = "tt0133093:1:1", + fallbackImdbId = "tt0133093", + ), + ) + } +}