diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt index 8f683a73..96c513e7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt @@ -5,10 +5,15 @@ import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.catalog.CatalogPage import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.tmdb.TmdbLocalizedArtwork +import com.nuvio.app.features.tmdb.TmdbMetadataService import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.tmdb.buildTmdbUrl import com.nuvio.app.features.tmdb.normalizeTmdbLanguage import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -228,8 +233,19 @@ object TmdbCollectionSourceResolver { apiKey = apiKey, query = mapOf("language" to language), ) ?: error("TMDB collection not found") - val items = body.parts.orEmpty() - .mapNotNull { it.toPreview(TmdbCollectionMediaType.MOVIE) } + val parts = body.parts.orEmpty() + val items = coroutineScope { + parts.map { part -> + async { + val artwork = TmdbMetadataService.fetchLocalizedArtwork( + tmdbId = part.id, + mediaType = "movie", + language = language, + ) + part.toPreview(TmdbCollectionMediaType.MOVIE, artwork) + } + }.awaitAll().filterNotNull() + } .sortedFor(source.sortBy) .distinctBy { it.id } return CatalogPage(items = items, rawItemCount = items.size, nextSkip = null) @@ -406,14 +422,21 @@ object TmdbCollectionSourceResolver { ) } - private fun TmdbCollectionPart.toPreview(mediaType: TmdbCollectionMediaType): MetaPreview? { + private fun TmdbCollectionPart.toPreview( + mediaType: TmdbCollectionMediaType, + localizedArtwork: TmdbLocalizedArtwork = TmdbLocalizedArtwork(null, null), + ): MetaPreview? { val title = title?.takeIf { it.isNotBlank() } ?: return null + val localizedPoster = TmdbMetadataService.imageUrl(localizedArtwork.poster, "w500") + val localizedBackdrop = TmdbMetadataService.imageUrl(localizedArtwork.backdrop, "w1280") return MetaPreview( id = "tmdb:$id", type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie", name = title, - poster = imageUrl(posterPath, "w500") ?: imageUrl(backdropPath, "w780"), - banner = imageUrl(backdropPath, "w1280"), + poster = localizedPoster + ?: imageUrl(posterPath, "w500") + ?: imageUrl(backdropPath, "w780"), + banner = localizedBackdrop ?: imageUrl(backdropPath, "w1280"), posterShape = PosterShape.Poster, description = overview?.takeIf { it.isNotBlank() }, releaseInfo = releaseDate?.take(4), 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 cc87a1e5..ecc0019a 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 @@ -36,6 +36,7 @@ object TmdbMetadataService { private val entityHeaderCache = mutableMapOf() private val entityRailCache = mutableMapOf>() private val previewArtworkCache = mutableMapOf() + private val localizedArtworkCache = mutableMapOf() suspend fun fetchPersonDetail( personId: Int, @@ -530,6 +531,56 @@ object TmdbMetadataService { val logo: String?, ) + /** + * Fetches a localized poster + backdrop for a movie or TV id from + * `/{mediaType}/{id}/images?include_image_language=,null,en`. + * + * TMDB's collection / list / credits endpoints only return one default + * poster path per part — usually the English-favored one regardless of the + * `language=fr` query param. Calling `/images` separately is the only way + * to get artwork actually localized in the user's language. Results cached + * per `(tmdbId, mediaType, language)` to avoid hammering TMDB. + * + * For English locales the call is skipped: the default poster is already + * the English-favored one. + */ + internal suspend fun fetchLocalizedArtwork( + tmdbId: Int, + mediaType: String, + language: String, + ): TmdbLocalizedArtwork = withContext(Dispatchers.Default) { + val normalizedLanguage = normalizeTmdbLanguage(language) + if (normalizedLanguage.startsWith("en", ignoreCase = true)) { + return@withContext TmdbLocalizedArtwork(poster = null, backdrop = null) + } + val cacheKey = "$tmdbId:$mediaType:$normalizedLanguage" + localizedArtworkCache[cacheKey]?.let { return@withContext it } + + val includeImageLanguage = buildString { + append(normalizedLanguage.substringBefore("-")) + append(",") + append(normalizedLanguage) + append(",en,null") + } + val images = fetch( + endpoint = "$mediaType/$tmdbId/images", + query = mapOf("include_image_language" to includeImageLanguage), + ) + val artwork = TmdbLocalizedArtwork( + poster = images?.posters.orEmpty().selectBestLocalizedImagePath(normalizedLanguage), + backdrop = images?.backdrops.orEmpty().selectBestLocalizedImagePath(normalizedLanguage), + ) + localizedArtworkCache[cacheKey] = artwork + artwork + } + + /** + * Builds an absolute TMDB image URL for the given relative `file_path`. + * Exposed for callers (e.g. collection resolvers) that need to construct + * URLs from paths returned by [fetchLocalizedArtwork]. + */ + internal fun imageUrl(filePath: String?, size: String): String? = buildImageUrl(filePath, size) + private suspend fun fetchPreviewArtwork( tmdbId: Int, mediaType: String, @@ -1074,24 +1125,38 @@ object TmdbMetadataService { query = mapOf("language" to language), ) ?: return null to emptyList() - val items = response.parts + val sortedParts = response.parts .sortedBy { it.releaseDate ?: "9999" } - .mapNotNull { part -> - val title = part.title?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null - MetaPreview( - id = "tmdb:${part.id}", - type = "movie", - name = title, - poster = buildImageUrl(part.backdropPath, "w780") - ?: buildImageUrl(part.posterPath, "w500"), - banner = buildImageUrl(part.backdropPath, "w1280"), - posterShape = PosterShape.Landscape, - description = part.overview?.trim()?.takeIf(String::isNotBlank), - releaseInfo = part.releaseDate?.take(4), - rawReleaseDate = part.releaseDate, - imdbRating = part.voteAverage?.formatRating(), - ) - } + val items = coroutineScope { + sortedParts.map { part -> + async { + val title = part.title?.trim()?.takeIf(String::isNotBlank) ?: return@async null + val localized = fetchLocalizedArtwork( + tmdbId = part.id, + mediaType = "movie", + language = language, + ) + val poster = buildImageUrl(localized.backdrop, "w780") + ?: buildImageUrl(part.backdropPath, "w780") + ?: buildImageUrl(localized.poster, "w500") + ?: buildImageUrl(part.posterPath, "w500") + val banner = buildImageUrl(localized.backdrop, "w1280") + ?: buildImageUrl(part.backdropPath, "w1280") + MetaPreview( + id = "tmdb:${part.id}", + type = "movie", + name = title, + poster = poster, + banner = banner, + posterShape = PosterShape.Landscape, + description = part.overview?.trim()?.takeIf(String::isNotBlank), + releaseInfo = part.releaseDate?.take(4), + rawReleaseDate = part.releaseDate, + imdbRating = part.voteAverage?.formatRating(), + ) + } + }.awaitAll().filterNotNull() + } val result = response.name?.trim()?.takeIf(String::isNotBlank) to items collectionCache[cacheKey] = result @@ -1606,6 +1671,13 @@ private data class TmdbCrewMember( @Serializable private data class TmdbImagesResponse( val logos: List = emptyList(), + val posters: List = emptyList(), + val backdrops: List = emptyList(), +) + +internal data class TmdbLocalizedArtwork( + val poster: String?, + val backdrop: String?, ) @Serializable