diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt index 61f4ba86..12e42ded 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt @@ -8,6 +8,7 @@ import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.mdblist.MdbListMetadataService import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.tmdb.TmdbMetadataService +import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.tmdb.TmdbSettingsRepository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -101,17 +102,22 @@ object MetaDetailsRepository { _uiState.value = MetaDetailsUiState(isLoading = true) scope.launch { - val manifests = AddonRepository.uiState.value.addons - .mapNotNull { it.manifest } - .filter { manifest -> - manifest.resources.any { resource -> - resource.name == "meta" && - resource.types.contains(type) && - (resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) }) - } - } + val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type) + val manifests = findMetaManifests(type = type, id = metaLookupId) if (manifests.isEmpty()) { + val tmdbMeta = tryFetchTmdbFallbackMeta(type = type, id = id) + if (tmdbMeta != null) { + publishLoadedMeta( + requestKey = requestKey, + meta = tmdbMeta, + fallbackItemId = id, + mdbListSettings = mdbListSettings, + metaScreenSettingsFingerprint = metaScreenSettingsFingerprint, + ) + return@launch + } + log.w { "No addon provides meta for type=$type id=$id" } _uiState.value = MetaDetailsUiState( errorMessage = getString(Res.string.details_no_addon_meta), @@ -122,42 +128,32 @@ object MetaDetailsRepository { for (manifest in manifests) { val result = withContext(Dispatchers.Default) { - tryFetchMeta(manifest, type, id, includeMdbList = false) + tryFetchMeta(manifest, type, metaLookupId, includeMdbList = false) } if (result != null) { - var cachedEntry = CachedMetaEntry(baseMeta = result) - cachedMetaByRequestKey[requestKey] = cachedEntry - - if (!shouldFetchMdbListOnMetaScreen(result, id, mdbListSettings)) { - _uiState.value = MetaDetailsUiState(meta = result) - activeRequestKey = requestKey - return@launch - } - - _uiState.value = MetaDetailsUiState( - isLoading = true, + publishLoadedMeta( + requestKey = requestKey, meta = result, - ) - val enrichedMeta = withContext(Dispatchers.Default) { - enrichForMetaScreen( - requestKey = requestKey, - meta = result, - fallbackItemId = id, - settings = mdbListSettings, - settingsFingerprint = metaScreenSettingsFingerprint, - ) - } - cachedEntry = cachedEntry.copy( - metaScreenMeta = enrichedMeta, + fallbackItemId = metaLookupId, + mdbListSettings = mdbListSettings, metaScreenSettingsFingerprint = metaScreenSettingsFingerprint, ) - cachedMetaByRequestKey[requestKey] = cachedEntry - _uiState.value = MetaDetailsUiState(meta = enrichedMeta) - activeRequestKey = requestKey return@launch } } + val tmdbMeta = tryFetchTmdbFallbackMeta(type = type, id = id) + if (tmdbMeta != null) { + publishLoadedMeta( + requestKey = requestKey, + meta = tmdbMeta, + fallbackItemId = id, + mdbListSettings = mdbListSettings, + metaScreenSettingsFingerprint = metaScreenSettingsFingerprint, + ) + return@launch + } + _uiState.value = MetaDetailsUiState( errorMessage = getString(Res.string.details_load_failed_all_addons), ) @@ -187,19 +183,12 @@ object MetaDetailsRepository { val requestKey = "$type:$id" cachedMetaByRequestKey[requestKey]?.let { return it.baseMeta } - val manifests = AddonRepository.uiState.value.addons - .mapNotNull { it.manifest } - .filter { manifest -> - manifest.resources.any { resource -> - resource.name == "meta" && - resource.types.contains(type) && - (resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) }) - } - } + val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type) + val manifests = findMetaManifests(type = type, id = metaLookupId) for (manifest in manifests) { val result = withTimeoutOrNull(FETCH_TIMEOUT_MS) { - tryFetchMeta(manifest, type, id, includeMdbList = false) + tryFetchMeta(manifest, type, metaLookupId, includeMdbList = false) } if (result != null) { cachedMetaByRequestKey[requestKey] = CachedMetaEntry(baseMeta = result) @@ -207,7 +196,9 @@ object MetaDetailsRepository { } } - return null + return tryFetchTmdbFallbackMeta(type = type, id = id)?.also { result -> + cachedMetaByRequestKey[requestKey] = CachedMetaEntry(baseMeta = result) + } } private const val FETCH_TIMEOUT_MS = 5_000L @@ -265,6 +256,78 @@ object MetaDetailsRepository { } } + private fun findMetaManifests(type: String, id: String): List = + AddonRepository.uiState.value.addons + .mapNotNull { it.manifest } + .filter { manifest -> + manifest.resources.any { resource -> + resource.name == "meta" && + resource.types.contains(type) && + (resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) }) + } + } + + private suspend fun resolveMetaLookupId(itemId: String, itemType: String): String { + val tmdbId = itemId + .takeIf { it.startsWith("tmdb:", ignoreCase = true) } + ?.substringAfter(':') + ?.substringBefore(':') + ?.toIntOrNull() + ?: return itemId + + return withTimeoutOrNull(FETCH_TIMEOUT_MS) { + TmdbService.tmdbToImdb(tmdbId = tmdbId, mediaType = itemType) + } + ?.takeIf { it.isNotBlank() } + ?: itemId + } + + private suspend fun tryFetchTmdbFallbackMeta(type: String, id: String): MetaDetails? = + withTimeoutOrNull(TMDB_ENRICH_TIMEOUT_MS) { + TmdbMetadataService.fetchStandaloneMeta( + type = type, + id = id, + settings = TmdbSettingsRepository.snapshot(), + ) + } + + private suspend fun publishLoadedMeta( + requestKey: String, + meta: MetaDetails, + fallbackItemId: String, + mdbListSettings: com.nuvio.app.features.mdblist.MdbListSettings, + metaScreenSettingsFingerprint: String, + ) { + val cachedEntry = CachedMetaEntry(baseMeta = meta) + cachedMetaByRequestKey[requestKey] = cachedEntry + + if (!shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, mdbListSettings)) { + _uiState.value = MetaDetailsUiState(meta = meta) + activeRequestKey = requestKey + return + } + + _uiState.value = MetaDetailsUiState( + isLoading = true, + meta = meta, + ) + val enrichedMeta = withContext(Dispatchers.Default) { + enrichForMetaScreen( + requestKey = requestKey, + meta = meta, + fallbackItemId = fallbackItemId, + settings = mdbListSettings, + settingsFingerprint = metaScreenSettingsFingerprint, + ) + } + cachedMetaByRequestKey[requestKey] = cachedEntry.copy( + metaScreenMeta = enrichedMeta, + metaScreenSettingsFingerprint = metaScreenSettingsFingerprint, + ) + _uiState.value = MetaDetailsUiState(meta = enrichedMeta) + activeRequestKey = requestKey + } + private suspend fun enrichForMetaScreen( requestKey: String, meta: MetaDetails, 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 f398257f..823125a6 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 @@ -638,6 +638,69 @@ object TmdbMetadataService { ) } + suspend fun fetchStandaloneMeta( + type: String, + id: String, + settings: TmdbSettings, + ): MetaDetails? { + if (!settings.hasApiKey) return null + + val tmdbId = id + .takeIf { it.startsWith("tmdb:", ignoreCase = true) } + ?.substringAfter(':') + ?.substringBefore(':') + ?.toIntOrNull() + ?: return null + val tmdbType = normalizeMetaType(type) + val enrichment = fetchEnrichment( + tmdbId = tmdbId.toString(), + mediaType = tmdbType, + language = settings.language, + settings = settings, + ) ?: return null + + return buildStandaloneMeta( + type = type, + id = id, + tmdbId = tmdbId, + enrichment = enrichment, + ) + } + + internal fun buildStandaloneMeta( + type: String, + id: String, + tmdbId: Int, + enrichment: TmdbEnrichment, + ): MetaDetails = + MetaDetails( + id = id, + type = type, + name = enrichment.localizedTitle ?: "TMDB $tmdbId", + poster = enrichment.poster, + background = enrichment.backdrop, + logo = enrichment.logo, + description = enrichment.description, + releaseInfo = enrichment.releaseInfo, + lastAirDate = enrichment.lastAirDate, + status = enrichment.status, + imdbRating = enrichment.rating?.formatRating(), + ageRating = enrichment.ageRating, + runtime = enrichment.runtimeMinutes?.formatRuntime(), + genres = enrichment.genres, + director = enrichment.director, + writer = enrichment.writer, + cast = enrichment.people, + productionCompanies = enrichment.productionCompanies, + networks = enrichment.networks, + country = enrichment.countries.takeIf { it.isNotEmpty() }?.joinToString(", "), + language = enrichment.language, + moreLikeThis = enrichment.moreLikeThis, + collectionName = enrichment.collectionName, + collectionItems = enrichment.collectionItems, + trailers = enrichment.trailers, + ) + internal fun applyEnrichment( meta: MetaDetails, enrichment: TmdbEnrichment?, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt index d4145c30..22dd5a59 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt @@ -8,6 +8,47 @@ import kotlin.test.Test import kotlin.test.assertEquals class TmdbMetadataServiceTest { + @Test + fun `buildStandaloneMeta maps tmdb enrichment without addon meta`() { + val enrichment = TmdbEnrichment( + localizedTitle = "TMDB Movie", + description = "TMDB description", + genres = listOf("Adventure"), + backdrop = "backdrop", + logo = "logo", + poster = "poster", + people = listOf(MetaPerson(name = "Cast Member", role = "Hero")), + director = listOf("Director"), + writer = listOf("Writer"), + releaseInfo = "2026-01-01", + rating = 8.4, + runtimeMinutes = 105, + ageRating = "PG-13", + status = "Released", + countries = listOf("US", "GB"), + language = "en", + productionCompanies = listOf(MetaCompany(name = "Studio")), + networks = emptyList(), + ) + + val result = TmdbMetadataService.buildStandaloneMeta( + type = "movie", + id = "tmdb:123", + tmdbId = 123, + enrichment = enrichment, + ) + + assertEquals("tmdb:123", result.id) + assertEquals("movie", result.type) + assertEquals("TMDB Movie", result.name) + assertEquals("TMDB description", result.description) + assertEquals("8.4", result.imdbRating) + assertEquals("105m", result.runtime) + assertEquals("US, GB", result.country) + assertEquals(listOf("Cast Member"), result.cast.map { it.name }) + assertEquals(listOf("Studio"), result.productionCompanies.map { it.name }) + } + @Test fun `applyEnrichment replaces enabled metadata groups`() { val base = MetaDetails( diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme new file mode 100644 index 00000000..e171e6d7 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +