Files
memby/VIEWERS.md
T
2026-08-20 15:06:00 +12:00

29 KiB

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.