Add optional MDBList movie ratings
This commit is contained in:
@@ -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>(
|
||||
|
||||
Reference in New Issue
Block a user