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
+15
View File
@@ -1,3 +1,18 @@
## 0.2.81 - 2026-08-19
- Improved: Moved poster media type icon to subtitle on Genres page.
- Fixed: Homepage heroes now update as soon as an administrator changes them.
- Fixed: Show/movie logos were not cached correctly.
- Fixed: Genres list in Genres page sorted by user activity.
## 0.2.80 - 2026-08-19
- New: Hold down on the user switcher to get a "clear all" option for notifications.
- Fixed: Improved navigation behaviour on the TV Shows page.
- Fixed: Design tweaks/fixes with Genres page.
- Removed: Recent Searches feature.
## 0.2.79 - 2026-08-19
- Bug fixes
## 0.2.78 - 2026-08-19
- Removed: Genres row from the TV Series and Movies pages. Replaced by Genres page.
- Improved: Added Mark as Watched button to Series detail pages.
+116
View File
@@ -809,6 +809,53 @@ at a time. Things to preserve:
`HomeViewModel.favoriteChanges` so a Favourite press and its possible rollback reach the
paged copy immediately. Its `type=Movie` / `type=Series` query is still enforced by the
gateway and on the direct Emby path, so no category can mix the two grids.
- **The rail is ordered by what this viewer watches, and the catalogue itself never
changes.** `GET /v1/genres/affinity` (`server/internal/api/genre_affinity.go`
`store.TracearrGenreAffinity``ui/genre/GenrePersonalisation.kt`) is a weight per Emby
genre label, and `personaliseGenreCategories` is what the television does with it. Things
to preserve:
- **The evidence is the server's and the catalogue is the set's.** Nothing is stored:
Tracearr's sessions and the imported catalogue's genres are already in Postgres, so the
reading is a join over two tables the gateway keeps for other reasons — the trade
`watchedMsExpr` makes for watch time, and for the same reason (a per-user genre table
would be a copy of both, wrong the moment either changed). The gateway sends Emby's own
spellings verbatim and never a category: folding "Science Fiction", "Sci-Fi" and
"Sci-Fi & Fantasy" into one shelf needs the alias table, which lives on the television,
and a second copy of it on the server would disagree the first time a category gained a
spelling — which is the failure the aliases exist to fix.
- **The order is settled before the rail is drawn and never re-ordered under a viewer.**
`EmbyRepository.genreAffinitySnapshot()` is a volatile read, the `snapshot` arrangement,
consulted once in `GenreBrowseViewModel`'s constructor; `warmGenreAffinity()` is what
fills it in, from `HomeViewModel`'s init, because the browser is never the first screen
a television draws. A set whose reading has not landed gets the catalogue order and is
personalised the next time the page is opened, which is the right way round: a rail that
reshuffled under a D-pad already travelling it is worse than one that never personalised.
- **Nothing is ever hidden, and the unpersonalised half stays in product order.** The sort
is stable, so a category with no evidence scores zero and keeps its place below the ones
the viewer watches. `MIN_GENRE_AFFINITY_SESSIONS` refuses to personalise at all below a
floor — one Western on a wet Sunday is not a taste — and `GENRE_AFFINITY_FLOOR` is a
*share* of the top genre rather than a count, so the long tail cannot reshuffle on noise
and a household that watches nightly and one that watches on Sundays order the same way.
- **An episode counts as its series, and an abandoned title counts as nothing.** The
session joins on `emby_series_id` before `emby_item_id`, so a household watching one
crime drama nightly reads as watching crime; `genreAffinityEngagement` requires half the
title, because a session that stopped four minutes in is evidence somebody did *not*
want it and counting those is how a rail comes to lead with what a viewer keeps
abandoning. Recency is three flat bands over 180 days rather than a decay — a half-life
nobody could defend would also make the answer move between two readings taken the same
evening.
- **Every failure is the default order.** The direct path has nobody to ask, a gateway
predating the route answers 404, a household running no Tracearr has no history, and the
operator's Tracearr switch is honoured even though the rows are already in Postgres —
switched off means Memby may not read their viewing, and old rows are still their
viewing. There is deliberately no second implementation on the direct path, the stance
the TV calendar takes: the answer is Tracearr's, which a television holds no credential
for, and the catalogue's own order is a perfectly good rail.
- **Six hours on both sides.** `genreAffinityTTL` and `GENRE_AFFINITY_REFRESH_MS` are
matched so a set refetching past it asks a question that has actually been recomputed
rather than paying a round trip for a cached repeat; an empty reading is kept for an
hour instead, because a new account genuinely acquires a history and a six-hour no would
hide their first evening of watching until the morning.
- **The gateway path degrades to a keyword search on the *first* page only**, so a set on a
new build talking to a gateway that predates the route still shows something. A later page
does not: a gateway that answered page one and failed on page two is having trouble, not
@@ -2513,6 +2560,40 @@ frame. Don't construct a bare `OkHttpClient.Builder()` — derive from `HttpStac
stream's derived client raises the read timeout and sets **no call timeout**, which would
cap the length of a film.
**A logo is fetched once and judged once.** `ui/TitleLogo.kt` holds both halves, and both
were being repeated on every screen that drew a title treatment — visible to a viewer as a
detail page returned to a second time showing the show's *name* and then swapping to its
logo. Things to preserve:
- **The darkness probe must not use Coil's memory cache.** That cache is keyed on the
image's URL and **not** on the size it was decoded at, so the 64×64 inspection copy was
written over the full-size bitmap the launcher had just cached: every page that probed a
logo threw away the copy the previous page had paid for, and the next display request
decoded it again. `probeLogoDarkness` sets `memoryCachePolicy(CachePolicy.DISABLED)` and
leaves the *disk* cache on deliberately — the bytes are worth keeping, and they are what
the display request reads instead of the network.
- **The verdict is remembered, and `useTextTitleForLogo` reads it as its initial value.**
`produceState` starting from "use the text title" is the right default the first time a
title is seen and a visible flash every time after it, since the answer cannot change.
`TitleLogoCache` is process-scoped, access-ordered and capped; concurrent asks for one
logo share a single probe on the cache's *own* scope, because the callers are a detail
page's `produceState` and a focus warm and both die when the D-pad moves.
- **"Could not tell" is not remembered.** A failed or cancelled probe returns null and is
not recorded — a hiccup must not condemn a title to its text heading for the life of the
process. `TitleLogoCacheTest` pins that, the single flight and the eviction.
- **One width, `data/LOGO_MAX_WIDTH` (720).** It was four — 800 in the screensaver, 720 on
the detail page and in the player, 640 on the home hero, 420 in the TV calendar — and a
width is part of the URL, so one show's logo was four cache keys, four fetches and four
verdicts. Ask for the default; a new surface that names its own width quietly opts out of
every cache the others share.
- **Invalidation is free and must stay that way.** An Emby logo URL carries the item id
*and* the image tag, so artwork replaced on the server is a different key in the verdict
cache and in both of Coil's. Nothing is told about it and nothing needs to be.
- **It is warmed on focus** (`HomeViewModel.warmDetailPage`), which is where the bytes and
the verdict both come from by the time a press composes the page. Skipped outright when
the viewer has logos switched off, and `TitleLogoCache.install` in `MembyApp` is what
gives that warm an application context rather than making the ViewModel hold one.
## Language
**Everything a person reads is New Zealand English.** No American spellings: `-ise`/
@@ -2939,6 +3020,41 @@ returns nothing rather than throwing. Things to preserve:
constantly behind it, which is why one slot must always yield the same draw.
The rotation further down belongs to the direct path, which has no merit to rank by.
**An operator's hero change is live, and it rides the status poll to get there.**
`heroRevision` (`server/internal/api/hero_revision.go`) is the `themeRevision` shape — a
hash rather than a counter, because what a hero resolves to changes when nobody has
written anything: a schedule window opens, the rotation slot turns over. `/v1/status`
carries it as `hero: {revision}`, `MaintenanceMonitor.heroRevision` publishes it, and the
launcher refetches `/v1/heroes/active` when it moves. Before it, a pin reached a set only
at its next cold start: the section heroes were fetched once per session and never again,
and Home's hero arrives with rows the launcher does not re-request while somebody is
sitting on them. Things to preserve:
- **The revision hashes what would be *resolved*, not the stored document.** `UpdatedAt`
moves on every write, so hashing the row would repaint every launcher in the house for a
save that changed nothing. For the same reason a schedule added for tomorrow evening does
not move it — only the schedules `activeHeroScheduleIDs` says are in force now do, which
is also what makes a window opening at 20:00 arrive by itself.
- **It is the same value in the cache key as on the poll**, in `handleHome` (`:hr`) and in
`hero:active:v2:`. That is what leaves no state in which the poll says something moved and
the fetch that follows returns what the set already had — and it replaced the cache
*sweep*: saving a hero used to call `InvalidateUser` for every account in the house,
throwing away every cached item lookup, row and image the household held in order to
change four cards. Nothing is invalidated now; the entries built under the old policy are
simply no longer named, and age out on their own TTL.
- **The policy is read through `currentHeroPolicy`**, never `store.HeroPolicy` directly.
The status poll is every open television every ten seconds, so this document needed the
`featurePolicyCache` treatment; the admin save invalidates that copy, which is the only
thing that write now does besides writing.
- **Home is not refetched at launch, only when the revision has moved under a set already
looking at it.** Its hero arrives with the home payload, and fetching it separately on
every launch would replace a hero already on screen with a second composition of the same
thing. The refetch replaces one placement at a time and never clears first, so unchanged
cards keep the artwork already decoded for them and nothing blanks between the two states.
- **The revision is collected inside the effect, not read as state in the launcher's body.**
A number arriving on a poll must never be a reason the launcher recomposes — the same rule
the animation and flow-collection conventions state.
**The reason sits above the ratings strip**, and that order is load-bearing. The featured
card's text column is what gives way when a title wraps onto two lines, so whatever is last
in it is cut — with the reason below the strip, the one line explaining why this card leads
+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,6 +2148,27 @@ private fun MediaCard(
modifier = Modifier.padding(top = 7.dp).fillMaxWidth(),
)
if (showSecondaryMetadata) {
// 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,
@@ -2154,8 +2176,9 @@ private fun MediaCard(
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
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,18 +2409,41 @@ 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(
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)
}
}
}
}
}
}
LaunchedEffect(selectedDestination, settings.userId) {
val placement = when (selectedDestination) {
BrowseDestination.MOVIES -> "movies"
@@ -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)
}
}
+10
View File
@@ -362,6 +362,16 @@ func TestServiceStatusCarriesEmbyHealthAndPreferenceRevision(t *testing.T) {
if _, ok := body["preferencesRevision"]; !ok {
t.Fatalf("status response carried no preferences revision: %v", body)
}
// Likewise present with no store behind it. This is the whole delivery channel for an
// operator's hero change: a television comparing against a missing field would go on
// drawing yesterday's hero until it was next restarted.
hero, ok := body["hero"].(map[string]any)
if !ok {
t.Fatalf("status response carried no hero revision: %v", body)
}
if revision, _ := hero["revision"].(string); revision == "" {
t.Fatalf("hero revision was empty; nothing would ever be refetched: %v", hero)
}
}
// With the probe switched off there is nothing to say, and a client must not be handed a
+4
View File
@@ -111,6 +111,9 @@ type Server struct {
// featurePolicy keeps the operator's feature switches out of the request path. See
// features_cache.go.
featurePolicy featurePolicyCache
// heroPolicy keeps the operator's hero choices out of the request path, which now
// includes the status poll every open television makes. See hero_revision.go.
heroPolicy heroPolicyCache
// upstream deduplicates concurrent cache misses for the same key, so two televisions
// asking for the same expensive answer at the same moment cost one upstream call
// rather than two. See coalesce.go.
@@ -272,6 +275,7 @@ func (s *Server) Routes() http.Handler {
// treats it as one. Paged, because a household's Drama shelf is not a screenful.
v1.Handle("GET /v1/genres/{genre}/items", s.authed(s.handleGenreItems))
v1.Handle("GET /v1/library/items", s.authed(s.handleLibraryItems))
v1.Handle("GET /v1/genres/affinity", s.authed(s.handleGenreAffinity))
v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory))
v1.Handle("GET /v1/requests/lookup", s.authed(s.handleRequestLookup))
v1.Handle("GET /v1/requests", s.authed(s.handleMyRequests))
+155
View File
@@ -0,0 +1,155 @@
package api
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The Genres browser's rail is a fixed catalogue in product order on the television, and
// that order is deliberately not server data — it must not jump around while home rows are
// arriving. What *is* server data is the evidence: Tracearr already knows who watched what,
// and the imported catalogue already knows each title's genres, so the gateway can say
// which labels a viewer actually watches and let the set decide what to do about it.
//
// The split is the point. The gateway sends Emby's own genre spellings with a weight each;
// the television folds them into its sixteen categories through the alias table it already
// owns and re-orders its own rail. A gateway that sorted the categories itself would need a
// second copy of that catalogue, and the two would disagree the first time a category
// gained an alias — which is the failure the aliases exist to fix in the first place.
const (
// genreAffinityTTL is how long one viewer's reading is kept. Long, because this is
// exactly the sort of answer that must never be on the path of opening a page and
// because taste does not move in an evening — but not indefinite, so a household whose
// viewing changes sees the rail follow it within a day.
genreAffinityTTL = 6 * time.Hour
// genreAffinityEmptyTTL remembers "nothing to say about this viewer" for a shorter
// span. A new account genuinely acquires a history, and a six-hour no would leave their
// first evening of watching invisible until the following morning.
genreAffinityEmptyTTL = time.Hour
)
// genreAffinityEntry is one label on the wire.
type genreAffinityEntry struct {
Genre string `json:"genre"`
Weight float64 `json:"weight"`
}
// genreAffinityResponse is what a television is told.
//
// Sessions rides along because the ordering rule on the set refuses to personalise below a
// floor, and a share of nothing is not evidence: a viewer three sessions old would
// otherwise have one of those three deciding what leads their rail. It is the count of
// qualifying sessions the weights were built from, not the household's total.
type genreAffinityResponse struct {
Sessions int `json:"sessions"`
Genres []genreAffinityEntry `json:"genres"`
}
// normaliseGenreAffinity scales the weights so the most-watched genre is 1.
//
// Pure, and the reason it exists is that the television's rule is written in shares rather
// than in counts: a household that watches every night and one that watches on Sundays must
// personalise the same way, and a floor expressed in raw sessions would mean something
// different for each of them. Anything not positive is dropped rather than sent as a zero —
// a genre with no weight is one the ordering has nothing to say about, and saying so with a
// row invites the set to treat it as a considered nil.
func normaliseGenreAffinity(affinity store.GenreAffinity) genreAffinityResponse {
out := genreAffinityResponse{Sessions: affinity.Sessions, Genres: []genreAffinityEntry{}}
top := 0.0
for _, entry := range affinity.Genres {
if entry.Score > top {
top = entry.Score
}
}
if top <= 0 {
return out
}
for _, entry := range affinity.Genres {
if entry.Genre == "" || entry.Score <= 0 {
continue
}
out.Genres = append(out.Genres, genreAffinityEntry{
Genre: entry.Genre,
Weight: entry.Score / top,
})
}
return out
}
// handleGenreAffinity answers which genres this viewer watches.
//
// It is its own route rather than a field on /v1/home for the reason the update verdict is:
// home is cached per user and is the response every television in the house is waiting on,
// while this is asked for at most once per session by the one set whose viewer has opened
// the Genres browser. Nothing on the launcher wants it.
//
// Every way this can fail is the same answer — an empty reading, which the television reads
// as "use the default order". A rail that refused to draw because Tracearr was unreachable
// would be a personalisation feature costing somebody their genre list.
func (s *Server) handleGenreAffinity(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
key := cache.UserKey(sess.EmbyUserID, "genre-affinity:v1")
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
}
response := s.genreAffinityFor(ctx, sess)
body, err := json.Marshal(response)
if err != nil {
writeJSON(w, http.StatusOK, genreAffinityResponse{Genres: []genreAffinityEntry{}})
return
}
ttl := genreAffinityTTL
if len(response.Genres) == 0 {
ttl = genreAffinityEmptyTTL
}
if err := s.cache.Set(ctx, key, body, ttl); err != nil {
s.loggerFor(ctx).Warn("genre affinity cache write failed", "error", err)
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, body)
}
// genreAffinityFor is the reading itself, separated from the caching so the failure stance
// is stated once: nothing below returns an error, because there is no trouble here a viewer
// could act on and the rail has a perfectly good answer without it.
func (s *Server) genreAffinityFor(ctx context.Context, sess store.Session) genreAffinityResponse {
empty := genreAffinityResponse{Genres: []genreAffinityEntry{}}
if s.store == nil {
return empty
}
// The operator's switch is honoured even though the rows are already in Postgres.
// Tracearr switched off means the household has said Memby may not read their viewing,
// and old rows are still their viewing.
if !s.integrationEnabled(ctx, integrationTracearr) {
return empty
}
identity, err := s.store.TracearrIdentity(ctx, sess.EmbyUserID)
if err != nil {
s.loggerFor(ctx).Debug("genre affinity identity failed", "error", err)
}
// The session's own username is the fallback join, and it is the only one a viewer the
// recommendation builder has never profiled has.
username := identity.Username
if username == "" {
username = sess.Username
}
affinity, err := s.store.TracearrGenreAffinity(ctx, identity.TracearrUserID, username, time.Now())
if err != nil {
s.loggerFor(ctx).Warn("genre affinity failed", "error", err)
return empty
}
response := normaliseGenreAffinity(affinity)
s.loggerFor(ctx).Debug("genre affinity read",
"sessions", response.Sessions, "genres", len(response.Genres))
return response
}
@@ -0,0 +1,76 @@
package api
import (
"testing"
"github.com/ponzischeme89/memby/server/internal/store"
)
func TestNormaliseGenreAffinityScalesToTheTopGenre(t *testing.T) {
response := normaliseGenreAffinity(store.GenreAffinity{
Sessions: 40,
Genres: []store.GenreWeight{
{Genre: "Crime", Score: 20},
{Genre: "Drama", Score: 10},
{Genre: "Western", Score: 1},
},
})
if response.Sessions != 40 {
t.Fatalf("sessions = %d, want 40", response.Sessions)
}
if len(response.Genres) != 3 {
t.Fatalf("genres = %d, want 3", len(response.Genres))
}
if response.Genres[0].Genre != "Crime" || response.Genres[0].Weight != 1 {
t.Fatalf("top = %+v, want Crime at 1", response.Genres[0])
}
if response.Genres[1].Weight != 0.5 {
t.Fatalf("Drama = %v, want 0.5", response.Genres[1].Weight)
}
}
// A share is the whole point: two households with wildly different amounts of viewing must
// produce the same ordering from the same balance of it.
func TestNormaliseGenreAffinityIsScaleFree(t *testing.T) {
light := normaliseGenreAffinity(store.GenreAffinity{Sessions: 12, Genres: []store.GenreWeight{
{Genre: "Comedy", Score: 3}, {Genre: "Horror", Score: 1},
}})
heavy := normaliseGenreAffinity(store.GenreAffinity{Sessions: 900, Genres: []store.GenreWeight{
{Genre: "Comedy", Score: 300}, {Genre: "Horror", Score: 100},
}})
for i := range light.Genres {
if light.Genres[i] != heavy.Genres[i] {
t.Fatalf("entry %d: %+v vs %+v", i, light.Genres[i], heavy.Genres[i])
}
}
}
// An empty or nonsensical reading has to come back as an empty *list* rather than as null,
// because the television reads "no genres" as "use the default order" and a null would be
// one more shape for it to have an opinion about.
func TestNormaliseGenreAffinityRefusesToInventWeights(t *testing.T) {
for name, affinity := range map[string]store.GenreAffinity{
"nothing watched": {},
"all zero": {Sessions: 5, Genres: []store.GenreWeight{{Genre: "Drama"}}},
"negative": {Sessions: 5, Genres: []store.GenreWeight{{Genre: "Drama", Score: -2}}},
} {
response := normaliseGenreAffinity(affinity)
if response.Genres == nil {
t.Fatalf("%s: genres is nil, want an empty list", name)
}
if len(response.Genres) != 0 {
t.Fatalf("%s: genres = %+v, want none", name, response.Genres)
}
}
}
// A blank label cannot be matched against anything on the television, so it is dropped
// rather than sent as a row with a weight the set would have to know to ignore.
func TestNormaliseGenreAffinityDropsBlankLabels(t *testing.T) {
response := normaliseGenreAffinity(store.GenreAffinity{Sessions: 20, Genres: []store.GenreWeight{
{Genre: "Drama", Score: 4}, {Genre: "", Score: 2},
}})
if len(response.Genres) != 1 || response.Genres[0].Genre != "Drama" {
t.Fatalf("genres = %+v, want Drama alone", response.Genres)
}
}
+4 -5
View File
@@ -673,11 +673,10 @@ func (s *Server) heroRow(
location = time.Local
}
candidates := s.heroCandidates(ctx, rows, now)
policy, err := s.store.HeroPolicy(ctx)
if err != nil {
s.loggerFor(ctx).Warn("hero policy unavailable", "error", err)
policy = store.HeroPolicy{}
}
// The cached read, not a query: this document is now also read by the status poll, and
// it must be the same copy the cache key was derived from a moment ago or the answer
// filed under a revision would not be the answer that revision names.
policy := s.currentHeroPolicy(ctx)
placementPolicy := policy.Placement(store.HeroPlacementHome)
pinned := s.pinnedHeroCandidates(ctx, placementPolicy.PinnedItemIDs)
// Manual pins always lead. Schedules resolve on the gateway (never on a television),
+9 -14
View File
@@ -29,11 +29,13 @@ func (s *Server) handleActiveHero(w http.ResponseWriter, r *http.Request, sess s
return
}
now := time.Now()
location := s.cfg.RadarrLocation
if location == nil {
location = time.Local
}
key := cache.UserKey(sess.EmbyUserID, "hero:active:v1:"+placement+":"+heroRotationSlot(now, location))
location := s.heroLocation()
// Keyed by the revision the status poll publishes, which already carries the rotation
// slot. That is what makes an operator's change reachable immediately without dropping
// anything else the household has cached: the old entry is not invalidated, it is
// simply no longer named. See heroRevision.
key := cache.UserKey(sess.EmbyUserID, "hero:active:v2:"+placement+":"+
heroRevision(s.currentHeroPolicy(r.Context()), sess.EmbyUserID, now, location))
if raw, err := s.cache.Get(r.Context(), key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
@@ -75,17 +77,10 @@ func (s *Server) resolveActiveHero(ctx context.Context, sess store.Session, plac
s.decorateItemRatings(ctx, result.Items)
rows := []recommend.Row{{ID: "hero-candidates-" + placement, Kind: "catalogue", Items: result.Items}}
policy, err := s.store.HeroPolicy(ctx)
if err != nil {
s.loggerFor(ctx).Warn("section hero policy unavailable", "placement", placement, "error", err)
policy = store.HeroPolicy{}
}
policy := s.currentHeroPolicy(ctx)
placementPolicy := policy.Placement(placement)
pinned := filterHeroPlacement(s.pinnedHeroCandidates(ctx, placementPolicy.PinnedItemIDs), placement)
location := s.cfg.RadarrLocation
if location == nil {
location = time.Local
}
location := s.heroLocation()
scheduledIDs := activeHeroScheduleIDs(policy.Schedules, placement, sess.EmbyUserID, now, location)
scheduled := filterHeroPlacement(s.pinnedHeroCandidates(ctx, scheduledIDs), placement)
+10 -14
View File
@@ -260,7 +260,16 @@ func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "could not save hero policy")
return
}
s.invalidateAllHomeCaches(r.Context())
// No cache sweep. The hero revision is part of the home and section-hero cache keys,
// so the entries built under the policy just replaced are already unreachable and age
// out on their own TTL; this only drops *this* instance's copy of the document, which
// is what makes the operator's own next read the answer they just saved rather than
// the one they replaced.
//
// It used to call InvalidateUser for every account in the house, which threw away
// every cached item lookup, image and row the household had in order to change four
// cards — so a hero edit made the next launcher on every set rebuild from Emby.
s.heroPolicy.invalidate()
writeJSON(w, http.StatusOK, map[string]any{"saved": true})
}
@@ -277,16 +286,3 @@ func uniqueHeroIDs(ids []string) []string {
}
return out
}
func (s *Server) invalidateAllHomeCaches(ctx context.Context) {
users, err := s.store.KnownUsers(ctx)
if err != nil {
s.loggerFor(ctx).Warn("hero cache invalidation could not list users", "error", err)
return
}
for _, user := range users {
if err := s.cache.InvalidateUser(ctx, user.ID); err != nil {
s.loggerFor(ctx).Warn("hero cache invalidation failed", "user", user.ID, "error", err)
}
}
}
+133
View File
@@ -0,0 +1,133 @@
package api
import (
"context"
"hash/fnv"
"strconv"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
// heroRevisionSchema is bumped when the *shape* of what a revision covers changes, so a
// gateway deployed mid-evening cannot hand a television a revision it has already acted
// on for a hero that would now resolve differently.
const heroRevisionSchema = 1
// heroPolicyTTL is how stale the cached hero policy may be, and it is the featurePolicyTTL
// figure for the featurePolicyTTL reason: the operator is the only writer, their own write
// clears this instance's copy outright, so the window is "how long until an instance that
// did not make the change notices" rather than "how long until my change takes effect".
//
// It matters more here than it did there. /v1/status carries the hero revision now, which
// means every open television would otherwise read this document from Postgres every ten
// seconds to answer a question whose answer changes when somebody presses Save.
const heroPolicyTTL = 5 * time.Second
// heroPolicyCache is the featurePolicyCache arrangement over the hero document: a stale
// read takes the lock and refreshes, and every other caller waits for that one refresh
// rather than starting its own.
type heroPolicyCache struct {
mu sync.Mutex
value store.HeroPolicy
valid bool
fetched time.Time
}
func (c *heroPolicyCache) read(
ctx context.Context, load func(context.Context) store.HeroPolicy,
) store.HeroPolicy {
c.mu.Lock()
defer c.mu.Unlock()
if c.valid && time.Since(c.fetched) < heroPolicyTTL {
return c.value
}
c.value = load(ctx)
c.valid = true
c.fetched = time.Now()
return c.value
}
func (c *heroPolicyCache) invalidate() {
c.mu.Lock()
defer c.mu.Unlock()
c.valid = false
}
// currentHeroPolicy is read by the status poll, by /v1/home and by /v1/heroes/active, so
// it is cached rather than queried — see heroPolicyCache.
//
// Every failure degrades to an empty policy, which is the automatic hero: a launcher that
// would not compose because a pin could not be looked up is a far worse trade than one
// evening's operator choices being missed.
func (s *Server) currentHeroPolicy(ctx context.Context) store.HeroPolicy {
if s.store == nil {
return store.HeroPolicy{}
}
return s.heroPolicy.read(ctx, func(ctx context.Context) store.HeroPolicy {
policy, err := s.store.HeroPolicy(ctx)
if err != nil {
s.loggerFor(ctx).Warn("hero policy unavailable", "error", err)
return store.HeroPolicy{}
}
return policy
})
}
func (s *Server) heroLocation() *time.Location {
if location := s.cfg.RadarrLocation; location != nil {
return location
}
return time.Local
}
// heroRevision is what makes an operator's hero change arrive on the poll the television
// is already making, and it is deliberately the themeRevision shape rather than a counter
// in a table: what a hero resolves to changes when nobody has written anything — a
// schedule window opens, the rotation slot turns over — and neither of those is a row
// anybody updates.
//
// It is a hash of *what would be resolved* rather than of the stored document, which is
// what keeps a save that changed nothing from repainting every launcher in the house. It
// is per viewer because schedules can be, and because the rotation seed already is.
//
// The same value keys the cached answers (see handleHome and handleActiveHero), so the
// revision a television holds names exactly the entry it would be served: there is no
// state in which the poll says something moved and the fetch that follows returns what
// the set already had.
func heroRevision(policy store.HeroPolicy, userID string, now time.Time, location *time.Location) string {
digest := fnv.New64a()
write := func(part string) {
_, _ = digest.Write([]byte(part))
_, _ = digest.Write([]byte{0})
}
write(strconv.Itoa(heroRevisionSchema))
// The slot rather than the clock: the draw is stable within it, so hashing the time
// itself would move the revision on every poll and refetch four heroes a minute.
write(heroRotationSlot(now, location))
// A fixed order, never a map range: two instances disagreeing about a revision is
// indistinguishable from a change, and would refetch on alternate polls.
for _, name := range []string{store.HeroPlacementHome, store.HeroPlacementMovies, store.HeroPlacementTVShows} {
placement := policy.Placement(name)
write(name)
for _, id := range placement.PinnedItemIDs {
write(id)
}
write(placement.PrimeSubtitle)
// Only the schedules that are *in force* for this viewer right now. A schedule
// added for tomorrow evening changes nothing on screen tonight, and moving the
// revision for it would be a repaint with nothing behind it.
for _, id := range activeHeroScheduleIDs(policy.Schedules, name, userID, now, location) {
write(id)
}
}
return strconv.FormatUint(digest.Sum64(), 10)
}
// heroStatus is the summary /v1/status carries: one opaque string, compared only for
// equality, which is all a television needs to know whether the hero it is drawing is
// still the one the gateway would compose.
func heroStatus(revision string) map[string]any {
return map[string]any{"revision": revision}
}
+112
View File
@@ -0,0 +1,112 @@
package api
import (
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
var heroRevisionZone = time.FixedZone("NZST", 12*60*60)
func heroRevisionAt(policy store.HeroPolicy, user string, at time.Time) string {
return heroRevision(policy, user, at, heroRevisionZone)
}
func pinnedPolicy(placement string, ids ...string) store.HeroPolicy {
return store.HeroPolicy{Placements: map[string]store.HeroPlacementPolicy{
placement: {PinnedItemIDs: ids},
}}
}
func TestHeroRevisionIsStableWithinASlot(t *testing.T) {
policy := pinnedPolicy(store.HeroPlacementHome, "film-1")
morning := time.Date(2026, 8, 20, 8, 0, 0, 0, heroRevisionZone)
if heroRevisionAt(policy, "user-1", morning) != heroRevisionAt(policy, "user-1", morning.Add(90*time.Minute)) {
t.Fatal("hero revision moved without anything changing; every poll would refetch")
}
}
func TestHeroRevisionMovesWhenAnOperatorEditsPins(t *testing.T) {
now := time.Date(2026, 8, 20, 8, 0, 0, 0, heroRevisionZone)
before := heroRevisionAt(pinnedPolicy(store.HeroPlacementHome, "film-1"), "user-1", now)
for name, policy := range map[string]store.HeroPolicy{
"added": pinnedPolicy(store.HeroPlacementHome, "film-1", "film-2"),
"removed": pinnedPolicy(store.HeroPlacementHome),
"replaced": pinnedPolicy(store.HeroPlacementHome, "film-2"),
} {
if heroRevisionAt(policy, "user-1", now) == before {
t.Fatalf("hero revision did not move when a pin was %s", name)
}
}
// Order is the operator's decision about which card leads, so reordering is a change
// even though the same titles are pinned.
reordered := pinnedPolicy(store.HeroPlacementHome, "film-2", "film-1")
if heroRevisionAt(reordered, "user-1", now) ==
heroRevisionAt(pinnedPolicy(store.HeroPlacementHome, "film-1", "film-2"), "user-1", now) {
t.Fatal("hero revision did not move when pins were reordered")
}
}
func TestHeroRevisionSeparatesPlacements(t *testing.T) {
now := time.Date(2026, 8, 20, 8, 0, 0, 0, heroRevisionZone)
home := heroRevisionAt(pinnedPolicy(store.HeroPlacementHome, "film-1"), "user-1", now)
movies := heroRevisionAt(pinnedPolicy(store.HeroPlacementMovies, "film-1"), "user-1", now)
if home == movies {
t.Fatal("pinning a title to Movies read as the same policy as pinning it to Home")
}
}
func TestHeroRevisionIgnoresACosmeticSave(t *testing.T) {
now := time.Date(2026, 8, 20, 8, 0, 0, 0, heroRevisionZone)
saved := pinnedPolicy(store.HeroPlacementHome, "film-1")
// UpdatedAt moves on every write. Hashing the document rather than what it resolves to
// would repaint every launcher in the house for a save that changed nothing.
resaved := pinnedPolicy(store.HeroPlacementHome, "film-1")
resaved.UpdatedAt = now
if heroRevisionAt(saved, "user-1", now) != heroRevisionAt(resaved, "user-1", now) {
t.Fatal("hero revision moved for a save that changed nothing")
}
}
func TestHeroRevisionMovesWhenAScheduleOpensAndCloses(t *testing.T) {
policy := store.HeroPolicy{Schedules: []store.HeroSchedule{{
ID: "s1", ItemID: "film-9", Enabled: true,
Frequency: "daily", StartTime: "20:00", EndTime: "22:00",
Placements: []string{store.HeroPlacementHome},
}}}
before := time.Date(2026, 8, 20, 19, 0, 0, 0, heroRevisionZone)
during := time.Date(2026, 8, 20, 21, 0, 0, 0, heroRevisionZone)
// Two readings within one rotation slot, so only the schedule can be the difference.
if heroRotationSlot(before, heroRevisionZone) != heroRotationSlot(during, heroRevisionZone) {
t.Fatal("test times straddle a rotation slot; the schedule is no longer the only variable")
}
if heroRevisionAt(policy, "user-1", before) == heroRevisionAt(policy, "user-1", during) {
t.Fatal("hero revision did not move when a scheduled hero came into force")
}
empty := store.HeroPolicy{}
if heroRevisionAt(policy, "user-1", before) != heroRevisionAt(empty, "user-1", before) {
t.Fatal("a schedule that is not yet in force changed the revision; the launcher would repaint for nothing")
}
}
func TestHeroRevisionIsPerViewerForAPersonalSchedule(t *testing.T) {
now := time.Date(2026, 8, 20, 21, 0, 0, 0, heroRevisionZone)
policy := store.HeroPolicy{Schedules: []store.HeroSchedule{{
ID: "s1", ItemID: "film-9", Enabled: true, UserID: "user-1",
Frequency: "daily", StartTime: "20:00", EndTime: "22:00",
Placements: []string{store.HeroPlacementHome},
}}}
if heroRevisionAt(policy, "user-1", now) == heroRevisionAt(policy, "user-2", now) {
t.Fatal("one viewer's scheduled hero moved another viewer's revision")
}
}
func TestHeroRevisionMovesWithTheRotationSlot(t *testing.T) {
policy := pinnedPolicy(store.HeroPlacementHome, "film-1")
morning := time.Date(2026, 8, 20, 8, 0, 0, 0, heroRevisionZone)
evening := time.Date(2026, 8, 20, 20, 0, 0, 0, heroRevisionZone)
if heroRevisionAt(policy, "user-1", morning) == heroRevisionAt(policy, "user-1", evening) {
t.Fatal("hero revision did not move across rotation slots; the day's draw would never be refetched")
}
}
+9 -2
View File
@@ -75,11 +75,18 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
sonarrSchedule := s.sonarrEnabled(r.Context()) && supportsSonarrSchedule(r)
radarrSchedule := s.radarrEnabled(r.Context()) && supportsRadarrSchedule(r)
hero := supportsHomeHero(r)
now := time.Now()
// The hero revision is part of the key rather than something to invalidate. An
// operator's change therefore makes the entries built under the old policy simply
// unreachable, and they age out on their own TTL — where the sweep it replaced dropped
// every cached answer this household had, artwork and item lookups included, to change
// four cards. See heroRevision.
heroRev := heroRevision(s.currentHeroPolicy(ctx), sess.EmbyUserID, now, s.heroLocation())
key := cache.UserKey(
sess.EmbyUserID,
"home:v4:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
":r"+strconv.FormatBool(radarrSchedule)+":h"+strconv.FormatBool(hero)+
":d"+sess.DeviceID,
":hr"+heroRev+":d"+sess.DeviceID,
)
if raw, err := s.cache.Get(ctx, key); err == nil {
@@ -331,7 +338,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
// does draw it put it somewhere sensible.
if hero {
compose := timing.Start(ctx, timing.StageHero)
row := s.heroRow(ctx, out.Rows, sess.EmbyUserID, time.Now())
row := s.heroRow(ctx, out.Rows, sess.EmbyUserID, now)
compose()
if row != nil {
out.Rows = append([]recommend.Row{*row}, out.Rows...)
+8
View File
@@ -161,6 +161,14 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
// season has to reach a set that is already switched on, without anybody doing
// anything.
"theme": themeStatus(s.themeFor(r.Context(), sess)),
// The hero, as one opaque revision rather than the cards themselves — the theme
// precedent, for the same reason. A television refetches its heroes only when this
// moves, which is what turns an operator pinning a title into a change that arrives
// on the poll the set is already making rather than at the next cold start. It also
// keys the cached answers, so a revision that has moved always names a fresh build.
"hero": heroStatus(heroRevision(
s.currentHeroPolicy(r.Context()), sess.EmbyUserID, time.Now(), s.heroLocation(),
)),
// Whether this viewer may ask the household for titles. Per person rather than per
// household, so it cannot ride the feature map beside it: the allowlist is the
// operator's decision about one account, and every television is polling this
+1 -1
View File
@@ -1 +1 @@
0.1.60
0.1.61
+139
View File
@@ -0,0 +1,139 @@
package store
import (
"context"
"fmt"
"strconv"
"strings"
"time"
)
// GenreWeight is one genre label and how much of this viewer's watching it accounts for.
//
// The label is Emby's own spelling, verbatim, because the television is what turns labels
// into the categories it draws — "Science Fiction", "Sci-Fi" and "Sci-Fi & Fantasy" are one
// shelf there and three different rows here. Folding them on this side would mean the
// gateway holding a second copy of a catalogue that is deliberately product design on the
// set, and the two would drift the first time a category gained an alias.
type GenreWeight struct {
Genre string
Score float64
}
// GenreAffinity is everything one reading of a viewer's history came to: the weighted
// genres and how many sessions were behind them.
//
// Sessions is carried because it is the only thing that separates "this household watches
// Westerns" from "somebody put a Western on once" — the television refuses to personalise
// below a floor, and a share of a tiny total is not evidence.
type GenreAffinity struct {
Genres []GenreWeight
Sessions int
}
const (
// genreAffinityWindow is how far back a session still counts for. Long enough that a
// household which watches a few evenings a week has something to say, short enough that
// the crime phase somebody went through two years ago is no longer shaping their rail.
genreAffinityWindow = 180 * 24 * time.Hour
// The recency bands. Recent viewing should count for more, and three flat bands are the
// whole of "lightly weight" — an exponential decay would need a half-life nobody could
// defend and would make the answer move between two readings taken the same evening.
genreAffinityRecentWindow = 30 * 24 * time.Hour
genreAffinityMidWindow = 90 * 24 * time.Hour
// genreAffinityLimit caps the labels returned. A real library has a few dozen distinct
// genre strings and the television folds them into sixteen categories; the tail past
// this cannot change an ordering.
genreAffinityLimit = 60
// genreAffinityEngagement is the least of a title somebody must have reached before it
// says anything about their taste. A session that stopped four minutes in is evidence
// they did *not* want it, and counting those is how a rail comes to lead with the genre
// somebody keeps abandoning.
genreAffinityEngagement = 0.5
)
// TracearrGenreAffinity weighs the genres one viewer actually watches.
//
// Nothing here is stored: Tracearr's sessions are already in Postgres and the imported
// catalogue already holds each title's genres as an indexed array, so this is a join over
// two tables the gateway keeps for other reasons. A per-user genre table would be a copy of
// both, wrong the moment either changed, and would need its own reconciliation to stay
// honest — the same trade watchedMsExpr makes for watch time.
//
// An episode is credited to its *series'* genres, which is what emby_series_id is for: an
// episode row in the catalogue inherits them anyway, and a household that watches one crime
// drama nightly should read as watching crime rather than as watching nothing identifiable.
//
// The identity is matched two ways for the reason attributeWatchTime does it — the username
// is what Tracearr and Emby genuinely share, and the recorded Tracearr id is what the
// recommendation builder actually matched, so a viewer renamed in one system keeps their
// history rather than silently reading as new.
func (s *Store) TracearrGenreAffinity(
ctx context.Context,
tracearrUserID, username string,
now time.Time,
) (GenreAffinity, error) {
id := strings.TrimSpace(tracearrUserID)
name := strings.ToLower(strings.TrimSpace(username))
if id == "" && name == "" {
return GenreAffinity{}, nil
}
rows, err := s.pool.Query(ctx, `
WITH viewed AS (
SELECT coalesce(nullif(emby_series_id, ''), emby_item_id) AS item_id,
CASE
WHEN started_at >= $5 THEN 1.0
WHEN started_at >= $4 THEN 0.6
ELSE 0.3
END AS weight
FROM tracearr_sessions
WHERE started_at >= $3
AND (($1::text <> '' AND tracearr_user_id = $1::text)
OR ($2::text <> '' AND lower(username) = $2::text))
AND (watched OR (
total_duration_ms > 0
AND progress_ms::float8 / total_duration_ms >= $6::float8
))
AND coalesce(nullif(emby_series_id, ''), emby_item_id) <> ''
),
joined AS (
SELECT viewed.weight, library_items.genres
FROM viewed
JOIN library_items ON library_items.id = viewed.item_id
WHERE cardinality(library_items.genres) > 0
),
scored AS (
SELECT btrim(label) AS genre, sum(joined.weight)::float8 AS score
FROM joined CROSS JOIN LATERAL unnest(joined.genres) AS label
WHERE btrim(label) <> ''
GROUP BY btrim(label)
)
SELECT scored.genre, scored.score, (SELECT count(*) FROM joined)::int
FROM scored
ORDER BY scored.score DESC, scored.genre
LIMIT `+strconv.Itoa(genreAffinityLimit),
id, name,
now.Add(-genreAffinityWindow),
now.Add(-genreAffinityMidWindow),
now.Add(-genreAffinityRecentWindow),
genreAffinityEngagement,
)
if err != nil {
return GenreAffinity{}, fmt.Errorf("store: tracearr genre affinity: %w", err)
}
defer rows.Close()
out := GenreAffinity{Genres: []GenreWeight{}}
for rows.Next() {
var weight GenreWeight
var sessions int
if err := rows.Scan(&weight.Genre, &weight.Score, &sessions); err != nil {
return GenreAffinity{}, fmt.Errorf("store: scan tracearr genre affinity: %w", err)
}
out.Genres = append(out.Genres, weight)
out.Sessions = sessions
}
return out, rows.Err()
}
+24
View File
@@ -2,8 +2,11 @@ package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Watch time is read out of tracearr_sessions rather than stored again.
@@ -178,3 +181,24 @@ func (s *Store) TracearrIdentities(ctx context.Context) (map[string]Recommendati
}
return out, rows.Err()
}
// TracearrIdentity is one Emby account's recorded Tracearr identity, or the zero value for
// somebody the recommendation builder has never profiled.
//
// The map above is what the console needs — it draws the whole household in one pass — and
// this is what a single request needs. Absence is not an error: a viewer with no profile
// row is matched by username alone, which is the join the two systems genuinely share.
func (s *Store) TracearrIdentity(ctx context.Context, embyUserID string) (RecommendationIdentity, error) {
var identity RecommendationIdentity
err := s.pool.QueryRow(ctx, `
SELECT tracearr_user_id, tracearr_username
FROM recommendation_user_profiles
WHERE emby_user_id = $1`, embyUserID).Scan(&identity.TracearrUserID, &identity.Username)
if errors.Is(err, pgx.ErrNoRows) {
return RecommendationIdentity{}, nil
}
if err != nil {
return RecommendationIdentity{}, fmt.Errorf("store: tracearr identity: %w", err)
}
return identity, nil
}