Add optional MDBList movie ratings

This commit is contained in:
ponzischeme89
2026-08-03 08:55:52 +12:00
parent b6b2a9c25a
commit 666da9c5d3
18 changed files with 840 additions and 4 deletions
@@ -611,6 +611,20 @@ class EmbyRepository(private val settings: SettingsStore) {
)
}
/**
* Optional movie ratings authored entirely by the gateway. Direct Emby mode has no
* MDBList configuration, and any gateway failure is deliberately indistinguishable
* from a movie with no external ratings.
*/
suspend fun getMovieRatings(
itemId: String,
): List<com.ponzischeme89.memby.data.model.GatewayMovieRating> {
if (!ServerConfig.isGateway || itemId.isBlank()) return emptyList()
return runCatching { requireGateway().movieRatings(itemId).ratings }
.getOrDefault(emptyList())
.filter { it.name.isNotBlank() && it.score.isNotBlank() && it.scale.isNotBlank() }
}
/** Whether this episode closes its season; failures are non-fatal playback metadata. */
suspend fun seasonFinale(itemId: String): com.ponzischeme89.memby.data.model.GatewaySeasonFinale {
if (itemId.isBlank()) return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
@@ -168,6 +168,21 @@ data class GatewayRows(
val rows: List<HomeRow> = emptyList(),
)
/** Server-formatted external movie rating. The TV renders these fields verbatim and
* never needs to know which providers are enabled or how their scales work. */
@Serializable
data class GatewayMovieRating(
val source: String = "",
val name: String = "",
val score: String = "",
val scale: String = "",
)
@Serializable
data class GatewayMovieRatings(
val ratings: List<GatewayMovieRating> = emptyList(),
)
@Serializable
data class RecommendationOnboarding(
val completed: Boolean = false,
@@ -11,6 +11,7 @@ import com.ponzischeme89.memby.data.model.GatewayLoginRequest
import com.ponzischeme89.memby.data.model.GatewayLoginResponse
import com.ponzischeme89.memby.data.model.GatewayMediaRequest
import com.ponzischeme89.memby.data.model.GatewayMediaRequestResult
import com.ponzischeme89.memby.data.model.GatewayMovieRatings
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
import com.ponzischeme89.memby.data.model.GatewayPlayback
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
@@ -143,6 +144,10 @@ interface GatewayApi {
@GET("v1/items/{id}")
suspend fun item(@Path("id") itemId: String): BaseItem
/** Optional, server-filtered external movie ratings. Empty is always a valid result. */
@GET("v1/items/{id}/ratings")
suspend fun movieRatings(@Path("id") itemId: String): GatewayMovieRatings
@GET("v1/items/{id}/season-finale")
suspend fun seasonFinale(
@Path("id") itemId: String,
@@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -72,6 +74,7 @@ import androidx.tv.material3.Text
import coil.compose.AsyncImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayMovieRating
import com.ponzischeme89.memby.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.TechnicalSpec
@@ -188,6 +191,7 @@ internal fun DetailPageScaffold(
progress: Float = 0f,
progressLabel: String? = null,
reasons: List<String> = emptyList(),
ratings: List<GatewayMovieRating> = emptyList(),
heroActions: List<DetailHeroAction> = emptyList(),
confirmation: String? = null,
pageListState: LazyListState = remember(item.id) { LazyListState() },
@@ -257,6 +261,7 @@ internal fun DetailPageScaffold(
progress = progress,
progressLabel = progressLabel,
reasons = reasons,
ratings = ratings,
actions = heroActions,
actionRequesters = actionRequesters,
height = heroHeight,
@@ -347,6 +352,7 @@ private fun DetailHero(
progress: Float,
progressLabel: String?,
reasons: List<String>,
ratings: List<GatewayMovieRating>,
actions: List<DetailHeroAction>,
actionRequesters: List<FocusRequester>,
height: Dp,
@@ -390,6 +396,10 @@ private fun DetailHero(
}
Spacer(Modifier.height(10.dp))
DetailFactRow(facts = facts, badges = badges, rating = ratingLabel(item))
if (ratings.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
DetailMovieRatings(ratings)
}
if (item.genres.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text(
@@ -448,6 +458,49 @@ private fun DetailHero(
}
}
/** Informational only: rating chips deliberately do not take focus, so a remote's
* navigation path remains Play/actions -> tabs. FlowRow wraps narrow layouts while
* keeping every configured and available source visible. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun DetailMovieRatings(
ratings: List<GatewayMovieRating>,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(7.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
ratings.forEach { rating ->
Row(
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(Color(0xB31B2025))
.border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(6.dp))
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Text(
text = rating.name,
color = DetailQuietText,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
)
Text(
text = rating.score + rating.scale,
color = DetailText,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
}
}
@Composable
private fun DetailCircularAction(
action: DetailHeroAction,
@@ -22,6 +22,7 @@ import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.RelatedContent
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayMovieRating
import com.ponzischeme89.memby.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.creditRows
@@ -55,12 +56,20 @@ fun MediaDetailsOverlay(
val settings by ServiceLocator.repository.settingsFlow.collectAsState(initial = Settings.EMPTY)
var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
var trailer by remember(item.id) { mutableStateOf<BaseItem?>(null) }
var ratings by remember(item.id) { mutableStateOf<List<GatewayMovieRating>>(emptyList()) }
LaunchedEffect(item.id) {
related = ServiceLocator.repository.getRelated(item)
}
LaunchedEffect(item.id) {
trailer = ServiceLocator.repository.getLocalTrailer(item.id)
}
LaunchedEffect(item.id, item.isMovie) {
ratings = if (item.isMovie) {
ServiceLocator.repository.getMovieRatings(item.id)
} else {
emptyList()
}
}
MediaDetailContent(
item = item,
onPlay = onPlay,
@@ -69,6 +78,7 @@ fun MediaDetailsOverlay(
onOpenItem = onOpenItem,
related = related,
trailer = trailer,
ratings = ratings,
hideWatchedMovies = settings.hideWatchedMovies,
modifier = modifier,
)
@@ -87,6 +97,7 @@ internal fun MediaDetailContent(
modifier: Modifier = Modifier,
related: RelatedContent? = null,
trailer: BaseItem? = null,
ratings: List<GatewayMovieRating> = emptyList(),
hideWatchedMovies: Boolean = false,
onOpenItem: (BaseItem) -> Unit = {},
) {
@@ -167,6 +178,7 @@ internal fun MediaDetailContent(
progressLabel = remainingLabel(item),
reasons = related?.reasons?.takeIf(List<String>::isNotEmpty)
?: listOfNotNull(item.membyRecommendationReason?.takeIf(String::isNotBlank)),
ratings = ratings,
confirmation = confirmation,
onZoneFocused = { zone -> detailPositions.update(item.id) { it.copy(zone = zone) } },
heroActions = buildList {
@@ -3,6 +3,7 @@ package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.GatewayHome
import com.ponzischeme89.memby.data.model.GatewayDevices
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
import com.ponzischeme89.memby.data.model.GatewayMovieRatings
import com.ponzischeme89.memby.data.model.GatewayPlayback
import com.ponzischeme89.memby.data.model.GatewayPlaybackReportResponse
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
@@ -23,6 +24,16 @@ import org.junit.Test
* side, this fails before a TV ever sees it.
*/
class GatewayPayloadTest {
@Test
fun `decodes server formatted movie rating sources and scales`() {
val response = json.decodeFromString<GatewayMovieRatings>(
"""{"ratings":[{"source":"imdb","name":"IMDb","score":"8.2","scale":"/10"},{"source":"tomatoes","name":"Rotten Tomatoes","score":"91","scale":"%"}]}""",
)
assertEquals(listOf("IMDb", "Rotten Tomatoes"), response.ratings.map { it.name })
assertEquals(listOf("8.2/10", "91%"), response.ratings.map { it.score + it.scale })
}
@Test
fun `decodes signed in devices without an allowance`() {
val response = json.decodeFromString<GatewayDevices>(