469 lines
29 KiB
Markdown
469 lines
29 KiB
Markdown
# 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.
|