This commit is contained in:
foXaCe 2026-05-16 01:42:00 -05:00 committed by GitHub
commit 514b96bc02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 117 additions and 22 deletions

View file

@ -5,10 +5,15 @@ import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.catalog.CatalogPage import com.nuvio.app.features.catalog.CatalogPage
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape 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.TmdbSettingsRepository
import com.nuvio.app.features.tmdb.buildTmdbUrl import com.nuvio.app.features.tmdb.buildTmdbUrl
import com.nuvio.app.features.tmdb.normalizeTmdbLanguage import com.nuvio.app.features.tmdb.normalizeTmdbLanguage
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -228,8 +233,19 @@ object TmdbCollectionSourceResolver {
apiKey = apiKey, apiKey = apiKey,
query = mapOf("language" to language), query = mapOf("language" to language),
) ?: error("TMDB collection not found") ) ?: error("TMDB collection not found")
val items = body.parts.orEmpty() val parts = body.parts.orEmpty()
.mapNotNull { it.toPreview(TmdbCollectionMediaType.MOVIE) } 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) .sortedFor(source.sortBy)
.distinctBy { it.id } .distinctBy { it.id }
return CatalogPage(items = items, rawItemCount = items.size, nextSkip = null) 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 title = title?.takeIf { it.isNotBlank() } ?: return null
val localizedPoster = TmdbMetadataService.imageUrl(localizedArtwork.poster, "w500")
val localizedBackdrop = TmdbMetadataService.imageUrl(localizedArtwork.backdrop, "w1280")
return MetaPreview( return MetaPreview(
id = "tmdb:$id", id = "tmdb:$id",
type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie", type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie",
name = title, name = title,
poster = imageUrl(posterPath, "w500") ?: imageUrl(backdropPath, "w780"), poster = localizedPoster
banner = imageUrl(backdropPath, "w1280"), ?: imageUrl(posterPath, "w500")
?: imageUrl(backdropPath, "w780"),
banner = localizedBackdrop ?: imageUrl(backdropPath, "w1280"),
posterShape = PosterShape.Poster, posterShape = PosterShape.Poster,
description = overview?.takeIf { it.isNotBlank() }, description = overview?.takeIf { it.isNotBlank() },
releaseInfo = releaseDate?.take(4), releaseInfo = releaseDate?.take(4),

View file

@ -36,6 +36,7 @@ object TmdbMetadataService {
private val entityHeaderCache = mutableMapOf<String, TmdbEntityHeader>() private val entityHeaderCache = mutableMapOf<String, TmdbEntityHeader>()
private val entityRailCache = mutableMapOf<String, List<MetaPreview>>() private val entityRailCache = mutableMapOf<String, List<MetaPreview>>()
private val previewArtworkCache = mutableMapOf<String, TmdbPreviewArtwork>() private val previewArtworkCache = mutableMapOf<String, TmdbPreviewArtwork>()
private val localizedArtworkCache = mutableMapOf<String, TmdbLocalizedArtwork>()
suspend fun fetchPersonDetail( suspend fun fetchPersonDetail(
personId: Int, personId: Int,
@ -530,6 +531,56 @@ object TmdbMetadataService {
val logo: String?, 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( private suspend fun fetchPreviewArtwork(
tmdbId: Int, tmdbId: Int,
mediaType: String, mediaType: String,
@ -1074,17 +1125,29 @@ object TmdbMetadataService {
query = mapOf("language" to language), query = mapOf("language" to language),
) ?: return null to emptyList() ) ?: return null to emptyList()
val items = response.parts val sortedParts = response.parts
.sortedBy { it.releaseDate ?: "9999" } .sortedBy { it.releaseDate ?: "9999" }
.mapNotNull { part -> val items = coroutineScope {
val title = part.title?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null 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( MetaPreview(
id = "tmdb:${part.id}", id = "tmdb:${part.id}",
type = "movie", type = "movie",
name = title, name = title,
poster = buildImageUrl(part.backdropPath, "w780") poster = poster,
?: buildImageUrl(part.posterPath, "w500"), banner = banner,
banner = buildImageUrl(part.backdropPath, "w1280"),
posterShape = PosterShape.Landscape, posterShape = PosterShape.Landscape,
description = part.overview?.trim()?.takeIf(String::isNotBlank), description = part.overview?.trim()?.takeIf(String::isNotBlank),
releaseInfo = part.releaseDate?.take(4), releaseInfo = part.releaseDate?.take(4),
@ -1092,6 +1155,8 @@ object TmdbMetadataService {
imdbRating = part.voteAverage?.formatRating(), imdbRating = part.voteAverage?.formatRating(),
) )
} }
}.awaitAll().filterNotNull()
}
val result = response.name?.trim()?.takeIf(String::isNotBlank) to items val result = response.name?.trim()?.takeIf(String::isNotBlank) to items
collectionCache[cacheKey] = result collectionCache[cacheKey] = result
@ -1606,6 +1671,13 @@ private data class TmdbCrewMember(
@Serializable @Serializable
private data class TmdbImagesResponse( private data class TmdbImagesResponse(
val logos: List<TmdbImage> = emptyList(), 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 @Serializable