feat: Introduce TMDB entity browsing screens

This commit is contained in:
tapframe 2026-03-21 10:47:50 +05:30
parent acb5eaceb8
commit 2ed5dcafe4
12 changed files with 1262 additions and 15 deletions

View file

@ -3,6 +3,7 @@ package com.nuvio.tv.core.tmdb
import android.util.Log
import com.nuvio.tv.BuildConfig
import com.nuvio.tv.data.remote.api.TmdbApi
import com.nuvio.tv.data.remote.api.TmdbDiscoverResult
import com.nuvio.tv.data.remote.api.TmdbEpisode
import com.nuvio.tv.data.remote.api.TmdbImage
import com.nuvio.tv.data.remote.api.TmdbPersonCreditCast
@ -19,6 +20,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import java.time.LocalDate
import java.util.concurrent.ConcurrentHashMap
import java.util.Locale
import javax.inject.Inject
@ -36,6 +38,9 @@ class TmdbMetadataService @Inject constructor(
private val episodeCache = ConcurrentHashMap<String, Map<Pair<Int, Int>, TmdbEpisodeEnrichment>>()
private val personCache = ConcurrentHashMap<String, PersonDetail>()
private val moreLikeThisCache = ConcurrentHashMap<String, List<MetaPreview>>()
private val entityHeaderCache = ConcurrentHashMap<String, TmdbEntityHeader>()
private val entityRailCache = ConcurrentHashMap<String, List<MetaPreview>>()
private val entityBrowseCache = ConcurrentHashMap<String, TmdbEntityBrowseData>()
suspend fun fetchEnrichment(
tmdbId: String,
@ -122,7 +127,8 @@ class TmdbMetadataService @Inject constructor(
val name = company.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
MetaCompany(
name = name,
logo = buildImageUrl(company.logoPath, size = "w300")
logo = buildImageUrl(company.logoPath, size = "w300"),
tmdbId = company.id
)
}
val networks = details?.networks
@ -131,7 +137,8 @@ class TmdbMetadataService @Inject constructor(
val name = network.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
MetaCompany(
name = name,
logo = buildImageUrl(network.logoPath, size = "w300")
logo = buildImageUrl(network.logoPath, size = "w300"),
tmdbId = network.id
)
}
val poster = buildImageUrl(details?.posterPath, size = "w500")
@ -513,6 +520,268 @@ class TmdbMetadataService @Inject constructor(
}
}
suspend fun fetchEntityBrowse(
entityKind: TmdbEntityKind,
entityId: Int,
sourceType: String,
fallbackName: String? = null,
language: String = "en"
): TmdbEntityBrowseData? = withContext(Dispatchers.IO) {
val normalizedLanguage = normalizeTmdbLanguage(language)
val normalizedSourceType = normalizeEntitySourceType(sourceType)
val cacheKey = "${entityKind.routeValue}:$entityId:$normalizedSourceType:$normalizedLanguage"
entityBrowseCache[cacheKey]?.let { return@withContext it }
val header = fetchEntityHeader(
entityKind = entityKind,
entityId = entityId,
fallbackName = fallbackName,
language = normalizedLanguage
)
val rails = buildEntityMediaOrder(entityKind, normalizedSourceType)
.flatMap { mediaType ->
TmdbEntityRailType.values().mapNotNull { railType ->
val items = fetchEntityRail(
entityKind = entityKind,
entityId = entityId,
mediaType = mediaType,
railType = railType,
language = normalizedLanguage
)
if (items.isEmpty()) {
null
} else {
TmdbEntityRail(
mediaType = mediaType,
railType = railType,
items = items
)
}
}
}
if (header == null && rails.isEmpty()) return@withContext null
val data = TmdbEntityBrowseData(
header = header ?: TmdbEntityHeader(
id = entityId,
kind = entityKind,
name = fallbackName?.takeIf { it.isNotBlank() } ?: "Unknown",
logo = null,
originCountry = null,
secondaryLabel = null,
description = null
),
rails = rails
)
entityBrowseCache[cacheKey] = data
data
}
private suspend fun fetchEntityHeader(
entityKind: TmdbEntityKind,
entityId: Int,
fallbackName: String?,
language: String
): TmdbEntityHeader? {
val cacheKey = "${entityKind.routeValue}:$entityId:$language:header"
entityHeaderCache[cacheKey]?.let { return it }
val header = try {
when (entityKind) {
TmdbEntityKind.COMPANY -> {
val body = tmdbApi.getCompanyDetails(entityId, TMDB_API_KEY).body()
if (body == null) {
null
} else {
TmdbEntityHeader(
id = body.id,
kind = entityKind,
name = body.name?.takeIf { it.isNotBlank() }
?: fallbackName?.takeIf { it.isNotBlank() }
?: "Unknown",
logo = buildImageUrl(body.logoPath, size = "w500"),
originCountry = body.originCountry?.takeIf { it.isNotBlank() },
secondaryLabel = body.headquarters?.takeIf { it.isNotBlank() },
description = body.description?.takeIf { it.isNotBlank() }
)
}
}
TmdbEntityKind.NETWORK -> {
val body = tmdbApi.getNetworkDetails(entityId, TMDB_API_KEY).body()
if (body == null) {
null
} else {
TmdbEntityHeader(
id = body.id,
kind = entityKind,
name = body.name?.takeIf { it.isNotBlank() }
?: fallbackName?.takeIf { it.isNotBlank() }
?: "Unknown",
logo = buildImageUrl(body.logoPath, size = "w500"),
originCountry = body.originCountry?.takeIf { it.isNotBlank() },
secondaryLabel = body.headquarters?.takeIf { it.isNotBlank() },
description = null
)
}
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch ${entityKind.routeValue} header for $entityId: ${e.message}")
null
} ?: fallbackName?.takeIf { it.isNotBlank() }?.let {
TmdbEntityHeader(
id = entityId,
kind = entityKind,
name = it,
logo = null,
originCountry = null,
secondaryLabel = null,
description = null
)
}
if (header != null) {
entityHeaderCache[cacheKey] = header
}
return header
}
private suspend fun fetchEntityRail(
entityKind: TmdbEntityKind,
entityId: Int,
mediaType: TmdbEntityMediaType,
railType: TmdbEntityRailType,
language: String
): List<MetaPreview> {
if (entityKind == TmdbEntityKind.NETWORK && mediaType == TmdbEntityMediaType.MOVIE) {
return emptyList()
}
val cacheKey = "${entityKind.routeValue}:$entityId:${mediaType.value}:${railType.value}:$language"
entityRailCache[cacheKey]?.let { return it }
val today = LocalDate.now().toString()
val voteCountFloor = if (railType == TmdbEntityRailType.TOP_RATED) TOP_RATED_VOTE_COUNT_FLOOR else null
val result = try {
val results = when (mediaType) {
TmdbEntityMediaType.MOVIE -> {
tmdbApi.discoverMovies(
apiKey = TMDB_API_KEY,
language = language,
sortBy = movieSortBy(railType),
withCompanies = entityId.toString(),
releaseDateLte = if (railType == TmdbEntityRailType.RECENT) today else null,
voteCountGte = voteCountFloor
).body()?.results.orEmpty()
}
TmdbEntityMediaType.TV -> {
tmdbApi.discoverTv(
apiKey = TMDB_API_KEY,
language = language,
sortBy = tvSortBy(railType),
withCompanies = if (entityKind == TmdbEntityKind.COMPANY) entityId.toString() else null,
withNetworks = if (entityKind == TmdbEntityKind.NETWORK) entityId.toString() else null,
firstAirDateLte = if (railType == TmdbEntityRailType.RECENT) today else null,
voteCountGte = voteCountFloor
).body()?.results.orEmpty()
}
}
results
.filter { it.id > 0 }
.mapNotNull { discoverItem ->
mapEntityDiscoverResult(
result = discoverItem,
mediaType = mediaType
)
}
.take(ENTITY_RAIL_MAX_ITEMS)
} catch (e: Exception) {
Log.w(
TAG,
"Failed to fetch ${entityKind.routeValue} rail ${railType.value}/${mediaType.value} for $entityId: ${e.message}"
)
emptyList()
}
if (result.isNotEmpty()) {
entityRailCache[cacheKey] = result
}
return result
}
private fun mapEntityDiscoverResult(
result: TmdbDiscoverResult,
mediaType: TmdbEntityMediaType
): MetaPreview? {
val title = result.title?.takeIf { it.isNotBlank() }
?: result.name?.takeIf { it.isNotBlank() }
?: result.originalTitle?.takeIf { it.isNotBlank() }
?: result.originalName?.takeIf { it.isNotBlank() }
?: return null
val poster = buildImageUrl(result.posterPath, size = "w500")
?: buildImageUrl(result.backdropPath, size = "w780")
?: return null
val background = buildImageUrl(result.backdropPath, size = "w1280")
val releaseInfo = when (mediaType) {
TmdbEntityMediaType.MOVIE -> result.releaseDate?.take(4)
TmdbEntityMediaType.TV -> result.firstAirDate?.take(4)
}
return MetaPreview(
id = "tmdb:${result.id}",
type = if (mediaType == TmdbEntityMediaType.TV) ContentType.SERIES else ContentType.MOVIE,
name = title,
poster = poster,
posterShape = PosterShape.POSTER,
background = background,
logo = null,
description = result.overview?.takeIf { it.isNotBlank() },
releaseInfo = releaseInfo,
imdbRating = result.voteAverage?.toFloat(),
genres = emptyList()
)
}
internal fun buildEntityMediaOrder(
entityKind: TmdbEntityKind,
sourceType: String
): List<TmdbEntityMediaType> {
if (entityKind == TmdbEntityKind.NETWORK) {
return listOf(TmdbEntityMediaType.TV)
}
return when (normalizeEntitySourceType(sourceType)) {
"movie" -> listOf(TmdbEntityMediaType.MOVIE, TmdbEntityMediaType.TV)
else -> listOf(TmdbEntityMediaType.TV, TmdbEntityMediaType.MOVIE)
}
}
private fun normalizeEntitySourceType(sourceType: String): String {
return when (sourceType.trim().lowercase(Locale.US)) {
"movie" -> "movie"
"tv", "series", "show" -> "tv"
else -> "tv"
}
}
private fun movieSortBy(railType: TmdbEntityRailType): String = when (railType) {
TmdbEntityRailType.POPULAR -> "popularity.desc"
TmdbEntityRailType.TOP_RATED -> "vote_average.desc"
TmdbEntityRailType.RECENT -> "primary_release_date.desc"
}
private fun tvSortBy(railType: TmdbEntityRailType): String = when (railType) {
TmdbEntityRailType.POPULAR -> "popularity.desc"
TmdbEntityRailType.TOP_RATED -> "vote_average.desc"
TmdbEntityRailType.RECENT -> "first_air_date.desc"
}
private fun buildShowYearRange(startYear: String, endYear: String?, status: String?): String {
val isEnded = status != null && status != "Returning Series" && status != "In Production"
return when {
@ -570,6 +839,8 @@ class TmdbMetadataService @Inject constructor(
"pt" to "PT",
"es" to "ES"
)
private const val ENTITY_RAIL_MAX_ITEMS = 20
private const val TOP_RATED_VOTE_COUNT_FLOOR = 200
}
suspend fun fetchPersonDetail(
@ -834,6 +1105,50 @@ data class TmdbEpisodeEnrichment(
val runtimeMinutes: Int?
)
enum class TmdbEntityKind(val routeValue: String) {
COMPANY("company"),
NETWORK("network");
companion object {
fun fromRouteValue(value: String): TmdbEntityKind = when (value.trim().lowercase(Locale.US)) {
"network" -> NETWORK
else -> COMPANY
}
}
}
enum class TmdbEntityMediaType(val value: String) {
MOVIE("movie"),
TV("tv")
}
enum class TmdbEntityRailType(val value: String) {
POPULAR("popular"),
TOP_RATED("top_rated"),
RECENT("recent")
}
data class TmdbEntityHeader(
val id: Int,
val kind: TmdbEntityKind,
val name: String,
val logo: String?,
val originCountry: String?,
val secondaryLabel: String?,
val description: String?
)
data class TmdbEntityRail(
val mediaType: TmdbEntityMediaType,
val railType: TmdbEntityRailType,
val items: List<MetaPreview>
)
data class TmdbEntityBrowseData(
val header: TmdbEntityHeader,
val rails: List<TmdbEntityRail>
)
private fun TmdbEpisode.toEnrichment(): TmdbEpisodeEnrichment {
val title = name?.takeIf { it.isNotBlank() }
val overview = overview?.takeIf { it.isNotBlank() }

View file

@ -140,6 +140,41 @@ interface TmdbApi {
@Query("api_key") apiKey: String,
@Query("language") language: String? = null
): Response<TmdbPersonCreditsResponse>
@GET("company/{company_id}")
suspend fun getCompanyDetails(
@Path("company_id") companyId: Int,
@Query("api_key") apiKey: String
): Response<TmdbCompanyDetailsResponse>
@GET("network/{network_id}")
suspend fun getNetworkDetails(
@Path("network_id") networkId: Int,
@Query("api_key") apiKey: String
): Response<TmdbNetworkDetailsResponse>
@GET("discover/movie")
suspend fun discoverMovies(
@Query("api_key") apiKey: String,
@Query("language") language: String? = null,
@Query("page") page: Int = 1,
@Query("sort_by") sortBy: String? = null,
@Query("with_companies") withCompanies: String? = null,
@Query("release_date.lte") releaseDateLte: String? = null,
@Query("vote_count.gte") voteCountGte: Int? = null
): Response<TmdbDiscoverResponse>
@GET("discover/tv")
suspend fun discoverTv(
@Query("api_key") apiKey: String,
@Query("language") language: String? = null,
@Query("page") page: Int = 1,
@Query("sort_by") sortBy: String? = null,
@Query("with_companies") withCompanies: String? = null,
@Query("with_networks") withNetworks: String? = null,
@Query("first_air_date.lte") firstAirDateLte: String? = null,
@Query("vote_count.gte") voteCountGte: Int? = null
): Response<TmdbDiscoverResponse>
}
@JsonClass(generateAdapter = true)
@ -225,12 +260,14 @@ data class TmdbGenre(
@JsonClass(generateAdapter = true)
data class TmdbCompany(
@Json(name = "id") val id: Int? = null,
@Json(name = "name") val name: String? = null,
@Json(name = "logo_path") val logoPath: String? = null
)
@JsonClass(generateAdapter = true)
data class TmdbNetwork(
@Json(name = "id") val id: Int? = null,
@Json(name = "name") val name: String? = null,
@Json(name = "logo_path") val logoPath: String? = null
)
@ -325,6 +362,31 @@ data class TmdbRecommendationsResponse(
@Json(name = "results") val results: List<TmdbRecommendationResult>? = null
)
@JsonClass(generateAdapter = true)
data class TmdbDiscoverResponse(
@Json(name = "page") val page: Int? = null,
@Json(name = "results") val results: List<TmdbDiscoverResult>? = null,
@Json(name = "total_pages") val totalPages: Int? = null,
@Json(name = "total_results") val totalResults: Int? = null
)
@JsonClass(generateAdapter = true)
data class TmdbDiscoverResult(
@Json(name = "id") val id: Int,
@Json(name = "title") val title: String? = null,
@Json(name = "name") val name: String? = null,
@Json(name = "original_title") val originalTitle: String? = null,
@Json(name = "original_name") val originalName: String? = null,
@Json(name = "poster_path") val posterPath: String? = null,
@Json(name = "backdrop_path") val backdropPath: String? = null,
@Json(name = "overview") val overview: String? = null,
@Json(name = "release_date") val releaseDate: String? = null,
@Json(name = "first_air_date") val firstAirDate: String? = null,
@Json(name = "vote_average") val voteAverage: Double? = null,
@Json(name = "vote_count") val voteCount: Int? = null,
@Json(name = "popularity") val popularity: Double? = null
)
@JsonClass(generateAdapter = true)
data class TmdbRecommendationResult(
@Json(name = "id") val id: Int,
@ -426,3 +488,23 @@ data class TmdbCollectionSummary(
@Json(name = "backdrop_path") val backdropPath: String? = null
)
@JsonClass(generateAdapter = true)
data class TmdbCompanyDetailsResponse(
@Json(name = "id") val id: Int,
@Json(name = "name") val name: String? = null,
@Json(name = "description") val description: String? = null,
@Json(name = "headquarters") val headquarters: String? = null,
@Json(name = "homepage") val homepage: String? = null,
@Json(name = "logo_path") val logoPath: String? = null,
@Json(name = "origin_country") val originCountry: String? = null
)
@JsonClass(generateAdapter = true)
data class TmdbNetworkDetailsResponse(
@Json(name = "id") val id: Int,
@Json(name = "name") val name: String? = null,
@Json(name = "headquarters") val headquarters: String? = null,
@Json(name = "homepage") val homepage: String? = null,
@Json(name = "logo_path") val logoPath: String? = null,
@Json(name = "origin_country") val originCountry: String? = null
)

View file

@ -64,7 +64,8 @@ data class MetaCastMember(
@Immutable
data class MetaCompany(
val name: String,
val logo: String? = null
val logo: String? = null,
val tmdbId: Int? = null
)
@Immutable

View file

@ -40,6 +40,7 @@ import com.nuvio.tv.ui.screens.account.AuthQrSignInScreen
import com.nuvio.tv.ui.screens.cast.CastDetailScreen
import com.nuvio.tv.ui.screens.profile.ProfileSelectionMode
import com.nuvio.tv.ui.screens.profile.ProfileSelectionScreen
import com.nuvio.tv.ui.screens.tmdb.TmdbEntityBrowseScreen
@Composable
fun NuvioNavHost(
@ -246,6 +247,16 @@ fun NuvioNavHost(
onNavigateToCastDetail = { personId, personName, preferCrew ->
navController.navigate(Screen.CastDetail.createRoute(personId, personName, preferCrew))
},
onNavigateToTmdbEntityBrowse = { entityKind, entityId, entityName, sourceType ->
navController.navigate(
Screen.TmdbEntityBrowse.createRoute(
entityKind = entityKind,
entityId = entityId,
entityName = entityName,
sourceType = sourceType
)
)
},
onNavigateToDetail = { itemId, itemType, addonBaseUrl ->
navController.navigate(Screen.Detail.createRoute(itemId, itemType, addonBaseUrl))
},
@ -979,5 +990,25 @@ fun NuvioNavHost(
}
)
}
composable(
route = Screen.TmdbEntityBrowse.route,
arguments = listOf(
navArgument("entityKind") { type = NavType.StringType },
navArgument("entityId") { type = NavType.IntType },
navArgument("entityName") { type = NavType.StringType },
navArgument("sourceType") {
type = NavType.StringType
defaultValue = "tv"
}
)
) {
TmdbEntityBrowseScreen(
onBackPress = { navController.popBackStack() },
onNavigateToDetail = { itemId, itemType, addonBaseUrl ->
navController.navigate(Screen.Detail.createRoute(itemId, itemType, addonBaseUrl))
}
)
}
}
}

View file

@ -160,4 +160,20 @@ sealed class Screen(val route: String) {
return "cast_detail/$personId/${encode(personName)}?preferCrew=$preferCrew"
}
}
data object TmdbEntityBrowse : Screen(
"tmdb_entity_browse/{entityKind}/{entityId}/{entityName}?sourceType={sourceType}"
) {
private fun encode(value: String): String =
URLEncoder.encode(value, "UTF-8").replace("+", "%20")
fun createRoute(
entityKind: String,
entityId: Int,
entityName: String,
sourceType: String
): String {
return "tmdb_entity_browse/${encode(entityKind)}/$entityId/${encode(entityName)}?sourceType=${encode(sourceType)}"
}
}
}

View file

@ -13,13 +13,17 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
@ -42,10 +46,28 @@ import com.nuvio.tv.ui.theme.NuvioTheme
@Composable
fun CompanyLogosSection(
title: String,
companies: List<MetaCompany>
companies: List<MetaCompany>,
onCompanyClick: (MetaCompany) -> Unit = {},
restoreCompanyId: Int? = null,
restoreFocusToken: Int = 0,
onRestoreFocusHandled: () -> Unit = {}
) {
if (companies.isEmpty()) return
val focusRequesters = remember(companies) {
companies
.mapNotNull { company -> company.tmdbId?.let { it to FocusRequester() } }
.toMap()
}
LaunchedEffect(restoreCompanyId, restoreFocusToken) {
if (restoreFocusToken <= 0 || restoreCompanyId == null) return@LaunchedEffect
val targetRequester = focusRequesters[restoreCompanyId] ?: return@LaunchedEffect
repeat(2) { withFrameNanos { } }
runCatching { targetRequester.requestFocus() }
onRestoreFocusHandled()
}
Column(
modifier = Modifier
.fillMaxWidth()
@ -69,14 +91,22 @@ fun CompanyLogosSection(
"$title-$index-${company.name}-${company.logo.orEmpty()}"
}
) { _, company ->
CompanyLogoCard(company = company)
CompanyLogoCard(
company = company,
focusRequester = focusRequesters[company.tmdbId],
onClick = { onCompanyClick(company) }
)
}
}
}
}
@Composable
private fun CompanyLogoCard(company: MetaCompany) {
private fun CompanyLogoCard(
company: MetaCompany,
focusRequester: FocusRequester? = null,
onClick: () -> Unit
) {
val context = LocalContext.current
val density = LocalDensity.current
val logoWidthPx = remember(density) { with(density) { 140.dp.roundToPx() } }
@ -93,10 +123,17 @@ private fun CompanyLogoCard(company: MetaCompany) {
var logoLoadFailed by remember(company.logo) { mutableStateOf(false) }
Card(
onClick = { },
onClick = {
if (company.tmdbId != null) {
onClick()
}
},
modifier = Modifier
.width(140.dp)
.height(56.dp),
.height(56.dp)
.then(
if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier
),
colors = CardDefaults.colors(
containerColor = Color.White,
focusedContainerColor = Color.White

View file

@ -111,7 +111,8 @@ private enum class RestoreTarget {
EPISODE,
CAST_MEMBER,
MORE_LIKE_THIS,
COLLECTION
COLLECTION,
COMPANY_OR_NETWORK
}
private enum class PeopleSectionTab {
@ -189,6 +190,7 @@ fun MetaDetailsScreen(
returnFocusEpisode: Int? = null,
onBackPress: () -> Unit,
onNavigateToCastDetail: (personId: Int, personName: String, preferCrew: Boolean) -> Unit = { _, _, _ -> },
onNavigateToTmdbEntityBrowse: (entityKind: String, entityId: Int, entityName: String, sourceType: String) -> Unit = { _, _, _, _ -> },
onNavigateToDetail: (itemId: String, itemType: String, addonBaseUrl: String?) -> Unit = { _, _, _ -> },
onPlayClick: (
videoId: String,
@ -529,6 +531,7 @@ fun MetaDetailsScreen(
onTrailerButtonClick = { viewModel.onEvent(MetaDetailsEvent.OnTrailerButtonClick) },
restorePlayFocusAfterTrailerBackToken = restorePlayFocusAfterTrailerBackToken,
onNavigateToCastDetail = onNavigateToCastDetail,
onNavigateToTmdbEntityBrowse = onNavigateToTmdbEntityBrowse,
onNavigateToDetail = onNavigateToDetail
)
}
@ -650,6 +653,7 @@ private fun MetaDetailsContent(
onTrailerButtonClick: () -> Unit,
restorePlayFocusAfterTrailerBackToken: Int,
onNavigateToCastDetail: (personId: Int, personName: String, preferCrew: Boolean) -> Unit = { _, _, _ -> },
onNavigateToTmdbEntityBrowse: (entityKind: String, entityId: Int, entityName: String, sourceType: String) -> Unit = { _, _, _, _ -> },
onNavigateToDetail: (itemId: String, itemType: String, addonBaseUrl: String?) -> Unit = { _, _, _ -> }
) {
val isSeries = remember(meta.type, meta.videos) {
@ -700,6 +704,8 @@ private fun MetaDetailsContent(
var pendingRestoreEpisodeId by rememberSaveable { mutableStateOf<String?>(null) }
var pendingRestoreCastPersonId by rememberSaveable { mutableStateOf<Int?>(null) }
var pendingRestoreMoreLikeItemId by rememberSaveable { mutableStateOf<String?>(null) }
var pendingRestoreCollectionItemId by rememberSaveable { mutableStateOf<String?>(null) }
var pendingRestoreCompanyId by rememberSaveable { mutableStateOf<Int?>(null) }
var restoreFocusToken by rememberSaveable { mutableIntStateOf(0) }
var initialHeroFocusRequested by rememberSaveable(meta.id) { mutableStateOf(false) }
var showHeroPlayOptionsDialog by rememberSaveable(meta.id) { mutableStateOf(false) }
@ -718,6 +724,8 @@ private fun MetaDetailsContent(
pendingRestoreEpisodeId = null
pendingRestoreCastPersonId = null
pendingRestoreMoreLikeItemId = null
pendingRestoreCollectionItemId = null
pendingRestoreCompanyId = null
}
fun markHeroRestore() {
@ -725,6 +733,8 @@ private fun MetaDetailsContent(
pendingRestoreEpisodeId = null
pendingRestoreCastPersonId = null
pendingRestoreMoreLikeItemId = null
pendingRestoreCollectionItemId = null
pendingRestoreCompanyId = null
}
fun markEpisodeRestore(episodeId: String) {
@ -732,6 +742,8 @@ private fun MetaDetailsContent(
pendingRestoreEpisodeId = episodeId
pendingRestoreCastPersonId = null
pendingRestoreMoreLikeItemId = null
pendingRestoreCollectionItemId = null
pendingRestoreCompanyId = null
}
fun markCastMemberRestore(personId: Int) {
@ -739,6 +751,8 @@ private fun MetaDetailsContent(
pendingRestoreEpisodeId = null
pendingRestoreCastPersonId = personId
pendingRestoreMoreLikeItemId = null
pendingRestoreCollectionItemId = null
pendingRestoreCompanyId = null
}
fun markMoreLikeThisRestore(itemId: String) {
@ -746,15 +760,26 @@ private fun MetaDetailsContent(
pendingRestoreEpisodeId = null
pendingRestoreCastPersonId = null
pendingRestoreMoreLikeItemId = itemId
pendingRestoreCollectionItemId = null
pendingRestoreCompanyId = null
}
var pendingRestoreCollectionItemId by rememberSaveable { mutableStateOf<String?>(null) }
fun markCollectionRestore(itemId: String) {
pendingRestoreType = RestoreTarget.COLLECTION
pendingRestoreEpisodeId = null
pendingRestoreCastPersonId = null
pendingRestoreMoreLikeItemId = null
pendingRestoreCollectionItemId = itemId
pendingRestoreCompanyId = null
}
fun markCompanyRestore(companyId: Int) {
pendingRestoreType = RestoreTarget.COMPANY_OR_NETWORK
pendingRestoreEpisodeId = null
pendingRestoreCastPersonId = null
pendingRestoreMoreLikeItemId = null
pendingRestoreCollectionItemId = null
pendingRestoreCompanyId = companyId
}
DisposableEffect(
@ -763,7 +788,8 @@ private fun MetaDetailsContent(
pendingRestoreEpisodeId,
pendingRestoreCastPersonId,
pendingRestoreMoreLikeItemId,
pendingRestoreCollectionItemId
pendingRestoreCollectionItemId,
pendingRestoreCompanyId
) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && pendingRestoreType != null) {
@ -1376,7 +1402,16 @@ private fun MetaDetailsContent(
item(key = "networks", contentType = "horizontal_row") {
CompanyLogosSection(
title = stringResource(R.string.detail_section_network),
companies = meta.networks
companies = meta.networks,
restoreCompanyId = if (pendingRestoreType == RestoreTarget.COMPANY_OR_NETWORK) pendingRestoreCompanyId else null,
restoreFocusToken = if (pendingRestoreType == RestoreTarget.COMPANY_OR_NETWORK) restoreFocusToken else 0,
onRestoreFocusHandled = { clearPendingRestore() },
onCompanyClick = { company ->
company.tmdbId?.let { entityId ->
markCompanyRestore(entityId)
onNavigateToTmdbEntityBrowse("network", entityId, company.name, meta.apiType)
}
}
)
}
}
@ -1385,7 +1420,16 @@ private fun MetaDetailsContent(
item(key = "production", contentType = "horizontal_row") {
CompanyLogosSection(
title = stringResource(R.string.detail_section_production),
companies = meta.productionCompanies
companies = meta.productionCompanies,
restoreCompanyId = if (pendingRestoreType == RestoreTarget.COMPANY_OR_NETWORK) pendingRestoreCompanyId else null,
restoreFocusToken = if (pendingRestoreType == RestoreTarget.COMPANY_OR_NETWORK) restoreFocusToken else 0,
onRestoreFocusHandled = { clearPendingRestore() },
onCompanyClick = { company ->
company.tmdbId?.let { entityId ->
markCompanyRestore(entityId)
onNavigateToTmdbEntityBrowse("company", entityId, company.name, meta.apiType)
}
}
)
}
}
@ -1394,7 +1438,16 @@ private fun MetaDetailsContent(
item(key = "production", contentType = "horizontal_row") {
CompanyLogosSection(
title = stringResource(R.string.detail_section_production),
companies = meta.productionCompanies
companies = meta.productionCompanies,
restoreCompanyId = if (pendingRestoreType == RestoreTarget.COMPANY_OR_NETWORK) pendingRestoreCompanyId else null,
restoreFocusToken = if (pendingRestoreType == RestoreTarget.COMPANY_OR_NETWORK) restoreFocusToken else 0,
onRestoreFocusHandled = { clearPendingRestore() },
onCompanyClick = { company ->
company.tmdbId?.let { entityId ->
markCompanyRestore(entityId)
onNavigateToTmdbEntityBrowse("company", entityId, company.name, meta.apiType)
}
}
)
}
}
@ -1403,7 +1456,16 @@ private fun MetaDetailsContent(
item(key = "networks", contentType = "horizontal_row") {
CompanyLogosSection(
title = stringResource(R.string.detail_section_network),
companies = meta.networks
companies = meta.networks,
restoreCompanyId = if (pendingRestoreType == RestoreTarget.COMPANY_OR_NETWORK) pendingRestoreCompanyId else null,
restoreFocusToken = if (pendingRestoreType == RestoreTarget.COMPANY_OR_NETWORK) restoreFocusToken else 0,
onRestoreFocusHandled = { clearPendingRestore() },
onCompanyClick = { company ->
company.tmdbId?.let { entityId ->
markCompanyRestore(entityId)
onNavigateToTmdbEntityBrowse("network", entityId, company.name, meta.apiType)
}
}
)
}
}

View file

@ -0,0 +1,381 @@
package com.nuvio.tv.ui.screens.tmdb
import androidx.activity.compose.BackHandler
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.nuvio.tv.R
import com.nuvio.tv.core.tmdb.TmdbEntityBrowseData
import com.nuvio.tv.core.tmdb.TmdbEntityKind
import com.nuvio.tv.core.tmdb.TmdbEntityMediaType
import com.nuvio.tv.core.tmdb.TmdbEntityRail
import com.nuvio.tv.core.tmdb.TmdbEntityRailType
import com.nuvio.tv.domain.model.MetaPreview
import com.nuvio.tv.ui.components.EmptyScreenState
import com.nuvio.tv.ui.components.ErrorState
import com.nuvio.tv.ui.components.GridContentCard
import com.nuvio.tv.ui.components.LoadingIndicator
import com.nuvio.tv.ui.components.PosterCardDefaults
import com.nuvio.tv.ui.components.PosterCardStyle
import com.nuvio.tv.ui.theme.NuvioColors
@Composable
fun TmdbEntityBrowseScreen(
viewModel: TmdbEntityBrowseViewModel = hiltViewModel(),
onBackPress: () -> Unit,
onNavigateToDetail: (itemId: String, itemType: String, addonBaseUrl: String?) -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
BackHandler { onBackPress() }
Box(
modifier = Modifier
.fillMaxSize()
.background(NuvioColors.Background)
) {
Crossfade(
targetState = uiState,
label = "TmdbEntityBrowseState"
) { state ->
when (state) {
TmdbEntityBrowseUiState.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
LoadingIndicator()
}
}
is TmdbEntityBrowseUiState.Error -> {
ErrorState(
message = state.message,
onRetry = { viewModel.retry() }
)
}
is TmdbEntityBrowseUiState.Success -> {
TmdbEntityBrowseContent(
data = state.data,
sourceType = viewModel.sourceType,
onNavigateToDetail = onNavigateToDetail
)
}
}
}
}
}
@Composable
private fun TmdbEntityBrowseContent(
data: TmdbEntityBrowseData,
sourceType: String,
onNavigateToDetail: (itemId: String, itemType: String, addonBaseUrl: String?) -> Unit
) {
val lifecycleOwner = LocalLifecycleOwner.current
var pendingRestoreItemId by rememberSaveable(data.header.id) { mutableStateOf<String?>(null) }
var restoreFocusToken by rememberSaveable(data.header.id) { mutableIntStateOf(0) }
DisposableEffect(lifecycleOwner, pendingRestoreItemId) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && pendingRestoreItemId != null) {
restoreFocusToken += 1
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
val posterCardStyle = PosterCardDefaults.Style
val backgroundRequest = rememberBackgroundRequest(
data = data,
sourceType = sourceType
)
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// Background image layer
if (backgroundRequest != null) {
AsyncImage(
model = backgroundRequest,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
alpha = 0.34f
)
}
// Gradient overlay
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
NuvioColors.Background.copy(alpha = 0.35f),
NuvioColors.Background.copy(alpha = 0.75f),
NuvioColors.Background
)
)
)
)
if (data.rails.isEmpty()) {
EmptyScreenState(
title = stringResource(R.string.tmdb_entity_empty_title),
subtitle = stringResource(R.string.tmdb_entity_empty_subtitle),
modifier = Modifier.align(Alignment.Center)
)
} else {
val railsViewportFraction = 0.55f
val railsViewportHeight = maxHeight * railsViewportFraction
// Fixed hero in the upper area
TmdbEntityHero(
data = data,
modifier = Modifier
.align(Alignment.TopStart)
.padding(top = 40.dp, bottom = 16.dp)
)
// Scrollable rails anchored to the bottom
LazyColumn(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.height(railsViewportHeight),
contentPadding = PaddingValues(top = 8.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
items(
items = data.rails,
key = { rail -> "${rail.mediaType.value}_${rail.railType.value}" }
) { rail ->
EntityRailRow(
rail = rail,
posterCardStyle = posterCardStyle,
restoreItemId = pendingRestoreItemId,
restoreFocusToken = restoreFocusToken,
onRestoreFocusHandled = { pendingRestoreItemId = null },
onItemClick = { item ->
pendingRestoreItemId = item.id
onNavigateToDetail(item.id, item.apiType, null)
}
)
}
}
}
}
}
@Composable
private fun rememberBackgroundRequest(
data: TmdbEntityBrowseData,
sourceType: String
) : ImageRequest? {
val context = LocalContext.current
return remember(context, data, sourceType) {
val backgroundItem = data.rails.firstOrNull {
it.mediaType == if (sourceType.trim().equals("movie", ignoreCase = true)) {
TmdbEntityMediaType.MOVIE
} else {
TmdbEntityMediaType.TV
}
}?.items?.firstOrNull()?.background ?: data.rails.firstOrNull()?.items?.firstOrNull()?.background
backgroundItem?.let {
ImageRequest.Builder(context)
.data(it)
.crossfade(true)
.build()
}
}
}
@Composable
private fun TmdbEntityHero(
data: TmdbEntityBrowseData,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 48.dp),
verticalAlignment = Alignment.CenterVertically
) {
val context = LocalContext.current
if (!data.header.logo.isNullOrBlank()) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(data.header.logo)
.crossfade(true)
.build(),
contentDescription = data.header.name,
modifier = Modifier
.width(220.dp)
.height(90.dp),
contentScale = ContentScale.Fit
)
Spacer(modifier = Modifier.width(24.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = entityKindLabel(data.header.kind),
style = MaterialTheme.typography.labelLarge,
color = NuvioColors.TextSecondary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = data.header.name,
style = MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.Bold,
letterSpacing = (-0.5).sp
),
color = NuvioColors.TextPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
val metaLine = listOfNotNull(
data.header.originCountry?.takeIf { it.isNotBlank() },
data.header.secondaryLabel?.takeIf { it.isNotBlank() }
).joinToString("")
if (metaLine.isNotBlank()) {
Spacer(modifier = Modifier.height(10.dp))
Text(
text = metaLine,
style = MaterialTheme.typography.bodyMedium,
color = NuvioColors.TextSecondary
)
}
data.header.description?.takeIf { it.isNotBlank() }?.let { description ->
Spacer(modifier = Modifier.height(14.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium.copy(
lineHeight = 20.sp
),
color = NuvioColors.TextSecondary,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(0.72f)
)
}
}
}
}
@Composable
private fun EntityRailRow(
rail: TmdbEntityRail,
posterCardStyle: PosterCardStyle,
restoreItemId: String?,
restoreFocusToken: Int,
onRestoreFocusHandled: () -> Unit,
onItemClick: (MetaPreview) -> Unit
) {
val focusRequesters = remember(rail.items) {
rail.items.associate { it.id to FocusRequester() }
}
LaunchedEffect(restoreItemId, restoreFocusToken) {
if (restoreFocusToken <= 0 || restoreItemId == null) return@LaunchedEffect
val requester = focusRequesters[restoreItemId] ?: return@LaunchedEffect
repeat(2) { withFrameNanos { } }
runCatching { requester.requestFocus() }
onRestoreFocusHandled()
}
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = railTitle(rail),
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.SemiBold
),
color = NuvioColors.TextPrimary,
modifier = Modifier.padding(horizontal = 48.dp)
)
Spacer(modifier = Modifier.height(8.dp))
LazyRow(
contentPadding = PaddingValues(horizontal = 48.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(
items = rail.items,
key = { item -> item.id }
) { item ->
GridContentCard(
item = item,
onClick = { onItemClick(item) },
posterCardStyle = posterCardStyle,
showLabel = true,
focusRequester = focusRequesters[item.id]
)
}
}
}
}
@Composable
private fun entityKindLabel(kind: TmdbEntityKind): String = when (kind) {
TmdbEntityKind.COMPANY -> stringResource(R.string.tmdb_entity_kind_company)
TmdbEntityKind.NETWORK -> stringResource(R.string.tmdb_entity_kind_network)
}
@Composable
private fun railTitle(rail: TmdbEntityRail): String {
val mediaLabel = when (rail.mediaType) {
TmdbEntityMediaType.MOVIE -> stringResource(R.string.type_movie)
TmdbEntityMediaType.TV -> stringResource(R.string.type_series)
}
val railLabel = when (rail.railType) {
TmdbEntityRailType.POPULAR -> stringResource(R.string.tmdb_entity_rail_popular)
TmdbEntityRailType.TOP_RATED -> stringResource(R.string.tmdb_entity_rail_top_rated)
TmdbEntityRailType.RECENT -> stringResource(R.string.tmdb_entity_rail_recent)
}
return "$mediaLabel$railLabel"
}

View file

@ -0,0 +1,9 @@
package com.nuvio.tv.ui.screens.tmdb
import com.nuvio.tv.core.tmdb.TmdbEntityBrowseData
sealed interface TmdbEntityBrowseUiState {
data object Loading : TmdbEntityBrowseUiState
data class Error(val message: String) : TmdbEntityBrowseUiState
data class Success(val data: TmdbEntityBrowseData) : TmdbEntityBrowseUiState
}

View file

@ -0,0 +1,76 @@
package com.nuvio.tv.ui.screens.tmdb
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.nuvio.tv.core.tmdb.TmdbEntityKind
import com.nuvio.tv.core.tmdb.TmdbMetadataService
import com.nuvio.tv.data.local.TmdbSettingsDataStore
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.net.URLDecoder
import javax.inject.Inject
@HiltViewModel
class TmdbEntityBrowseViewModel @Inject constructor(
private val tmdbMetadataService: TmdbMetadataService,
private val tmdbSettingsDataStore: TmdbSettingsDataStore,
savedStateHandle: SavedStateHandle
) : ViewModel() {
val entityKind: TmdbEntityKind = TmdbEntityKind.fromRouteValue(
savedStateHandle.get<String>("entityKind").orEmpty()
)
val entityId: Int = savedStateHandle.get<Int>("entityId") ?: 0
val entityName: String = URLDecoder.decode(
savedStateHandle.get<String>("entityName").orEmpty(),
"UTF-8"
)
val sourceType: String = savedStateHandle.get<String>("sourceType").orEmpty()
private val _uiState = MutableStateFlow<TmdbEntityBrowseUiState>(TmdbEntityBrowseUiState.Loading)
val uiState: StateFlow<TmdbEntityBrowseUiState> = _uiState.asStateFlow()
init {
load()
}
fun retry() {
_uiState.value = TmdbEntityBrowseUiState.Loading
load()
}
private fun load() {
viewModelScope.launch {
try {
val language = tmdbSettingsDataStore.settings.first().language
val browseData = tmdbMetadataService.fetchEntityBrowse(
entityKind = entityKind,
entityId = entityId,
sourceType = sourceType,
fallbackName = entityName,
language = language
)
_uiState.value = if (browseData != null) {
TmdbEntityBrowseUiState.Success(browseData)
} else {
TmdbEntityBrowseUiState.Error(
if (entityName.isNotBlank()) {
"Could not load $entityName"
} else {
"Could not load TMDB entity"
}
)
}
} catch (e: Exception) {
_uiState.value = TmdbEntityBrowseUiState.Error(
e.message ?: "Could not load TMDB entity"
)
}
}
}
}

View file

@ -124,6 +124,13 @@
<string name="detail_tab_more_like_this">More like this</string>
<string name="detail_section_network">Network</string>
<string name="detail_section_production">Production</string>
<string name="tmdb_entity_kind_company">Production Company</string>
<string name="tmdb_entity_kind_network">Network</string>
<string name="tmdb_entity_rail_popular">Popular</string>
<string name="tmdb_entity_rail_top_rated">Top Rated</string>
<string name="tmdb_entity_rail_recent">Recent</string>
<string name="tmdb_entity_empty_title">No titles found</string>
<string name="tmdb_entity_empty_subtitle">TMDB does not currently have browseable titles for this selection.</string>
<string name="episodes_unavailable">Unavailable</string>
<string name="series_status_ended">Ended</string>
<string name="series_status_continuing">Ongoing</string>

View file

@ -0,0 +1,230 @@
package com.nuvio.tv.core.tmdb
import com.nuvio.tv.data.remote.api.TmdbApi
import com.nuvio.tv.data.remote.api.TmdbCompany
import com.nuvio.tv.data.remote.api.TmdbCompanyDetailsResponse
import com.nuvio.tv.data.remote.api.TmdbCreditsResponse
import com.nuvio.tv.data.remote.api.TmdbDetailsResponse
import com.nuvio.tv.data.remote.api.TmdbDiscoverResponse
import com.nuvio.tv.data.remote.api.TmdbDiscoverResult
import com.nuvio.tv.data.remote.api.TmdbImagesResponse
import com.nuvio.tv.data.remote.api.TmdbMovieReleaseDatesResponse
import com.nuvio.tv.data.remote.api.TmdbNetwork
import com.nuvio.tv.data.remote.api.TmdbNetworkDetailsResponse
import com.nuvio.tv.domain.model.ContentType
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class TmdbMetadataServiceTest {
@Test
fun `fetchEnrichment maps tmdb ids onto production and network companies`() = runTest {
val api = mockk<TmdbApi>()
coEvery { api.getMovieDetails(any(), any(), any()) } returns Response.success(
TmdbDetailsResponse(
id = 10,
productionCompanies = listOf(
TmdbCompany(id = 55, name = "Acme Pictures", logoPath = "/company.png")
),
networks = listOf(
TmdbNetwork(id = 77, name = "Prime TV", logoPath = "/network.png")
)
)
)
coEvery { api.getMovieCredits(any(), any(), any()) } returns Response.success(TmdbCreditsResponse())
coEvery { api.getMovieImages(any(), any(), any()) } returns Response.success(TmdbImagesResponse())
coEvery { api.getMovieReleaseDates(any(), any()) } returns Response.success(TmdbMovieReleaseDatesResponse())
val service = TmdbMetadataService(api)
val enrichment = service.fetchEnrichment(
tmdbId = "10",
contentType = ContentType.MOVIE,
language = "en"
)
assertNotNull(enrichment)
assertEquals(55, enrichment?.productionCompanies?.firstOrNull()?.tmdbId)
assertEquals(77, enrichment?.networks?.firstOrNull()?.tmdbId)
}
@Test
fun `company browse requests movie then tv rails when source type is movie`() = runTest {
val api = mockk<TmdbApi>()
val movieCalls = mutableListOf<MovieDiscoverCall>()
val tvCalls = mutableListOf<TvDiscoverCall>()
coEvery { api.getCompanyDetails(99, any()) } returns Response.success(
TmdbCompanyDetailsResponse(
id = 99,
name = "Acme Pictures",
originCountry = "US"
)
)
coEvery {
api.discoverMovies(any(), any(), any(), any(), any(), any(), any())
} answers {
movieCalls += MovieDiscoverCall(
sortBy = arg(3),
withCompanies = arg(4),
releaseDateLte = arg(5),
voteCountGte = arg(6)
)
Response.success(
TmdbDiscoverResponse(
results = listOf(
TmdbDiscoverResult(
id = movieCalls.size,
title = "Movie ${movieCalls.size}",
posterPath = "/movie-${movieCalls.size}.jpg",
backdropPath = "/movie-bg-${movieCalls.size}.jpg",
releaseDate = "2024-01-01",
voteAverage = 7.4
)
)
)
)
}
coEvery {
api.discoverTv(any(), any(), any(), any(), any(), any(), any(), any())
} answers {
tvCalls += TvDiscoverCall(
sortBy = arg(3),
withCompanies = arg(4),
withNetworks = arg(5),
firstAirDateLte = arg(6),
voteCountGte = arg(7)
)
Response.success(
TmdbDiscoverResponse(
results = listOf(
TmdbDiscoverResult(
id = 100 + tvCalls.size,
name = "Show ${tvCalls.size}",
posterPath = "/show-${tvCalls.size}.jpg",
backdropPath = "/show-bg-${tvCalls.size}.jpg",
firstAirDate = "2023-03-10",
voteAverage = 8.0
)
)
)
)
}
val service = TmdbMetadataService(api)
val data = service.fetchEntityBrowse(
entityKind = TmdbEntityKind.COMPANY,
entityId = 99,
sourceType = "movie",
fallbackName = "Acme Pictures",
language = "en"
)
assertNotNull(data)
assertEquals(
listOf(
TmdbEntityMediaType.MOVIE,
TmdbEntityMediaType.MOVIE,
TmdbEntityMediaType.MOVIE,
TmdbEntityMediaType.TV,
TmdbEntityMediaType.TV,
TmdbEntityMediaType.TV
),
data?.rails?.map { it.mediaType }
)
assertEquals(3, movieCalls.size)
assertEquals(3, tvCalls.size)
assertTrue(movieCalls.all { it.withCompanies == "99" })
assertTrue(tvCalls.all { it.withCompanies == "99" })
assertTrue(tvCalls.all { it.withNetworks == null })
assertEquals(200, movieCalls.first { it.sortBy == "vote_average.desc" }.voteCountGte)
assertTrue(movieCalls.first { it.sortBy == "primary_release_date.desc" }.releaseDateLte != null)
assertTrue(tvCalls.first { it.sortBy == "first_air_date.desc" }.firstAirDateLte != null)
assertTrue(data?.rails?.flatMap { it.items }.orEmpty().all { it.id.startsWith("tmdb:") })
}
@Test
fun `network browse only requests tv rails and scopes by network id`() = runTest {
val api = mockk<TmdbApi>()
val tvCalls = mutableListOf<TvDiscoverCall>()
coEvery { api.getNetworkDetails(77, any()) } returns Response.success(
TmdbNetworkDetailsResponse(
id = 77,
name = "Prime TV",
originCountry = "US"
)
)
coEvery {
api.discoverTv(any(), any(), any(), any(), any(), any(), any(), any())
} answers {
tvCalls += TvDiscoverCall(
sortBy = arg(3),
withCompanies = arg(4),
withNetworks = arg(5),
firstAirDateLte = arg(6),
voteCountGte = arg(7)
)
Response.success(
TmdbDiscoverResponse(
results = listOf(
TmdbDiscoverResult(
id = 500 + tvCalls.size,
name = "Network Show ${tvCalls.size}",
posterPath = "/network-${tvCalls.size}.jpg",
backdropPath = "/network-bg-${tvCalls.size}.jpg",
firstAirDate = "2022-07-01",
voteAverage = 8.3
)
)
)
)
}
coEvery {
api.discoverMovies(any(), any(), any(), any(), any(), any(), any())
} throws AssertionError("movie discovery must not run for networks")
val service = TmdbMetadataService(api)
val data = service.fetchEntityBrowse(
entityKind = TmdbEntityKind.NETWORK,
entityId = 77,
sourceType = "series",
fallbackName = "Prime TV",
language = "en"
)
assertNotNull(data)
assertEquals(3, tvCalls.size)
assertTrue(data?.rails?.all { it.mediaType == TmdbEntityMediaType.TV } == true)
assertTrue(tvCalls.all { it.withNetworks == "77" })
assertTrue(tvCalls.all { it.withCompanies == null })
assertNull(tvCalls.firstOrNull { it.sortBy == "popularity.desc" }?.voteCountGte)
assertEquals(200, tvCalls.first { it.sortBy == "vote_average.desc" }.voteCountGte)
}
private data class MovieDiscoverCall(
val sortBy: String?,
val withCompanies: String?,
val releaseDateLte: String?,
val voteCountGte: Int?
)
private data class TvDiscoverCall(
val sortBy: String?,
val withCompanies: String?,
val withNetworks: String?,
val firstAirDateLte: String?,
val voteCountGte: Int?
)
}