# 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::@#`. 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.