feat(entity-browse): add TmdbEntityBrowseScreen and related functionality

This commit is contained in:
tapframe 2026-04-03 01:52:44 +05:30
parent 9bcbe21d46
commit fe04d6cf72
7 changed files with 830 additions and 3 deletions

View file

@ -81,6 +81,8 @@ import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaDetailsScreen
import com.nuvio.app.features.details.MetaPerson
import com.nuvio.app.features.details.PersonDetailScreen
import com.nuvio.app.features.details.TmdbEntityBrowseScreen
import com.nuvio.app.features.tmdb.TmdbEntityKind
import com.nuvio.app.features.home.HomeCatalogSection
import com.nuvio.app.features.home.HomeScreen
import com.nuvio.app.features.home.MetaPreview
@ -143,6 +145,14 @@ data class PersonDetailRoute(
val preferCrew: Boolean = false,
)
@Serializable
data class EntityBrowseRoute(
val entityKind: String,
val entityId: Int,
val entityName: String,
val sourceType: String = "tv",
)
@Serializable
object HomescreenSettingsRoute
@ -632,6 +642,19 @@ private fun MainAppContent(
)
}
},
onCompanyClick = { company, entityKind ->
val tmdbId = company.tmdbId
if (tmdbId != null && tmdbId > 0) {
navController.navigate(
EntityBrowseRoute(
entityKind = entityKind,
entityId = tmdbId,
entityName = company.name,
sourceType = route.type,
),
)
}
},
modifier = Modifier.fillMaxSize(),
)
}
@ -666,6 +689,38 @@ private fun MainAppContent(
modifier = Modifier.fillMaxSize(),
)
}
composable<EntityBrowseRoute> { backStackEntry ->
val route = backStackEntry.toRoute<EntityBrowseRoute>()
TmdbEntityBrowseScreen(
entityKind = TmdbEntityKind.fromRouteValue(route.entityKind),
entityId = route.entityId,
entityName = route.entityName,
sourceType = route.sourceType,
onBack = { navController.popBackStack() },
onOpenMeta = { preview ->
coroutineScope.launch {
val resolvedId = if (preview.id.startsWith("tmdb:")) {
val tmdbId = preview.id.removePrefix("tmdb:").toIntOrNull()
tmdbId?.let {
TmdbService.tmdbToImdb(
tmdbId = it,
mediaType = preview.type,
)
} ?: preview.id
} else {
preview.id
}
navController.navigate(
DetailRoute(
type = preview.type,
id = resolvedId,
),
)
}
},
modifier = Modifier.fillMaxSize(),
)
}
composable<StreamRoute> { backStackEntry ->
val route = backStackEntry.toRoute<StreamRoute>()
val pauseDescription = remember(route.streamContextId) {

View file

@ -90,6 +90,7 @@ fun MetaDetailsScreen(
onPlay: ((type: String, videoId: String, parentMetaId: String, parentMetaType: String, title: String, logo: String?, poster: String?, background: String?, seasonNumber: Int?, episodeNumber: Int?, episodeTitle: String?, episodeThumbnail: String?, pauseDescription: String?, resumePositionMs: Long?) -> Unit)? = null,
onOpenMeta: ((MetaPreview) -> Unit)? = null,
onCastClick: ((MetaPerson) -> Unit)? = null,
onCompanyClick: ((MetaCompany, String) -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val uiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle()
@ -516,6 +517,7 @@ fun MetaDetailsScreen(
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
onOpenMeta = onOpenMeta,
onCastClick = onCastClick,
onCompanyClick = onCompanyClick,
)
Spacer(modifier = Modifier.height(32.dp + nuvioPlatformExtraBottomPadding))
@ -755,6 +757,7 @@ private fun ConfiguredMetaSections(
onEpisodeLongPress: (MetaVideo) -> Unit,
onOpenMeta: ((MetaPreview) -> Unit)?,
onCastClick: ((MetaPerson) -> Unit)?,
onCompanyClick: ((MetaCompany, String) -> Unit)?,
) {
settings.items
.filter { it.enabled }
@ -777,7 +780,10 @@ private fun ConfiguredMetaSections(
MetaScreenSectionKey.PRODUCTION -> {
if (hasProductionSection) {
DetailProductionSection(meta = meta)
DetailProductionSection(
meta = meta,
onCompanyClick = onCompanyClick,
)
}
}

View file

@ -378,7 +378,7 @@ private fun PersonDetailSkeleton(personName: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 0.dp),
.padding(horizontal = 20.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
repeat(4) {

View file

@ -0,0 +1,396 @@
package com.nuvio.app.features.details
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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
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.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
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 coil3.compose.AsyncImage
import com.nuvio.app.features.details.components.DetailPosterRailSection
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.tmdb.TmdbEntityBrowseData
import com.nuvio.app.features.tmdb.TmdbEntityKind
import com.nuvio.app.features.tmdb.TmdbEntityMediaType
import com.nuvio.app.features.tmdb.TmdbEntityRailType
import com.nuvio.app.features.tmdb.TmdbMetadataService
private sealed interface EntityBrowseUiState {
data object Loading : EntityBrowseUiState
data class Error(val message: String) : EntityBrowseUiState
data class Success(val data: TmdbEntityBrowseData) : EntityBrowseUiState
}
@Composable
fun TmdbEntityBrowseScreen(
entityKind: TmdbEntityKind,
entityId: Int,
entityName: String,
sourceType: String,
onBack: () -> Unit,
onOpenMeta: (MetaPreview) -> Unit,
modifier: Modifier = Modifier,
) {
var uiState by remember(entityKind, entityId) {
mutableStateOf<EntityBrowseUiState>(EntityBrowseUiState.Loading)
}
LaunchedEffect(entityKind, entityId) {
uiState = EntityBrowseUiState.Loading
val data = TmdbMetadataService.fetchEntityBrowse(
entityKind = entityKind,
entityId = entityId,
sourceType = sourceType,
fallbackName = entityName,
)
uiState = if (data != null) {
EntityBrowseUiState.Success(data)
} else {
EntityBrowseUiState.Error("Could not load $entityName")
}
}
Box(modifier = modifier.fillMaxSize()) {
Crossfade(
targetState = uiState,
label = "EntityBrowseCrossfade",
) { state ->
when (state) {
is EntityBrowseUiState.Loading -> EntityBrowseSkeleton()
is EntityBrowseUiState.Error -> EntityBrowseError(
message = state.message,
onRetry = { uiState = EntityBrowseUiState.Loading },
)
is EntityBrowseUiState.Success -> EntityBrowseContent(
data = state.data,
sourceType = sourceType,
onOpenMeta = onOpenMeta,
)
}
}
IconButton(
onClick = onBack,
modifier = Modifier
.windowInsetsPadding(WindowInsets.statusBars)
.padding(start = 4.dp, top = 4.dp)
.align(Alignment.TopStart),
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@Composable
private fun EntityBrowseContent(
data: TmdbEntityBrowseData,
sourceType: String,
onOpenMeta: (MetaPreview) -> Unit,
) {
val backgroundUrl = remember(data.rails, sourceType) {
val preferredMediaType = if (sourceType.trim().equals("movie", ignoreCase = true)) {
TmdbEntityMediaType.MOVIE
} else {
TmdbEntityMediaType.TV
}
data.rails.firstOrNull { it.mediaType == preferredMediaType }
?.items?.firstOrNull()?.poster
?: data.rails.firstOrNull()?.items?.firstOrNull()?.poster
}
Box(modifier = Modifier.fillMaxSize()) {
if (backgroundUrl != null) {
AsyncImage(
model = backgroundUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
alpha = 0.10f,
)
}
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
0f to MaterialTheme.colorScheme.background.copy(alpha = 0.7f),
0.3f to MaterialTheme.colorScheme.background.copy(alpha = 0.95f),
1f to MaterialTheme.colorScheme.background,
),
),
)
if (data.rails.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = "No titles found",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = 56.dp),
) {
EntityHeroSection(
header = data.header,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
)
data.rails.forEach { rail ->
val railTitle = remember(rail.mediaType, rail.railType) {
val mediaLabel = when (rail.mediaType) {
TmdbEntityMediaType.MOVIE -> "Movies"
TmdbEntityMediaType.TV -> "Series"
}
val railLabel = when (rail.railType) {
TmdbEntityRailType.POPULAR -> "Popular"
TmdbEntityRailType.TOP_RATED -> "Top Rated"
TmdbEntityRailType.RECENT -> "Recent"
}
"$mediaLabel$railLabel"
}
DetailPosterRailSection(
title = railTitle,
items = rail.items,
watchedKeys = emptySet(),
headerHorizontalPadding = 20.dp,
onPosterClick = onOpenMeta,
)
Spacer(modifier = Modifier.height(8.dp))
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}
@Composable
private fun EntityHeroSection(
header: com.nuvio.app.features.tmdb.TmdbEntityHeader,
modifier: Modifier = Modifier,
) {
val hasLogo = !header.logo.isNullOrBlank()
Column(modifier = modifier.padding(horizontal = 20.dp)) {
Text(
text = when (header.kind) {
TmdbEntityKind.COMPANY -> "Production Company"
TmdbEntityKind.NETWORK -> "Network"
},
style = MaterialTheme.typography.labelLarge.copy(
fontWeight = FontWeight.Medium,
letterSpacing = 0.4.sp,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
if (hasLogo) {
Box(
modifier = Modifier
.height(60.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White)
.padding(horizontal = 16.dp, vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = header.logo,
contentDescription = header.name,
modifier = Modifier.height(44.dp),
contentScale = ContentScale.Fit,
)
}
Spacer(modifier = Modifier.height(12.dp))
}
Text(
text = header.name,
style = MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.ExtraBold,
letterSpacing = (-0.5).sp,
),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
val metaLine = listOfNotNull(
header.originCountry?.takeIf { it.isNotBlank() },
header.secondaryLabel?.takeIf { it.isNotBlank() },
).joinToString("")
if (metaLine.isNotBlank()) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = metaLine,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
header.description?.takeIf { it.isNotBlank() }?.let { description ->
Spacer(modifier = Modifier.height(10.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun EntityBrowseSkeleton() {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = 56.dp),
) {
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
Box(
modifier = Modifier
.width(120.dp)
.height(14.dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
)
Spacer(modifier = Modifier.height(12.dp))
Box(
modifier = Modifier
.width(200.dp)
.height(28.dp)
.clip(RoundedCornerShape(6.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
)
Spacer(modifier = Modifier.height(10.dp))
Box(
modifier = Modifier
.width(140.dp)
.height(14.dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)),
)
}
Spacer(modifier = Modifier.height(28.dp))
repeat(3) {
Column(modifier = Modifier.padding(bottom = 20.dp)) {
Box(
modifier = Modifier
.padding(horizontal = 20.dp)
.width(160.dp)
.height(16.dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)),
)
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
repeat(4) {
Box(
modifier = Modifier
.width(110.dp)
.height(163.dp)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)),
)
}
}
}
}
}
}
@Composable
private fun EntityBrowseError(
message: String,
onRetry: () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onRetry,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
) {
Text("Retry")
}
}
}
}

View file

@ -28,7 +28,7 @@ fun DetailPosterRailSection(
entries = items,
modifier = modifier,
headerHorizontalPadding = headerHorizontalPadding,
rowContentPadding = PaddingValues(0.dp),
rowContentPadding = PaddingValues(horizontal = headerHorizontalPadding),
showHeaderAccent = false,
key = { item -> item.stableKey() },
) { item ->

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.details.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@ -30,8 +31,10 @@ import com.nuvio.app.features.details.MetaDetails
fun DetailProductionSection(
meta: MetaDetails,
modifier: Modifier = Modifier,
onCompanyClick: ((MetaCompany, String) -> Unit)? = null,
) {
val isSeriesLike = meta.type == "series" || meta.videos.any { it.season != null || it.episode != null }
val isNetworkSource = isSeriesLike && meta.networks.isNotEmpty()
val sourceItems = if (isSeriesLike) {
meta.networks.ifEmpty { meta.productionCompanies }
} else {
@ -39,6 +42,8 @@ fun DetailProductionSection(
}
if (sourceItems.isEmpty()) return
val entityKind = if (isNetworkSource) "network" else "company"
val displayItems = if (isSeriesLike) {
sourceItems.take(6)
} else {
@ -78,6 +83,9 @@ fun DetailProductionSection(
chipHeight = chipHeight,
logoWidth = logoWidth,
logoHeight = logoHeight,
onClick = if (onCompanyClick != null && item.tmdbId != null) {
{ onCompanyClick(item, entityKind) }
} else null,
)
}
}
@ -91,11 +99,15 @@ private fun ProductionChip(
chipHeight: androidx.compose.ui.unit.Dp,
logoWidth: androidx.compose.ui.unit.Dp,
logoHeight: androidx.compose.ui.unit.Dp,
onClick: (() -> Unit)? = null,
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(color = ProductionChipBackground)
.then(
if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier
)
.padding(horizontal = 12.dp, vertical = 8.dp)
.height(chipHeight),
contentAlignment = Alignment.Center,

View file

@ -29,6 +29,9 @@ object TmdbMetadataService {
private val collectionCache = mutableMapOf<String, Pair<String?, List<MetaPreview>>>()
private val trailerCache = mutableMapOf<String, List<MetaTrailer>>()
private val personCache = mutableMapOf<String, PersonDetail>()
private val entityBrowseCache = mutableMapOf<String, TmdbEntityBrowseData>()
private val entityHeaderCache = mutableMapOf<String, TmdbEntityHeader>()
private val entityRailCache = mutableMapOf<String, List<MetaPreview>>()
suspend fun fetchPersonDetail(
personId: Int,
@ -189,6 +192,259 @@ object TmdbMetadataService {
}
}
suspend fun fetchEntityBrowse(
entityKind: TmdbEntityKind,
entityId: Int,
sourceType: String,
fallbackName: String? = null,
): TmdbEntityBrowseData? = withContext(Dispatchers.Default) {
val settings = TmdbSettingsRepository.snapshot()
if (!settings.enabled || !settings.hasApiKey) return@withContext null
val language = normalizeTmdbLanguage(settings.language)
val normalizedSourceType = normalizeEntitySourceType(sourceType)
val cacheKey = "${entityKind.routeValue}:$entityId:$normalizedSourceType:$language"
entityBrowseCache[cacheKey]?.let { return@withContext it }
val header = fetchEntityHeader(
entityKind = entityKind,
entityId = entityId,
fallbackName = fallbackName,
language = language,
)
val rails = buildEntityMediaOrder(entityKind, normalizedSourceType)
.flatMap { mediaType ->
TmdbEntityRailType.entries.mapNotNull { railType ->
val pageResult = fetchEntityRailPage(
entityKind = entityKind,
entityId = entityId,
mediaType = mediaType,
railType = railType,
language = language,
page = 1,
)
if (pageResult.items.isEmpty()) {
null
} else {
TmdbEntityRail(
mediaType = mediaType,
railType = railType,
items = pageResult.items,
currentPage = 1,
hasMore = pageResult.hasMore,
)
}
}
}
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
}
suspend fun fetchEntityRailPage(
entityKind: TmdbEntityKind,
entityId: Int,
mediaType: TmdbEntityMediaType,
railType: TmdbEntityRailType,
language: String,
page: Int,
): TmdbEntityRailPageResult {
if (entityKind == TmdbEntityKind.NETWORK && mediaType == TmdbEntityMediaType.MOVIE) {
return TmdbEntityRailPageResult(items = emptyList(), hasMore = false)
}
val cacheKey = "${entityKind.routeValue}:$entityId:${mediaType.value}:${railType.value}:$language:page:$page"
entityRailCache[cacheKey]?.let { cached ->
return TmdbEntityRailPageResult(items = cached, hasMore = cached.isNotEmpty())
}
val voteCountFloor = if (railType == TmdbEntityRailType.TOP_RATED) ENTITY_TOP_RATED_VOTE_FLOOR else null
val result = try {
val sortBy = when (mediaType) {
TmdbEntityMediaType.MOVIE -> when (railType) {
TmdbEntityRailType.POPULAR -> "popularity.desc"
TmdbEntityRailType.TOP_RATED -> "vote_average.desc"
TmdbEntityRailType.RECENT -> "primary_release_date.desc"
}
TmdbEntityMediaType.TV -> when (railType) {
TmdbEntityRailType.POPULAR -> "popularity.desc"
TmdbEntityRailType.TOP_RATED -> "vote_average.desc"
TmdbEntityRailType.RECENT -> "first_air_date.desc"
}
}
val queryParams = buildMap {
put("language", language)
put("page", page.toString())
put("sort_by", sortBy)
when (mediaType) {
TmdbEntityMediaType.MOVIE -> {
put("with_companies", entityId.toString())
}
TmdbEntityMediaType.TV -> {
if (entityKind == TmdbEntityKind.COMPANY) put("with_companies", entityId.toString())
if (entityKind == TmdbEntityKind.NETWORK) put("with_networks", entityId.toString())
}
}
if (voteCountFloor != null) put("vote_count.gte", voteCountFloor.toString())
}
val endpoint = when (mediaType) {
TmdbEntityMediaType.MOVIE -> "discover/movie"
TmdbEntityMediaType.TV -> "discover/tv"
}
val response = fetch<TmdbDiscoverResponse>(endpoint = endpoint, query = queryParams)
val results = response?.results.orEmpty()
val totalPages = response?.totalPages ?: page
val mappedItems = results
.filter { it.id > 0 }
.mapNotNull { item -> mapEntityDiscoverResult(item, mediaType) }
.take(ENTITY_RAIL_MAX_ITEMS)
TmdbEntityRailPageResult(
items = mappedItems,
hasMore = page < totalPages && mappedItems.isNotEmpty(),
)
} catch (e: Exception) {
log.w(e) { "Failed to fetch entity rail ${railType.value}/${mediaType.value} for $entityId" }
TmdbEntityRailPageResult(items = emptyList(), hasMore = false)
}
if (result.items.isNotEmpty()) {
entityRailCache[cacheKey] = result.items
}
return result
}
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 = fetch<TmdbCompanyDetailsResponse>(endpoint = "company/$entityId")
body?.let {
TmdbEntityHeader(
id = it.id,
kind = entityKind,
name = it.name?.takeIf { n -> n.isNotBlank() }
?: fallbackName?.takeIf { n -> n.isNotBlank() }
?: "Unknown",
logo = buildImageUrl(it.logoPath, "w500"),
originCountry = it.originCountry?.takeIf { c -> c.isNotBlank() },
secondaryLabel = it.headquarters?.takeIf { h -> h.isNotBlank() },
description = it.description?.takeIf { d -> d.isNotBlank() },
)
}
}
TmdbEntityKind.NETWORK -> {
val body = fetch<TmdbNetworkDetailsResponse>(endpoint = "network/$entityId")
body?.let {
TmdbEntityHeader(
id = it.id,
kind = entityKind,
name = it.name?.takeIf { n -> n.isNotBlank() }
?: fallbackName?.takeIf { n -> n.isNotBlank() }
?: "Unknown",
logo = buildImageUrl(it.logoPath, "w500"),
originCountry = it.originCountry?.takeIf { c -> c.isNotBlank() },
secondaryLabel = it.headquarters?.takeIf { h -> h.isNotBlank() },
description = null,
)
}
}
}
} catch (e: Exception) {
log.w(e) { "Failed to fetch ${entityKind.routeValue} header for $entityId" }
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 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, "w500")
?: buildImageUrl(result.backdropPath, "w780")
?: return null
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) "series" else "movie",
name = title,
poster = poster,
description = result.overview?.takeIf { it.isNotBlank() },
releaseInfo = releaseInfo,
)
}
private fun buildEntityMediaOrder(
entityKind: TmdbEntityKind,
sourceType: String,
): List<TmdbEntityMediaType> {
if (entityKind == TmdbEntityKind.NETWORK) {
return listOf(TmdbEntityMediaType.TV)
}
return when (sourceType) {
"movie" -> listOf(TmdbEntityMediaType.MOVIE, TmdbEntityMediaType.TV)
else -> listOf(TmdbEntityMediaType.TV, TmdbEntityMediaType.MOVIE)
}
}
private fun normalizeEntitySourceType(sourceType: String): String {
return when (sourceType.trim().lowercase()) {
"movie" -> "movie"
"tv", "series", "show" -> "tv"
else -> "tv"
}
}
suspend fun enrichMeta(
meta: MetaDetails,
fallbackItemId: String,
@ -1278,3 +1534,105 @@ private data class TmdbPersonCreditCrew(
@SerialName("first_air_date") val firstAirDate: String? = null,
@SerialName("vote_average") val voteAverage: Double? = null,
)
// ─── Entity Browse (Company / Network) Models ───
private const val ENTITY_RAIL_MAX_ITEMS = 20
private const val ENTITY_TOP_RATED_VOTE_FLOOR = 200
enum class TmdbEntityKind(val routeValue: String) {
COMPANY("company"),
NETWORK("network");
companion object {
fun fromRouteValue(value: String): TmdbEntityKind = when (value.trim().lowercase()) {
"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>,
val currentPage: Int = 1,
val hasMore: Boolean = false,
val isLoading: Boolean = false,
)
data class TmdbEntityBrowseData(
val header: TmdbEntityHeader,
val rails: List<TmdbEntityRail>,
)
data class TmdbEntityRailPageResult(
val items: List<MetaPreview>,
val hasMore: Boolean,
)
@Serializable
private data class TmdbCompanyDetailsResponse(
val id: Int,
val name: String? = null,
val description: String? = null,
val headquarters: String? = null,
val homepage: String? = null,
@SerialName("logo_path") val logoPath: String? = null,
@SerialName("origin_country") val originCountry: String? = null,
)
@Serializable
private data class TmdbNetworkDetailsResponse(
val id: Int,
val name: String? = null,
val headquarters: String? = null,
val homepage: String? = null,
@SerialName("logo_path") val logoPath: String? = null,
@SerialName("origin_country") val originCountry: String? = null,
)
@Serializable
private data class TmdbDiscoverResponse(
val page: Int? = null,
val results: List<TmdbDiscoverResult> = emptyList(),
@SerialName("total_pages") val totalPages: Int? = null,
@SerialName("total_results") val totalResults: Int? = null,
)
@Serializable
private data class TmdbDiscoverResult(
val id: Int,
val title: String? = null,
val name: String? = null,
@SerialName("original_title") val originalTitle: String? = null,
@SerialName("original_name") val originalName: String? = null,
@SerialName("poster_path") val posterPath: String? = null,
@SerialName("backdrop_path") val backdropPath: String? = null,
val overview: String? = null,
@SerialName("release_date") val releaseDate: String? = null,
@SerialName("first_air_date") val firstAirDate: String? = null,
@SerialName("vote_average") val voteAverage: Double? = null,
@SerialName("vote_count") val voteCount: Int? = null,
)