mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 07:21:58 +00:00
fix(tmdb): localize collection part artwork via /images endpoint
TMDB's `collection/{id}?language=fr` returns one default `poster_path` /
`backdrop_path` per part — almost always the English-favored asset
regardless of the language query param. The only way to actually get
artwork in the user's language is to call
`/movie/{id}/images?include_image_language=fr,null,en` separately and
pick the best localized poster/backdrop.
Two consumers were affected:
- `TmdbCollectionSourceResolver.resolveCollection` — Collection-folder
rows on home (user-defined "Star Wars Saga", "Marvel", etc.) showed
English posters even when the app's TMDB language is `fr`.
- `TmdbMetadataService.fetchCollection` — the "Saga" / "Belongs to
collection" row on movie detail pages had the same problem.
Both now fan out one parallel `/images` call per part, ranked through the
existing `selectBestLocalizedImagePath` helper (already used for logos),
and fall back to `part.posterPath` / `part.backdropPath` only when no
localized image is available.
The new helper `TmdbMetadataService.fetchLocalizedArtwork()` is shared
and cached per `(tmdbId, mediaType, language)`. For English locales the
extra call is skipped — the default poster path is already English-favored.
Mirrors the equivalent fix in NuvioTV (the `fetchMovieCollection` path
already does this; `resolveCollection` did not).
This commit is contained in:
parent
37203d1fc1
commit
6e84b3ac36
2 changed files with 117 additions and 22 deletions
|
|
@ -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)
|
||||
|
|
@ -399,14 +415,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),
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ object TmdbMetadataService {
|
|||
private val entityHeaderCache = mutableMapOf<String, TmdbEntityHeader>()
|
||||
private val entityRailCache = mutableMapOf<String, List<MetaPreview>>()
|
||||
private val previewArtworkCache = mutableMapOf<String, TmdbPreviewArtwork>()
|
||||
private val localizedArtworkCache = mutableMapOf<String, TmdbLocalizedArtwork>()
|
||||
|
||||
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=<lang>,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<TmdbImagesResponse>(
|
||||
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<TmdbImage> = emptyList(),
|
||||
val posters: List<TmdbImage> = emptyList(),
|
||||
val backdrops: List<TmdbImage> = emptyList(),
|
||||
)
|
||||
|
||||
internal data class TmdbLocalizedArtwork(
|
||||
val poster: String?,
|
||||
val backdrop: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
|
|
|||
Loading…
Reference in a new issue