This commit is contained in:
ponzischeme89
2026-08-20 07:54:03 +12:00
parent 769fe01c84
commit 434371e9cf
37 changed files with 1767 additions and 102 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.80"
val defaultVersionName = "0.2.81"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -7,6 +7,7 @@ import coil.disk.DiskCache
import coil.memory.MemoryCache
import com.ponzischeme89.memby.data.remote.HttpStack
import com.ponzischeme89.memby.performance.StartupTrace
import com.ponzischeme89.memby.ui.TitleLogoCache
import com.ponzischeme89.memby.ui.player.PrerollPreloader
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
@@ -51,6 +52,9 @@ class MembyApp : Application() {
.crossfade(false)
.build(),
)
// A logo's darkness verdict and the address of a warm are both process-scoped, and
// the launcher warms logos from a ViewModel that holds no context of its own.
TitleLogoCache.install(this)
ServiceLocator.init(this)
// Registers an idle callback only: the local four-second clip is prepared after
// the launcher's queued start-up work, never on its critical path.
@@ -2,6 +2,8 @@ package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.AuthRequest
import com.ponzischeme89.memby.data.model.BaseItem
import android.os.SystemClock
import com.ponzischeme89.memby.data.model.GenreAffinity
import com.ponzischeme89.memby.data.model.GatewayCalendar
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
import com.ponzischeme89.memby.data.model.GatewayDevice
@@ -372,6 +374,27 @@ class EmbyRepository internal constructor(
LinkedHashMap<String, RadarrMovieDetail>(RADARR_MOVIE_CACHE_SIZE, 0.75f, true)
private val radarrMovieInFlight = mutableMapOf<String, Deferred<RadarrMovieDetail?>>()
/**
* What this viewer watches, for the order of the Genres browser's rail.
*
* Held as a plain volatile rather than behind a suspend call because the reader is a
* ViewModel's constructor — the same reason [snapshot] exists. A screen that had to
* await this before drawing its rail would be a screen whose genre list appears late,
* and re-ordering a rail somebody is already looking at is worse than not personalising
* it at all.
*/
@Volatile
private var genreAffinity: GenreAffinity = GenreAffinity()
/**
* When that reading was taken, in [SystemClock.elapsedRealtime], so a television
* correcting its clock cannot make an hour-old answer look like a fresh one.
*/
@Volatile
private var genreAffinityReadAt = 0L
private val genreAffinityMutex = Mutex()
private var genreAffinityInFlight: Deferred<GenreAffinity?>? = null
fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
suspend fun cacheHome(content: HomeCache) = settings.setHomeCache(content)
@@ -471,6 +494,7 @@ class EmbyRepository internal constructor(
clearPlayableCache()
clearSeriesEpisodeCache()
clearLocalResume()
clearGenreAffinity()
settings.ensureDeviceId()
observedSettings = settings.snapshot() // pick up the freshly-generated device id
@@ -563,6 +587,7 @@ class EmbyRepository internal constructor(
clearPlayableCache()
clearSeriesEpisodeCache()
clearLocalResume()
clearGenreAffinity()
}
suspend fun signOut() {
@@ -578,6 +603,7 @@ class EmbyRepository internal constructor(
clearPlayableCache()
clearSeriesEpisodeCache()
clearLocalResume()
clearGenreAffinity()
}
suspend fun switchProfile(profile: EmbyProfile) {
@@ -588,6 +614,7 @@ class EmbyRepository internal constructor(
clearPlayableCache()
clearSeriesEpisodeCache()
clearLocalResume()
clearGenreAffinity()
}
suspend fun removeProfile(profile: EmbyProfile) {
@@ -605,6 +632,8 @@ class EmbyRepository internal constructor(
clearPlayableCache()
clearSeriesEpisodeCache()
clearLocalResume()
clearGenreAffinity()
clearGenreAffinity()
}
}
@@ -910,6 +939,89 @@ class EmbyRepository internal constructor(
return GenrePage(items, offset, total)
}
/**
* Which genres this viewer actually watches, for the order of the Genres browser's rail.
*
* The answer comes from the gateway because that is where the evidence is: Tracearr's
* sessions and the imported catalogue's genres are both already in Postgres, and a
* television holds neither. It is a weight per Emby genre label and nothing more — the
* category catalogue, its aliases and its product order all stay on the set, which is
* what stops the two ends needing to agree about a list only one of them draws.
*
* Deliberately *not* on the path of drawing anything. [genreAffinitySnapshot] is what
* the rail reads, and it returns whatever is already known; this is what fills that in,
* behind the viewer. A genre browser that waited on a request before showing its
* categories would have paid for personalisation with the one thing it was told not to
* cost.
*
* Never throws, and every way it can fail produces the same empty answer: the direct
* path has nobody to ask, a gateway that predates the route answers 404, a household
* running no Tracearr has no history, and an operator may have switched it off. All four
* are "use the catalogue's own order", which is a perfectly good rail.
*/
suspend fun getGenreAffinity(): GenreAffinity {
if (!ServerConfig.isGateway) return GenreAffinity()
val inFlight = genreAffinityMutex.withLock {
if (!genreAffinityStale()) return genreAffinity
genreAffinityInFlight ?: newGenreAffinityRequest()
}
// A failed reading leaves the previous one standing rather than clearing it: an
// unreachable gateway is no evidence that somebody's taste has changed.
return inFlight.await() ?: genreAffinity
}
/**
* The reading already in hand, with no request and no suspension.
*
* This is what the Genres browser's ViewModel reads when it is constructed, the
* [snapshot] arrangement. The rail is ordered once, from this, and never re-ordered
* while somebody is looking at it — a list that reshuffled under a D-pad would be worse
* than one that was never personalised, and the warm below is what makes it almost
* always already here.
*/
fun genreAffinitySnapshot(): GenreAffinity = genreAffinity
/**
* Fetches the reading if it is missing or stale, and returns immediately.
*
* Called as the launcher settles, because the Genres browser is never the first thing a
* television draws — a viewer has to travel the rail to reach it — so by the time it is
* opened this has long since landed. The cost is one request per six hours per set.
*/
fun warmGenreAffinity() {
if (!ServerConfig.isGateway) return
scope.launch { runCatching { getGenreAffinity() } }
}
private fun genreAffinityStale(): Boolean =
genreAffinityReadAt == 0L ||
SystemClock.elapsedRealtime() - genreAffinityReadAt >= GENRE_AFFINITY_REFRESH_MS
private fun newGenreAffinityRequest(): Deferred<GenreAffinity?> {
val request = scope.async(start = CoroutineStart.LAZY) {
try {
val loaded = runCatching { requireGateway().genreAffinity() }.getOrNull()
?: return@async null
genreAffinityMutex.withLock {
genreAffinity = loaded
genreAffinityReadAt = SystemClock.elapsedRealtime()
loaded
}
} finally {
genreAffinityMutex.withLock { genreAffinityInFlight = null }
}
}
genreAffinityInFlight = request
request.start()
return request
}
/** Another viewer's taste is not this one's; cleared wherever the session changes. */
private fun clearGenreAffinity() {
genreAffinity = GenreAffinity()
genreAffinityReadAt = 0L
}
/**
* One stable, paged slice of all films, all series, or both.
*
@@ -3135,7 +3247,7 @@ class EmbyRepository internal constructor(
* or null when the item has no logo. Used to show artwork in place of the
* plain-text title in the screensaver.
*/
fun logoUrl(item: BaseItem, maxWidth: Int = 800): String? {
fun logoUrl(item: BaseItem, maxWidth: Int = LOGO_MAX_WIDTH): String? {
val (id, tag) = when {
item.imageTags["Logo"] != null -> item.id to item.imageTags.getValue("Logo")
item.parentLogoItemId != null && item.parentLogoImageTag != null ->
@@ -3457,6 +3569,18 @@ data class ChapterMarkers(
/** An empty reading is a real answer — most titles have no markers — so it is cached too. */
private data class CachedIntro(val value: ChapterMarkers)
/**
* The one width every surface asks Emby for a logo at.
*
* It used to be four — 800 for the screensaver, 720 for the detail page and the player, 640
* for the home hero, 420 for the TV calendar — and a width is part of the URL, so one show's
* title treatment was four cache keys, four fetches and four darkness verdicts. One width
* makes it one entry in Coil's memory and disk caches and one entry in `TitleLogoCache`,
* shared by every screen that draws it. 720px comfortably covers the largest stage in the
* app (the screensaver's 320dp) on a 1080p television.
*/
const val LOGO_MAX_WIDTH = 720
private const val PLAYABLE_CACHE_SIZE = 16
private const val PLAYABLE_CACHE_TTL_MS = 5L * 60L * 1_000L
internal const val PLAYABLE_PREFETCH_MAX_AGE_MS = 15_000L
@@ -25,6 +25,16 @@ data class GenrePage(
/** A page this size, in items. Several television screenfuls, so the scroll stays ahead. */
const val GENRE_PAGE_SIZE = 48
/**
* How long a viewer's genre affinity is good for on the television.
*
* Matched to the gateway's own cache lifetime, so a set that refetches on the far side of it
* is asking a question that has actually been recomputed rather than paying a round trip for
* a cached repeat of what it already holds. Taste does not move in an evening; what this
* span buys is that a household whose viewing changes sees the rail follow it within a day.
*/
const val GENRE_AFFINITY_REFRESH_MS = 6L * 60L * 60L * 1000L
/**
* Whether the grid should ask for another page.
*
@@ -90,6 +90,7 @@ class MaintenanceMonitor(
private val _preferencesRevision = MutableStateFlow(0L)
private val _theme = MutableStateFlow(GatewayThemeStatus())
private val _heroRevision = MutableStateFlow("")
private val _installPermissionPrompt = MutableStateFlow(false)
private val _genreBrowserEnabled = MutableStateFlow(false)
private val _tvCalendarEnabled = MutableStateFlow(false)
@@ -107,6 +108,19 @@ class MaintenanceMonitor(
*/
val theme: StateFlow<GatewayThemeStatus> = _theme.asStateFlow()
/**
* The revision of the hero configuration the gateway would compose for this viewer.
*
* The launcher refetches its heroes only when this moves, which is what makes an
* operator pinning a title arrive on the poll a set is already making rather than at
* the next cold start. It rides this poll for the theme's reason: the change has to
* reach a television that is already switched on and sitting on the home screen.
*
* Blank whenever nothing has said otherwise — signed out, on the direct path, or on a
* gateway that predates the field — and blank never triggers a refetch.
*/
val heroRevision: StateFlow<String> = _heroRevision.asStateFlow()
/**
* The viewer's server-held settings revision, as of the last successful poll. This is
* how an operator's push reaches a television: the number changes, [PreferencesSync]
@@ -242,6 +256,7 @@ class MaintenanceMonitor(
if (ServerConfig.isGateway) _embyOutage.value = null
_preferencesRevision.value = 0
_theme.value = GatewayThemeStatus()
_heroRevision.value = ""
_installPermissionPrompt.value = false
_genreBrowserEnabled.value = false
_tvCalendarEnabled.value = false
@@ -275,6 +290,7 @@ class MaintenanceMonitor(
}
_preferencesRevision.value = status.preferencesRevision
_theme.value = status.theme
_heroRevision.value = status.hero.revision
_installPermissionPrompt.value =
status.features[INSTALL_PERMISSION_FEATURE] == true
_genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true
@@ -314,6 +330,7 @@ class MaintenanceMonitor(
_embyOutage.value = null
_preferencesRevision.value = 0
_theme.value = GatewayThemeStatus()
_heroRevision.value = ""
_installPermissionPrompt.value = false
_genreBrowserEnabled.value = false
_tvCalendarEnabled.value = false
@@ -201,11 +201,34 @@ data class GatewayServiceStatus(
* the switcher rather than offering a menu item its own handlers would refuse.
*/
val requests: GatewayRequestAccess = GatewayRequestAccess(),
/**
* The hero configuration this viewer's televisions should be drawing, as one opaque
* revision rather than the cards themselves — the [theme] precedent, for the same
* reason: this poll runs every ten seconds on every open set.
*
* It moves when an operator pins, removes or reorders a hero, when a scheduled hero
* comes into or goes out of force, and when the gateway's rotation slot turns over. A
* server that predates it sends none, which decodes to "" and never triggers a refetch:
* a set that cannot be told keeps the hero it was given with its home rows, which is
* exactly how the launcher behaved before this existed.
*/
val hero: GatewayHeroStatus = GatewayHeroStatus(),
)
@Serializable
data class GatewayRequestAccess(val allowed: Boolean = false)
/** The summary of the hero configuration that rides the status poll. */
@Serializable
data class GatewayHeroStatus(
/**
* Opaque, and compared only for equality. It is also what keys the gateway's cached
* hero answers, so a revision that has moved always names a freshly composed hero
* rather than the entry the television already holds.
*/
val revision: String = "",
)
/** The summary of a theme that rides the status poll. See [GatewayTheme] for the document. */
@Serializable
data class GatewayThemeStatus(
@@ -540,6 +563,40 @@ data class GatewayGenrePage(
val total: Int = 0,
)
/**
* One genre label and how much of this viewer's watching it accounts for.
*
* [genre] is Emby's own spelling, verbatim — "Science Fiction", "Sci-Fi & Fantasy" and
* "Sci Fi" all arrive as themselves. Folding them is the television's job, because the
* television is what owns the category catalogue and its aliases; a gateway that folded
* them would need a second copy of that list and the two would disagree the first time a
* category gained a spelling.
*
* [weight] is scaled so the most-watched genre is 1, which is what lets the ordering rule be
* written in shares rather than in counts.
*/
@Serializable
data class GenreWeight(
val genre: String = "",
val weight: Double = 0.0,
)
/**
* Response of `GET /v1/genres/affinity` — which genres this viewer actually watches,
* derived from Tracearr's sessions and the imported catalogue's genres.
*
* [sessions] is the evidence behind the weights, and it is on the wire because the ordering
* rule refuses to personalise below a floor: a share of three sessions is not a taste. Every
* field defaults to nothing, so a gateway that predates this route, a household running no
* Tracearr and an operator who has switched it off all produce the same answer — the
* catalogue's own order.
*/
@Serializable
data class GenreAffinity(
val sessions: Int = 0,
val genres: List<GenreWeight> = emptyList(),
)
/**
* Response of `GET /v1/items/{id}/related` — the detail page's two additions.
*
@@ -90,6 +90,16 @@ interface GatewayApi {
@Query("type") itemType: String? = null,
): com.ponzischeme89.memby.data.model.GatewayGenrePage
/**
* Which genres this viewer watches, for the order of the Genres browser's rail.
*
* Asked for at most once per session and never on the path of drawing anything: the
* rail has a perfectly good default order, and this only decides which end of it a
* household sees first.
*/
@GET("v1/genres/affinity")
suspend fun genreAffinity(): com.ponzischeme89.memby.data.model.GenreAffinity
/** One paged media-type shelf without a genre filter. */
@GET("v1/library/items")
suspend fun libraryItems(
@@ -121,7 +121,7 @@ class MembyDreamService : DreamService() {
TrailerPlaybackRequest(
subjectId = item.id,
title = item.name,
logoUrl = com.ponzischeme89.memby.ServiceLocator.repository.logoUrl(item, 720),
logoUrl = com.ponzischeme89.memby.ServiceLocator.repository.logoUrl(item),
),
)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -617,7 +617,7 @@ private fun DetailHero(
// treatment on this near-black scrim is an invisible heading.
val repository = ServiceLocator.repository
val logoUrl = remember(item.id, item.imageTags, repository.showTitleLogo) {
if (repository.showTitleLogo) repository.logoUrl(item, 720) else null
if (repository.showTitleLogo) repository.logoUrl(item) else null
}
val logo = logoUrl.takeIf { !useTextTitleForLogo(it) }
val hasRatings = remember(ratings, showRatingsStrip) {
@@ -964,10 +964,9 @@ private fun DetailIdentity(
contentAlignment = Alignment.BottomStart,
) {
if (logo != null) {
AsyncImage(
model = logo,
TitleLogoImage(
logoUrl = logo,
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
alignment = Alignment.BottomStart,
modifier = Modifier
.width(DetailHeroMetrics.LogoMaxWidth)
@@ -1526,13 +1526,27 @@ internal fun HomeRowHeaderIcon(icon: ImageVector, modifier: Modifier = Modifier)
}
}
/**
* The size of the media-type mark on a card's metadata line.
*
* Deliberately smaller than any icon a viewer can aim at — the rail's marks are 21dp and
* the circular detail actions larger still. This one is read, never pressed, and at 13dp
* it sits inside the 12sp line's own height, so a card carrying it is exactly as tall as
* one that does not.
*/
private val MediaTypeMarkSize = 13.dp
/**
* Which of the two kinds of thing a card is, for a grid that holds both.
*
* Only the Genres destination genuinely mixes them — the Movies and TV Shows pages name a
* type, so every card on those wears the same mark and it says nothing. It is a *mark*
* rather than a word because it sits over artwork at three metres, where "TV SHOW" in a
* pill would be read before the poster it is covering.
* rather than a word because the metadata line is one line at three metres, and "TV SHOW"
* spelled out there would crowd out the year and the runtime it sits beside.
*
* It belongs to that line rather than to the poster: the type is informational in the same
* way the year and the runtime are, and grouping the three together leaves the artwork —
* the only thing on the card worth looking at — carrying nothing it did not come with.
*
* A season or an episode is marked as television: both are parts of a show, and a viewer
* separating films from shows is not asking about the difference between them. Anything
@@ -2012,7 +2026,7 @@ private fun MediaCard(
onFocused = onFocused,
onClick = onClick,
onLongClick = onLongClick,
contentDescription = cardDescription(item, progress, showWatchedEpisodeCount),
contentDescription = cardDescription(item, progress, showWatchedEpisodeCount, showMediaTypeIcon),
modifier = modifier.width(width),
) { focused ->
Column {
@@ -2119,19 +2133,6 @@ private fun MediaCard(
airingLabel,
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
)
} else if (showMediaTypeIcon) {
// Which of the two things this is, for a grid that holds both. The
// top-left is the airing badge's corner first: a schedule card already
// says what it is by saying when it is on, and two marks in one corner
// would overlap. The watched tick and the heart own the other corner.
mediaTypeMark(item)?.let { (icon, description) ->
MediaStatusIcon(
icon = icon,
description = description,
tint = Color.White.copy(alpha = 0.92f),
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
)
}
}
if (focused) {
MembyArtworkPlayCue(Modifier.align(Alignment.Center))
@@ -2147,15 +2148,37 @@ private fun MediaCard(
modifier = Modifier.padding(top = 7.dp).fillMaxWidth(),
)
if (showSecondaryMetadata) {
Text(
cardSubtitle(item, showProgress, position, showWatchedEpisodeCount),
color = QuietText,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
// The media type sits *in* the metadata line rather than over the poster:
// it is informational, like the year and the runtime beside it, and the
// artwork is the one thing on the card worth looking at. The row is
// vertically centred and the mark is smaller than the line it sits on, so
// it cannot make the card taller than one without it.
val typeMark = if (showMediaTypeIcon) mediaTypeMark(item) else null
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
)
) {
typeMark?.let { (icon, _) ->
Icon(
icon,
// The card's own description already names the type; a second
// reading of it here would be the same fact said twice.
contentDescription = null,
tint = EmbyGreen,
modifier = Modifier.size(MediaTypeMarkSize),
)
Spacer(Modifier.width(5.dp))
}
Text(
cardSubtitle(item, showProgress, position, showWatchedEpisodeCount),
color = QuietText,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
} else {
Spacer(Modifier.height(3.dp))
}
@@ -2395,8 +2418,12 @@ private fun cardDescription(
item: BaseItem,
progress: Float,
showWatchedEpisodeCount: Boolean,
showMediaTypeIcon: Boolean = false,
): String = buildString {
append(item.name)
if (showMediaTypeIcon) mediaTypeMark(item)?.let { (_, description) ->
append(", ").append(description)
}
item.seriesName?.let { append(", ").append(it) }
if (progress > 0f) append(", ${(progress * 100).toInt()} percent watched")
if (item.userData?.played == true) append(", watched")
@@ -367,7 +367,7 @@ private fun FeaturedMovieCard(
val repository = ServiceLocator.repository
val showLogo = repository.showTitleLogo
val logoUrl = remember(item.id, item.imageTags, item.parentLogoItemId, item.parentLogoImageTag, showLogo) {
if (showLogo) repository.logoUrl(item, 640) else null
if (showLogo) repository.logoUrl(item) else null
}
val useTextTitle = useTextTitleForLogo(logoUrl)
FocusScaleContainer(
@@ -424,11 +424,10 @@ private fun FeaturedMovieCard(
overflow = TextOverflow.Ellipsis,
onTextLayout = { titleLines = it.lineCount },
)
} else {
AsyncImage(
model = logoUrl,
} else if (logoUrl != null) {
TitleLogoImage(
logoUrl = logoUrl,
contentDescription = item.name,
contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart,
modifier = Modifier.width(240.dp).height(56.dp),
)
@@ -183,6 +183,12 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
init {
refreshAll()
// The Genres browser orders its rail from what this viewer watches, and reads that
// answer synchronously when the page is constructed. Warmed here because the browser
// is never the first thing a television draws — somebody has to travel the rail to
// reach it — so by the time it is opened this has long since landed, and the page
// pays nothing for being personalised. Costs one request per six hours per set.
repository.warmGenreAffinity()
viewModelScope.launch {
// Arrives as the player exits, ahead of the report and well ahead of the rows
// coming back, so the card a viewer is standing on already shows the progress
@@ -463,6 +469,16 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
launch { runCatching { repository.getSeriesEpisodes(seriesId) } }
}
launch { runCatching { repository.hasTrailer(item.id) } }
// The logo is the one part of a detail page's hero that arrived after the page
// did: it is a second image, and it cannot be drawn until it has been judged
// legible against the near-black scrim, so until that judgement lands the page
// shows the plain-text title and swaps. Warming it here means the bytes and the
// verdict are both held by the time the press composes the page, so the title
// treatment is on its first frame. Skipped outright when the viewer has turned
// logos off, which is the one case where the fetch could never be spent.
if (repository.showTitleLogo) {
launch { warmTitleLogo(repository.logoUrl(item)) }
}
}
}
@@ -183,8 +183,10 @@ import androidx.tv.material3.Card
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
@@ -2356,7 +2358,7 @@ private fun HomeScreen(
subjectId = item.id,
title = item.name,
posterUrl = repo.primaryUrl(item, maxWidth = 500),
logoUrl = repo.logoUrl(item, maxWidth = 720),
logoUrl = repo.logoUrl(item),
),
),
)
@@ -2407,14 +2409,37 @@ private fun HomeScreen(
// rendered as a shelf, so this is the only thing that can still see it.
// Warm both section heroes while Home is being read. Navigation then normally draws
// from memory, while the gateway's user/placement cache keeps this cheap across TVs.
//
// The same effect is what makes an operator's hero change arrive on a set that is
// already switched on: the gateway announces the configuration behind the heroes as a
// revision on the status poll, so this waits on that flow rather than on a timer, and
// refetches only when it moves. Collected inside the effect rather than read as state
// in the body, because the launcher must not recompose merely to be told a number.
LaunchedEffect(settings.userId) {
listOf(
BrowseDestination.MOVIES to "movies",
BrowseDestination.SHOWS to "tv_shows",
).forEach { (destination, placement) ->
launch {
runCatching { repo.getActiveHero(placement) }.onSuccess { resolved ->
sectionHeroRows = sectionHeroRows + (destination to resolved)
var appliedHeroRevision: String? = null
ServiceLocator.maintenance.heroRevision.collectLatest { revision ->
val moved = appliedHeroRevision != null &&
revision.isNotBlank() && revision != appliedHeroRevision
if (revision.isNotBlank()) appliedHeroRevision = revision
coroutineScope {
// Home's hero arrives with the home rows, so it is asked for separately
// only once the configuration behind it has actually moved. Fetching it at
// launch would replace a hero already on screen with a second composition
// of the same thing, which is a flash for nothing.
listOfNotNull(
BrowseDestination.MOVIES to "movies",
BrowseDestination.SHOWS to "tv_shows",
(BrowseDestination.HOME to "home").takeIf { moved },
).forEach { (destination, placement) ->
if (!moved && sectionHeroRows.containsKey(destination)) return@forEach
launch {
runCatching { repo.getActiveHero(placement) }.onSuccess { resolved ->
// Replaced one placement at a time, never cleared first: the
// cards that did not change keep the artwork already decoded
// for them and the row never blanks between the two states.
sectionHeroRows = sectionHeroRows + (destination to resolved)
}
}
}
}
}
@@ -2432,7 +2457,15 @@ private fun HomeScreen(
}
val contextualHeroItems = remember(rows, homeContent.rows, sectionHeroRows, selectedDestination, heroDay) {
when (selectedDestination) {
BrowseDestination.HOME -> selectHomeHeroMovies(rows, heroDay, homeContent.rows)
// The separately fetched home hero wins where there is one, which is only
// after the configuration moved under a set sitting on this screen; otherwise
// the hero row that came with the home payload is still the right answer.
BrowseDestination.HOME -> selectHomeHeroMovies(
rows,
heroDay,
sectionHeroRows[BrowseDestination.HOME]?.takeIf(List<HomeRow>::isNotEmpty)
?: homeContent.rows,
)
BrowseDestination.MOVIES, BrowseDestination.SHOWS ->
serverHeroPicks(sectionHeroRows[selectedDestination].orEmpty())
else -> emptyList()
@@ -1,14 +1,128 @@
package com.ponzischeme89.memby.ui
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.core.graphics.get
import androidx.core.graphics.drawable.toBitmap
import coil.compose.AsyncImage
import coil.imageLoader
import coil.request.CachePolicy
import coil.request.ImageRequest
import coil.request.SuccessResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlin.coroutines.cancellation.CancellationException
/**
* Everything the app knows about a title's logo artwork, remembered for the life of the
* process.
*
* A logo is asked about far more often than it changes: the launcher's hero draws one, the
* detail page it opens draws the same one, walking Back and pressing again draws it a third
* time, and the screensaver and the TV calendar draw it for the same shows. Two things used
* to be repeated on every one of those passes and both are visible to a viewer as a logo
* that flashes into a text title and back:
*
* - **The darkness verdict was recomputed.** [useTextTitleForLogo] decoded a small copy on
* every composition and started from "use the text title", so a page whose logo had
* already been judged legible still drew its name first and swapped a frame or more later.
* It is remembered here instead, keyed on the URL, so a title seen once resolves
* synchronously and the logo is on the first frame.
* - **The probe evicted the artwork it was probing.** Coil's memory cache is keyed on the
* image's URL and *not* on the size it was decoded at, so a 64×64 inspection copy written
* under that key replaced the full-size bitmap the hero had just cached which is why a
* detail page returned to a second time decoded its logo again from disk. The probe now
* runs with the memory cache switched off, leaving the display copy alone.
*
* Invalidation is free and deliberate: an Emby logo URL carries the item id *and* the image
* tag, so artwork changed on the server is a different key here, in Coil's memory cache and
* in its disk cache alike. Nothing has to be told about it.
*/
internal object TitleLogoCache {
/**
* Enough for every logo a household's launcher, calendar and screensaver could put on
* screen in a session, and small enough to be nothing next to the bitmaps themselves
* a verdict is one boolean and a URL.
*/
private const val MAX_ENTRIES = 512
private val lock = Any()
/** Access-ordered, so the entries that survive are the ones still being looked at. */
private val verdicts = object : LinkedHashMap<String, Boolean>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Boolean>) =
size > MAX_ENTRIES
}
private val inFlight = mutableMapOf<String, Deferred<Boolean?>>()
/**
* Its own scope, not the caller's, for the reason the repository's single flights have
* one: the caller here is usually a detail page's [produceState] or a focus warm, both
* of which die the moment the D-pad moves. Shared work that inherited that cancellation
* would fail everybody who had joined it.
*/
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/**
* The application context, so a warm can be started from somewhere that holds no
* Android context of its own [com.ponzischeme89.memby.ui.HomeViewModel] is the caller
* that matters, and giving a ViewModel a context to keep would be the worse trade.
* Application-scoped, so it holds nothing that could outlive its owner.
*/
@Volatile
private var appContext: Context? = null
/** Called once, from `MembyApp.onCreate`. */
fun install(context: Context) {
appContext = context.applicationContext
}
internal fun context(): Context? = appContext
/** The remembered verdict for [url], or null if this logo has never been judged. */
fun verdict(url: String): Boolean? = synchronized(lock) { verdicts[url] }
/**
* Whether [url]'s artwork is too dark to use as a heading, judged once per URL.
*
* Null means "could not tell" a failed or cancelled fetch and is deliberately not
* remembered: a title must not be condemned to its text heading for the life of the
* process because the network hiccuped once.
*
* Concurrent callers for one URL share a single probe. That is not only a saving: the
* home hero and the detail page it opens ask about the same logo within a frame of each
* other, and two probes would be two decodes of the same bytes.
*/
suspend fun resolve(url: String, probe: suspend (String) -> Boolean?): Boolean? {
verdict(url)?.let { return it }
val work = synchronized(lock) {
inFlight[url] ?: scope.async {
probe(url)?.also { dark -> synchronized(lock) { verdicts[url] = dark } }
}.also { fresh ->
inFlight[url] = fresh
fresh.invokeOnCompletion { synchronized(lock) { inFlight.remove(url) } }
}
}
return work.await()
}
/** Test seam only: a verdict cache that outlived one test would decide the next. */
internal fun reset() = synchronized(lock) {
verdicts.clear()
inFlight.clear()
}
}
/**
* Whether a title's logo artwork should be replaced by its plain-text name.
@@ -18,6 +132,9 @@ import coil.request.SuccessResult
* overwhelmingly dark. Until the image has been inspected, text is the safe default a
* title that arrives a frame late is better than a title that never appears.
*
* A logo judged once is judged for the life of the process ([TitleLogoCache]), so that
* frame is paid the first time a title is seen and never again.
*
* Shared by the screensaver and the detail hero: both draw a title over a near-black scrim,
* and a logo that is invisible on one is invisible on the other.
*/
@@ -25,21 +142,99 @@ import coil.request.SuccessResult
internal fun useTextTitleForLogo(logoUrl: String?): Boolean {
if (logoUrl == null) return true
val context = LocalContext.current
val isDark by produceState(initialValue = true, logoUrl) {
value = runCatching {
val result = context.imageLoader.execute(
ImageRequest.Builder(context)
.data(logoUrl)
.allowHardware(false)
.size(64, 64)
.build(),
) as? SuccessResult ?: return@runCatching true
isPredominantlyDarkLogo(result.drawable.toBitmap(width = 64, height = 64))
}.getOrDefault(true)
// Read outside the producer so a logo already judged is the *initial* value rather than
// a correction applied a frame later. This is the whole of "no flash on the way back".
val known = TitleLogoCache.verdict(logoUrl)
val isDark by produceState(initialValue = known ?: true, logoUrl, known) {
if (known != null) return@produceState
value = TitleLogoCache.resolve(logoUrl) { probeLogoDarkness(context, it) } ?: true
}
return isDark
}
/**
* The logo itself, drawn the same way everywhere it appears.
*
* Every surface asks Emby for one width ([com.ponzischeme89.memby.data.LOGO_MAX_WIDTH]), so
* the URL is identical on the launcher, the detail page, the calendar and the screensaver
* one entry in Coil's caches, one probe, one fetch, whatever size the box happens to be.
*/
@Composable
internal fun TitleLogoImage(
logoUrl: String,
contentDescription: String?,
modifier: Modifier = Modifier,
alignment: Alignment = Alignment.Center,
) {
AsyncImage(
model = logoUrl,
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
alignment = alignment,
modifier = modifier,
)
}
/**
* Fetches a logo and judges it before anybody asks to see it.
*
* Called while a card holds focus, which is the best warning of a press this app gets: by
* the time the detail page composes, the bytes are in Coil's caches and the verdict is
* known, so the logo is on the page's first frame instead of arriving after it.
*
* It requests the artwork at [WARM_SIZE_PX] rather than at any one screen's box, because
* Coil's memory cache is keyed on the URL and validated against the requested size: a copy
* decoded at least as large as the biggest stage that draws it satisfies every smaller one,
* while a small copy would be rejected and re-decoded by the largest. Failure is silent
* this is a hint, and every consumer still works exactly as it did without it.
*/
internal suspend fun warmTitleLogo(logoUrl: String?) {
if (logoUrl.isNullOrBlank()) return
val context = TitleLogoCache.context() ?: return
runCatching {
context.imageLoader.execute(
ImageRequest.Builder(context)
.data(logoUrl)
.size(WARM_SIZE_PX, WARM_SIZE_PX)
.build(),
)
}
TitleLogoCache.resolve(logoUrl) { probeLogoDarkness(context, it) }
}
/**
* The largest logo stage in the app is the screensaver's 320×96dp; on a 1080p television
* that is 320×96 device pixels at density 1.0 and 480×144 at the 1.5 a Chromecast reports.
* Asking for a square of this side leaves [ContentScale.Fit] room for both a very wide and
* a very tall title treatment without holding a needlessly large bitmap.
*/
private const val WARM_SIZE_PX = 512
/**
* Decodes a small copy of [url] and reports whether it is too dark to read as a heading.
*
* **The memory cache is switched off for this request and that is load-bearing.** Coil keys
* that cache on the image's URL alone, so writing this 64×64 copy under the same key as the
* displayed logo replaced it every page that probed a logo threw away the full-size
* bitmap the last page had cached, and the next display request decoded it again. The disk
* cache is left on deliberately: the bytes are worth keeping, and they are what the display
* request reads instead of going back to the network.
*/
private suspend fun probeLogoDarkness(context: Context, url: String): Boolean? = runCatching {
val result = context.imageLoader.execute(
ImageRequest.Builder(context)
.data(url)
.allowHardware(false)
.memoryCachePolicy(CachePolicy.DISABLED)
.size(64, 64)
.build(),
) as? SuccessResult ?: return@runCatching null
isPredominantlyDarkLogo(result.drawable.toBitmap(width = 64, height = 64))
}.getOrElse { error ->
if (error is CancellationException) throw error
null
}
private fun isPredominantlyDarkLogo(bitmap: android.graphics.Bitmap): Boolean {
var opaquePixels = 0
var darkPixels = 0
@@ -53,6 +53,7 @@ import coil.compose.AsyncImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.TitleLogoImage
import com.ponzischeme89.memby.ui.distinctItems
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
@@ -85,7 +86,7 @@ internal fun CalendarContent(
modifier: Modifier = Modifier,
posterUrlFor: (BaseItem) -> String? = { ServiceLocator.repository.primaryUrl(it, 360) },
backdropUrlFor: (BaseItem) -> String? = { ServiceLocator.repository.backdropUrl(it, 720) },
logoUrlFor: (BaseItem) -> String? = { ServiceLocator.repository.logoUrl(it, 420) },
logoUrlFor: (BaseItem) -> String? = { ServiceLocator.repository.logoUrl(it) },
) {
val weeks = remember(state.calendar) { calendarAgendaWeeks(state.calendar) }
val weekIndex = remember(weeks, state.selectedDate) {
@@ -589,10 +590,9 @@ private fun ProgrammeCard(
verticalArrangement = Arrangement.Center,
) {
if (logoUrl != null) {
AsyncImage(
model = logoUrl,
TitleLogoImage(
logoUrl = logoUrl,
contentDescription = item.name,
contentScale = ContentScale.Fit,
modifier = Modifier.widthIn(max = 190.dp).height(34.dp),
)
} else {
@@ -41,7 +41,20 @@ class GenreBrowseViewModel(
private val repository: EmbyRepository,
private val itemType: String,
) : ViewModel() {
private val categories = genreCategoryTabs(itemType)
/**
* The rail, in this viewer's order, settled once when the screen is constructed.
*
* Read from the repository's snapshot rather than awaited, and never recomputed while
* the screen is alive. Both halves matter: the categories are the first thing the page
* draws and must not appear late, and a rail that re-ordered itself under a D-pad
* already travelling down it would be worse than one that was never personalised at all.
* A set whose affinity has not landed yet gets the catalogue's own order and is
* personalised the next time the page is opened.
*/
private val categories = personaliseGenreCategories(
genreCategoryTabs(itemType),
repository.genreAffinitySnapshot(),
)
private val _state = MutableStateFlow(GenreBrowseUiState(categories = categories))
val state: StateFlow<GenreBrowseUiState> = _state.asStateFlow()
private var pageJob: Job? = null
@@ -0,0 +1,118 @@
package com.ponzischeme89.memby.ui.genre
import com.ponzischeme89.memby.data.model.GenreAffinity
/**
* How the Genres browser's rail is ordered for one viewer.
*
* The catalogue itself is still product design the same sixteen categories, the same
* labels, the same aliases and this only decides which of them a household sees first.
* That separation is deliberate: the gateway knows *what* somebody watches, because
* Tracearr's sessions and the imported catalogue's genres are both already in Postgres, but
* it holds no copy of this list and could not be the thing that sorts it without growing
* one. So the wire carries Emby's own genre spellings with a weight each and the fold
* happens here, against the alias table this file's neighbour already owns.
*
* Nothing is ever hidden. A genre somebody has never watched is still in the rail, below
* the ones they have which is the whole difference between personalisation and a filter.
*/
/**
* The least history worth re-ordering somebody's rail for.
*
* Below this the weights are not a taste, they are the last few things that happened to be
* on: one Western on a wet Sunday would otherwise lead the rail of a household that has
* never watched another. A viewer under the floor keeps the catalogue order and acquires
* their own as they watch, which is exactly what a new account should do.
*/
const val MIN_GENRE_AFFINITY_SESSIONS = 12
/**
* The share of the top genre a category must reach before it is lifted at all.
*
* The weights are already scaled so the most-watched genre is 1, so this is a proportion
* rather than a count and means the same thing for a household that watches nightly and one
* that watches on Sundays. Its job is the long tail: without it, a category holding a single
* qualifying session outranks every category holding none, and the bottom of the rail
* reshuffles on noise while the top the half anybody looks at says nothing new.
*/
const val GENRE_AFFINITY_FLOOR = 0.08
/**
* Scores one category against a viewer's genre weights.
*
* A category is several Emby labels (Sci-Fi & Fantasy is six), so its score is their sum:
* a household whose science fiction is catalogued by one agent as "Science Fiction" and by
* another as "Sci-Fi & Fantasy" watches one shelf, and reading the two apart would rank it
* as half of what it is. Matching is case-insensitive because the labels come from three
* metadata agents and only one of them is consistent about it.
*/
fun genreCategoryScore(category: GenreCategory, weights: Map<String, Double>): Double {
if (category.genres.isEmpty() || weights.isEmpty()) return 0.0
var total = 0.0
category.genres.forEach { label ->
total += weights[label.lowercase()] ?: 0.0
}
return total
}
/**
* The rail, in this viewer's order.
*
* Three properties hold it together and each is pinned by a test:
*
* - **"All" never moves.** It is the catalogue itself rather than a genre, it is the first
* focus target on the page, and a rail whose first entry moved under somebody would lose
* them the one place they can always return to.
* - **The sort is stable, so the default order is the tie-break.** Every category the
* viewer has no evidence for scores zero and therefore keeps its product order, below the
* ones they watch which is what makes an unpersonalised half of the rail still read as
* a considered list rather than as an arbitrary one.
* - **Nothing is dropped.** The output is a permutation of the input, always, whatever the
* weights say.
*
* Insufficient history returns the catalogue untouched, which is also what a gateway that
* predates the route, a household running no Tracearr, and an operator who has switched it
* off all produce.
*/
fun personaliseGenreCategories(
categories: List<GenreCategory>,
affinity: GenreAffinity,
): List<GenreCategory> {
if (categories.size < 2) return categories
if (affinity.sessions < MIN_GENRE_AFFINITY_SESSIONS) return categories
val weights = affinity.weightsByLabel()
if (weights.isEmpty()) return categories
val scores = categories.associate { category ->
val score = genreCategoryScore(category, weights)
category.id to if (score >= GENRE_AFFINITY_FLOOR) score else 0.0
}
if (scores.values.none { it > 0.0 }) return categories
// The pinned head is whatever leads the list without being a genre — the "All" entry —
// and it is taken off before the sort rather than given an unbeatable score, because a
// score is a claim about watching and this is a claim about layout.
val pinned = categories.takeWhile { it.genres.isEmpty() }
val sortable = categories.drop(pinned.size)
return pinned + sortable.sortedByDescending { scores[it.id] ?: 0.0 }
}
/**
* The weights keyed for lookup, lower-cased once rather than per comparison.
*
* A repeated label keeps its largest weight rather than the last one seen: the list is the
* gateway's, ordered by weight, and a duplicate can only be a spelling that reached it
* twice reading the smaller of the two would quietly understate the shelf.
*/
private fun GenreAffinity.weightsByLabel(): Map<String, Double> {
if (genres.isEmpty()) return emptyMap()
val out = HashMap<String, Double>(genres.size)
genres.forEach { entry ->
val key = entry.genre.trim().lowercase()
if (key.isEmpty() || entry.weight <= 0.0) return@forEach
val existing = out[key]
if (existing == null || entry.weight > existing) out[key] = entry.weight
}
return out
}
@@ -33,7 +33,7 @@ class ScreensaverActivity : ComponentActivity() {
TrailerPlaybackRequest(
subjectId = item.id,
title = item.name,
logoUrl = com.ponzischeme89.memby.ServiceLocator.repository.logoUrl(item, 720),
logoUrl = com.ponzischeme89.memby.ServiceLocator.repository.logoUrl(item),
),
),
)
@@ -79,6 +79,7 @@ import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.TitleLogoImage
import com.ponzischeme89.memby.ui.useTextTitleForLogo
import com.ponzischeme89.memby.ui.visibleWithWatchedPreference
import com.ponzischeme89.memby.ui.settings.SettingsSheet
@@ -763,11 +764,10 @@ private fun InfoAndActions(
// Prefer the item's Emby "Logo" artwork over a plain-text title, when the
// user has it enabled and this item actually has a logo image.
val logoUrl = if (showTitleLogo) ServiceLocator.repository.logoUrl(item) else null
if (!useTextTitleForLogo(logoUrl)) {
AsyncImage(
model = logoUrl,
if (logoUrl != null && !useTextTitleForLogo(logoUrl)) {
TitleLogoImage(
logoUrl = logoUrl,
contentDescription = item.name,
contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart,
modifier = Modifier
// A fixed logo stage keeps artwork consistently sized even when
@@ -0,0 +1,105 @@
package com.ponzischeme89.memby.ui
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* The remembering half of the logo work, which is the half a viewer notices.
*
* The probe itself needs a decoder and a network and is not testable here; what is testable
* is that it is asked **once** per logo a second ask is a second decode of bytes the app
* already has, and, because the verdict starts at "use the text title", a second frame of a
* page showing its name where its title treatment belongs.
*/
class TitleLogoCacheTest {
@Before
fun clearCache() = TitleLogoCache.reset()
@Test
fun `a logo never seen has no verdict`() {
assertNull(TitleLogoCache.verdict("https://gw/v1/images/1/logo?tag=a"))
}
@Test
fun `a resolved verdict is remembered and the probe is not asked again`() = runBlocking {
val probes = AtomicInteger()
val url = "https://gw/v1/images/1/logo?tag=a"
val probe: suspend (String) -> Boolean? = { probes.incrementAndGet(); false }
assertEquals(false, TitleLogoCache.resolve(url, probe))
// The synchronous read is what lets the detail page draw the logo on its first
// frame rather than correcting itself a frame later.
assertEquals(false, TitleLogoCache.verdict(url))
assertEquals(false, TitleLogoCache.resolve(url, probe))
assertEquals(1, probes.get())
}
@Test
fun `an image tag change is a different logo`() = runBlocking {
val probes = AtomicInteger()
val probe: suspend (String) -> Boolean? = { probes.incrementAndGet(); true }
TitleLogoCache.resolve("https://gw/v1/images/1/logo?tag=old", probe)
TitleLogoCache.resolve("https://gw/v1/images/1/logo?tag=new", probe)
// Artwork replaced on the server carries a new tag, so nothing has to invalidate
// anything: the old verdict simply stops being asked for.
assertEquals(2, probes.get())
}
@Test
fun `concurrent asks for one logo share a single probe`() = runBlocking {
val probes = AtomicInteger()
val release = CompletableDeferred<Unit>()
val url = "https://gw/v1/images/1/logo?tag=a"
val probe: suspend (String) -> Boolean? = {
probes.incrementAndGet()
release.await()
false
}
val first = async { TitleLogoCache.resolve(url, probe) }
val second = async { TitleLogoCache.resolve(url, probe) }
val third = async { TitleLogoCache.resolve(url, probe) }
release.complete(Unit)
assertEquals(listOf(false, false, false), listOf(first.await(), second.await(), third.await()))
// The home hero and the detail page it opens ask within a frame of each other.
assertEquals(1, probes.get())
}
@Test
fun `a probe that could not tell is not remembered as dark`() = runBlocking {
val url = "https://gw/v1/images/1/logo?tag=a"
assertNull(TitleLogoCache.resolve(url) { null })
// A hiccup must not condemn a title to its text heading for the life of the process.
assertNull(TitleLogoCache.verdict(url))
assertEquals(false, TitleLogoCache.resolve(url) { false })
assertEquals(false, TitleLogoCache.verdict(url))
}
@Test
fun `the cache is bounded and keeps what is still being looked at`() = runBlocking {
val kept = "https://gw/v1/images/kept/logo?tag=a"
TitleLogoCache.resolve(kept) { false }
repeat(600) { index ->
TitleLogoCache.resolve("https://gw/v1/images/$index/logo?tag=a") { true }
// Touching it keeps it, which is the whole point of an access-ordered cache:
// the logo a viewer keeps coming back to is the one worth holding.
TitleLogoCache.verdict(kept)
}
assertEquals(false, TitleLogoCache.verdict(kept))
assertTrue("early entries should have been evicted", TitleLogoCache.verdict("https://gw/v1/images/0/logo?tag=a") == null)
}
}
@@ -0,0 +1,149 @@
package com.ponzischeme89.memby.ui.genre
import com.ponzischeme89.memby.data.model.GenreAffinity
import com.ponzischeme89.memby.data.model.GenreWeight
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The ordering rule for the Genres browser's rail.
*
* What is worth pinning here is not that a well-watched genre rises that is one line
* but the three promises around it: nothing is ever hidden, a viewer with no history keeps
* the catalogue's order, and the unpersonalised half of the rail stays in product order
* rather than becoming arbitrary.
*/
class GenrePersonalisationTest {
private fun affinity(sessions: Int, vararg weights: Pair<String, Double>) = GenreAffinity(
sessions = sessions,
genres = weights.map { GenreWeight(it.first, it.second) },
)
private val tabs get() = genreCategoryTabs(ALL_MEDIA_ITEM_TYPE)
@Test
fun `frequently watched genres rise to the top`() {
val ordered = personaliseGenreCategories(
tabs,
affinity(120, "Crime" to 1.0, "Drama" to 0.8, "Thriller" to 0.6, "Documentary" to 0.4),
)
assertEquals(
listOf("all", "crime", "drama", "thriller", "documentary"),
ordered.take(5).map(GenreCategory::id),
)
}
/**
* The catalogue itself is the first focus target on the page and the one place a viewer
* can always return to, so it is pinned rather than sorted.
*/
@Test
fun `the all entry never moves`() {
val ordered = personaliseGenreCategories(tabs, affinity(200, "Western" to 1.0))
assertEquals(ALL_MEDIA_CATEGORY_ID, ordered.first().id)
}
/** Personalisation, not a filter: every genre stays reachable however little it is watched. */
@Test
fun `no genre is ever dropped`() {
val ordered = personaliseGenreCategories(tabs, affinity(300, "Horror" to 1.0, "Comedy" to 0.5))
assertEquals(tabs.size, ordered.size)
assertEquals(tabs.map(GenreCategory::id).sorted(), ordered.map(GenreCategory::id).sorted())
}
/**
* Below the floor the weights are not a taste, they are the last few things that
* happened to be on and a new account must look like the product, not like a rail
* built out of one wet Sunday.
*/
@Test
fun `insufficient history keeps the default order`() {
val ordered = personaliseGenreCategories(
tabs,
affinity(MIN_GENRE_AFFINITY_SESSIONS - 1, "Western" to 1.0),
)
assertEquals(tabs.map(GenreCategory::id), ordered.map(GenreCategory::id))
}
@Test
fun `no history at all keeps the default order`() {
assertEquals(
tabs.map(GenreCategory::id),
personaliseGenreCategories(tabs, GenreAffinity()).map(GenreCategory::id),
)
}
/** A gateway that answered with a count but no labels has said nothing about anybody. */
@Test
fun `sessions without weights keep the default order`() {
assertEquals(
tabs.map(GenreCategory::id),
personaliseGenreCategories(tabs, affinity(500)).map(GenreCategory::id),
)
}
/**
* The categories a viewer has no evidence for keep their product order below the ones
* they watch the sort is stable, and that is what stops the bottom of the rail reading
* as an arbitrary list.
*/
@Test
fun `unwatched genres keep their catalogue order`() {
val ordered = personaliseGenreCategories(tabs, affinity(90, "Horror" to 1.0))
val defaultTail = tabs.map(GenreCategory::id).filterNot { it == "horror" || it == "all" }
assertEquals(defaultTail, ordered.map(GenreCategory::id).filterNot { it == "horror" || it == "all" })
}
/**
* The long tail must not reshuffle on noise: a category holding a single stray session
* has nothing to say, and lifting it above every category holding none would change the
* bottom of the rail every week while the top said nothing new.
*/
@Test
fun `a weight below the floor does not lift a category`() {
val ordered = personaliseGenreCategories(
tabs,
affinity(80, "Comedy" to 1.0, "Western" to GENRE_AFFINITY_FLOOR / 2),
)
val ids = ordered.map(GenreCategory::id)
assertTrue(ids.indexOf("western") > ids.indexOf("romance"))
}
/**
* A category is several Emby labels because three metadata agents describe one shelf
* three ways, so its score is their sum reading them apart would rank science fiction
* as a fraction of what a household actually watches.
*/
@Test
fun `a category sums every spelling of its genre`() {
val weights = mapOf("science fiction" to 0.4, "sci-fi & fantasy" to 0.5)
val category = genreCategory(ALL_MEDIA_ITEM_TYPE, "sci-fi-fantasy")
assertEquals(0.9, genreCategoryScore(category, weights), 1e-9)
}
@Test
fun `matching a genre label ignores case`() {
val ordered = personaliseGenreCategories(tabs, affinity(90, "cRiMe" to 1.0))
assertEquals("crime", ordered[1].id)
}
/** The All entry is the catalogue, not a genre; nothing it is scored against exists. */
@Test
fun `the all entry scores nothing`() {
assertEquals(
0.0,
genreCategoryScore(genreCategory(ALL_MEDIA_ITEM_TYPE, ALL_MEDIA_CATEGORY_ID), mapOf("drama" to 1.0)),
1e-9,
)
}
/** A viewer's taste orders the Movies and TV Series rails as well as the mixed one. */
@Test
fun `each media type is personalised from the same reading`() {
val watching = affinity(150, "Documentary" to 1.0)
assertEquals("documentary", personaliseGenreCategories(genreCategoryTabs("Movie"), watching)[1].id)
assertEquals("documentary", personaliseGenreCategories(genreCategoryTabs("Series"), watching)[1].id)
}
}