From 434371e9cfb3cc755b299a52a6f4375fd97dda97 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Thu, 20 Aug 2026 07:54:03 +1200 Subject: [PATCH] 0.2.81 --- CHANGELOG.md | 15 ++ CLAUDE.md | 116 ++++++++++ app/build.gradle.kts | 2 +- .../java/com/ponzischeme89/memby/MembyApp.kt | 4 + .../memby/data/EmbyRepository.kt | 126 +++++++++- .../ponzischeme89/memby/data/GenreBrowse.kt | 10 + .../memby/data/MaintenanceMonitor.kt | 17 ++ .../memby/data/model/GatewayModels.kt | 57 +++++ .../memby/data/remote/GatewayApi.kt | 10 + .../memby/screensaver/MembyDreamService.kt | 2 +- .../memby/ui/DetailPageComponents.kt | 7 +- .../ponzischeme89/memby/ui/HomeComponents.kt | 75 ++++-- .../ponzischeme89/memby/ui/HomeMovieHero.kt | 9 +- .../ponzischeme89/memby/ui/HomeViewModel.kt | 16 ++ .../ponzischeme89/memby/ui/MainActivity.kt | 51 +++- .../com/ponzischeme89/memby/ui/TitleLogo.kt | 217 +++++++++++++++++- .../memby/ui/calendar/CalendarAgenda.kt | 8 +- .../memby/ui/genre/GenreBrowseViewModel.kt | 15 +- .../memby/ui/genre/GenrePersonalisation.kt | 118 ++++++++++ .../ui/screensaver/ScreensaverActivity.kt | 2 +- .../ui/screensaver/ScreensaverContent.kt | 8 +- .../memby/ui/TitleLogoCacheTest.kt | 105 +++++++++ .../ui/genre/GenrePersonalisationTest.kt | 149 ++++++++++++ server/internal/api/admin_test.go | 10 + server/internal/api/api.go | 4 + server/internal/api/genre_affinity.go | 155 +++++++++++++ server/internal/api/genre_affinity_test.go | 76 ++++++ server/internal/api/hero.go | 9 +- server/internal/api/hero_active.go | 23 +- server/internal/api/hero_admin.go | 24 +- server/internal/api/hero_revision.go | 133 +++++++++++ server/internal/api/hero_revision_test.go | 112 +++++++++ server/internal/api/home.go | 11 +- server/internal/api/maintenance.go | 8 + server/internal/buildinfo/VERSION | 2 +- server/internal/store/genre_affinity.go | 139 +++++++++++ server/internal/store/watch_time.go | 24 ++ 37 files changed, 1767 insertions(+), 102 deletions(-) create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/genre/GenrePersonalisation.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/TitleLogoCacheTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/genre/GenrePersonalisationTest.kt create mode 100644 server/internal/api/genre_affinity.go create mode 100644 server/internal/api/genre_affinity_test.go create mode 100644 server/internal/api/hero_revision.go create mode 100644 server/internal/api/hero_revision_test.go create mode 100644 server/internal/store/genre_affinity.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 66c699e..18c13ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 9488e75..c51e6de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6424918..6e486f6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() diff --git a/app/src/main/java/com/ponzischeme89/memby/MembyApp.kt b/app/src/main/java/com/ponzischeme89/memby/MembyApp.kt index 99b0488..f60e947 100644 --- a/app/src/main/java/com/ponzischeme89/memby/MembyApp.kt +++ b/app/src/main/java/com/ponzischeme89/memby/MembyApp.kt @@ -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. diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index b7bb467..3b06bb7 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -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(RADARR_MOVIE_CACHE_SIZE, 0.75f, true) private val radarrMovieInFlight = mutableMapOf>() + /** + * 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? = 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 { + 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 diff --git a/app/src/main/java/com/ponzischeme89/memby/data/GenreBrowse.kt b/app/src/main/java/com/ponzischeme89/memby/data/GenreBrowse.kt index 09cf2fa..fddc6cd 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/GenreBrowse.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/GenreBrowse.kt @@ -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. * diff --git a/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt b/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt index 2488ebb..30b28eb 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt @@ -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 = _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 = _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 diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt index 3f341db..0477ad3 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt @@ -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 = emptyList(), +) + /** * Response of `GET /v1/items/{id}/related` — the detail page's two additions. * diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt index 7db43f8..6f3203c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt @@ -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( diff --git a/app/src/main/java/com/ponzischeme89/memby/screensaver/MembyDreamService.kt b/app/src/main/java/com/ponzischeme89/memby/screensaver/MembyDreamService.kt index 1b63cd0..db37475 100644 --- a/app/src/main/java/com/ponzischeme89/memby/screensaver/MembyDreamService.kt +++ b/app/src/main/java/com/ponzischeme89/memby/screensaver/MembyDreamService.kt @@ -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) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt index 036ec47..c73f305 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt @@ -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) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt index 22b1084..dd1032a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -1526,13 +1526,27 @@ internal fun HomeRowHeaderIcon(icon: ImageVector, modifier: Modifier = Modifier) } } +/** + * The size of the media-type mark on a card's metadata line. + * + * Deliberately smaller than any icon a viewer can aim at — the rail's marks are 21dp and + * the circular detail actions larger still. This one is read, never pressed, and at 13dp + * it sits inside the 12sp line's own height, so a card carrying it is exactly as tall as + * one that does not. + */ +private val MediaTypeMarkSize = 13.dp + /** * Which of the two kinds of thing a card is, for a grid that holds both. * * Only the Genres destination genuinely mixes them — the Movies and TV Shows pages name a * type, so every card on those wears the same mark and it says nothing. It is a *mark* - * rather than a word because it sits over artwork at three metres, where "TV SHOW" in a - * pill would be read before the poster it is covering. + * rather than a word because the metadata line is one line at three metres, and "TV SHOW" + * spelled out there would crowd out the year and the runtime it sits beside. + * + * It belongs to that line rather than to the poster: the type is informational in the same + * way the year and the runtime are, and grouping the three together leaves the artwork — + * the only thing on the card worth looking at — carrying nothing it did not come with. * * A season or an episode is marked as television: both are parts of a show, and a viewer * separating films from shows is not asking about the difference between them. Anything @@ -2012,7 +2026,7 @@ private fun MediaCard( onFocused = onFocused, onClick = onClick, onLongClick = onLongClick, - contentDescription = cardDescription(item, progress, showWatchedEpisodeCount), + contentDescription = cardDescription(item, progress, showWatchedEpisodeCount, showMediaTypeIcon), modifier = modifier.width(width), ) { focused -> Column { @@ -2119,19 +2133,6 @@ private fun MediaCard( airingLabel, modifier = Modifier.align(Alignment.TopStart).padding(8.dp), ) - } else if (showMediaTypeIcon) { - // Which of the two things this is, for a grid that holds both. The - // top-left is the airing badge's corner first: a schedule card already - // says what it is by saying when it is on, and two marks in one corner - // would overlap. The watched tick and the heart own the other corner. - mediaTypeMark(item)?.let { (icon, description) -> - MediaStatusIcon( - icon = icon, - description = description, - tint = Color.White.copy(alpha = 0.92f), - modifier = Modifier.align(Alignment.TopStart).padding(8.dp), - ) - } } if (focused) { MembyArtworkPlayCue(Modifier.align(Alignment.Center)) @@ -2147,15 +2148,37 @@ private fun MediaCard( modifier = Modifier.padding(top = 7.dp).fillMaxWidth(), ) if (showSecondaryMetadata) { - Text( - cardSubtitle(item, showProgress, position, showWatchedEpisodeCount), - color = QuietText, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + // The media type sits *in* the metadata line rather than over the poster: + // it is informational, like the year and the runtime beside it, and the + // artwork is the one thing on the card worth looking at. The row is + // vertically centred and the mark is smaller than the line it sits on, so + // it cannot make the card taller than one without it. + val typeMark = if (showMediaTypeIcon) mediaTypeMark(item) else null + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 2.dp).fillMaxWidth(), - ) + ) { + typeMark?.let { (icon, _) -> + Icon( + icon, + // The card's own description already names the type; a second + // reading of it here would be the same fact said twice. + contentDescription = null, + tint = EmbyGreen, + modifier = Modifier.size(MediaTypeMarkSize), + ) + Spacer(Modifier.width(5.dp)) + } + Text( + cardSubtitle(item, showProgress, position, showWatchedEpisodeCount), + color = QuietText, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } } else { Spacer(Modifier.height(3.dp)) } @@ -2395,8 +2418,12 @@ private fun cardDescription( item: BaseItem, progress: Float, showWatchedEpisodeCount: Boolean, + showMediaTypeIcon: Boolean = false, ): String = buildString { append(item.name) + if (showMediaTypeIcon) mediaTypeMark(item)?.let { (_, description) -> + append(", ").append(description) + } item.seriesName?.let { append(", ").append(it) } if (progress > 0f) append(", ${(progress * 100).toInt()} percent watched") if (item.userData?.played == true) append(", watched") diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt index 37bdd01..5f0e422 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt @@ -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), ) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index 404d1ab..29e22cb 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -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)) } + } } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index af20e53..2b5d8a4 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -183,8 +183,10 @@ import androidx.tv.material3.Card import androidx.tv.material3.Icon import androidx.tv.material3.Text import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.withTimeout @@ -2356,7 +2358,7 @@ private fun HomeScreen( subjectId = item.id, title = item.name, posterUrl = repo.primaryUrl(item, maxWidth = 500), - logoUrl = repo.logoUrl(item, maxWidth = 720), + logoUrl = repo.logoUrl(item), ), ), ) @@ -2407,14 +2409,37 @@ private fun HomeScreen( // rendered as a shelf, so this is the only thing that can still see it. // Warm both section heroes while Home is being read. Navigation then normally draws // from memory, while the gateway's user/placement cache keeps this cheap across TVs. + // + // The same effect is what makes an operator's hero change arrive on a set that is + // already switched on: the gateway announces the configuration behind the heroes as a + // revision on the status poll, so this waits on that flow rather than on a timer, and + // refetches only when it moves. Collected inside the effect rather than read as state + // in the body, because the launcher must not recompose merely to be told a number. LaunchedEffect(settings.userId) { - listOf( - BrowseDestination.MOVIES to "movies", - BrowseDestination.SHOWS to "tv_shows", - ).forEach { (destination, placement) -> - launch { - runCatching { repo.getActiveHero(placement) }.onSuccess { resolved -> - sectionHeroRows = sectionHeroRows + (destination to resolved) + var appliedHeroRevision: String? = null + ServiceLocator.maintenance.heroRevision.collectLatest { revision -> + val moved = appliedHeroRevision != null && + revision.isNotBlank() && revision != appliedHeroRevision + if (revision.isNotBlank()) appliedHeroRevision = revision + coroutineScope { + // Home's hero arrives with the home rows, so it is asked for separately + // only once the configuration behind it has actually moved. Fetching it at + // launch would replace a hero already on screen with a second composition + // of the same thing, which is a flash for nothing. + listOfNotNull( + BrowseDestination.MOVIES to "movies", + BrowseDestination.SHOWS to "tv_shows", + (BrowseDestination.HOME to "home").takeIf { moved }, + ).forEach { (destination, placement) -> + if (!moved && sectionHeroRows.containsKey(destination)) return@forEach + launch { + runCatching { repo.getActiveHero(placement) }.onSuccess { resolved -> + // Replaced one placement at a time, never cleared first: the + // cards that did not change keep the artwork already decoded + // for them and the row never blanks between the two states. + sectionHeroRows = sectionHeroRows + (destination to resolved) + } + } } } } @@ -2432,7 +2457,15 @@ private fun HomeScreen( } val contextualHeroItems = remember(rows, homeContent.rows, sectionHeroRows, selectedDestination, heroDay) { when (selectedDestination) { - BrowseDestination.HOME -> selectHomeHeroMovies(rows, heroDay, homeContent.rows) + // The separately fetched home hero wins where there is one, which is only + // after the configuration moved under a set sitting on this screen; otherwise + // the hero row that came with the home payload is still the right answer. + BrowseDestination.HOME -> selectHomeHeroMovies( + rows, + heroDay, + sectionHeroRows[BrowseDestination.HOME]?.takeIf(List::isNotEmpty) + ?: homeContent.rows, + ) BrowseDestination.MOVIES, BrowseDestination.SHOWS -> serverHeroPicks(sectionHeroRows[selectedDestination].orEmpty()) else -> emptyList() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/TitleLogo.kt b/app/src/main/java/com/ponzischeme89/memby/ui/TitleLogo.kt index d2599a7..d599ece 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/TitleLogo.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/TitleLogo.kt @@ -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(64, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry) = + size > MAX_ENTRIES + } + + private val inFlight = mutableMapOf>() + + /** + * 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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarAgenda.kt b/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarAgenda.kt index b4635df..6d88d08 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarAgenda.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarAgenda.kt @@ -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 { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt index 44bdcf9..69f1661 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt @@ -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 = _state.asStateFlow() private var pageJob: Job? = null diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenrePersonalisation.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenrePersonalisation.kt new file mode 100644 index 0000000..8d0ef37 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenrePersonalisation.kt @@ -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): 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, + affinity: GenreAffinity, +): List { + 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 { + if (genres.isEmpty()) return emptyMap() + val out = HashMap(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 +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverActivity.kt index 9f586c5..828427c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverActivity.kt @@ -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), ), ), ) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt index 302e1cf..83b21cd 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt @@ -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 diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/TitleLogoCacheTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/TitleLogoCacheTest.kt new file mode 100644 index 0000000..df13f3b --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/TitleLogoCacheTest.kt @@ -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() + 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) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenrePersonalisationTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenrePersonalisationTest.kt new file mode 100644 index 0000000..9752751 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenrePersonalisationTest.kt @@ -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) = 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) + } +} diff --git a/server/internal/api/admin_test.go b/server/internal/api/admin_test.go index dcd2f2b..2f6a5d6 100644 --- a/server/internal/api/admin_test.go +++ b/server/internal/api/admin_test.go @@ -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 diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 5f705ee..79ec833 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -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)) diff --git a/server/internal/api/genre_affinity.go b/server/internal/api/genre_affinity.go new file mode 100644 index 0000000..f4631d5 --- /dev/null +++ b/server/internal/api/genre_affinity.go @@ -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 +} diff --git a/server/internal/api/genre_affinity_test.go b/server/internal/api/genre_affinity_test.go new file mode 100644 index 0000000..be22cd6 --- /dev/null +++ b/server/internal/api/genre_affinity_test.go @@ -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) + } +} diff --git a/server/internal/api/hero.go b/server/internal/api/hero.go index 0a4e2a4..5d6a2e0 100644 --- a/server/internal/api/hero.go +++ b/server/internal/api/hero.go @@ -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), diff --git a/server/internal/api/hero_active.go b/server/internal/api/hero_active.go index 04d7544..acf68ba 100644 --- a/server/internal/api/hero_active.go +++ b/server/internal/api/hero_active.go @@ -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) diff --git a/server/internal/api/hero_admin.go b/server/internal/api/hero_admin.go index 228a8ff..4e1bba1 100644 --- a/server/internal/api/hero_admin.go +++ b/server/internal/api/hero_admin.go @@ -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) - } - } -} diff --git a/server/internal/api/hero_revision.go b/server/internal/api/hero_revision.go new file mode 100644 index 0000000..ecd6c61 --- /dev/null +++ b/server/internal/api/hero_revision.go @@ -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} +} diff --git a/server/internal/api/hero_revision_test.go b/server/internal/api/hero_revision_test.go new file mode 100644 index 0000000..856ea67 --- /dev/null +++ b/server/internal/api/hero_revision_test.go @@ -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") + } +} diff --git a/server/internal/api/home.go b/server/internal/api/home.go index 50e2467..e3dc38a 100644 --- a/server/internal/api/home.go +++ b/server/internal/api/home.go @@ -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...) diff --git a/server/internal/api/maintenance.go b/server/internal/api/maintenance.go index a45aa8a..f26579e 100644 --- a/server/internal/api/maintenance.go +++ b/server/internal/api/maintenance.go @@ -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 diff --git a/server/internal/buildinfo/VERSION b/server/internal/buildinfo/VERSION index 1ec9b0b..ab831f0 100644 --- a/server/internal/buildinfo/VERSION +++ b/server/internal/buildinfo/VERSION @@ -1 +1 @@ -0.1.60 +0.1.61 \ No newline at end of file diff --git a/server/internal/store/genre_affinity.go b/server/internal/store/genre_affinity.go new file mode 100644 index 0000000..a344790 --- /dev/null +++ b/server/internal/store/genre_affinity.go @@ -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() +} diff --git a/server/internal/store/watch_time.go b/server/internal/store/watch_time.go index e5ce7a2..a1f7c2f 100644 --- a/server/internal/store/watch_time.go +++ b/server/internal/store/watch_time.go @@ -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 +}