This commit is contained in:
ponzischeme89
2026-08-20 07:54:03 +12:00
parent 769fe01c84
commit 434371e9cf
37 changed files with 1767 additions and 102 deletions
+116
View File
@@ -809,6 +809,53 @@ at a time. Things to preserve:
`HomeViewModel.favoriteChanges` so a Favourite press and its possible rollback reach the
paged copy immediately. Its `type=Movie` / `type=Series` query is still enforced by the
gateway and on the direct Emby path, so no category can mix the two grids.
- **The rail is ordered by what this viewer watches, and the catalogue itself never
changes.** `GET /v1/genres/affinity` (`server/internal/api/genre_affinity.go`
`store.TracearrGenreAffinity``ui/genre/GenrePersonalisation.kt`) is a weight per Emby
genre label, and `personaliseGenreCategories` is what the television does with it. Things
to preserve:
- **The evidence is the server's and the catalogue is the set's.** Nothing is stored:
Tracearr's sessions and the imported catalogue's genres are already in Postgres, so the
reading is a join over two tables the gateway keeps for other reasons — the trade
`watchedMsExpr` makes for watch time, and for the same reason (a per-user genre table
would be a copy of both, wrong the moment either changed). The gateway sends Emby's own
spellings verbatim and never a category: folding "Science Fiction", "Sci-Fi" and
"Sci-Fi & Fantasy" into one shelf needs the alias table, which lives on the television,
and a second copy of it on the server would disagree the first time a category gained a
spelling — which is the failure the aliases exist to fix.
- **The order is settled before the rail is drawn and never re-ordered under a viewer.**
`EmbyRepository.genreAffinitySnapshot()` is a volatile read, the `snapshot` arrangement,
consulted once in `GenreBrowseViewModel`'s constructor; `warmGenreAffinity()` is what
fills it in, from `HomeViewModel`'s init, because the browser is never the first screen
a television draws. A set whose reading has not landed gets the catalogue order and is
personalised the next time the page is opened, which is the right way round: a rail that
reshuffled under a D-pad already travelling it is worse than one that never personalised.
- **Nothing is ever hidden, and the unpersonalised half stays in product order.** The sort
is stable, so a category with no evidence scores zero and keeps its place below the ones
the viewer watches. `MIN_GENRE_AFFINITY_SESSIONS` refuses to personalise at all below a
floor — one Western on a wet Sunday is not a taste — and `GENRE_AFFINITY_FLOOR` is a
*share* of the top genre rather than a count, so the long tail cannot reshuffle on noise
and a household that watches nightly and one that watches on Sundays order the same way.
- **An episode counts as its series, and an abandoned title counts as nothing.** The
session joins on `emby_series_id` before `emby_item_id`, so a household watching one
crime drama nightly reads as watching crime; `genreAffinityEngagement` requires half the
title, because a session that stopped four minutes in is evidence somebody did *not*
want it and counting those is how a rail comes to lead with what a viewer keeps
abandoning. Recency is three flat bands over 180 days rather than a decay — a half-life
nobody could defend would also make the answer move between two readings taken the same
evening.
- **Every failure is the default order.** The direct path has nobody to ask, a gateway
predating the route answers 404, a household running no Tracearr has no history, and the
operator's Tracearr switch is honoured even though the rows are already in Postgres —
switched off means Memby may not read their viewing, and old rows are still their
viewing. There is deliberately no second implementation on the direct path, the stance
the TV calendar takes: the answer is Tracearr's, which a television holds no credential
for, and the catalogue's own order is a perfectly good rail.
- **Six hours on both sides.** `genreAffinityTTL` and `GENRE_AFFINITY_REFRESH_MS` are
matched so a set refetching past it asks a question that has actually been recomputed
rather than paying a round trip for a cached repeat; an empty reading is kept for an
hour instead, because a new account genuinely acquires a history and a six-hour no would
hide their first evening of watching until the morning.
- **The gateway path degrades to a keyword search on the *first* page only**, so a set on a
new build talking to a gateway that predates the route still shows something. A later page
does not: a gateway that answered page one and failed on page two is having trouble, not
@@ -2513,6 +2560,40 @@ frame. Don't construct a bare `OkHttpClient.Builder()` — derive from `HttpStac
stream's derived client raises the read timeout and sets **no call timeout**, which would
cap the length of a film.
**A logo is fetched once and judged once.** `ui/TitleLogo.kt` holds both halves, and both
were being repeated on every screen that drew a title treatment — visible to a viewer as a
detail page returned to a second time showing the show's *name* and then swapping to its
logo. Things to preserve:
- **The darkness probe must not use Coil's memory cache.** That cache is keyed on the
image's URL and **not** on the size it was decoded at, so the 64×64 inspection copy was
written over the full-size bitmap the launcher had just cached: every page that probed a
logo threw away the copy the previous page had paid for, and the next display request
decoded it again. `probeLogoDarkness` sets `memoryCachePolicy(CachePolicy.DISABLED)` and
leaves the *disk* cache on deliberately — the bytes are worth keeping, and they are what
the display request reads instead of the network.
- **The verdict is remembered, and `useTextTitleForLogo` reads it as its initial value.**
`produceState` starting from "use the text title" is the right default the first time a
title is seen and a visible flash every time after it, since the answer cannot change.
`TitleLogoCache` is process-scoped, access-ordered and capped; concurrent asks for one
logo share a single probe on the cache's *own* scope, because the callers are a detail
page's `produceState` and a focus warm and both die when the D-pad moves.
- **"Could not tell" is not remembered.** A failed or cancelled probe returns null and is
not recorded — a hiccup must not condemn a title to its text heading for the life of the
process. `TitleLogoCacheTest` pins that, the single flight and the eviction.
- **One width, `data/LOGO_MAX_WIDTH` (720).** It was four — 800 in the screensaver, 720 on
the detail page and in the player, 640 on the home hero, 420 in the TV calendar — and a
width is part of the URL, so one show's logo was four cache keys, four fetches and four
verdicts. Ask for the default; a new surface that names its own width quietly opts out of
every cache the others share.
- **Invalidation is free and must stay that way.** An Emby logo URL carries the item id
*and* the image tag, so artwork replaced on the server is a different key in the verdict
cache and in both of Coil's. Nothing is told about it and nothing needs to be.
- **It is warmed on focus** (`HomeViewModel.warmDetailPage`), which is where the bytes and
the verdict both come from by the time a press composes the page. Skipped outright when
the viewer has logos switched off, and `TitleLogoCache.install` in `MembyApp` is what
gives that warm an application context rather than making the ViewModel hold one.
## Language
**Everything a person reads is New Zealand English.** No American spellings: `-ise`/
@@ -2939,6 +3020,41 @@ returns nothing rather than throwing. Things to preserve:
constantly behind it, which is why one slot must always yield the same draw.
The rotation further down belongs to the direct path, which has no merit to rank by.
**An operator's hero change is live, and it rides the status poll to get there.**
`heroRevision` (`server/internal/api/hero_revision.go`) is the `themeRevision` shape — a
hash rather than a counter, because what a hero resolves to changes when nobody has
written anything: a schedule window opens, the rotation slot turns over. `/v1/status`
carries it as `hero: {revision}`, `MaintenanceMonitor.heroRevision` publishes it, and the
launcher refetches `/v1/heroes/active` when it moves. Before it, a pin reached a set only
at its next cold start: the section heroes were fetched once per session and never again,
and Home's hero arrives with rows the launcher does not re-request while somebody is
sitting on them. Things to preserve:
- **The revision hashes what would be *resolved*, not the stored document.** `UpdatedAt`
moves on every write, so hashing the row would repaint every launcher in the house for a
save that changed nothing. For the same reason a schedule added for tomorrow evening does
not move it — only the schedules `activeHeroScheduleIDs` says are in force now do, which
is also what makes a window opening at 20:00 arrive by itself.
- **It is the same value in the cache key as on the poll**, in `handleHome` (`:hr`) and in
`hero:active:v2:`. That is what leaves no state in which the poll says something moved and
the fetch that follows returns what the set already had — and it replaced the cache
*sweep*: saving a hero used to call `InvalidateUser` for every account in the house,
throwing away every cached item lookup, row and image the household held in order to
change four cards. Nothing is invalidated now; the entries built under the old policy are
simply no longer named, and age out on their own TTL.
- **The policy is read through `currentHeroPolicy`**, never `store.HeroPolicy` directly.
The status poll is every open television every ten seconds, so this document needed the
`featurePolicyCache` treatment; the admin save invalidates that copy, which is the only
thing that write now does besides writing.
- **Home is not refetched at launch, only when the revision has moved under a set already
looking at it.** Its hero arrives with the home payload, and fetching it separately on
every launch would replace a hero already on screen with a second composition of the same
thing. The refetch replaces one placement at a time and never clears first, so unchanged
cards keep the artwork already decoded for them and nothing blanks between the two states.
- **The revision is collected inside the effect, not read as state in the launcher's body.**
A number arriving on a poll must never be a reason the launcher recomposes — the same rule
the animation and flow-collection conventions state.
**The reason sits above the ratings strip**, and that order is load-bearing. The featured
card's text column is what gives way when a title wraps onto two lines, so whatever is last
in it is cut — with the reason below the strip, the one line explaining why this card leads