This commit is contained in:
ponzischeme89
2026-08-20 15:06:00 +12:00
parent 549f9c5eed
commit f1164db2c5
52 changed files with 5441 additions and 158 deletions
+468
View File
@@ -0,0 +1,468 @@
# Viewers
**A Memby account is the household's relationship with an Emby user. A viewer is one
person under it.** Every account has exactly one **main** viewer, whose state is Emby's,
and any number of **shadow** viewers, whose state is Memby's alone. A shadow viewer
watches through the account's Emby credentials and Emby never learns what they watched.
```
Emby user
└── Memby account (Living Room)
├── Matt main → watched state, progress and favourites go to Emby
├── Alessandra shadow → state lives in Memby
└── Guest shadow → state lives in Memby
```
A viewer does **not** belong to a television. Somebody starts an episode in the lounge and
finishes it in the bedroom, because the state is the gateway's.
## What was verified before any of this was written
**Every write of Emby viewer state passes through four methods** on `emby.Client`:
`ReportPlayback`, `SetFavorite`, `SetPlayed` and `HideFromResume`. All four are called
only from `api` handlers holding a `store.Session`. Nothing else writes viewer state, so
keeping a shadow viewer out of Emby is four branches rather than a sweep of the codebase.
That is the property this feature is built on, and it is the one to re-check before
adding any new call into `emby.Client`.
**Two things still reach Emby, and both are accepted deliberately.** `PlaybackInfo` is
sent with `IsPlayback: true`, which registers a play session — so shadow playback appears
in Emby's Now Playing and activity log as the account. And `tracearr` identifies viewers
by *Emby username* (`tracearr/client.go`), so a shadow viewer's watching feeds the
account's Tracearr history, watch-time figures and genre affinity. Neither touches watched
state or resume position, which is what the feature promises. Both are v2 problems.
**There is no second implementation on the direct path.** On the direct-to-Emby path the
television reports straight to Emby itself, so there is nothing in between to route a
viewer's state to — the stance the TV calendar and genre affinity already take. Viewers
are gateway-only, and a set with no gateway has one viewer, which is the account.
## The seam: a viewer id, not a second user
Before this, `sess.EmbyUserID` did two unrelated jobs — it was the Emby credential *and*
Memby's key for "who is watching". Viewers separate them:
- **The credential stays the account's.** `credentials(sess)` is unchanged, and every
library read, image, stream URL and subtitle still goes out as the Emby user. A shadow
viewer has no Emby identity and needs none.
- **The person is `viewer.ID`.** Preferences, notifications, followed shows, row
statistics, search history, recommendation profiles and cache keys belong to whoever is
watching.
**The main viewer's id *is* the Emby user id**, which is the whole reason this needed no
migration. Every table in the schema keys a person by a bare `emby_user_id TEXT` with no
foreign key behind it, so substituting a viewer id leaves an existing household's rows
exactly where they were, and the main viewer behaves as the account did by construction
rather than by care. A shadow id is `"v"` plus 32 hex characters, so it can be told apart
from Emby's 32-hex GUIDs by inspection in a log line or a cache key
(`store.IsShadowViewerID`).
`Viewer.Kind` is still stored and is what every branch reads. The id shape is a safety
property, not a source of truth.
## How a request learns who is watching
`X-Memby-Viewer`, resolved by `s.activeViewer` (`api/viewers.go`).
It is a header rather than part of the session because switching between two people on one
television must not be a re-authentication, and because the same person is one viewer on
every set in the house. The session still answers "which account is this and what may it
read".
Things to preserve:
- **It is stated, never inferred** — the stance `Credentials.Gateway` takes. An app that
predates viewers sends nothing and resolves to the main viewer, which is exactly its
previous behaviour.
- **Every unknown case falls back to the main viewer**, including a list that will not
load. This is on the path of every authenticated request, and a television unable to do
anything because a header could not be checked is a far worse failure than one request
attributed to the account. The one thing it will not do is accept an id it could not
confirm belongs to this account.
- **An unknown id is logged, not refused.** The ordinary cause is a set still holding a
viewer somebody has since deleted, and a 403 on every request would leave it unable to
reach the picker that would fix it.
- **The list is cached for `viewerListTTL`** and the write clears it, because `/v1/status`
alone is every open television every ten seconds — the bargain `featurePolicyCache`
already makes. A failed refresh keeps the previous reading rather than dropping to the
main viewer, which would silently move a shadow viewer's playback back onto Emby.
## The common state layer
`viewerUserData` renders a viewer's state in the shape of Emby's `UserData` block, and the
television never learns which it received. That is what keeps viewers from becoming a
special case in every screen: a card draws its progress bar, tick and heart from
`UserData`, and where the block came from is not a question anything above that line asks.
The client reads exactly five fields (`UserItemData` in `EmbyModels.kt`), and
`viewers_test.go` pins them. Two are omitted rather than sent empty — a title with no
runtime has no percentage, and one never played has no date — because a card claiming a
zero-length progress bar is worse than one claiming nothing.
## Where state is written
`viewer_playback_state`, keyed `(viewer_id, item_id)`. The Emby item id is the common
identifier, so no library metadata is duplicated and nothing here needs invalidating when
the catalogue changes. **There is deliberately no row for a main viewer**: their state is
Emby's, and a copy here would be a second answer free to disagree with what the
household's other Emby clients see.
- **A completed title is stored at position zero**, the way Emby stores one. The position
is what Continue Watching reads, and a finished episode left at its last frame is one the
row keeps offering to resume four seconds from the end.
- **Only a stop can complete a title.** A progress report crossing the threshold is
somebody still watching the closing minutes, and marking it played there would take the
episode out of Continue Watching underneath them.
- **`PlayedFromPosition` is the completion rule and it matches Emby's own** (90%), so a
household cannot disagree with itself about whether an episode is finished depending on
who watched it. A runtime of zero means the length was not known rather than that the
title is zero long, so it can never complete anything.
- **`play_count` moves only on the transition into played**, so the ten-second reports
either side of the threshold cannot count one viewing several times.
- **The series is read out of `library_items` inside the upsert**, not asked of Emby and
not carried by the television. It is already there, it is what will order this viewer's
Continue Watching, and a report arrives every ten seconds.
- **Hiding keeps the position.** Hiding is a statement about the row, not about where
somebody got to, and pressing Play again should still resume.
## What a viewer reads
The write side is four branches. The read side is the larger half, and it is deliberately
**one substitution rather than a second code path**: the fan-out, the failure counting and
the Continue Watching merge in `handleHome` are identical for both kinds of viewer, and
only the fetch behind three rows changes.
**Everything is keyed on `viewerKeyOf(ctx, sess)`.** The viewer is resolved once, in
`authed`, and carried in the request context (`withViewer` / `viewerOf`). That is the
point: the alternative is calling `activeViewer` at each of the fifteen sites that build a
cache key, and the failure of forgetting one is silent — that view is keyed under the
account, and one viewer is served another's rows. Resolving at the boundary makes
forgetting impossible. A request that never passed through `authed` — a scheduled task, a
probe, a test — resolves to the account, which is the value every one of these keys held
before viewers existed.
**Cache keys that moved to the viewer**: home, search, genre and library browse, item
detail, series episodes, person filmography, related, the active hero. **Keys that
deliberately did not**: genre affinity, trailers, the screensaver, recommendations and the
Magic pool. Those answer for the household or are computed from the account's Tracearr
history, and keying them per viewer would multiply the misses without changing the answer.
They are the v2 list.
**`decorateItems` is the one door items leave the gateway through.** It attaches the
stored review scores and, for a shadow viewer, replaces the `UserData` block. It rides
exactly where `decorateItemRatings` rode — one indexed read for a whole launcher, at every
point items are served. The two remained separate functions because they are separate
concerns, but every call site wanted both, and *a decoration added at seven sites is a
decoration missing from the eighth*.
Things to preserve in `decorateViewerState`:
- **A main viewer returns immediately.** Their state is Emby's and is already on the
payload, so a household running no viewers pays one comparison for the whole launcher.
- **Every item is rewritten, not only the ones with something stored.** The `UserData`
that arrived from Emby is the *account's*, and leaving it on a title this viewer has
never touched is precisely the leak the feature exists to prevent. A title with no row
gets the zero state, which is the truth about it. A failed state read blanks everything
for the same reason: a launcher with no progress bars is a poor answer, one showing
somebody else's is a wrong one.
- **The block is replaced, never merged.** A partial overlay leaves whichever fields Memby
had nothing to say about carrying the account's values — the same leak from a narrower
angle.
- **A series and a season are answered from a count**, not from a row of their own. Emby
fills their block in from children it has never heard of for this person, so
`ViewerContainerStates` counts the episodes the shared catalogue holds against this
viewer's own played set and `viewerAggregateUserData` renders the result. This was the
last place a shadow viewer was shown the account's answer — a series ticked because
somebody else had finished it.
## What a series card says
`UnplayedItemCount` and `PlayedPercentage` are **omitted rather than sent as zero** when the
catalogue cannot count the title, the rule the leaf block follows: a library not yet
imported, and a show the import has never seen, would otherwise tick every series in the
house. `Played` is only true where there is something to have finished, for the same reason.
The **favourite is the container's own** and comes from the row against the series id, not
from the count: somebody marks a *show* a favourite, not the sum of its episodes. And a
container is never resumable — what resumes is an episode — so `PlaybackPositionTicks` is
zero, which is what Emby reports too.
The query groups by the **season/series pair** and the two rollups are done in Go
(`addViewerAggregate`). That is deliberately dull — no grouping sets, no second pass — and
it is exact for both answers because a season belongs to exactly one series. `containerIDsIn`
asks for a season's series alongside it, which is what keeps a partial series total from
belonging to anything drawn. `library_items_series_episodes_idx` is what makes it affordable;
without it, a series card costs a scan of every episode in the library, and it is also what
`ViewerNextUp` walks.
## The three rows that are about a person
`resume`, `favourites` and `nextup` are substituted in the home fan-out. The ranking is
Postgres's and Emby is asked only to *describe* the titles — which is why `orderItemsByID`
exists: Emby answers an `Ids=` query in its own order, and for these rows the order **is**
the answer. Handing Emby's order back would keep the right titles and throw away the reason
they were chosen. It also drops a title Emby will not answer for (a card that cannot be
opened is worse than a missing one) and collapses a repeated id, because every keyed list
on the television throws on a duplicate.
**Next Up is computed entirely in Postgres** (`store.ViewerNextUp`), out of the shared
catalogue and this viewer's own state. Emby's `NextUp` answers for the account and there is
nobody else to ask — and the alternative, walking each series' episode list over the wire,
is one Emby request per show on the tail of the launcher. It applies the same three rules
Emby's own answer does: an episode already resumable is left out (it is in Continue
Watching, and the merge would offer the show twice), specials are not next episodes, and a
series with nothing unwatched left contributes no row rather than an empty one.
**The merge is unchanged.** `ViewerWatchedSeries` returns exactly the
`map[string]time.Time` that `recentlyPlayedSeries` does, so `mergeContinueWatching` never
learns which viewer it is ordering for.
## Where a shadow viewer's playback starts
Taken from the store in `handlePlayback`, not trusted from the card. The hint the
television sends is read off a card this gateway already decorated with the viewer's own
state, so the two normally agree — but only normally: the store has heard about the episode
they were part-way through on the *other* television, and a card is only as fresh as the
last home refresh. It is also the value handed to `PlaybackInfo`, so taking it here fixes
the negotiated stream as well as the number sent back. A failed read starts from the
beginning, because that is a recoverable disappointment where starting from where somebody
else got to is not.
**Pressing Play on a series** resolves through `firstUnwatchedEpisodeFor` rather than
Emby's `NextUp`, which is what would otherwise drop a shadow viewer into the middle of
somebody else's season. A part-watched episode wins over the first unwatched one — somebody
eleven minutes in wants that episode, the same judgement the Continue Watching merge makes.
A viewer with nothing recorded for the series falls through to Emby's first episode, which
is the right answer for somebody who has never watched any of it.
**Auto-advance** keeps Emby's answer for *which* episode follows — that is a property of
the season and is the same for everybody — and replaces only how far into it this viewer
already is.
## Signing out is about the account
`invalidateAccountViews` drops every viewer's cached views, not only the account's.
Invalidating one key would leave each shadow viewer's rows behind, to be served intact to
the next person who signs in on that set. It is best-effort: what it misses expires on its
own TTL, and nothing there is worth failing a sign-out over.
## The television
**`X-Memby-Viewer` is sent by `GatewayAuthInterceptor`**, read on each request rather than
captured — the bargain the audio capability tokens already make. Switching between two
people must not rebuild the HTTP client, and the answer can change between any two
requests. The header is **omitted rather than sent empty** when nobody has been chosen,
because that is precisely what an app predating viewers sends and what the gateway reads as
the account's own viewer. The read is guarded, like the audio tokens, because the
interceptor also runs before the service locator exists in a screenshot context.
`viewers_v1` joins `MEMBY_CAPABILITIES`, so an operator cannot switch the feature on for a
household half of whose televisions have no way of choosing between people.
**The active viewer is device state**, and it is the one thing about viewers that is: a
viewer follows the person to every set in the house, but which of them is sitting in front
of *this* one is that set's own answer — the lounge and the bedroom are commonly two
different people at the same moment. `activeViewerId` and `activeViewerName` are written in
**one** edit (DataStore rewrites the whole file per edit, and a name landing apart from the
id it labels would leave the launcher greeting one person while every request named
another), and both are cleared with the session and on any account switch: the people under
one Emby account are not the people under another.
**The home cache is keyed per viewer**, `home_cache::<userId>@<serverUrl>#<viewerId>`. A
cold start draws the cache before the first refresh lands, so without this a shadow viewer
would open on the account's evening. **The account's own viewer keys exactly as it always
did, with no suffix**, so every existing install keeps the cache it already has. Removing a
profile now clears *every* viewer's key for it (`profileHomeCacheKeys`), or the largest
values this store holds would be orphaned.
`switchViewer` clears the playable, series-episode, local-resume and genre-affinity caches —
the same set a profile switch clears, for the same reason: they hold one person's watched
state, resume positions and reasons. It deliberately does **not** clear the persisted home
cache, which is keyed per viewer, so switching back is instant.
**The gateway's `active` field is adopted.** `viewers()` compares what the server resolved
the request to against what this set is sending, and takes the server's answer when the two
disagree and the id is not in the list. The case it exists for is a viewer deleted on
another television: this set is still sending an id nothing recognises, the gateway has
quietly fallen back, and without adopting that the picker would go on showing somebody who
no longer exists as selected.
## The picker
`ui/viewers/ViewerPicker.kt` — "Who's watching?", a full screen rather than another row in
the user switcher. That panel lists *accounts* and the actions beside them; a viewer is a
different grain of thing, and a household picks a person the way they pick one on any
television service, by looking at a row of faces. Folding them into the same 292dp column
would have made two unrelated questions look like one list.
Stateless, the stance `SignInContent` and the detail panes take, so
`ViewerPickerScreenshotTest` renders it with no gateway → `build/screenshots/viewers/`.
Things to preserve:
- **Focus opens on whoever is watching**, not on the first card: a television is switched on
by the person who last used it far more often than not, so the common case is one confirm
press rather than a walk along the row.
- **The scroll keeps one card of context behind the focused one**
(`viewerPickerScrollIndex`). Scrolling straight to the focused card pins it against the
left edge and the people before it vanish with nothing saying they are there — the capture
is what caught it. Focus and scroll are separate functions because they answer different
questions: where the remote is, and what the eye can see.
- **"Synced with Emby" is a reserved line, not a conditional one**, or a card without it
sits taller than the one beside it — the rule the cast grid's character line follows. It
is most of the difference between a household understanding this feature and being puzzled
by it.
- **The scale is read only inside `graphicsLayer`**, so travelling the row redraws two cards
rather than recomposing every card in it.
- **A blank active id means the account's own viewer**, because that is what the header's
absence means to the gateway. Writing the main viewer's id instead would work on the wire
and would be worse in one way: an app never told the account's Emby user id could not then
express "nobody in particular", which is the state every existing install starts in.
- **The Add control is removed at the limit, never dimmed** — the stance the two optional
transport controls take.
- `MAX_SHADOW_VIEWERS` mirrors `store.MaxShadowViewers`; the gateway is what enforces it,
and this copy only decides whether to offer a button whose one possible outcome would be a
refusal.
## The way in
A row in `UserSwitcherOverlay`, above Notifications, because it changes *whose* menu that
is — the notifications and requests below belong to whichever viewer it selects.
`viewerMenuLabel` names the person once somebody other than the account is watching
("Watching as Alessandra") and asks the question otherwise: a household running no viewers
must not have a badge appear over its launcher explaining a feature it is not using. The
panel's own subtitle becomes "Choose an account" when the row is present, since the list
above it is no longer the thing that answers "who is watching".
`shouldOfferViewerPicker` gates it on two conditions and both matter: there is nobody to ask
on the direct path, and an account nobody has added a viewer to would be offered a question
with one answer, which reads as a fault rather than as a feature waiting to be used.
**The switcher's rows are now a list** (`userSwitcherMenuItems`) rather than four
hand-written offsets (`profiles.size + 1`, `if (showRequests) 2 else 1`,
`actionCount - 1`). A count that disagrees with the rows actually drawn is how the last item
in a menu becomes unreachable, and a fifth conditional row is exactly the change that breaks
it — the shape `QuickAction` already moved to for the same reason. The D-pad test now runs
over every combination of optional rows.
## Built
- `viewers` and `viewer_playback_state` in `store/schema.sql`, with the main viewer created
on demand so an account predating the feature resolves on its first request.
- `store/viewers.go`, `store/viewer_playback.go` — the viewer list, the state layer,
Continue Watching, Next Up, favourites and the series-recency ordering.
- `api/viewers.go` — header resolution, the cached list, the context plumbing,
`viewerUserData`, `invalidateAccountViews`, and the four CRUD routes.
- `api/viewer_state_attach.go`, `api/viewer_rows.go` — the read side.
- **The four mutations are gated**, and For You is marked dirty only for the main viewer.
- Every log line names the viewer when it is somebody other than the account.
- **The television**: the header and its capability, the active viewer as device state, the
per-viewer home cache, `EmbyRepository`'s viewer operations, the picker, the row in the
user menu that opens it, and the name-entry and manage screens behind it.
- **The operator's switch**, and the Viewers card on the console's account page.
- **Series and season aggregates**, so a shadow viewer no longer sees the account's progress
anywhere.
## Naming somebody, from the television
`ui/viewers/ViewerNameEntry.kt` reuses the **search keyboard** rather than growing a second
one — the note `TvKeyboard` already carried, written before there was a second caller to
prove it. Two on-screen keyboards is two focus contracts to keep in step, and where the
letters are is the one thing a viewer must never have to relearn. Things to preserve:
- **The keyboard is the child that gives way, never the buttons.** A `Column` hands each
child what the ones before it left over, so the confirm row — being last — was measured
from the remainder and drew as two squeezed slivers with their labels pressed out. The
keyboard takes `weight(1f, fill = false)`, and weighted children are measured from what
the unweighted ones leave: the same inversion the home hero makes for its Play chip, and
it broke here in exactly the same way. `viewers-name-empty.png` is the capture it is
answerable to.
- **The rules are pure and refuse early** (`ViewerEditing.kt`). A blank name is refused
because the gateway refuses it. A **repeated** name is refused by the app alone — the
gateway is happy to hold two people called Sam — because the picker is a row of faces with
a name under each, and two identical names is a choice nobody in the household can make.
A rename skips the person being renamed, so correcting somebody's capitalisation is not
refused as a duplicate of themselves.
- **The limit is enforced by refusing the keypress**, not by rejecting the save. A remote
types one character at a time, and a limit that only announces itself at the end is one
somebody discovers after typing a sentence.
- **A refusal is not drawn in the accent.** Every affirmative thing on a Memby screen is
green, and a refusal wearing the confirmation colour reads at a glance as the name having
been accepted. The message line is *reserved* rather than conditional, or it would push
the keyboard down by a line at the moment somebody is typing into it.
- **A local rule and a server refusal are separate parameters.** One is true before anything
is sent and the other is what came back; showing them in one slot would leave a stale
refusal sitting under a name that has since been corrected. A refused save keeps what was
typed — retyping a name somebody has just entered is the worst possible answer to a
request that failed for a reason the television does not know.
`ViewerManageScreen` is the list beside it. It is a *list* where the picker is a row of
faces, because the two answer different questions — "who is watching" is a glance and one
press, "who is here" is read a line at a time and acted on per person. A row holds **two
focus targets** rather than opening a menu, the shape the alerts page settled on: a remote
has one confirm key, and a press that opened a list of actions would make renaming somebody
three presses deep for nothing. The **main viewer has neither** and is still listed, because
a list of the people here that omitted the one whose watching actually reaches Emby would be
the more confusing of the two. Removing somebody is a full-stop question following
`ExitConfirmation`'s rules — the two answers do not look alike, the safe one takes focus
first, and **Back means keep**.
Both open **over** the picker rather than instead of it, the arrangement the add-a-user
sign-in already takes over the manage-users page: Back is one step out of each and the row
of faces is still underneath, with nothing to restore because nothing was unmounted.
`ViewerActionButton` is the one button all three screens are built from — there were nearly
three of it, the same shape at the same size in the same green written on three different
days.
## The operator's switch
`viewers` in `featureCatalogue`, capability `viewers_v1`. It is read in **`activeViewer`**
and nowhere else: that is the one place a request learns who is watching, so with it off
every branch downstream — the gated writes, the substituted rows, the per-viewer cache keys
— falls back to the account by construction rather than by fifteen separate checks.
**Off is not a deletion.** A viewer's rows stay in Postgres untouched and come back intact;
what stops is the gateway routing anybody's watching anywhere but Emby, which is the state a
household was in before this existed. `/v1/viewers` answers with `mainViewerOnly`, a
*shortened list* rather than an error or an empty one, because the television decides whether
to offer the picker by counting what it was sent — so a switched-off household looks like one
that never used the feature rather than like one whose picker has broken. The four mutations
answer 403 with a sentence naming the reason, since a television has no log and no support
channel.
**Default off**, the stance the genre browser takes. This is the switch that decides where a
household's watched state is written, and a feature arriving already on is one every server
running the build starts using before anybody has decided to — so it is opted into rather
than out of. An account with no shadow viewers behaves identically either way, which is what
makes switching it on a safe thing to try rather than a migration.
## The operator's copy
`/admin/accounts/{userID}` carries a Viewers card (`admin_viewers.go`,
`admin-ui/src/pages/Account.tsx`). It is on the **account page** rather than a rail entry of
its own because a viewer only exists under an account, and a top-level page would open by
asking which account — the question the page an operator reached this from has answered. It
is the arrangement the per-account preference editor and the device list already take.
The televisions can do all of this themselves now, so this is the operator's copy rather than
the only way in: what it is for is a household asking for help, and the case a remote cannot
reach — a viewer created on a set that has since been unplugged. Two things to preserve: the
write clears the cached list (`forgetViewers`) for the same reason the client-facing route
does, and the card **states** that the feature is switched off rather than quietly offering
controls whose effect nothing on any television would show.
The list is its own request rather than a field on `/admin/api/accounts`: that response is the
whole household and this is a list per person, so folding it in would make every accounts poll
read one table per account for a page showing one of them.
## Not built yet, in the order it should be
1. **The personalisation layer** keyed on the viewer: recommendations, the Magic pool, genre
affinity, journeys, notifications and watch time all still answer for the account.
2. **PINs**, and the Tracearr attribution problem.
## Untested
`ViewerContainerStates` is the one piece of this with no test behind its SQL: the store's
tests are pure and there is no Postgres in the build, so the query has been read but not run.
The Go either side of it — the rollup and the block it renders — is pinned. It is the first
thing to exercise against a real database.