This commit is contained in:
ponzischeme89
2026-08-28 23:00:02 +12:00
parent 3e89036f7b
commit d5632e844a
66 changed files with 2870 additions and 689 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
val defaultVersionName = "0.3.39"
val defaultVersionName = "0.3.40"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -2145,10 +2145,24 @@ class EmbyRepository internal constructor(
* Uploads a batch of row-engagement events. Silent on the direct path (nothing is
* listening) and silent on failure — telemetry must never surface on a TV.
*/
fun reportRowEvents(events: List<GatewayRowEvent>) {
if (!ServerConfig.isGateway || events.isEmpty() || snapshot.token.isNullOrBlank()) return
fun reportRowEvents(
events: List<GatewayRowEvent>,
onUnsent: (List<GatewayRowEvent>) -> Unit = {},
) {
if (events.isEmpty()) return
// Nothing is ever listening on the direct path, so drop rather than re-buffer.
if (!ServerConfig.isGateway) return
if (snapshot.token.isNullOrBlank()) {
// A session being (re)established: hand the batch back so it is retried on the
// next flush rather than dropped by the caller's drain.
onUnsent(events)
return
}
scope.launch {
runCatching { requireGateway().reportRowEvents(GatewayRowEvents(events)) }
val delivered = runCatching {
requireGateway().reportRowEvents(GatewayRowEvents(events))
}.isSuccess
if (!delivered) onUnsent(events)
}
}
@@ -62,8 +62,13 @@ class RowAnalytics(
*/
fun rowFocused(rowId: String, rowKind: String, itemId: String) {
synchronized(lock) {
if (focusedRowId == rowId) return
closeOpenFocus()
if (focusedRowId == rowId) {
// Still the same strip — extend the dwell, but remember the card the
// remote actually came to rest on rather than the one it entered on.
lastFocusedItemId = itemId
return
}
closeOpenFocus(reArm = false)
focusedRowId = rowId
focusedRowKind = rowKind
focusStartedAt = now()
@@ -92,11 +97,34 @@ class RowAnalytics(
}
/**
* Closes the open focus measurement — call when leaving the home screen, or before a
* flush, so dwell is not lost while the viewer sits on one row.
* Closes the open focus measurement outright — call when focus genuinely leaves the
* rows (the rail, the hero, an overlay, a selection) or the home screen is left.
*/
fun endFocus() {
synchronized(lock) { closeOpenFocus() }
synchronized(lock) { closeOpenFocus(reArm = false) }
}
/**
* Emits the dwell accrued so far and keeps measuring the same row. Used by the periodic
* flush: a viewer who sits on one row for two minutes should contribute the whole two
* minutes, not one chunk ending at the first flush and nothing after it until they
* move to a different row.
*/
fun checkpointFocus() {
synchronized(lock) { closeOpenFocus(reArm = true) }
}
/**
* Returns events to the buffer after an upload could not deliver them, so a transient
* network failure costs a delay rather than the batch. Bounded like every other write:
* if the buffer is now over capacity the oldest events are dropped.
*/
fun restore(events: List<GatewayRowEvent>) {
if (events.isEmpty()) return
synchronized(lock) {
buffer.addAll(0, events)
while (buffer.size > maxBuffered) buffer.removeAt(0)
}
}
/** Returns everything buffered and clears it. */
@@ -116,28 +144,37 @@ class RowAnalytics(
impressed.clear()
impressedItems.clear()
focusedRowId = null
focusStartedAt = 0
lastFocusedItemId = ""
}
}
private var lastFocusedItemId: String = ""
private fun closeOpenFocus() {
/**
* @param reArm keep measuring the same row from now, rather than clearing it. A
* checkpoint flush re-arms; a genuine departure does not.
*/
private fun closeOpenFocus(reArm: Boolean) {
val rowId = focusedRowId ?: return
val dwell = (now() - focusStartedAt).coerceAtLeast(0)
focusedRowId = null
// Sub-second glances are D-pad travel, not attention. Dropping them keeps the
// numbers meaningful and the batches small.
if (dwell < MIN_DWELL_MS) return
add(
GatewayRowEvent(
rowId = rowId,
rowKind = focusedRowKind,
event = EVENT_FOCUS,
itemId = lastFocusedItemId,
dwellMs = dwell,
occurredAt = timestamp(),
),
)
// Sub-threshold glances are D-pad travel, not attention. Drop them — and, when
// re-arming, leave the clock running so the unrecorded fraction is not discarded
// on every flush.
if (dwell >= MIN_DWELL_MS) {
add(
GatewayRowEvent(
rowId = rowId,
rowKind = focusedRowKind,
event = EVENT_FOCUS,
itemId = lastFocusedItemId,
dwellMs = dwell,
occurredAt = timestamp(),
),
)
focusStartedAt = now()
}
if (!reArm) focusedRowId = null
}
/** Caller already holds the lock. */
@@ -498,6 +498,13 @@ data class BaseItem(
@SerialName("MembyLifecycle") val membyLifecycle: String? = null,
@SerialName("MembyLifecycleText") val membyLifecycleText: String? = null,
@SerialName("MembyPlayable") val membyPlayable: Boolean = true,
// Set by the gateway on a Continue Watching card for a title this viewer requested that
// has just become watchable, so it can wear a REQUEST READY tag until they start it.
// The label is the server's wording (the MembyAirLabel precedent); the flag alone is
// enough for an older build, which falls back to its own constant. Both default, so a
// cached home payload decodes unchanged.
@SerialName("MembyRequestReady") val membyRequestReady: Boolean = false,
@SerialName("MembyRequestReadyLabel") val membyRequestReadyLabel: String? = null,
@SerialName("MembySearchState") val membySearchState: String? = null,
@SerialName("MembyRequestable") val membyRequestable: Boolean = false,
@SerialName("MembyPosterURL") val membyPosterUrl: String? = null,
@@ -176,6 +176,12 @@ data class HomeRow(
val title: String = "",
val kind: String = "",
val items: List<BaseItem> = emptyList(),
/**
* Operator-pinned card shape: "poster" forces upright poster cards, "thumb" forces the
* wide landscape cards Continue Watching uses. Empty (the default, and every row from a
* gateway that predates this) leaves the client's automatic choice alone.
*/
val layout: String = "",
)
/** Everything the launcher renders, in one response. */
@@ -271,6 +271,12 @@ internal fun HomeContentPane(
// back. Keyed on the destination so arriving at Home never inherits where
// focus happened to be on another one.
var rowFocusedBelowHero by remember(homeState.selectedDestination) { mutableStateOf(false) }
// The moment focus is no longer on a shelf — up to the hero, across to My Shows, or a
// destination switch — close the open dwell measurement, so minutes spent above the
// rows are not credited to whichever row was focused last.
LaunchedEffect(rowFocusedBelowHero) {
if (!rowFocusedBelowHero) homeViewModel.notifyFocusLeftRows()
}
val showHomeHero = shouldShowHomeMovieHero(
hasMovies = hasContextualHero,
listAtTop = homeListAtTop,
@@ -616,6 +616,7 @@ internal fun serverHomeRows(
"continue" -> CONTINUE_ROW_SECONDARY_METADATA
else -> BROWSE_ROW_SECONDARY_METADATA
},
cardLayout = row.layout,
)
}
// A server response is already context-ranked. Do not reapply the static bundled
@@ -654,7 +654,7 @@ internal fun HomeScreen(
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, homeViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) homeViewModel.flushAnalytics()
if (event == Lifecycle.Event.ON_STOP) homeViewModel.flushAnalytics(endMeasurement = true)
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
@@ -683,6 +683,9 @@ internal fun HomeScreen(
navigationFocusRequester = navigationFocusRequester,
onRailFocusChanged = {
homeState.navigationExpanded = it
// Focus on the rail is focus off the shelves: end the open row dwell so
// it stops accruing while the viewer is in the navigation or an overlay.
if (it) homeViewModel.notifyFocusLeftRows()
},
onDestinationSelected = { destination ->
homeViewModel.trackJourney(
@@ -262,17 +262,30 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
fun trackRowFocused(rowId: String, rowKind: String, itemId: String) =
analytics.rowFocused(rowId, rowKind, itemId)
fun trackRowSelected(rowId: String, rowKind: String, itemId: String) =
fun trackRowSelected(rowId: String, rowKind: String, itemId: String) {
analytics.rowSelected(rowId, rowKind, itemId)
// Opening something ends the current dwell — the viewer has left the row for a
// detail page, so time on that page must not be added to the row's total.
analytics.endFocus()
}
/**
* Closes the open dwell measurement and uploads. Called on a timer and when the home
* screen stops, so time spent sitting on one row is not lost.
* Focus has moved off the shelves — to the rail, the hero, My Shows or an overlay.
* Closes the open dwell measurement so it stops accruing away from the rows.
*/
fun flushAnalytics() {
fun notifyFocusLeftRows() = analytics.endFocus()
/**
* Emits accrued dwell and uploads. On a timer it *checkpoints* — a viewer sitting on
* one row keeps accruing dwell across flushes rather than contributing a single ~20s
* chunk until they move — and [endMeasurement] closes it outright for the cases where
* the home screen is genuinely being left. A batch the upload cannot deliver is put
* back for the next flush rather than lost to a transient network failure.
*/
fun flushAnalytics(endMeasurement: Boolean = false) {
if (analyticsPausedForPlayback) return
analytics.endFocus()
repository.reportRowEvents(analytics.drain())
if (endMeasurement) analytics.endFocus() else analytics.checkpointFocus()
repository.reportRowEvents(analytics.drain()) { unsent -> analytics.restore(unsent) }
repository.reportJourneyEvents(journey.drain())
}
@@ -310,7 +323,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
*/
val journeySink: JourneySink get() = journey
fun endJourney(screen: String) { journey.end(screen); flushAnalytics() }
fun endJourney(screen: String) { journey.end(screen); flushAnalytics(endMeasurement = true) }
suspend fun endJourneyBeforeProfileSwitch(screen: String) {
analytics.endFocus()
@@ -991,7 +1004,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
override fun onCleared() {
analyticsPausedForPlayback = false
flushAnalytics()
flushAnalytics(endMeasurement = true)
super.onCleared()
}
@@ -40,6 +40,7 @@ import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.MembyMutedText
@@ -69,6 +70,12 @@ data class ResumableMediaCardModel(
val played: Boolean = false,
val favourite: Boolean = false,
val isNextUp: Boolean = false,
/**
* Non-null when this card is a title the viewer requested that has just become
* watchable. Holds the tag wording, which the gateway supplies; [REQUEST_READY_TAG] is
* the fallback for a payload from a gateway that predates the label field.
*/
val requestReadyTag: String? = null,
) {
val episodeLabel: String? get() = episodeLabel(seasonNumber, episodeNumber, episodeName)
val progress: Float get() = resumableProgress(playbackPositionTicks, runtimeTicks)
@@ -76,6 +83,9 @@ data class ResumableMediaCardModel(
val nextUpLabel: String? get() = "Next up".takeIf { isNextUp }
}
/** Client fallback for the request-ready tag when the gateway sent no wording. */
const val REQUEST_READY_TAG = "REQUEST READY"
internal fun BaseItem.toResumableMediaCardModel(
thumbUrl: String?,
backdropUrl: String?,
@@ -102,6 +112,11 @@ internal fun BaseItem.toResumableMediaCardModel(
primaryUrl = primaryUrl,
played = played,
favourite = isFavorite,
requestReadyTag = if (membyRequestReady) {
membyRequestReadyLabel?.trim()?.takeIf(String::isNotEmpty) ?: REQUEST_READY_TAG
} else {
null
},
// This adapter is used by the Continue Watching card. Within that merged row, an
// unplayed episode with no playhead is the Next Up half; resumable episodes have a
// positive playhead and films are never supplied by Emby's Next Up feed.
@@ -333,6 +348,22 @@ fun ResumableMediaCard(
}
}
}
model.requestReadyTag?.let { tag ->
Text(
text = tag,
color = MembyAccentInk,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.5.sp,
maxLines = 1,
modifier = Modifier
.align(Alignment.TopStart)
.padding(8.dp)
.clip(RoundedCornerShape(4.dp))
.background(MembyAccent)
.padding(horizontal = 7.dp, vertical = 4.dp),
)
}
if (focused) MembyArtworkPlayCue(Modifier.align(Alignment.Center))
}
Text(
@@ -63,6 +63,11 @@ data class HomeBrowseRow(
val emptyMessage: String,
val showSecondaryMetadata: Boolean = true,
val showWatchedEpisodeCount: Boolean = false,
/**
* Operator-pinned card shape from the gateway's section definitions: "poster" or
* "thumb". Empty leaves [cardFormat]'s automatic choice alone.
*/
val cardLayout: String = "",
)
private fun getHomeRowIcon(row: HomeBrowseRow): ImageVector = when {
@@ -175,8 +180,12 @@ internal fun MediaRow(
when {
row.items.isEmpty() && row.loading -> {
val isPortrait = remember(row.kind) {
row.kind == MediaRowKind.MOVIES || row.kind == MediaRowKind.SHOWS
val isPortrait = remember(row.kind, row.cardLayout) {
when (row.cardLayout) {
"poster" -> true
"thumb", "landscape" -> false
else -> row.kind == MediaRowKind.MOVIES || row.kind == MediaRowKind.SHOWS
}
}
LazyRow(
contentPadding = PaddingValues(
@@ -226,7 +235,7 @@ internal fun MediaRow(
items = row.items,
key = { _, item -> item.id },
contentType = { _, item ->
if (cardFormat(row.kind, item, artworkStyle) == MediaCardFormat.PORTRAIT) "portrait" else "landscape"
if (cardFormat(row.kind, item, artworkStyle, row.cardLayout) == MediaCardFormat.PORTRAIT) "portrait" else "landscape"
},
) { index, item ->
val targetFocusRequester = when {
@@ -266,8 +275,8 @@ internal fun MediaRow(
{ currentOnItemLongPressed(currentItem) }
}
val format = remember(row.kind, item.id, item.isEpisode, artworkStyle) {
cardFormat(row.kind, item, artworkStyle)
val format = remember(row.kind, item.id, item.isEpisode, artworkStyle, row.cardLayout) {
cardFormat(row.kind, item, artworkStyle, row.cardLayout)
}
if (row.kind == MediaRowKind.CONTINUE) {
@@ -314,18 +323,29 @@ internal fun MediaRow(
}
}
private enum class MediaCardFormat { PORTRAIT, LANDSCAPE }
internal enum class MediaCardFormat { PORTRAIT, LANDSCAPE }
private fun cardFormat(
/**
* Resolves a card's shape. A per-row [rowLayout] pinned by the operator ("poster" /
* "thumb") wins over the viewer's global [artworkStyle], which in turn wins over the
* automatic rule (Continue Watching and episodes landscape, everything else poster).
* Pure so [MediaRowCardFormatTest] can pin the precedence.
*/
internal fun cardFormat(
kind: MediaRowKind,
item: BaseItem,
artworkStyle: String = "automatic",
): MediaCardFormat = when (artworkStyle) {
rowLayout: String = "",
): MediaCardFormat = when (rowLayout) {
"poster" -> MediaCardFormat.PORTRAIT
"backdrop" -> MediaCardFormat.LANDSCAPE
else -> when {
kind == MediaRowKind.CONTINUE -> MediaCardFormat.LANDSCAPE
item.isEpisode -> MediaCardFormat.LANDSCAPE
else -> MediaCardFormat.PORTRAIT
"thumb", "landscape" -> MediaCardFormat.LANDSCAPE
else -> when (artworkStyle) {
"poster" -> MediaCardFormat.PORTRAIT
"backdrop" -> MediaCardFormat.LANDSCAPE
else -> when {
kind == MediaRowKind.CONTINUE -> MediaCardFormat.LANDSCAPE
item.isEpisode -> MediaCardFormat.LANDSCAPE
else -> MediaCardFormat.PORTRAIT
}
}
}
@@ -46,6 +46,72 @@ class RowAnalyticsTest {
assertEquals(6_000L, focuses.single().dwellMs)
}
@Test
fun `a checkpoint emits dwell and keeps measuring the same row`() {
val clock = FakeClock()
val collector = analytics(clock)
collector.rowFocused("recommended", "MOVIES", "item-1")
clock.advance(20_000)
collector.checkpointFocus()
clock.advance(20_000)
collector.checkpointFocus()
clock.advance(5_000)
collector.endFocus()
val dwell = collector.drain()
.filter { it.event == RowAnalytics.EVENT_FOCUS }
.sumOf { it.dwellMs }
// The whole 45s is credited to the row, not just the first chunk.
assertEquals(45_000L, dwell)
}
@Test
fun `a checkpoint below the threshold does not discard the accrued fraction`() {
val clock = FakeClock()
val collector = analytics(clock)
collector.rowFocused("recommended", "MOVIES", "item-1")
clock.advance(300)
collector.checkpointFocus() // below MIN_DWELL_MS — nothing emitted, clock left running
clock.advance(300)
collector.endFocus()
val focus = collector.drain().single { it.event == RowAnalytics.EVENT_FOCUS }
assertEquals(600L, focus.dwellMs)
}
@Test
fun `restored events are retried on the next drain`() {
val collector = analytics(FakeClock())
collector.rowImpression("favorites", "FAVORITES")
val undelivered = collector.drain()
assertEquals(1, undelivered.size)
collector.restore(undelivered)
collector.rowImpression("recommended", "MOVIES")
assertEquals(
listOf("favorites", "recommended"),
collector.drain().map { it.rowId },
)
}
@Test
fun `restored events past capacity drop the oldest`() {
val collector = analytics(FakeClock(), maxBuffered = 2)
val stale = List(3) {
com.ponzischeme89.memby.data.model.GatewayRowEvent(
rowId = "stale-$it", rowKind = "MOVIES",
event = RowAnalytics.EVENT_IMPRESSION, occurredAt = "x",
)
}
collector.restore(stale)
assertEquals(listOf("stale-1", "stale-2"), collector.drain().map { it.rowId })
}
@Test
fun `passing through a row is not counted as attention`() {
val clock = FakeClock()
@@ -0,0 +1,42 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import org.junit.Assert.assertEquals
import org.junit.Test
/** The precedence in [cardFormat]: an operator's per-row pin beats the viewer's global
* artwork style, which beats the automatic episode/poster rule. */
class MediaRowCardFormatTest {
private val movie = BaseItem(id = "m", name = "A Film", type = "Movie")
private val episode = BaseItem(id = "e", name = "Pilot", type = "Episode")
@Test
fun automaticRuleWhenNothingIsPinned() {
assertEquals(MediaCardFormat.PORTRAIT, cardFormat(MediaRowKind.MOVIES, movie))
assertEquals(MediaCardFormat.LANDSCAPE, cardFormat(MediaRowKind.MOVIES, episode))
assertEquals(MediaCardFormat.LANDSCAPE, cardFormat(MediaRowKind.CONTINUE, movie))
}
@Test
fun rowLayoutOverridesEverything() {
assertEquals(
MediaCardFormat.LANDSCAPE,
cardFormat(MediaRowKind.FAVORITES, movie, artworkStyle = "poster", rowLayout = "thumb"),
)
assertEquals(
MediaCardFormat.PORTRAIT,
cardFormat(MediaRowKind.CONTINUE, episode, artworkStyle = "backdrop", rowLayout = "poster"),
)
}
@Test
fun globalArtworkStyleStillAppliesWithoutARowPin() {
assertEquals(MediaCardFormat.LANDSCAPE, cardFormat(MediaRowKind.MOVIES, movie, artworkStyle = "backdrop"))
assertEquals(MediaCardFormat.PORTRAIT, cardFormat(MediaRowKind.MOVIES, episode, artworkStyle = "poster"))
}
@Test
fun unknownRowLayoutFallsThrough() {
assertEquals(MediaCardFormat.PORTRAIT, cardFormat(MediaRowKind.MOVIES, movie, rowLayout = "sideways"))
}
}
@@ -0,0 +1,73 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import androidx.test.core.app.ApplicationProvider
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import com.ponzischeme89.memby.ui.components.media.ContinueWatchingCard
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* The REQUEST READY tag on a Continue Watching card. The claim it makes that the tag reads
* as a temporary marker rather than as part of the artwork, and sits clear of the watched /
* favourite icons in the opposite corner is not something a unit test can check.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class RequestReadyCardScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
@Test
fun `a freshly arrived request wears the request ready tag`() {
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) {
Box(Modifier.fillMaxSize().padding(48.dp)) {
ContinueWatchingCard(
item = BaseItem(
id = "requested-film",
name = "The Slow Return",
type = "Movie",
runTimeTicks = 108L * 60L * 10_000_000L,
userData = UserItemData(playbackPositionTicks = 0L),
membyRequestReady = true,
membyRequestReadyLabel = "REQUEST READY",
),
availableWidth = 864.dp,
portraitArtwork = false,
onFocused = {},
onClick = {},
onLongClick = {},
)
}
}
}
compose.onNodeWithText("REQUEST READY").assertExists()
compose.onRoot().captureRoboImage(
"build/screenshots/request-ready/continue-watching-tag.png",
)
}
}
@@ -105,6 +105,35 @@ class ResumableMediaCardTest {
assertNull(model.nextUpLabel)
}
@Test
fun `a request-ready title carries the gateway's tag wording`() {
val model = BaseItem(
id = "film",
name = "A Requested Film",
type = "Movie",
membyRequestReady = true,
membyRequestReadyLabel = "READY FOR YOU",
).toResumableMediaCardModel(thumbUrl = null, backdropUrl = null, primaryUrl = null)
assertEquals("READY FOR YOU", model.requestReadyTag)
}
@Test
fun `a request-ready title with no wording falls back to the client constant`() {
val model = BaseItem(id = "film", name = "A Film", type = "Movie", membyRequestReady = true)
.toResumableMediaCardModel(thumbUrl = null, backdropUrl = null, primaryUrl = null)
assertEquals(REQUEST_READY_TAG, model.requestReadyTag)
}
@Test
fun `an ordinary card has no request-ready tag`() {
val model = BaseItem(id = "film", name = "A Film", type = "Movie")
.toResumableMediaCardModel(thumbUrl = null, backdropUrl = null, primaryUrl = null)
assertNull(model.requestReadyTag)
}
@Test
fun `landscape artwork prefers thumb over backdrop and primary`() {
val model = ResumableMediaCardModel(