0.2.82
This commit is contained in:
+468
@@ -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.
|
||||
Reference in New Issue
Block a user