This commit is contained in:
ponzischeme89
2026-08-20 15:06:00 +12:00
parent 549f9c5eed
commit f1164db2c5
52 changed files with 5441 additions and 158 deletions
+468
View File
@@ -0,0 +1,468 @@
# Viewers
**A Memby account is the household's relationship with an Emby user. A viewer is one
person under it.** Every account has exactly one **main** viewer, whose state is Emby's,
and any number of **shadow** viewers, whose state is Memby's alone. A shadow viewer
watches through the account's Emby credentials and Emby never learns what they watched.
```
Emby user
└── Memby account (Living Room)
├── Matt main → watched state, progress and favourites go to Emby
├── Alessandra shadow → state lives in Memby
└── Guest shadow → state lives in Memby
```
A viewer does **not** belong to a television. Somebody starts an episode in the lounge and
finishes it in the bedroom, because the state is the gateway's.
## What was verified before any of this was written
**Every write of Emby viewer state passes through four methods** on `emby.Client`:
`ReportPlayback`, `SetFavorite`, `SetPlayed` and `HideFromResume`. All four are called
only from `api` handlers holding a `store.Session`. Nothing else writes viewer state, so
keeping a shadow viewer out of Emby is four branches rather than a sweep of the codebase.
That is the property this feature is built on, and it is the one to re-check before
adding any new call into `emby.Client`.
**Two things still reach Emby, and both are accepted deliberately.** `PlaybackInfo` is
sent with `IsPlayback: true`, which registers a play session — so shadow playback appears
in Emby's Now Playing and activity log as the account. And `tracearr` identifies viewers
by *Emby username* (`tracearr/client.go`), so a shadow viewer's watching feeds the
account's Tracearr history, watch-time figures and genre affinity. Neither touches watched
state or resume position, which is what the feature promises. Both are v2 problems.
**There is no second implementation on the direct path.** On the direct-to-Emby path the
television reports straight to Emby itself, so there is nothing in between to route a
viewer's state to — the stance the TV calendar and genre affinity already take. Viewers
are gateway-only, and a set with no gateway has one viewer, which is the account.
## The seam: a viewer id, not a second user
Before this, `sess.EmbyUserID` did two unrelated jobs — it was the Emby credential *and*
Memby's key for "who is watching". Viewers separate them:
- **The credential stays the account's.** `credentials(sess)` is unchanged, and every
library read, image, stream URL and subtitle still goes out as the Emby user. A shadow
viewer has no Emby identity and needs none.
- **The person is `viewer.ID`.** Preferences, notifications, followed shows, row
statistics, search history, recommendation profiles and cache keys belong to whoever is
watching.
**The main viewer's id *is* the Emby user id**, which is the whole reason this needed no
migration. Every table in the schema keys a person by a bare `emby_user_id TEXT` with no
foreign key behind it, so substituting a viewer id leaves an existing household's rows
exactly where they were, and the main viewer behaves as the account did by construction
rather than by care. A shadow id is `"v"` plus 32 hex characters, so it can be told apart
from Emby's 32-hex GUIDs by inspection in a log line or a cache key
(`store.IsShadowViewerID`).
`Viewer.Kind` is still stored and is what every branch reads. The id shape is a safety
property, not a source of truth.
## How a request learns who is watching
`X-Memby-Viewer`, resolved by `s.activeViewer` (`api/viewers.go`).
It is a header rather than part of the session because switching between two people on one
television must not be a re-authentication, and because the same person is one viewer on
every set in the house. The session still answers "which account is this and what may it
read".
Things to preserve:
- **It is stated, never inferred** — the stance `Credentials.Gateway` takes. An app that
predates viewers sends nothing and resolves to the main viewer, which is exactly its
previous behaviour.
- **Every unknown case falls back to the main viewer**, including a list that will not
load. This is on the path of every authenticated request, and a television unable to do
anything because a header could not be checked is a far worse failure than one request
attributed to the account. The one thing it will not do is accept an id it could not
confirm belongs to this account.
- **An unknown id is logged, not refused.** The ordinary cause is a set still holding a
viewer somebody has since deleted, and a 403 on every request would leave it unable to
reach the picker that would fix it.
- **The list is cached for `viewerListTTL`** and the write clears it, because `/v1/status`
alone is every open television every ten seconds — the bargain `featurePolicyCache`
already makes. A failed refresh keeps the previous reading rather than dropping to the
main viewer, which would silently move a shadow viewer's playback back onto Emby.
## The common state layer
`viewerUserData` renders a viewer's state in the shape of Emby's `UserData` block, and the
television never learns which it received. That is what keeps viewers from becoming a
special case in every screen: a card draws its progress bar, tick and heart from
`UserData`, and where the block came from is not a question anything above that line asks.
The client reads exactly five fields (`UserItemData` in `EmbyModels.kt`), and
`viewers_test.go` pins them. Two are omitted rather than sent empty — a title with no
runtime has no percentage, and one never played has no date — because a card claiming a
zero-length progress bar is worse than one claiming nothing.
## Where state is written
`viewer_playback_state`, keyed `(viewer_id, item_id)`. The Emby item id is the common
identifier, so no library metadata is duplicated and nothing here needs invalidating when
the catalogue changes. **There is deliberately no row for a main viewer**: their state is
Emby's, and a copy here would be a second answer free to disagree with what the
household's other Emby clients see.
- **A completed title is stored at position zero**, the way Emby stores one. The position
is what Continue Watching reads, and a finished episode left at its last frame is one the
row keeps offering to resume four seconds from the end.
- **Only a stop can complete a title.** A progress report crossing the threshold is
somebody still watching the closing minutes, and marking it played there would take the
episode out of Continue Watching underneath them.
- **`PlayedFromPosition` is the completion rule and it matches Emby's own** (90%), so a
household cannot disagree with itself about whether an episode is finished depending on
who watched it. A runtime of zero means the length was not known rather than that the
title is zero long, so it can never complete anything.
- **`play_count` moves only on the transition into played**, so the ten-second reports
either side of the threshold cannot count one viewing several times.
- **The series is read out of `library_items` inside the upsert**, not asked of Emby and
not carried by the television. It is already there, it is what will order this viewer's
Continue Watching, and a report arrives every ten seconds.
- **Hiding keeps the position.** Hiding is a statement about the row, not about where
somebody got to, and pressing Play again should still resume.
## What a viewer reads
The write side is four branches. The read side is the larger half, and it is deliberately
**one substitution rather than a second code path**: the fan-out, the failure counting and
the Continue Watching merge in `handleHome` are identical for both kinds of viewer, and
only the fetch behind three rows changes.
**Everything is keyed on `viewerKeyOf(ctx, sess)`.** The viewer is resolved once, in
`authed`, and carried in the request context (`withViewer` / `viewerOf`). That is the
point: the alternative is calling `activeViewer` at each of the fifteen sites that build a
cache key, and the failure of forgetting one is silent — that view is keyed under the
account, and one viewer is served another's rows. Resolving at the boundary makes
forgetting impossible. A request that never passed through `authed` — a scheduled task, a
probe, a test — resolves to the account, which is the value every one of these keys held
before viewers existed.
**Cache keys that moved to the viewer**: home, search, genre and library browse, item
detail, series episodes, person filmography, related, the active hero. **Keys that
deliberately did not**: genre affinity, trailers, the screensaver, recommendations and the
Magic pool. Those answer for the household or are computed from the account's Tracearr
history, and keying them per viewer would multiply the misses without changing the answer.
They are the v2 list.
**`decorateItems` is the one door items leave the gateway through.** It attaches the
stored review scores and, for a shadow viewer, replaces the `UserData` block. It rides
exactly where `decorateItemRatings` rode — one indexed read for a whole launcher, at every
point items are served. The two remained separate functions because they are separate
concerns, but every call site wanted both, and *a decoration added at seven sites is a
decoration missing from the eighth*.
Things to preserve in `decorateViewerState`:
- **A main viewer returns immediately.** Their state is Emby's and is already on the
payload, so a household running no viewers pays one comparison for the whole launcher.
- **Every item is rewritten, not only the ones with something stored.** The `UserData`
that arrived from Emby is the *account's*, and leaving it on a title this viewer has
never touched is precisely the leak the feature exists to prevent. A title with no row
gets the zero state, which is the truth about it. A failed state read blanks everything
for the same reason: a launcher with no progress bars is a poor answer, one showing
somebody else's is a wrong one.
- **The block is replaced, never merged.** A partial overlay leaves whichever fields Memby
had nothing to say about carrying the account's values — the same leak from a narrower
angle.
- **A series and a season are answered from a count**, not from a row of their own. Emby
fills their block in from children it has never heard of for this person, so
`ViewerContainerStates` counts the episodes the shared catalogue holds against this
viewer's own played set and `viewerAggregateUserData` renders the result. This was the
last place a shadow viewer was shown the account's answer — a series ticked because
somebody else had finished it.
## What a series card says
`UnplayedItemCount` and `PlayedPercentage` are **omitted rather than sent as zero** when the
catalogue cannot count the title, the rule the leaf block follows: a library not yet
imported, and a show the import has never seen, would otherwise tick every series in the
house. `Played` is only true where there is something to have finished, for the same reason.
The **favourite is the container's own** and comes from the row against the series id, not
from the count: somebody marks a *show* a favourite, not the sum of its episodes. And a
container is never resumable — what resumes is an episode — so `PlaybackPositionTicks` is
zero, which is what Emby reports too.
The query groups by the **season/series pair** and the two rollups are done in Go
(`addViewerAggregate`). That is deliberately dull — no grouping sets, no second pass — and
it is exact for both answers because a season belongs to exactly one series. `containerIDsIn`
asks for a season's series alongside it, which is what keeps a partial series total from
belonging to anything drawn. `library_items_series_episodes_idx` is what makes it affordable;
without it, a series card costs a scan of every episode in the library, and it is also what
`ViewerNextUp` walks.
## The three rows that are about a person
`resume`, `favourites` and `nextup` are substituted in the home fan-out. The ranking is
Postgres's and Emby is asked only to *describe* the titles — which is why `orderItemsByID`
exists: Emby answers an `Ids=` query in its own order, and for these rows the order **is**
the answer. Handing Emby's order back would keep the right titles and throw away the reason
they were chosen. It also drops a title Emby will not answer for (a card that cannot be
opened is worse than a missing one) and collapses a repeated id, because every keyed list
on the television throws on a duplicate.
**Next Up is computed entirely in Postgres** (`store.ViewerNextUp`), out of the shared
catalogue and this viewer's own state. Emby's `NextUp` answers for the account and there is
nobody else to ask — and the alternative, walking each series' episode list over the wire,
is one Emby request per show on the tail of the launcher. It applies the same three rules
Emby's own answer does: an episode already resumable is left out (it is in Continue
Watching, and the merge would offer the show twice), specials are not next episodes, and a
series with nothing unwatched left contributes no row rather than an empty one.
**The merge is unchanged.** `ViewerWatchedSeries` returns exactly the
`map[string]time.Time` that `recentlyPlayedSeries` does, so `mergeContinueWatching` never
learns which viewer it is ordering for.
## Where a shadow viewer's playback starts
Taken from the store in `handlePlayback`, not trusted from the card. The hint the
television sends is read off a card this gateway already decorated with the viewer's own
state, so the two normally agree — but only normally: the store has heard about the episode
they were part-way through on the *other* television, and a card is only as fresh as the
last home refresh. It is also the value handed to `PlaybackInfo`, so taking it here fixes
the negotiated stream as well as the number sent back. A failed read starts from the
beginning, because that is a recoverable disappointment where starting from where somebody
else got to is not.
**Pressing Play on a series** resolves through `firstUnwatchedEpisodeFor` rather than
Emby's `NextUp`, which is what would otherwise drop a shadow viewer into the middle of
somebody else's season. A part-watched episode wins over the first unwatched one — somebody
eleven minutes in wants that episode, the same judgement the Continue Watching merge makes.
A viewer with nothing recorded for the series falls through to Emby's first episode, which
is the right answer for somebody who has never watched any of it.
**Auto-advance** keeps Emby's answer for *which* episode follows — that is a property of
the season and is the same for everybody — and replaces only how far into it this viewer
already is.
## Signing out is about the account
`invalidateAccountViews` drops every viewer's cached views, not only the account's.
Invalidating one key would leave each shadow viewer's rows behind, to be served intact to
the next person who signs in on that set. It is best-effort: what it misses expires on its
own TTL, and nothing there is worth failing a sign-out over.
## The television
**`X-Memby-Viewer` is sent by `GatewayAuthInterceptor`**, read on each request rather than
captured — the bargain the audio capability tokens already make. Switching between two
people must not rebuild the HTTP client, and the answer can change between any two
requests. The header is **omitted rather than sent empty** when nobody has been chosen,
because that is precisely what an app predating viewers sends and what the gateway reads as
the account's own viewer. The read is guarded, like the audio tokens, because the
interceptor also runs before the service locator exists in a screenshot context.
`viewers_v1` joins `MEMBY_CAPABILITIES`, so an operator cannot switch the feature on for a
household half of whose televisions have no way of choosing between people.
**The active viewer is device state**, and it is the one thing about viewers that is: a
viewer follows the person to every set in the house, but which of them is sitting in front
of *this* one is that set's own answer — the lounge and the bedroom are commonly two
different people at the same moment. `activeViewerId` and `activeViewerName` are written in
**one** edit (DataStore rewrites the whole file per edit, and a name landing apart from the
id it labels would leave the launcher greeting one person while every request named
another), and both are cleared with the session and on any account switch: the people under
one Emby account are not the people under another.
**The home cache is keyed per viewer**, `home_cache::<userId>@<serverUrl>#<viewerId>`. A
cold start draws the cache before the first refresh lands, so without this a shadow viewer
would open on the account's evening. **The account's own viewer keys exactly as it always
did, with no suffix**, so every existing install keeps the cache it already has. Removing a
profile now clears *every* viewer's key for it (`profileHomeCacheKeys`), or the largest
values this store holds would be orphaned.
`switchViewer` clears the playable, series-episode, local-resume and genre-affinity caches —
the same set a profile switch clears, for the same reason: they hold one person's watched
state, resume positions and reasons. It deliberately does **not** clear the persisted home
cache, which is keyed per viewer, so switching back is instant.
**The gateway's `active` field is adopted.** `viewers()` compares what the server resolved
the request to against what this set is sending, and takes the server's answer when the two
disagree and the id is not in the list. The case it exists for is a viewer deleted on
another television: this set is still sending an id nothing recognises, the gateway has
quietly fallen back, and without adopting that the picker would go on showing somebody who
no longer exists as selected.
## The picker
`ui/viewers/ViewerPicker.kt` — "Who's watching?", a full screen rather than another row in
the user switcher. That panel lists *accounts* and the actions beside them; a viewer is a
different grain of thing, and a household picks a person the way they pick one on any
television service, by looking at a row of faces. Folding them into the same 292dp column
would have made two unrelated questions look like one list.
Stateless, the stance `SignInContent` and the detail panes take, so
`ViewerPickerScreenshotTest` renders it with no gateway → `build/screenshots/viewers/`.
Things to preserve:
- **Focus opens on whoever is watching**, not on the first card: a television is switched on
by the person who last used it far more often than not, so the common case is one confirm
press rather than a walk along the row.
- **The scroll keeps one card of context behind the focused one**
(`viewerPickerScrollIndex`). Scrolling straight to the focused card pins it against the
left edge and the people before it vanish with nothing saying they are there — the capture
is what caught it. Focus and scroll are separate functions because they answer different
questions: where the remote is, and what the eye can see.
- **"Synced with Emby" is a reserved line, not a conditional one**, or a card without it
sits taller than the one beside it — the rule the cast grid's character line follows. It
is most of the difference between a household understanding this feature and being puzzled
by it.
- **The scale is read only inside `graphicsLayer`**, so travelling the row redraws two cards
rather than recomposing every card in it.
- **A blank active id means the account's own viewer**, because that is what the header's
absence means to the gateway. Writing the main viewer's id instead would work on the wire
and would be worse in one way: an app never told the account's Emby user id could not then
express "nobody in particular", which is the state every existing install starts in.
- **The Add control is removed at the limit, never dimmed** — the stance the two optional
transport controls take.
- `MAX_SHADOW_VIEWERS` mirrors `store.MaxShadowViewers`; the gateway is what enforces it,
and this copy only decides whether to offer a button whose one possible outcome would be a
refusal.
## The way in
A row in `UserSwitcherOverlay`, above Notifications, because it changes *whose* menu that
is — the notifications and requests below belong to whichever viewer it selects.
`viewerMenuLabel` names the person once somebody other than the account is watching
("Watching as Alessandra") and asks the question otherwise: a household running no viewers
must not have a badge appear over its launcher explaining a feature it is not using. The
panel's own subtitle becomes "Choose an account" when the row is present, since the list
above it is no longer the thing that answers "who is watching".
`shouldOfferViewerPicker` gates it on two conditions and both matter: there is nobody to ask
on the direct path, and an account nobody has added a viewer to would be offered a question
with one answer, which reads as a fault rather than as a feature waiting to be used.
**The switcher's rows are now a list** (`userSwitcherMenuItems`) rather than four
hand-written offsets (`profiles.size + 1`, `if (showRequests) 2 else 1`,
`actionCount - 1`). A count that disagrees with the rows actually drawn is how the last item
in a menu becomes unreachable, and a fifth conditional row is exactly the change that breaks
it — the shape `QuickAction` already moved to for the same reason. The D-pad test now runs
over every combination of optional rows.
## Built
- `viewers` and `viewer_playback_state` in `store/schema.sql`, with the main viewer created
on demand so an account predating the feature resolves on its first request.
- `store/viewers.go`, `store/viewer_playback.go` — the viewer list, the state layer,
Continue Watching, Next Up, favourites and the series-recency ordering.
- `api/viewers.go` — header resolution, the cached list, the context plumbing,
`viewerUserData`, `invalidateAccountViews`, and the four CRUD routes.
- `api/viewer_state_attach.go`, `api/viewer_rows.go` — the read side.
- **The four mutations are gated**, and For You is marked dirty only for the main viewer.
- Every log line names the viewer when it is somebody other than the account.
- **The television**: the header and its capability, the active viewer as device state, the
per-viewer home cache, `EmbyRepository`'s viewer operations, the picker, the row in the
user menu that opens it, and the name-entry and manage screens behind it.
- **The operator's switch**, and the Viewers card on the console's account page.
- **Series and season aggregates**, so a shadow viewer no longer sees the account's progress
anywhere.
## Naming somebody, from the television
`ui/viewers/ViewerNameEntry.kt` reuses the **search keyboard** rather than growing a second
one — the note `TvKeyboard` already carried, written before there was a second caller to
prove it. Two on-screen keyboards is two focus contracts to keep in step, and where the
letters are is the one thing a viewer must never have to relearn. Things to preserve:
- **The keyboard is the child that gives way, never the buttons.** A `Column` hands each
child what the ones before it left over, so the confirm row — being last — was measured
from the remainder and drew as two squeezed slivers with their labels pressed out. The
keyboard takes `weight(1f, fill = false)`, and weighted children are measured from what
the unweighted ones leave: the same inversion the home hero makes for its Play chip, and
it broke here in exactly the same way. `viewers-name-empty.png` is the capture it is
answerable to.
- **The rules are pure and refuse early** (`ViewerEditing.kt`). A blank name is refused
because the gateway refuses it. A **repeated** name is refused by the app alone — the
gateway is happy to hold two people called Sam — because the picker is a row of faces with
a name under each, and two identical names is a choice nobody in the household can make.
A rename skips the person being renamed, so correcting somebody's capitalisation is not
refused as a duplicate of themselves.
- **The limit is enforced by refusing the keypress**, not by rejecting the save. A remote
types one character at a time, and a limit that only announces itself at the end is one
somebody discovers after typing a sentence.
- **A refusal is not drawn in the accent.** Every affirmative thing on a Memby screen is
green, and a refusal wearing the confirmation colour reads at a glance as the name having
been accepted. The message line is *reserved* rather than conditional, or it would push
the keyboard down by a line at the moment somebody is typing into it.
- **A local rule and a server refusal are separate parameters.** One is true before anything
is sent and the other is what came back; showing them in one slot would leave a stale
refusal sitting under a name that has since been corrected. A refused save keeps what was
typed — retyping a name somebody has just entered is the worst possible answer to a
request that failed for a reason the television does not know.
`ViewerManageScreen` is the list beside it. It is a *list* where the picker is a row of
faces, because the two answer different questions — "who is watching" is a glance and one
press, "who is here" is read a line at a time and acted on per person. A row holds **two
focus targets** rather than opening a menu, the shape the alerts page settled on: a remote
has one confirm key, and a press that opened a list of actions would make renaming somebody
three presses deep for nothing. The **main viewer has neither** and is still listed, because
a list of the people here that omitted the one whose watching actually reaches Emby would be
the more confusing of the two. Removing somebody is a full-stop question following
`ExitConfirmation`'s rules — the two answers do not look alike, the safe one takes focus
first, and **Back means keep**.
Both open **over** the picker rather than instead of it, the arrangement the add-a-user
sign-in already takes over the manage-users page: Back is one step out of each and the row
of faces is still underneath, with nothing to restore because nothing was unmounted.
`ViewerActionButton` is the one button all three screens are built from — there were nearly
three of it, the same shape at the same size in the same green written on three different
days.
## The operator's switch
`viewers` in `featureCatalogue`, capability `viewers_v1`. It is read in **`activeViewer`**
and nowhere else: that is the one place a request learns who is watching, so with it off
every branch downstream — the gated writes, the substituted rows, the per-viewer cache keys
— falls back to the account by construction rather than by fifteen separate checks.
**Off is not a deletion.** A viewer's rows stay in Postgres untouched and come back intact;
what stops is the gateway routing anybody's watching anywhere but Emby, which is the state a
household was in before this existed. `/v1/viewers` answers with `mainViewerOnly`, a
*shortened list* rather than an error or an empty one, because the television decides whether
to offer the picker by counting what it was sent — so a switched-off household looks like one
that never used the feature rather than like one whose picker has broken. The four mutations
answer 403 with a sentence naming the reason, since a television has no log and no support
channel.
**Default off**, the stance the genre browser takes. This is the switch that decides where a
household's watched state is written, and a feature arriving already on is one every server
running the build starts using before anybody has decided to — so it is opted into rather
than out of. An account with no shadow viewers behaves identically either way, which is what
makes switching it on a safe thing to try rather than a migration.
## The operator's copy
`/admin/accounts/{userID}` carries a Viewers card (`admin_viewers.go`,
`admin-ui/src/pages/Account.tsx`). It is on the **account page** rather than a rail entry of
its own because a viewer only exists under an account, and a top-level page would open by
asking which account — the question the page an operator reached this from has answered. It
is the arrangement the per-account preference editor and the device list already take.
The televisions can do all of this themselves now, so this is the operator's copy rather than
the only way in: what it is for is a household asking for help, and the case a remote cannot
reach — a viewer created on a set that has since been unplugged. Two things to preserve: the
write clears the cached list (`forgetViewers`) for the same reason the client-facing route
does, and the card **states** that the feature is switched off rather than quietly offering
controls whose effect nothing on any television would show.
The list is its own request rather than a field on `/admin/api/accounts`: that response is the
whole household and this is a list per person, so folding it in would make every accounts poll
read one table per account for a page showing one of them.
## Not built yet, in the order it should be
1. **The personalisation layer** keyed on the viewer: recommendations, the Magic pool, genre
affinity, journeys, notifications and watch time all still answer for the account.
2. **PINs**, and the Tracearr attribution problem.
## Untested
`ViewerContainerStates` is the one piece of this with no test behind its SQL: the store's
tests are pure and there is no Postgres in the build, so the query has been read but not run.
The Go either side of it — the rollup and the block it renders — is pinned. It is the first
thing to exercise against a real database.
+204 -1
View File
@@ -65,6 +65,24 @@ interface AccountDevice {
versions: DeviceVersion[] | null;
}
/* One person under this account. A viewer is not an account: the credential and the
library permissions stay the Emby user's, and only whose evening it is changes. */
interface Viewer {
id: string;
name: string;
shortName?: string;
colour?: string;
kind: string;
hasPin?: boolean;
createdAt?: string;
}
interface ViewersPayload {
viewers: Viewer[];
enabled: boolean;
maxShadowViewers: number;
}
interface RecommendationState {
prompted?: boolean;
completed?: boolean;
@@ -144,7 +162,8 @@ type Pending =
| { kind: 'reset-recommendations' }
| { kind: 'cancel-prompt' }
| { kind: 'reset-preferences' }
| { kind: 'no-themes' };
| { kind: 'no-themes' }
| { kind: 'remove-viewer'; viewer: Viewer };
export function AccountPage() {
const { userId = '' } = useParams();
@@ -166,8 +185,23 @@ export function AccountPage() {
const [notifications, setNotifications] = useState<NotificationPreferences | null>(null);
const [pending, setPending] = useState<Pending | null>(null);
const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null);
/* Renaming or adding a viewer. One piece of state for both, because the dialog is the
same question either way and a null id is what says there is nobody to rename yet. */
const [namingViewer, setNamingViewer] = useState<{ id: string | null; name: string } | null>(null);
/* Viewers are their own request rather than a field on the accounts payload: 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. */
const viewersQuery = useQuery<ViewersPayload>(`${base}/viewers`);
const account = (data?.accounts ?? []).find((entry) => entry.id === userId);
const shadowViewers = (viewersQuery.data?.viewers ?? []).filter((viewer) => viewer.kind !== 'main');
/* The gateway is what enforces the limit; this only decides whether to offer a button
whose one possible outcome would be a refusal — the rule the television's own picker
follows. */
const canAddViewer =
Boolean(viewersQuery.data?.enabled) &&
shadowViewers.length < (viewersQuery.data?.maxShadowViewers ?? 0);
const catalogue = data?.catalogue ?? [];
const themeCatalogue = data?.themes ?? [];
@@ -319,6 +353,92 @@ export function AccountPage() {
)}
</Card>
{/* The people under this account. It sits beside the devices rather than on a page
of its own because a viewer only exists under an account, and a top-level page
would open by asking which account — the question this page has answered.
The televisions can do all of this themselves now, so this is the operator's
copy: for a household asking for help, and for the case a remote cannot reach,
such as a viewer created on a set that has since been unplugged. */}
<Card
title="Viewers"
intro="Several people under one Emby account, each with their own Continue Watching, watched history and favourites. Only the account itself is synced with Emby; a shadow viewer's watching is kept by Memby and never reported."
icon="people"
tone="note"
actions={
viewersQuery.data && !viewersQuery.data.enabled ? (
<Tag tone="warn">switched off</Tag>
) : (
<Tag tone="ok">{num(shadowViewers.length)} beside the account</Tag>
)
}
footer={
canAddViewer ? (
<Button variant="primary" onClick={() => setNamingViewer({ id: null, name: '' })}>
Add viewer
</Button>
) : (
<Button disabled>
{viewersQuery.data ? `${num(viewersQuery.data.maxShadowViewers)} is the limit` : 'Add viewer'}
</Button>
)
}
>
{/* Stated rather than implied. An operator who has switched viewers off and then
adds one has done something that looks as though it worked and changes nothing
on any television, because the gateway resolves every request to the account. */}
{viewersQuery.data && !viewersQuery.data.enabled ? (
<Banner
message={
'Viewers are switched off for this server, so every television watches as the account. ' +
'Nothing here is deleted — turn the feature on under Features to bring these people back.'
}
/>
) : null}
{viewersQuery.loading ? (
<Loading />
) : (viewersQuery.data?.viewers ?? []).length === 0 ? (
<Empty>Nobody is set up yet, so this account has one viewer: itself.</Empty>
) : (
<div className="list">
{(viewersQuery.data?.viewers ?? []).map((viewer) => (
<div className="list-item" key={viewer.id}>
<div className="list-body">
<b>{viewer.name}</b>
<p>
{viewer.kind === 'main'
? 'The account itself — named by Emby, and the only one whose watching Emby hears about'
: 'Watches privately; nothing reaches Emby'}
{viewer.createdAt && viewer.kind !== 'main' ? ` · added ${when(viewer.createdAt)}` : ''}
</p>
</div>
<div className="list-actions">
{viewer.kind === 'main' ? (
<Tag tone="info">synced with Emby</Tag>
) : (
<>
<Button
size="sm"
onClick={() => setNamingViewer({ id: viewer.id, name: viewer.name })}
>
Rename
</Button>
<Button
size="sm"
variant="danger"
onClick={() => setPending({ kind: 'remove-viewer', viewer })}
>
Remove
</Button>
</>
)}
</div>
</div>
))}
</div>
)}
</Card>
<Card
title="Recommendation setup"
intro="The prompt appears the next time this person opens Memby on any of their televisions."
@@ -687,6 +807,29 @@ export function AccountPage() {
/>
) : null}
{namingViewer ? (
<ViewerNameDialog
initial={namingViewer.name}
renaming={namingViewer.id !== null}
busy={busy === 'viewer-name'}
onCancel={() => setNamingViewer(null)}
onConfirm={(name) =>
void act(
'viewer-name',
() =>
namingViewer.id
? api.put(`${base}/viewers/${encodeURIComponent(namingViewer.id)}`, { name })
: api.post(`${base}/viewers`, { name }),
namingViewer.id ? 'Viewer renamed.' : 'Viewer added.',
() => {
setNamingViewer(null);
void viewersQuery.reload();
},
)
}
/>
) : null}
{pending ? (
<PendingDialog
pending={pending}
@@ -701,6 +844,13 @@ export function AccountPage() {
() => api.del(`${base}/devices/${encodeURIComponent(pending.deviceId)}`),
'Device signed out.',
);
case 'remove-viewer':
return void act(
'remove-viewer',
() => api.del(`${base}/viewers/${encodeURIComponent(pending.viewer.id)}`),
'Viewer removed.',
() => void viewersQuery.reload(),
);
case 'remove-account':
return void act(
'remove-account',
@@ -929,6 +1079,53 @@ function RenameDialog({
);
}
/* Naming a viewer, added or renamed. It is the same question either way, so it is one
dialog with one field rather than two that would drift apart — the shape RenameDialog
above already takes for a device. */
function ViewerNameDialog({
initial,
renaming,
busy,
onConfirm,
onCancel,
}: {
initial: string;
renaming: boolean;
busy: boolean;
onConfirm: (name: string) => void;
onCancel: () => void;
}) {
const [name, setName] = useState(initial);
return (
<div className="scrim" onPointerDown={(event) => event.target === event.currentTarget && onCancel()}>
<div className="dialog" role="dialog" aria-modal="true">
<h2>{renaming ? 'Rename this viewer' : 'Add a viewer'}</h2>
<p>
The name shown on the television&rsquo;s &ldquo;Who&rsquo;s watching?&rdquo; screen. Everything
they watch is kept by Memby and never reported to Emby.
</p>
<Field label="Name">
<input
type="text"
value={name}
autoFocus
maxLength={40}
onChange={(event) => setName(event.target.value)}
/>
</Field>
<div className="dialog-actions">
<Button variant="quiet" onClick={onCancel}>
Cancel
</Button>
<Button variant="primary" busy={busy} disabled={!name.trim()} onClick={() => onConfirm(name.trim())}>
{renaming ? 'Rename' : 'Add viewer'}
</Button>
</div>
</div>
</div>
);
}
function PendingDialog({
pending,
busy,
@@ -973,6 +1170,12 @@ function PendingDialog({
label: 'Restore defaults',
destructive: true,
},
'remove-viewer': {
title: 'Remove this viewer?',
body: 'What they were part-way through, what they had watched and their favourites are deleted, on every television in the house. The Emby account is untouched.',
label: 'Remove viewer',
destructive: true,
},
'no-themes': {
title: 'Allow this person no colour schemes?',
body: 'They will be left on Midnight with nothing to choose between.',
+1 -1
View File
@@ -63,7 +63,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.81"
val defaultVersionName = "0.2.82"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -8,6 +8,8 @@ import com.ponzischeme89.memby.data.model.GatewayCalendar
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
import com.ponzischeme89.memby.data.model.GatewayDevice
import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.data.model.MembyViewerRequest
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
@@ -556,6 +558,98 @@ class EmbyRepository internal constructor(
}
}
// --- Viewers -------------------------------------------------------------
/**
* The people under this account, or an empty list where there is nobody to ask.
*
* Gateway-only, like the TV calendar and genre affinity: a viewer's state lives on the
* server, so with no gateway there is exactly one viewer and it is the account. Every
* failure answers empty rather than throwing — the picker is a convenience, and a
* household that cannot reach the gateway has larger problems than not being able to
* change who is watching.
*/
suspend fun viewers(): List<MembyViewer> {
if (!ServerConfig.isGateway) return emptyList()
val response = runCatching { requireGateway().viewers() }.getOrNull() ?: return emptyList()
// The gateway says whom it resolved this request to. A viewer deleted on another
// television is the case this exists for: this set is still sending an id nothing
// recognises, the gateway has quietly fallen back to the account, and without
// adopting that answer the picker would go on showing somebody who no longer
// exists as selected.
if (response.active != snapshot.activeViewerId &&
response.viewers.none { it.id == snapshot.activeViewerId }
) {
val resolved = response.viewers.firstOrNull { it.id == response.active }
settings.setActiveViewer(resolved?.id.orEmpty(), resolved?.name.orEmpty())
observedSettings = settings.snapshot()
}
return response.viewers
}
/**
* Changes who is watching on this television.
*
* Everything cleared here is cleared for the same reason a profile switch clears it:
* these caches hold one person's watched state, resume positions and reasons, and the
* next person must not inherit them. The persisted home cache is *not* cleared — it is
* keyed per viewer, so switching back is instant and each viewer keeps their own rows
* for the next cold start.
*
* Passing a blank id selects the account's own viewer, which is what the header's
* absence means to the gateway.
*/
suspend fun switchViewer(viewer: MembyViewer?) {
val id = if (viewer == null || viewer.isMain) "" else viewer.id
if (id == snapshot.activeViewerId) return
settings.setActiveViewer(id, viewer?.name.orEmpty())
observedSettings = settings.snapshot()
clearPlayableCache()
clearSeriesEpisodeCache()
clearLocalResume()
clearGenreAffinity()
}
suspend fun createViewer(name: String, shortName: String = "", colour: String = ""): MembyViewer? {
if (!ServerConfig.isGateway) return null
val trimmed = name.trim()
require(trimmed.isNotEmpty() && trimmed.length <= 40) { "Invalid viewer name" }
return runCatching {
requireGateway().createViewer(MembyViewerRequest(trimmed, shortName.trim(), colour.trim()))
}.getOrNull()
}
suspend fun renameViewer(viewer: MembyViewer, name: String): MembyViewer? {
if (!ServerConfig.isGateway) return null
val trimmed = name.trim()
require(trimmed.isNotEmpty() && trimmed.length <= 40) { "Invalid viewer name" }
return runCatching {
requireGateway().updateViewer(
viewer.id,
MembyViewerRequest(trimmed, viewer.shortName, viewer.colour),
)
}.getOrNull().also { updated ->
// The label this television prints follows the rename immediately rather than
// waiting for the next list request.
if (updated != null && viewer.id == snapshot.activeViewerId) {
settings.setActiveViewer(updated.id, updated.name)
observedSettings = settings.snapshot()
}
}
}
/**
* Removes a viewer and everything the gateway held for them. Removing whoever is
* watching returns this television to the account's own viewer — leaving it naming a
* person who no longer exists would send a header the gateway refuses on every request.
*/
suspend fun removeViewer(viewer: MembyViewer): Boolean {
if (!ServerConfig.isGateway || viewer.isMain) return false
val removed = runCatching { requireGateway().deleteViewer(viewer.id) }.isSuccess
if (removed && viewer.id == snapshot.activeViewerId) switchViewer(null)
return removed
}
/**
* False only when the gateway explicitly rejects the restored token with 401.
*
@@ -431,6 +431,25 @@ data class Settings(
* what is here rather than wait to be told.
*/
val preferencesRevision: Long = 0,
/**
* Which person under this account is watching on *this* television.
*
* Device state, deliberately, and 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. Blank means the account's own viewer, which is what every
* television reported before viewers existed, so the header is simply not sent.
*
* Cleared with the session, because the people under one Emby account are not the
* people under another.
*/
val activeViewerId: String = "",
/**
* The active viewer's name, kept so the launcher can say whose evening it is without
* waiting on a request. Purely a label: [activeViewerId] is the identity, and the
* gateway is what validates it.
*/
val activeViewerName: String = "",
val profiles: List<EmbyProfile> = emptyList(),
) {
val isSignedIn: Boolean
@@ -591,6 +610,8 @@ class SettingsStore(private val context: Context) {
val UPDATE_ALERT_READ = booleanPreferencesKey("update_alert_read")
val REQUIRED_UPDATE_VERSION = stringPreferencesKey("required_update_version")
val PREFERENCES_REVISION = longPreferencesKey("preferences_revision")
val ACTIVE_VIEWER_ID = stringPreferencesKey("active_viewer_id")
val ACTIVE_VIEWER_NAME = stringPreferencesKey("active_viewer_name")
}
/**
@@ -647,6 +668,32 @@ class SettingsStore(private val context: Context) {
context.dataStore.edit { it[Keys.CONFIRM_EXIT_MEMBY] = enabled }
}
/**
* Records who is watching on this television.
*
* The id and the name are written in **one** edit, the rule
* [applyRemotePreferences] follows and for the same reason: 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.
*
* A blank id is how the account's own viewer is chosen — the header is then omitted
* entirely, which is what an app predating viewers sends and what the gateway reads as
* the main viewer.
*/
suspend fun setActiveViewer(viewerId: String, viewerName: String) {
val id = viewerId.trim()
val name = viewerName.trim().take(40)
context.dataStore.edit {
if (id.isEmpty()) {
it.remove(Keys.ACTIVE_VIEWER_ID)
it.remove(Keys.ACTIVE_VIEWER_NAME)
} else {
it[Keys.ACTIVE_VIEWER_ID] = id
it[Keys.ACTIVE_VIEWER_NAME] = name
}
}
}
/**
* Ids of alerts already shown on this TV. The gateway keeps offering an alert for as
* long as it is current, so without this an "aired" banner would return every poll —
@@ -1009,6 +1056,7 @@ class SettingsStore(private val context: Context) {
val profileKey = profileHomeCacheKey(
userId = preferences[Keys.USER_ID],
serverUrl = preferences[Keys.SERVER_URL],
viewerId = preferences[Keys.ACTIVE_VIEWER_ID],
)
if (profileKey == null) {
// No active profile to key against; the flat slot is all there is.
@@ -1028,21 +1076,48 @@ class SettingsStore(private val context: Context) {
/**
* Where a given profile's home cache is stored. Null when there is no active profile
* to key it against, in which case only the flat [Keys.HOME_CACHE] is written.
*
* The viewer is part of the key because the rows are theirs — a shadow viewer's
* Continue Watching is a different shelf from the account's, and a cold start drawing
* the cache before the first refresh lands would otherwise open on somebody else's
* evening. **The account's own viewer keys exactly as it always did**, with no suffix
* at all, so every existing install keeps the cache it already has.
*/
private fun profileHomeCacheKey(userId: String?, serverUrl: String?): Preferences.Key<String>? {
private fun profileHomeCacheKey(
userId: String?,
serverUrl: String?,
viewerId: String? = null,
): Preferences.Key<String>? {
if (userId.isNullOrBlank() || serverUrl.isNullOrBlank()) return null
return stringPreferencesKey("home_cache::$userId@$serverUrl")
val base = "home_cache::$userId@$serverUrl"
return stringPreferencesKey(
if (viewerId.isNullOrBlank()) base else "$base#$viewerId",
)
}
/** Every home-cache key belonging to one profile, across all of its viewers. */
private fun profileHomeCacheKeys(
preferences: Preferences,
userId: String?,
serverUrl: String?,
): List<Preferences.Key<String>> {
if (userId.isNullOrBlank() || serverUrl.isNullOrBlank()) return emptyList()
val base = "home_cache::$userId@$serverUrl"
return preferences.asMap().keys
.filter { it.name == base || it.name.startsWith("$base#") }
.map { stringPreferencesKey(it.name) }
}
/**
* The active profile's cached rows. The per-profile key is authoritative; the flat
* [Keys.HOME_CACHE] slot survives only for installs written before the split and for
* the case where there is no profile to key against.
* The active profile's cached rows, for whoever is watching. The per-profile key is
* authoritative; the flat [Keys.HOME_CACHE] slot survives only for installs written
* before the split and for the case where there is no profile to key against.
*/
private fun activeHomeCache(preferences: Preferences): String? =
profileHomeCacheKey(
userId = preferences[Keys.USER_ID],
serverUrl = preferences[Keys.SERVER_URL],
viewerId = preferences[Keys.ACTIVE_VIEWER_ID],
)?.let { preferences[it] } ?: preferences[Keys.HOME_CACHE]
suspend fun setForYouMinutes(minutes: Int) {
@@ -1314,9 +1389,11 @@ class SettingsStore(private val context: Context) {
val profiles = profilesFrom(preferences)
val removed = profiles.firstOrNull { it.id == profileId } ?: return@edit
writeProfiles(preferences, profiles.filterNot { it.id == profileId })
// Forget the departing profile's cached rows too; nothing will read that key
// again and it is the largest single value this store holds.
profileHomeCacheKey(removed.userId, removed.serverUrl)?.let(preferences::remove)
// Forget the departing profile's cached rows too — every viewer's, not only
// the account's; nothing will read those keys again and they are the largest
// single values this store holds.
profileHomeCacheKeys(preferences, removed.userId, removed.serverUrl)
.forEach(preferences::remove)
if (
preferences[Keys.USER_ID] == removed.userId &&
preferences[Keys.SERVER_URL] == removed.serverUrl
@@ -1346,7 +1423,8 @@ class SettingsStore(private val context: Context) {
it.userId == activeUserId && it.serverUrl == activeServer
}
writeProfiles(preferences, remaining)
profileHomeCacheKey(activeUserId, activeServer)?.let(preferences::remove)
profileHomeCacheKeys(preferences, activeUserId, activeServer)
.forEach(preferences::remove)
clearActiveSession(preferences)
}
}
@@ -1384,9 +1462,27 @@ class SettingsStore(private val context: Context) {
preferences.remove(Keys.PROFILE_INITIALS)
preferences.remove(Keys.SHORT_NAME)
preferences.remove(Keys.USERNAME)
// The people under one Emby account are not the people under another, and an id
// carried across would name somebody this account has never heard of. The gateway
// refuses it and falls back to the main viewer, so the consequence is cosmetic
// rather than a leak — but a television claiming to be Alessandra when it is
// signed into a different household is still wrong on its face.
clearActiveViewer(preferences)
}
private fun clearActiveViewer(preferences: MutablePreferences) {
preferences.remove(Keys.ACTIVE_VIEWER_ID)
preferences.remove(Keys.ACTIVE_VIEWER_NAME)
}
private fun applyProfile(preferences: MutablePreferences, profile: EmbyProfile) {
// Switching account resets who is watching, for the same reason clearing the
// session does: the viewer list belongs to the account being left.
if (preferences[Keys.USER_ID] != profile.userId ||
preferences[Keys.SERVER_URL] != profile.serverUrl
) {
clearActiveViewer(preferences)
}
preferences[Keys.SERVER_URL] = profile.serverUrl
preferences[Keys.TOKEN] = profile.token
preferences[Keys.USER_ID] = profile.userId
@@ -1558,6 +1654,8 @@ class SettingsStore(private val context: Context) {
updateAlertRead = preferences[Keys.UPDATE_ALERT_READ] ?: false,
requiredUpdateVersion = preferences[Keys.REQUIRED_UPDATE_VERSION],
preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
activeViewerId = preferences[Keys.ACTIVE_VIEWER_ID].orEmpty(),
activeViewerName = preferences[Keys.ACTIVE_VIEWER_NAME].orEmpty(),
profiles = profiles,
)
}
@@ -87,6 +87,60 @@ data class GatewayTrailerReport(
@Serializable
data class GatewayDeviceNameRequest(val deviceName: String)
/**
* One person under this Memby account.
*
* A device is a television and a profile is a signed-in Emby account; a *viewer* is one of
* the people using them. The main viewer's state is Emby's and its id is the Emby user id,
* which is why nothing here needs a separate notion of "the account's own viewer" it is
* simply the one whose [kind] is `main`.
*/
@Serializable
data class MembyViewer(
val id: String = "",
val name: String = "",
val shortName: String = "",
val colour: String = "",
val kind: String = "",
val hasPin: Boolean = false,
val createdAt: String = "",
) {
/** True when this viewer's watching is published to Emby. */
val isMain: Boolean get() = kind == KIND_MAIN
/**
* What the picker draws in the avatar. Emby usernames are commonly one word, so the
* first letter alone is what distinguishes them at three metres; a short name the
* household set is preferred because it is the thing they chose to be called.
*/
val initials: String
get() = (shortName.takeIf(String::isNotBlank) ?: name)
.trim()
.takeIf(String::isNotEmpty)
?.take(1)
?.uppercase()
.orEmpty()
companion object {
const val KIND_MAIN = "main"
const val KIND_SHADOW = "shadow"
}
}
@Serializable
data class MembyViewers(
val viewers: List<MembyViewer> = emptyList(),
/** Whom the gateway resolved this request to, which is the answer this TV adopts. */
val active: String = "",
)
@Serializable
data class MembyViewerRequest(
val name: String,
val shortName: String = "",
val colour: String = "",
)
/**
* One horizontal strip, described entirely by the server.
*
@@ -24,6 +24,9 @@ import com.ponzischeme89.memby.data.model.GatewayRows
import com.ponzischeme89.memby.data.model.GatewayRequestLookup
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.data.model.MembyViewerRequest
import com.ponzischeme89.memby.data.model.MembyViewers
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
import com.ponzischeme89.memby.data.model.RecommendationPreferences
import com.ponzischeme89.memby.data.model.UserItemData
@@ -66,6 +69,26 @@ interface GatewayApi {
@Body body: GatewayDeviceNameRequest,
)
/**
* The people under this account. `active` is the gateway's own answer about whom this
* request resolved to, which is what makes a viewer this television no longer has
* deleted on another set visibly fall back to the main viewer rather than silently.
*/
@GET("v1/viewers")
suspend fun viewers(): MembyViewers
@POST("v1/viewers")
suspend fun createViewer(@Body body: MembyViewerRequest): MembyViewer
@PUT("v1/viewers/{viewerId}")
suspend fun updateViewer(
@Path("viewerId") viewerId: String,
@Body body: MembyViewerRequest,
): MembyViewer
@DELETE("v1/viewers/{viewerId}")
suspend fun deleteViewer(@Path("viewerId") viewerId: String)
@GET("v1/home")
suspend fun home(@Query("limit") limit: Int): GatewayHome
@@ -119,12 +119,31 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) :
audioCapabilityTokens()
).joinToString(","),
)
activeViewerId()?.let { builder.header(VIEWER_HEADER, it) }
tokenProvider()?.takeIf { it.isNotBlank() }?.let {
builder.header("Authorization", "Bearer $it")
}
return chain.proceed(builder.build())
}
/**
* Who is watching, as distinct from which account is streaming.
*
* Read on each request rather than captured, for the reason the audio tokens are:
* switching between two people on one television must not rebuild the HTTP client, and
* the answer can change at any moment between two requests. The header is *omitted*
* rather than sent empty when nobody has been chosen the gateway states that an
* absent header means the account's own viewer, and an empty one would be a value it
* has to have an opinion about.
*
* Guarded like the audio tokens, because this interceptor also runs before the service
* locator exists in a screenshot or instrumentation context, where throwing here would
* fail the request rather than merely decline to name a viewer.
*/
private fun activeViewerId(): String? = runCatching {
ServiceLocator.settings.current?.activeViewerId?.takeIf(String::isNotBlank)
}.getOrNull()
/**
* What this set can put through its speakers, resolved against the viewer's own
* passthrough choice so a manual override reaches the *server's* device profile too,
@@ -160,6 +179,9 @@ private object RequiredUpdateInterceptor : Interceptor {
}
}
/** Names the person watching. See `X-Memby-Viewer` in the gateway's `api/viewers.go`. */
internal const val VIEWER_HEADER = "X-Memby-Viewer"
internal const val MEMBY_PROTOCOL_VERSION = 1
internal val MEMBY_CAPABILITIES = listOf(
@@ -190,6 +212,10 @@ internal val MEMBY_CAPABILITIES = listOf(
// "processing", the single word the whole span used to be, so an older television reads
// its request page exactly as it always did.
"request_progress_v1",
// Declares that this build can draw the viewer picker and sends X-Memby-Viewer. An
// older app never receives the feature, so the operator cannot switch on a household
// of people that half its televisions have no way of choosing between.
"viewers_v1",
)
internal const val HEVC_DECODE_CAPABILITY = "video_hevc_decode"
@@ -1,5 +1,6 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.viewers.viewerMenuLabel
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.compose.animation.animateColorAsState
@@ -481,13 +482,24 @@ fun UserSwitcherOverlay(
*/
showRequests: Boolean = false,
onOpenRequests: () -> Unit = {},
/**
* Whether this household has anybody to choose between. False hides the row entirely,
* for the same reason [showRequests] does: on the direct path there is nobody to ask,
* and an account nobody has added a viewer to would be offered a question with one
* answer. See `shouldOfferViewerPicker`.
*/
showViewers: Boolean = false,
activeViewerId: String = "",
activeViewerName: String = "",
onOpenViewers: () -> Unit = {},
) {
val profileIds = profiles.map(EmbyProfile::id)
val actionCount = userSwitcherActionCount(showRequests)
val menuItems = userSwitcherMenuItems(showRequests, showViewers)
val actionCount = menuItems.size
// Re-keyed on the action count as well as the profiles: a permission arriving on a poll
// while this menu is open changes how many rows there are, and a requester list of the
// old length would leave the new row unfocusable.
val focusRequesters = remember(profileIds, actionCount) {
val focusRequesters = remember(profileIds, menuItems) {
List(profiles.size + actionCount) { FocusRequester() }
}
val profileListState = remember(profileIds) { LazyListState() }
@@ -571,7 +583,11 @@ fun UserSwitcherOverlay(
modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 5.dp),
)
Text(
"Choose whos watching",
// With viewers in play this list is *accounts*, and the row below it is the
// person — so the panel must stop claiming to be the thing that answers
// "who is watching" when there is now a control directly beneath it that
// does. A household running no viewers keeps the wording it always had.
if (showViewers) "Choose an account" else "Choose whos watching",
color = QuietText,
fontSize = 12.sp,
modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 8.dp),
@@ -608,69 +624,61 @@ fun UserSwitcherOverlay(
.background(Color.White.copy(alpha = 0.07f)),
)
Spacer(Modifier.height(6.dp))
// Notifications live here rather than on the launcher: these belong to a
// person and follow them between televisions, so the menu that already answers
// "who is watching" is where somebody looks for their own news. The badge is
// what replaces the bell that used to sit in the corner of Home.
UserSwitcherAction(
label = "Notifications",
icon = MembyIcon.Notification.mark,
badge = alertBadgeLabel(alertCount),
modifier = Modifier
.focusRequester(focusRequesters[profiles.size])
.onFocusChanged {
if (it.isFocused) focusedIndex = profiles.size
},
onClick = onOpenAlerts,
)
// Requests sits beside Notifications because both are personal activity.
if (showRequests) {
UserSwitcherAction(
label = "Requests",
icon = MembyIcon.PlaylistAdd.mark,
modifier = Modifier
.focusRequester(focusRequesters[profiles.size + 1])
.onFocusChanged {
if (it.isFocused) focusedIndex = profiles.size + 1
},
onClick = onOpenRequests,
)
// Every row's index comes from its position in [menuItems] rather than being
// written out here. The four hand-computed offsets this replaced were what made
// adding a fifth conditional row unsafe.
menuItems.forEachIndexed { offset, item ->
val index = profiles.size + offset
val modifier = Modifier
.focusRequester(focusRequesters[index])
.onFocusChanged { if (it.isFocused) focusedIndex = index }
when (item) {
// Who is watching sits above the rest because it changes whose menu
// this is: the notifications and requests below belong to whichever
// viewer it selects.
UserSwitcherMenuItem.VIEWERS -> UserSwitcherAction(
label = viewerMenuLabel(activeViewerId, activeViewerName),
icon = MembyIcon.Person.mark,
modifier = modifier,
onClick = onOpenViewers,
)
// Notifications live here rather than on the launcher: these belong to
// a person and follow them between televisions, so the menu that
// already answers "who is watching" is where somebody looks for their
// own news. The badge is what replaces the bell that used to sit in the
// corner of Home.
UserSwitcherMenuItem.NOTIFICATIONS -> UserSwitcherAction(
label = "Notifications",
icon = MembyIcon.Notification.mark,
badge = alertBadgeLabel(alertCount),
modifier = modifier,
onClick = onOpenAlerts,
)
// Requests sits beside Notifications because both are personal activity.
UserSwitcherMenuItem.REQUESTS -> UserSwitcherAction(
label = "Requests",
icon = MembyIcon.PlaylistAdd.mark,
modifier = modifier,
onClick = onOpenRequests,
)
UserSwitcherMenuItem.SETTINGS -> UserSwitcherAction(
label = "Settings",
icon = MembyIcon.Settings.mark,
modifier = modifier,
onClick = onOpenSettings,
)
UserSwitcherMenuItem.MANAGE_USERS -> UserSwitcherAction(
label = "Manage users",
icon = MembyIcon.Grid.mark,
modifier = modifier,
onClick = onManageProfiles,
)
}
}
val settingsIndex = profiles.size + if (showRequests) 2 else 1
UserSwitcherAction(
label = "Settings",
icon = MembyIcon.Settings.mark,
modifier = Modifier
.focusRequester(focusRequesters[settingsIndex])
.onFocusChanged {
if (it.isFocused) focusedIndex = settingsIndex
},
onClick = onOpenSettings,
)
val manageIndex = profiles.size + actionCount - 1
UserSwitcherAction(
label = "Manage users",
icon = MembyIcon.Person.mark,
modifier = Modifier
.focusRequester(focusRequesters[manageIndex])
.onFocusChanged {
if (it.isFocused) focusedIndex = manageIndex
},
onClick = onManageProfiles,
)
}
}
}
/**
* Notifications, then Requests when this viewer may make them, Settings, then Manage users.
*
* Pure and derived in one place because three things read it the requester list's length,
* the D-pad's lower bound and Manage users' own index and a count that disagreed with the
* rows actually drawn is how the last item in a menu becomes unreachable.
*/
internal fun userSwitcherActionCount(showRequests: Boolean): Int = if (showRequests) 4 else 3
@Composable
private fun UserSwitcherProfileItem(
profile: EmbyProfile,
@@ -127,6 +127,7 @@ import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.analytics.PlaybackJourney
import com.ponzischeme89.memby.data.analytics.playbackEntryPointFor
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.MyShow
@@ -169,6 +170,13 @@ import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyOutline
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySplashTint
import com.ponzischeme89.memby.ui.viewers.MAX_SHADOW_VIEWERS
import com.ponzischeme89.memby.ui.viewers.ViewerManageScreen
import com.ponzischeme89.memby.ui.viewers.ViewerNameEntry
import com.ponzischeme89.memby.ui.viewers.ViewerNameTarget
import com.ponzischeme89.memby.ui.viewers.ViewerPicker
import com.ponzischeme89.memby.ui.viewers.shouldOfferViewerPicker
import com.ponzischeme89.memby.ui.viewers.viewerNameFor
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import com.ponzischeme89.memby.ui.theme.MembyTheme
@@ -1975,6 +1983,35 @@ private fun HomeScreen(
) + notificationState.notifications
var showNotifications by remember { mutableStateOf(false) }
var showRequests by remember { mutableStateOf(false) }
// The people under this account. Fetched once the launcher is up rather than on the
// critical path: nothing on the signed-in path may block on a request, and until the
// answer lands this television watches as whoever it watched as last — which is the
// right answer far more often than not.
var viewers by remember { mutableStateOf<List<MembyViewer>>(emptyList()) }
var showViewerPicker by remember { mutableStateOf(false) }
// Managing viewers is a stack over the picker rather than a replacement for it — the
// arrangement the "add another user" sign-in already takes over the manage-users page:
// cancelling a name comes straight back to the list the button was pressed from, with
// nothing to restore because nothing was ever unmounted.
var showViewerManage by remember { mutableStateOf(false) }
var viewerNameTarget by remember { mutableStateOf<ViewerNameTarget?>(null) }
var viewerName by remember { mutableStateOf("") }
var viewerNameFailure by remember { mutableStateOf<String?>(null) }
var viewerSaving by remember { mutableStateOf(false) }
var viewerBusyId by remember { mutableStateOf<String?>(null) }
LaunchedEffect(settings.userId, settings.serverUrl) {
viewers = if (settings.isSignedIn) repo.viewers() else emptyList()
}
// One place the list is re-read, so every mutation ends the same way and none of them
// has to work out what the answer should now be. The gateway is the thing that knows.
val refreshViewers: suspend () -> Unit = {
viewers = runCatching { repo.viewers() }.getOrDefault(viewers)
}
val openViewerName: (ViewerNameTarget) -> Unit = { target ->
viewerName = viewerNameFor(target)
viewerNameFailure = null
viewerNameTarget = target
}
// Two different things: [launchingItem] is the gate that stops a second Play press
// stacking a second player, and stays shut until one comes back. [resolvingItem] is
// the loading screen, and belongs only to a launch that is waiting on the server.
@@ -3398,6 +3435,14 @@ private fun HomeScreen(
notificationsLoading = false
}
},
showViewers = shouldOfferViewerPicker(ServerConfig.isGateway, viewers.size),
activeViewerId = settings.activeViewerId,
activeViewerName = settings.activeViewerName,
onOpenViewers = {
userSwitcherVisible = false
navigationExpanded = false
showViewerPicker = true
},
showRequests = requestsAllowed,
onOpenRequests = {
homeViewModel.trackJourney(
@@ -3417,6 +3462,106 @@ private fun HomeScreen(
},
)
}
if (showViewerPicker) {
val closeViewerPicker: () -> Unit = {
showViewerPicker = false
scope.launch {
kotlinx.coroutines.delay(16L)
runCatching { navigationFocusRequester.requestFocus() }
}
}
BackHandler(onBack = closeViewerPicker)
Box(Modifier.fillMaxSize().zIndex(20f).background(MembySurface)) {
ViewerPicker(
viewers = viewers,
activeViewerId = settings.activeViewerId,
onViewerSelected = { viewer ->
closeViewerPicker()
scope.launch {
// Everything on the launcher belongs to the outgoing viewer, so
// the journey is closed and the rows are refreshed rather than
// left standing under a different person's name.
homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen)
repo.switchViewer(viewer)
homeViewModel.refreshAll()
}
},
// Both open over the picker rather than instead of it, so Back is one
// step out of each and the row of faces is still underneath.
onAddViewer = { openViewerName(ViewerNameTarget.Add) },
onManageViewers = { showViewerManage = true },
canAddViewer = viewers.count { !it.isMain } < MAX_SHADOW_VIEWERS,
)
}
}
if (showViewerManage) {
BackHandler(onBack = { showViewerManage = false })
Box(Modifier.fillMaxSize().zIndex(21f).background(MembySurface)) {
ViewerManageScreen(
viewers = viewers,
onRename = { openViewerName(ViewerNameTarget.Rename(it)) },
onRemove = { viewer ->
viewerBusyId = viewer.id
scope.launch {
// Removing whoever is watching returns this set to the account,
// which the repository does; the launcher has to be told, or it
// goes on drawing the removed person's rows.
val watching = viewer.id == settings.activeViewerId
val removed = repo.removeViewer(viewer)
if (removed && watching) {
homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen)
homeViewModel.refreshAll()
}
refreshViewers()
viewerBusyId = null
}
},
onAdd = { openViewerName(ViewerNameTarget.Add) },
onClose = { showViewerManage = false },
canAddViewer = viewers.count { !it.isMain } < MAX_SHADOW_VIEWERS,
busyViewerId = viewerBusyId,
)
}
}
viewerNameTarget?.let { target ->
val closeViewerName = { viewerNameTarget = null }
BackHandler(onBack = closeViewerName)
Box(Modifier.fillMaxSize().zIndex(22f).background(MembySurface)) {
ViewerNameEntry(
target = target,
name = viewerName,
existing = viewers,
onNameChanged = { viewerName = it; viewerNameFailure = null },
onCancel = closeViewerName,
onConfirm = {
if (!viewerSaving) {
viewerSaving = true
scope.launch {
val saved = when (target) {
ViewerNameTarget.Add -> repo.createViewer(viewerName)
is ViewerNameTarget.Rename ->
repo.renameViewer(target.viewer, viewerName)
}
viewerSaving = false
if (saved == null) {
// The one thing the television can say about a refusal
// it has no wording for. The screen stays up holding
// what was typed, because retyping a name somebody has
// just entered is the worst possible answer to a
// request that failed for a reason nothing here knows.
viewerNameFailure = "That could not be saved. Try again."
} else {
viewerNameTarget = null
refreshViewers()
}
}
}
},
failure = viewerNameFailure,
saving = viewerSaving,
)
}
}
if (userQuickActionsVisible) {
val closeUserQuickActions: () -> Unit = {
userQuickActionsVisible = false
@@ -26,3 +26,32 @@ internal fun userSwitcherNextIndex(
UserSwitcherDirection.DOWN -> (current + 1).coerceAtMost(lastIndex)
}
}
/**
* The pinned rows below the profile list, in the order they are drawn.
*
* It is a list rather than four pieces of arithmetic because five things read it the
* requester list's length, the D-pad's lower bound, and each row's own index and every
* one of those was previously written out by hand (`profiles.size + 1`,
* `if (showRequests) 2 else 1`, `actionCount - 1`). A count that disagreed 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. This is the shape `QuickAction`
* already moved to for the same reason.
*/
internal enum class UserSwitcherMenuItem { VIEWERS, NOTIFICATIONS, REQUESTS, SETTINGS, MANAGE_USERS }
/**
* Who's watching first, because it changes *whose* menu this is: the notifications and
* requests below it belong to whichever viewer it selects, so offering it after them would
* put the answer below the things that depend on it.
*/
internal fun userSwitcherMenuItems(
showRequests: Boolean,
showViewers: Boolean = false,
): List<UserSwitcherMenuItem> = buildList {
if (showViewers) add(UserSwitcherMenuItem.VIEWERS)
add(UserSwitcherMenuItem.NOTIFICATIONS)
if (showRequests) add(UserSwitcherMenuItem.REQUESTS)
add(UserSwitcherMenuItem.SETTINGS)
add(UserSwitcherMenuItem.MANAGE_USERS)
}
@@ -3,6 +3,8 @@ package com.ponzischeme89.memby.ui.theme
import com.composables.icons.fontawesome.FontAwesome
import com.composables.icons.fontawesome.solid.AngleDoubleLeft
import com.composables.icons.fontawesome.solid.ArrowDown
import com.composables.icons.fontawesome.solid.Pen
import com.composables.icons.fontawesome.solid.TrashAlt
import com.composables.icons.fontawesome.solid.ArrowLeft
import com.composables.icons.fontawesome.solid.ArrowRight
import com.composables.icons.fontawesome.solid.ArrowUp
@@ -100,6 +102,8 @@ internal val fontAwesomeIconPack = MembyIconPack(
MembyIcon.Add to { FontAwesome.Solid.Plus },
MembyIcon.Close to { FontAwesome.Solid.Times },
MembyIcon.Refresh to { FontAwesome.Solid.Sync },
MembyIcon.Rename to { FontAwesome.Solid.Pen },
MembyIcon.Remove to { FontAwesome.Solid.TrashAlt },
MembyIcon.ChevronLeft to { FontAwesome.Solid.ChevronLeft },
MembyIcon.ChevronRight to { FontAwesome.Solid.ChevronRight },
MembyIcon.ChevronDown to { FontAwesome.Solid.ChevronDown },
@@ -13,6 +13,8 @@ import com.composables.icons.lucide.Building2
import com.composables.icons.lucide.CalendarClock
import com.composables.icons.lucide.CalendarDays
import com.composables.icons.lucide.Check
import com.composables.icons.lucide.Pencil
import com.composables.icons.lucide.Trash2
import com.composables.icons.lucide.ChevronDown
import com.composables.icons.lucide.ChevronLeft
import com.composables.icons.lucide.ChevronRight
@@ -100,6 +102,8 @@ internal val lucideIconPack = MembyIconPack(
MembyIcon.Add to { Lucide.Plus },
MembyIcon.Close to { Lucide.X },
MembyIcon.Refresh to { Lucide.RefreshCw },
MembyIcon.Rename to { Lucide.Pencil },
MembyIcon.Remove to { Lucide.Trash2 },
MembyIcon.ChevronLeft to { Lucide.ChevronLeft },
MembyIcon.ChevronRight to { Lucide.ChevronRight },
MembyIcon.ChevronDown to { Lucide.ChevronDown },
@@ -24,8 +24,10 @@ import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.FirstPage
@@ -118,6 +120,8 @@ object MaterialIconPack {
MembyIcon.Add to { Icons.Default.Add },
MembyIcon.Close to { Icons.Default.Close },
MembyIcon.Refresh to { Icons.Default.Refresh },
MembyIcon.Rename to { Icons.Default.Edit },
MembyIcon.Remove to { Icons.Default.DeleteOutline },
MembyIcon.ChevronLeft to { Icons.Default.ChevronLeft },
MembyIcon.ChevronRight to { Icons.Default.ChevronRight },
MembyIcon.ChevronDown to { Icons.Default.KeyboardArrowDown },
@@ -53,6 +53,12 @@ enum class MembyIcon {
Close,
Refresh,
// Editing a thing rather than acting on media: the manage-viewers list is what needed
// them, and they are named for the job so a pack answering with a pencil and a pack
// answering with a pen both sit under a name that stays true.
Rename,
Remove,
// Movement
ChevronLeft,
ChevronRight,
@@ -0,0 +1,108 @@
package com.ponzischeme89.memby.ui.viewers
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyDisabledText
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOutline
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.mark
/**
* The one button the three viewer screens are built from.
*
* There were nearly three of it the picker's Add and Manage, the manage list's Rename and
* Remove, and the name screen's confirm and cancel all the same shape at the same size in
* the same green, written on three different days. This is the rule the rest of the app
* already follows for Play (`ui/MembyButtons.kt`) and for detail-page cards
* (`Modifier.detailCardFocus`): one language per kind of control, or the copies drift and a
* viewer notices before anybody else does.
*
* [emphasised] marks the answer the screen is *for* adding the person, saving the name
* so that on a row of two the destructive or the neutral one is never the one wearing the
* accent. It is a quiet outline the rest of the time, which is the same distinction
* `ExitConfirmation` draws between staying and closing.
*/
@Composable
internal fun ViewerActionButton(
label: String,
icon: MembyIcon,
onClick: () -> Unit,
modifier: Modifier = Modifier,
emphasised: Boolean = false,
enabled: Boolean = true,
/**
* Draws the button as though the remote were on it. Robolectric's window never takes
* focus and the ring is the whole of what says which answer a press would take, so a
* capture of a two-answer row would otherwise prove nothing the flag `ExitConfirmation`
* carries, for the same reason.
*/
focusedForCapture: Boolean = false,
) {
var hasFocus by remember { mutableStateOf(false) }
val focused = hasFocus || focusedForCapture
val shape = RoundedCornerShape(MembyPanelCorner)
// Disabled is drawn rather than removed only where the control is the point of the
// screen: the confirm button on the name screen is what explains what to do next, and
// a button that appeared once enough had been typed would move the row under a thumb.
// Everywhere the control is optional it is removed instead — see the picker's Add.
val background = when {
!enabled -> Color.Transparent
focused -> MembyAccent
emphasised -> MembyAccent.copy(alpha = 0.16f)
else -> MembyControlSurface
}
val content = when {
!enabled -> MembyDisabledText
focused -> MembyAccentInk
emphasised -> MembyAccent
else -> MembyMutedText
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.clip(shape)
.background(background)
// The outline survives being disabled: without it a dimmed button has no fill and
// no edge, and reads as a label somebody forgot to finish rather than as a control
// waiting for a name to be typed.
.border(1.dp, if (focused) Color.Transparent else MembyOutline, shape)
.onFocusChanged { hasFocus = it.isFocused }
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 20.dp, vertical = 12.dp),
) {
Icon(
imageVector = icon.mark,
contentDescription = null,
tint = content,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(10.dp))
Text(text = label, color = content, fontSize = 15.sp)
}
}
@@ -0,0 +1,116 @@
package com.ponzischeme89.memby.ui.viewers
import com.ponzischeme89.memby.data.model.MembyViewer
/**
* The rules behind naming a person, kept pure so they can be tested without a television.
*
* Naming is the whole of what this feature needed and did not have: the gateway's routes
* and the repository's calls have existed since viewers shipped, and both the Add and the
* Manage controls opened the *account* list instead, because a name has to be typed and
* this app has one text-entry idiom the search keyboard that nothing else reused.
*/
/**
* The longest name the gateway will accept, in characters rather than bytes, because that
* is what it counts in runes on the other side.
*
* It is enforced here by *refusing the keypress* rather than 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.
*/
internal const val MAX_VIEWER_NAME_LENGTH = 40
/** What the name-entry screen is being opened for. */
internal sealed interface ViewerNameTarget {
/** A new person under this account. */
data object Add : ViewerNameTarget
/** Renaming somebody who is already here. */
data class Rename(val viewer: MembyViewer) : ViewerNameTarget
}
/** The name the screen opens holding: empty for a new person, their own for a rename. */
internal fun viewerNameFor(target: ViewerNameTarget): String = when (target) {
ViewerNameTarget.Add -> ""
is ViewerNameTarget.Rename -> target.viewer.name
}
internal fun viewerNameHeading(target: ViewerNameTarget): String = when (target) {
ViewerNameTarget.Add -> "Who is watching?"
is ViewerNameTarget.Rename -> "Rename ${target.viewer.name}"
}
/**
* The button names the *outcome*, not the screen "Add viewer" rather than "Save",
* because on a television the label under the focus ring is commonly the only thing
* saying what a confirm press is about to do.
*/
internal fun viewerNameAction(target: ViewerNameTarget): String = when (target) {
ViewerNameTarget.Add -> "Add viewer"
is ViewerNameTarget.Rename -> "Save name"
}
/**
* Adds one typed character, or refuses it at the limit.
*
* Returning the unchanged name is what makes the refusal silent, which is the right
* failure here: the keyboard is on screen, the name is on screen above it, and a key that
* simply stops writing is self-explanatory in a way an error message about a maximum is
* not.
*/
internal fun viewerNameWith(current: String, typed: String): String =
if (current.length + typed.length > MAX_VIEWER_NAME_LENGTH) current else current + typed
/**
* What is wrong with this name, or null when nothing is.
*
* Two refusals and they fail for different reasons. A blank name is refused because the
* gateway refuses it, and a card with no name on it is not a person anybody could pick.
* A **repeated** name is refused by this app alone the gateway is perfectly 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.
*
* The comparison ignores case and surrounding space, since "sam" and "Sam " are the same
* answer to "who is this", and a rename skips the person being renamed so that correcting
* somebody's capitalisation is not refused as a duplicate of themselves.
*/
internal fun viewerNameError(
name: String,
existing: List<MembyViewer>,
target: ViewerNameTarget,
): String? {
val trimmed = name.trim()
if (trimmed.isEmpty()) return "Type a name first."
val renaming = (target as? ViewerNameTarget.Rename)?.viewer?.id
val clash = existing.any { it.id != renaming && it.name.trim().equals(trimmed, ignoreCase = true) }
return if (clash) "There is already somebody called $trimmed." else null
}
/** Whether the confirm button does anything yet. */
internal fun viewerNameSubmittable(
name: String,
existing: List<MembyViewer>,
target: ViewerNameTarget,
): Boolean = viewerNameError(name, existing, target) == null
/**
* The people the manage screen can actually change.
*
* The main viewer is listed but never editable: its name is the Emby account's and belongs
* to Emby, which is why `store.UpdateShadowViewer` refuses it and why removing it is not
* offered at all an account with no main viewer has nothing left to fall back to.
*/
internal fun viewerIsEditable(viewer: MembyViewer): Boolean = !viewer.isMain
/**
* Where the manage list puts focus after somebody is removed.
*
* The row that took their place, falling back to the last row when the one removed was at
* the end, and to nothing at all when the list has emptied the rule
* [profileFocusIndexAfterRemoval][com.ponzischeme89.memby.ui.profileFocusIndexAfterRemoval]
* already follows, for the same reason: a viewer who has just deleted three people in a
* row must not be sent back to the top of the list between each one.
*/
internal fun viewerFocusIndexAfterRemoval(removedIndex: Int, remainingCount: Int): Int? =
if (remainingCount <= 0) null else removedIndex.coerceIn(0, remainingCount - 1)
@@ -0,0 +1,324 @@
package com.ponzischeme89.memby.ui.viewers
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.ui.distinctForKeys
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyOutline
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
/**
* The people under this account, and what can be done about them.
*
* It is a *list* where the picker is a row of faces, because the two answer different
* questions. The picker asks "who is watching", which is a glance and one press; this asks
* "who is here", which is read one line at a time and acted on per person the same
* distinction the alerts page draws between a badge and its inbox.
*
* Stateless but for the remote's own business: which row a removal is aimed at, and where
* focus goes when that row disappears. The caller owns the list and the requests.
*/
@Composable
fun ViewerManageScreen(
viewers: List<MembyViewer>,
onRename: (MembyViewer) -> Unit,
onRemove: (MembyViewer) -> Unit,
onAdd: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
canAddViewer: Boolean = true,
/** The viewer a request is in flight for, so a second press cannot start a second one. */
busyViewerId: String? = null,
) {
// Ids come off a wire and a keyed list throws on a repeat — see ui/ListKeys.kt.
val people = remember(viewers) { viewers.distinctForKeys(MembyViewer::id) }
var pendingRemoval by remember { mutableStateOf<MembyViewer?>(null) }
var removalReturnIndex by remember { mutableStateOf<Int?>(null) }
val renameFocus = remember(people) { List(people.size) { FocusRequester() } }
val addFocus = remember { FocusRequester() }
// Focus lands on the row that took the removed one's place rather than back at the top,
// because emptying a household of guests is a run of presses and being sent to the top
// between each one loses the viewer's place every time.
LaunchedEffect(people) {
val index = removalReturnIndex?.let { viewerFocusIndexAfterRemoval(it, people.size) }
removalReturnIndex = null
val target = index ?: people.indexOfFirst(::viewerIsEditable).takeIf { it >= 0 }
runCatching {
target?.let { renameFocus.getOrNull(it)?.requestFocus() } ?: addFocus.requestFocus()
}
}
Box(modifier.fillMaxSize().background(MembySurface)) {
Column(
Modifier
.fillMaxSize()
.padding(horizontal = 72.dp, vertical = 40.dp),
) {
Text("VIEWERS", color = MembyAccent, fontSize = 11.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(4.dp))
Text(
text = "Who is under this account",
color = MembyOnSurface,
fontSize = 28.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(4.dp))
Text(
text = "Everybody here keeps their own Continue Watching, watched history " +
"and favourites. Only the account itself is synced with Emby.",
color = MembyQuietText,
fontSize = 14.sp,
)
Spacer(Modifier.height(22.dp))
LazyColumn(
modifier = Modifier.fillMaxWidth().weight(1f),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(bottom = 12.dp),
) {
itemsIndexed(people, key = { _, viewer -> viewer.id }) { index, viewer ->
ViewerManageRow(
viewer = viewer,
busy = viewer.id == busyViewerId,
onRename = { onRename(viewer) },
onRemove = {
removalReturnIndex = index
pendingRemoval = viewer
},
renameModifier = Modifier.focusRequester(renameFocus[index]),
)
}
}
Spacer(Modifier.height(14.dp))
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
if (canAddViewer) {
ViewerActionButton(
label = "Add viewer",
icon = MembyIcon.Add,
onClick = onAdd,
emphasised = true,
modifier = Modifier.focusRequester(addFocus),
)
}
ViewerActionButton(label = "Done", icon = MembyIcon.Check, onClick = onClose)
}
}
pendingRemoval?.let { viewer ->
ViewerRemovalConfirmation(
viewer = viewer,
onCancel = {
removalReturnIndex = null
pendingRemoval = null
},
onConfirm = {
pendingRemoval = null
onRemove(viewer)
},
)
}
}
}
/**
* One person and the two things that can be done to them.
*
* The row holds **two focus targets** rather than opening a menu, the shape the alerts page
* settled on for the same reason: a remote has one confirm key, and a press that opened a
* list of actions would make renaming somebody three presses deep for no gain. Down still
* reaches the next row from either, so the second target costs nothing to somebody who only
* ever renames.
*
* The **main viewer has neither**. Its name is the Emby account's `UpdateShadowViewer`
* refuses to touch it and removing it would leave the account with nothing to fall back
* to. It is still listed, because a list of the people here that omitted the person whose
* watching actually reaches Emby would be the more confusing of the two.
*/
@Composable
private fun ViewerManageRow(
viewer: MembyViewer,
busy: Boolean,
onRename: () -> Unit,
onRemove: () -> Unit,
renameModifier: Modifier = Modifier,
) {
var hasFocus by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(MembyPanelCorner)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
// The lit surface is the *row's*, driven by hasFocus rather than isFocused, or
// the row goes dark the moment the remote steps sideways into its own control.
.onFocusChanged { hasFocus = it.hasFocus }
.clip(shape)
.background(if (hasFocus) MembyControlSurfaceRaised else MembyControlSurface)
.border(1.dp, if (hasFocus) MembyAccent else Color.Transparent, shape)
.padding(horizontal = 20.dp, vertical = 14.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(46.dp)
.clip(CircleShape)
.background(MembySurface)
.border(1.dp, MembyOutline, CircleShape),
) {
Text(
text = viewer.initials,
color = MembyOnSurface,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
)
}
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(
text = viewer.name,
color = MembyOnSurface,
fontSize = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = when {
busy -> "Working…"
viewer.isMain -> "The account itself — named by Emby, and synced with it"
else -> "Watches privately; nothing reaches Emby"
},
color = MembyQuietText,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (viewerIsEditable(viewer)) {
Spacer(Modifier.width(16.dp))
ViewerActionButton(
label = "Rename",
icon = MembyIcon.Rename,
onClick = onRename,
enabled = !busy,
modifier = renameModifier,
)
Spacer(Modifier.width(10.dp))
ViewerActionButton(
label = "Remove",
icon = MembyIcon.Remove,
onClick = onRemove,
enabled = !busy,
)
}
}
}
/**
* The full-stop question, because removing somebody deletes everything Memby kept for them
* and there is nothing to undo it with.
*
* It follows `ExitConfirmation`'s rules, which are the app's rules for a question asked over
* the thing it is about: the two answers **do not look alike**, the safe one takes focus
* first, and **Back means keep** it is the key that raised the panel, and pressing it
* again must not be what deletes a person.
*/
@Composable
internal fun ViewerRemovalConfirmation(
viewer: MembyViewer,
onCancel: () -> Unit,
onConfirm: () -> Unit,
/** Draws the keep button lit, so a capture states which answer a press would take. */
focusedForCapture: Boolean = false,
) {
val keepFocus = remember { FocusRequester() }
BackHandler(onBack = onCancel)
LaunchedEffect(viewer.id) { runCatching { keepFocus.requestFocus() } }
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.78f)),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.width(520.dp)
.background(MembyControlSurface, RoundedCornerShape(18.dp))
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "Remove ${viewer.name}?",
color = MembyOnSurface,
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "What they were part-way through, what they had watched and their " +
"favourites are deleted, on every television in the house. Nothing on " +
"the Emby account changes.",
color = MembyMutedText,
fontSize = 15.sp,
textAlign = TextAlign.Center,
)
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
ViewerActionButton(
label = "Keep ${viewer.name}",
icon = MembyIcon.Close,
onClick = onCancel,
emphasised = true,
focusedForCapture = focusedForCapture,
modifier = Modifier.focusRequester(keepFocus),
)
ViewerActionButton(
label = "Remove",
icon = MembyIcon.Remove,
onClick = onConfirm,
)
}
}
}
}
@@ -0,0 +1,241 @@
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
package com.ponzischeme89.memby.ui.viewers
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.key.utf16CodePoint
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.ui.search.BACKSPACE_CODE
import com.ponzischeme89.memby.ui.search.FIRST_PRINTABLE_CODE
import com.ponzischeme89.memby.ui.search.TvKeyboard
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyOutline
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
/**
* What a refusal is printed in. A literal rather than a token, the stance every status
* colour in this app takes: it carries meaning of its own, and a palette that could repaint
* it could make a refusal look like a confirmation.
*/
private val ViewerRefusal = Color(0xFFFF8A80)
/**
* Naming a person, which is the one thing this feature could not do from a television.
*
* It reuses the **search keyboard** rather than growing a second one. Two on-screen
* keyboards in one app is two focus contracts to keep in step, and the one thing a viewer
* must never have to relearn is where the letters are which is the note [TvKeyboard]
* already carries, written before there was a second caller to prove it.
*
* Stateless, the stance the picker and `SignInContent` take: the caller owns the name, the
* request and what happens after it, so the screenshot test can render every state of it
* with no gateway.
*/
@Composable
internal fun ViewerNameEntry(
target: ViewerNameTarget,
name: String,
existing: List<MembyViewer>,
onNameChanged: (String) -> Unit,
onConfirm: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
/**
* What the gateway said when it refused. It is kept apart from the local rules in
* [viewerNameError] on purpose: one is a sentence about what is typed and is true
* before anything is sent, the other is what came back, and showing them in one slot
* would let a stale server refusal sit under a name that has since been corrected.
*/
failure: String? = null,
saving: Boolean = false,
) {
val keyboardEntry = remember { FocusRequester() }
val keyboardReturn = remember { FocusRequester() }
val confirmFocus = remember { FocusRequester() }
var lastKeyIndex by remember { mutableStateOf(0) }
// The keyboard, not the confirm button: somebody who opened this screen came to type.
LaunchedEffect(target) { runCatching { keyboardEntry.requestFocus() } }
val localError = viewerNameError(name, existing, target)
// The local rule is only worth printing once there is something to be wrong about — a
// screen that opens by telling somebody their empty name is empty is scolding them for
// not having started.
val message = failure ?: localError.takeIf { name.isNotEmpty() }
val canConfirm = localError == null && !saving
Column(
modifier = modifier
.fillMaxSize()
.background(MembySurface)
.padding(horizontal = 48.dp, vertical = 24.dp)
// A USB keyboard, or a phone remote app sending key events, types into the same
// name the on-screen keys do — the Search tab's rule, and the same limits: only
// printable characters and backspace are consumed, so D-pad and Back fall
// through untouched and the screen can still be left.
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
val code = event.utf16CodePoint
when {
code == BACKSPACE_CODE -> {
onNameChanged(name.dropLast(1)); true
}
code >= FIRST_PRINTABLE_CODE -> {
onNameChanged(viewerNameWith(name, code.toChar().toString())); true
}
else -> false
}
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = viewerNameHeading(target),
color = MembyOnSurface,
fontSize = 26.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(4.dp))
Text(
text = "They keep their own Continue Watching, watched history and favourites. " +
"Nothing they watch reaches Emby.",
color = MembyQuietText,
fontSize = 13.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
NamePlate(name = name)
// Reserved rather than conditional, the rule the picker's "Synced with Emby" line
// follows: a message appearing under the plate would push the keyboard down by a
// line at the moment somebody is typing into it.
//
// Deliberately not 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. This is the red the search tab's own refusals use.
Text(
text = message ?: " ",
color = ViewerRefusal,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
Spacer(Modifier.height(6.dp))
// The keyboard is the child that gives way, never the buttons under it.
//
// A Column hands each child what the ones before it left over, so the confirm row —
// being last — was measured from the remainder and rendered as two squeezed slivers
// with their labels pressed out. Weighted children are measured from what the
// *unweighted* ones leave, so this inverts it: the buttons take their natural size
// first and the letters give up a row if a set is short of height. It is the same
// inversion the home hero makes for its Play chip, and it broke here in the same way.
Box(Modifier.width(340.dp).weight(1f, fill = false)) {
TvKeyboard(
// There is no rail beside this screen and no results grid to its right, so
// both edges are dead ends rather than jumps to a distant control: a screen
// this small has nowhere for focus to go sideways that would not be a
// surprise.
navigationFocusRequester = FocusRequester.Cancel,
resultsEntry = FocusRequester.Cancel,
keyboardEntry = keyboardEntry,
keyboardReturn = keyboardReturn,
lastKeyIndex = lastKeyIndex,
hasResultsTarget = false,
onKeyFocused = { lastKeyIndex = it },
onCharacter = { onNameChanged(viewerNameWith(name, it)) },
onBackspace = { onNameChanged(name.dropLast(1)) },
onClear = { onNameChanged("") },
// No submit key. The keyboard's own Search key is a lookup that costs
// something; here the confirm is a decision about a person and belongs
// beside the way out of the screen, not in the middle of the letters.
onSearch = null,
compact = true,
)
}
Spacer(Modifier.height(14.dp))
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
ViewerActionButton(
label = "Cancel",
icon = MembyIcon.Close,
onClick = onCancel,
)
ViewerActionButton(
label = if (saving) "Saving…" else viewerNameAction(target),
icon = MembyIcon.Check,
onClick = { if (canConfirm) onConfirm() },
emphasised = true,
enabled = canConfirm,
modifier = Modifier.focusRequester(confirmFocus),
)
}
}
}
/**
* What has been typed, shown at the size the room can read.
*
* The caret is drawn rather than blinking: nothing on this screen may animate, because an
* animation here would run for as long as somebody takes to type a name, and this app ships
* to boxes with nothing spare. A steady mark says the same thing.
*/
@Composable
private fun NamePlate(name: String) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.width(340.dp)
.height(56.dp)
.background(MembySurface, RoundedCornerShape(MembyPanelCorner))
.border(1.dp, MembyOutline, RoundedCornerShape(MembyPanelCorner))
.padding(horizontal = 16.dp),
) {
Text(
text = if (name.isEmpty()) "Type a name" else name + "|",
color = if (name.isEmpty()) MembyQuietText else MembyOnSurface,
fontSize = 24.sp,
fontWeight = if (name.isEmpty()) FontWeight.Normal else FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
}
}
@@ -0,0 +1,242 @@
package com.ponzischeme89.memby.ui.viewers
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.ui.distinctForKeys
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyOutline
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
/**
* "Who's watching?" the people under one Memby account.
*
* It is deliberately a full screen rather than another row in the user switcher. That panel
* lists *accounts* and the actions beside them, and a viewer is a different grain of thing:
* 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 on purpose, the stance `SignInContent` and the detail panes take: everything it
* needs is a parameter, so [ViewerPickerScreenshotTest] can render it with no gateway, and
* the caller owns the requests.
*/
@Composable
fun ViewerPicker(
viewers: List<MembyViewer>,
activeViewerId: String,
onViewerSelected: (MembyViewer) -> Unit,
onAddViewer: () -> Unit,
onManageViewers: () -> Unit,
modifier: Modifier = Modifier,
/**
* Whether an "Add viewer" card is offered. False once the household has reached the
* gateway's limit the control is *removed* rather than dimmed, the stance the two
* optional transport controls take: a remote is driven by a D-pad, and a dead stop on
* the way to the next control is worse than no control at all.
*/
canAddViewer: Boolean = true,
loading: Boolean = false,
) {
// Ids come off a wire and a keyed LazyRow throws on a repeat, taking the screen with
// it. Deduplicate, never disambiguate — see ui/ListKeys.kt.
val people = remember(viewers) { viewers.distinctForKeys(MembyViewer::id) }
val cardFocusers = remember(people) { List(people.size) { FocusRequester() } }
val listState = rememberLazyListState()
// Focus opens on whoever is watching rather than 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 instead of a walk along the row.
val initialIndex = remember(people, activeViewerId) {
viewerPickerInitialIndex(people, activeViewerId)
}
LaunchedEffect(people, initialIndex) {
if (people.isEmpty()) return@LaunchedEffect
listState.scrollToItem(viewerPickerScrollIndex(initialIndex))
runCatching { cardFocusers[initialIndex].requestFocus() }
}
Column(
modifier = modifier
.fillMaxSize()
.background(MembySurface)
.padding(horizontal = 48.dp, vertical = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = "Who's watching?",
color = MembyOnSurface,
fontSize = 34.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(6.dp))
Text(
text = "Everyone keeps their own Continue Watching and their own watched history.",
color = MembyQuietText,
fontSize = 15.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(34.dp))
if (people.isEmpty()) {
// "Still fetching" and "nobody here" are different things to be told — the
// distinction the player's cast panel makes with its own `loaded` flag.
Text(
text = if (loading) "Loading viewers…" else "No viewers yet.",
color = MembyMutedText,
fontSize = 16.sp,
)
} else {
LazyRow(
state = listState,
horizontalArrangement = Arrangement.spacedBy(20.dp),
contentPadding = PaddingValues(horizontal = 8.dp),
) {
itemsIndexed(people, key = { _, viewer -> viewer.id }) { index, viewer ->
ViewerCard(
viewer = viewer,
selected = viewerIsActive(viewer, activeViewerId),
onClick = { onViewerSelected(viewer) },
modifier = Modifier.focusRequester(cardFocusers[index]),
)
}
}
}
Spacer(Modifier.height(30.dp))
Row {
if (canAddViewer) {
ViewerActionButton(
label = "Add viewer",
icon = MembyIcon.Add,
onClick = onAddViewer,
)
Spacer(Modifier.width(14.dp))
}
ViewerActionButton(
label = "Manage",
icon = MembyIcon.Settings,
onClick = onManageViewers,
)
}
}
}
/**
* One person, as a face and a name.
*
* The animated scale is read only inside [graphicsLayer], never in the composable body, so
* travelling the row redraws two cards rather than recomposing every card in it the rule
* every focus treatment in this app follows.
*/
@Composable
private fun ViewerCard(
viewer: MembyViewer,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (focused) 1.06f else 1f, label = "viewerCardScale")
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.width(150.dp)
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(112.dp)
.clip(CircleShape)
.background(if (focused) MembyAccent else MembyControlSurface)
.border(
width = if (selected) 3.dp else 1.dp,
color = when {
focused -> Color.Transparent
selected -> MembyAccent
else -> MembyOutline
},
shape = CircleShape,
),
) {
Text(
text = viewer.initials,
color = if (focused) MembyAccentInk else MembyOnSurface,
fontSize = 40.sp,
fontWeight = FontWeight.SemiBold,
)
}
Spacer(Modifier.height(12.dp))
Text(
text = viewer.name,
color = if (focused) MembyOnSurface else MembyMutedText,
fontSize = 16.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
// The main viewer is the one whose watching reaches Emby, and saying so is most of
// the difference between a household understanding this feature and being puzzled
// by it. The line is *reserved* rather than conditional, or a card without it would
// sit taller than the one beside it — the rule the cast grid's character line
// follows.
Text(
text = if (viewer.isMain) "Synced with Emby" else " ",
color = MembyQuietText,
fontSize = 12.sp,
maxLines = 1,
textAlign = TextAlign.Center,
)
}
}
@@ -0,0 +1,94 @@
package com.ponzischeme89.memby.ui.viewers
import com.ponzischeme89.memby.data.model.MembyViewer
/**
* The rules the picker is built on, kept pure so they can be tested without a television.
*
* All three are about the same awkwardness: a blank active id means "the account's own
* viewer", because that is what the absence of the `X-Memby-Viewer` header means to the
* gateway. Writing the main viewer's id into the setting instead would work equally well
* on the wire and would be worse in one specific way an app that had never been told the
* account's Emby user id could not then express "nobody in particular", which is the state
* every existing install starts in.
*/
/**
* How many shadow viewers an account may hold. Mirrors `store.MaxShadowViewers` on the
* gateway, which is the one that enforces it this copy only decides whether the picker
* offers the control, so that a household at the limit is never handed a button whose only
* possible outcome is a refusal.
*/
internal const val MAX_SHADOW_VIEWERS = 7
/** Whether [viewer] is the one this television is currently watching as. */
internal fun viewerIsActive(viewer: MembyViewer, activeViewerId: String): Boolean =
if (activeViewerId.isEmpty()) viewer.isMain else viewer.id == activeViewerId
/**
* Where the picker opens focus.
*
* On whoever is watching, falling back to the first card. A television is switched on by
* the person who last used it far more often than not, so the common case should be one
* confirm press rather than a walk along the row and an id this account no longer
* recognises must still land somewhere real rather than off the end of the list.
*/
internal fun viewerPickerInitialIndex(viewers: List<MembyViewer>, activeViewerId: String): Int {
if (viewers.isEmpty()) return 0
val index = viewers.indexOfFirst { viewerIsActive(it, activeViewerId) }
return if (index >= 0) index else 0
}
/**
* What the launcher calls whoever is watching.
*
* Empty for the account's own viewer, which is the whole point: a household that has never
* added anybody must not have a name badge appear over its launcher explaining a feature it
* is not using. Only a *shadow* viewer is somebody worth naming, because only then is there
* a question of whose evening it is.
*/
internal fun activeViewerLabel(activeViewerId: String, activeViewerName: String): String =
if (activeViewerId.isEmpty()) "" else activeViewerName.trim()
/**
* Whether this television should offer the picker at all.
*
* Two conditions, and both matter. There is no gateway to ask on the direct path, so there
* is exactly one viewer and it is the account. And an account with a single viewer is one
* nobody has added anybody to offering "Who's watching?" there is a question with one
* answer, which reads as a fault rather than as a feature waiting to be used. The entry
* point that *adds* the first viewer therefore lives with the other account management,
* not behind this.
*/
internal fun shouldOfferViewerPicker(gatewayMode: Boolean, viewerCount: Int): Boolean =
gatewayMode && viewerCount > 1
/**
* What the user menu's row is called.
*
* It names the *person* once somebody other than the account is watching, because that is
* the one thing a household needs to be able to check at a glance "am I about to add this
* to Alessandra's Continue Watching or to mine?" — and the menu is where they would look.
* With the account's own viewer selected there is nobody to name, so it asks the question
* instead.
*/
internal fun viewerMenuLabel(activeViewerId: String, activeViewerName: String): String {
val name = activeViewerLabel(activeViewerId, activeViewerName)
return if (name.isEmpty()) "Who's watching?" else "Watching as $name"
}
/**
* Where the row is scrolled so the picker opens with context.
*
* Scrolling straight to the focused card pins it against the left edge, and the people
* before it disappear with nothing on screen saying they are there a household of six
* whose fourth viewer is watching would open on what looks like a list starting at them.
* Leaving one card visible behind the focused one is the cheapest possible answer: it says
* "there is more this way" without a chevron, a fade or anything else to maintain.
*
* It is separate from [viewerPickerInitialIndex] because they answer different questions
* one is where the remote is, the other is what the eye can see and conflating them would
* mean focus landing on the wrong person to make the scroll look right.
*/
internal fun viewerPickerScrollIndex(focusedIndex: Int): Int =
(focusedIndex - 1).coerceAtLeast(0)
@@ -1,7 +1,8 @@
package com.ponzischeme89.memby.ui.requests
import com.ponzischeme89.memby.ui.userSwitcherActionCount
import com.ponzischeme89.memby.ui.UserSwitcherDirection
import com.ponzischeme89.memby.ui.UserSwitcherMenuItem
import com.ponzischeme89.memby.ui.userSwitcherMenuItems
import com.ponzischeme89.memby.ui.userSwitcherNextIndex
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -146,38 +147,49 @@ class RequestPresentationTest {
}
@Test
fun `the switcher's action count matches the rows actually drawn`() {
assertEquals(3, userSwitcherActionCount(showRequests = false))
assertEquals(4, userSwitcherActionCount(showRequests = true))
fun `the switcher's rows are exactly the ones its conditions ask for`() {
assertEquals(3, userSwitcherMenuItems(showRequests = false).size)
assertEquals(4, userSwitcherMenuItems(showRequests = true).size)
assertEquals(4, userSwitcherMenuItems(showRequests = false, showViewers = true).size)
assertEquals(5, userSwitcherMenuItems(showRequests = true, showViewers = true).size)
// Who is watching comes first, because the rows below it belong to whichever
// viewer it selects; Manage users stays last, where a menu's escape hatch belongs.
val full = userSwitcherMenuItems(showRequests = true, showViewers = true)
assertEquals(UserSwitcherMenuItem.VIEWERS, full.first())
assertEquals(UserSwitcherMenuItem.MANAGE_USERS, full.last())
// An optional row is absent rather than present-and-dead, so nothing below it
// shifts under a D-pad that has already started travelling.
assertFalse(UserSwitcherMenuItem.VIEWERS in userSwitcherMenuItems(showRequests = true))
assertFalse(UserSwitcherMenuItem.REQUESTS in userSwitcherMenuItems(showRequests = false))
}
@Test
fun `the D-pad reaches the last switcher row whether or not Requests is shown`() {
// The bug this guards is the one that makes Manage users unreachable: an action
// count that disagrees with the number of rows caps the D-pad one row short.
fun `the D-pad reaches the last switcher row whatever is shown`() {
// The bug this guards is the one that makes Manage users unreachable: a row count
// that disagrees with the rows actually drawn caps the D-pad one row short. It is
// run over every combination because each new optional row is a fresh chance to
// reintroduce it.
val profiles = listOf("p1", "p2")
val withRequests = userSwitcherActionCount(showRequests = true)
var index = 0
repeat(10) {
index = userSwitcherNextIndex(
currentIndex = index,
profileCount = profiles.size,
direction = UserSwitcherDirection.DOWN,
actionCount = withRequests,
)
for (requests in listOf(false, true)) {
for (people in listOf(false, true)) {
val rows = userSwitcherMenuItems(requests, people).size
var index = 0
repeat(12) {
index = userSwitcherNextIndex(
currentIndex = index,
profileCount = profiles.size,
direction = UserSwitcherDirection.DOWN,
actionCount = rows,
)
}
assertEquals(
"requests=$requests viewers=$people",
profiles.size + rows - 1,
index,
)
}
}
assertEquals(profiles.size + withRequests - 1, index)
val withoutRequests = userSwitcherActionCount(showRequests = false)
index = 0
repeat(10) {
index = userSwitcherNextIndex(
currentIndex = index,
profileCount = profiles.size,
direction = UserSwitcherDirection.DOWN,
actionCount = withoutRequests,
)
}
assertEquals(profiles.size + withoutRequests - 1, index)
}
}
@@ -0,0 +1,114 @@
package com.ponzischeme89.memby.ui.viewers
import com.ponzischeme89.memby.data.model.MembyViewer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The rules behind naming a person. They are pure so they can be pinned here, which matters
* because the failures they prevent are all the same shape: a name that reached the gateway
* and came back refused, after somebody had typed it one character at a time with a remote.
*/
class ViewerEditingTest {
private val household = listOf(
MembyViewer(id = "emby-user-1", name = "Matt", kind = MembyViewer.KIND_MAIN),
MembyViewer(id = "v1", name = "Alessandra", kind = MembyViewer.KIND_SHADOW),
)
@Test
fun `a blank name is refused`() {
assertNotNull(viewerNameError("", household, ViewerNameTarget.Add))
assertNotNull(viewerNameError(" ", household, ViewerNameTarget.Add))
assertFalse(viewerNameSubmittable("", household, ViewerNameTarget.Add))
}
/**
* The gateway is perfectly happy to hold two people called Sam. This app is not: 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.
*/
@Test
fun `a name already in the house is refused, whatever its case`() {
assertNotNull(viewerNameError("Alessandra", household, ViewerNameTarget.Add))
assertNotNull(viewerNameError(" alessandra ", household, ViewerNameTarget.Add))
// The account's own name counts too — it is a card in the same row.
assertNotNull(viewerNameError("Matt", household, ViewerNameTarget.Add))
assertNull(viewerNameError("Sam", household, ViewerNameTarget.Add))
}
/**
* Correcting somebody's own capitalisation must not be refused as a duplicate of
* themselves, which is the one case a plain "is this name taken" check gets wrong.
*/
@Test
fun `a rename may keep the name it started with`() {
val target = ViewerNameTarget.Rename(household[1])
assertNull(viewerNameError("Alessandra", household, target))
assertNull(viewerNameError("alessandra", household, target))
// Somebody else's name is still taken.
assertNotNull(viewerNameError("Matt", household, target))
}
/**
* The limit is enforced by refusing the keypress rather than 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.
*/
@Test
fun `typing stops at the limit rather than overrunning it`() {
val full = "x".repeat(MAX_VIEWER_NAME_LENGTH)
assertEquals(full, viewerNameWith(full, "y"))
val nearly = "x".repeat(MAX_VIEWER_NAME_LENGTH - 1)
assertEquals(nearly + "y", viewerNameWith(nearly, "y"))
// A paste-sized addition that would overrun is refused whole rather than truncated:
// half of what somebody dictated is worse than none of it.
assertEquals(nearly, viewerNameWith(nearly, "yz"))
}
/** The screen opens holding the name being changed, and nothing for a new person. */
@Test
fun `the screen opens on the right name`() {
assertEquals("", viewerNameFor(ViewerNameTarget.Add))
assertEquals("Alessandra", viewerNameFor(ViewerNameTarget.Rename(household[1])))
}
/**
* The button names the outcome rather than the screen, because on a television the
* label under the focus ring is commonly the only thing saying what a press will do.
*/
@Test
fun `the confirm button names what it will do`() {
assertEquals("Add viewer", viewerNameAction(ViewerNameTarget.Add))
assertEquals("Save name", viewerNameAction(ViewerNameTarget.Rename(household[1])))
}
/**
* The main viewer's name is the Emby account's and belongs to Emby
* `store.UpdateShadowViewer` refuses to touch it so the manage list must not offer
* a control whose only possible outcome is a refusal.
*/
@Test
fun `the account itself cannot be renamed or removed`() {
assertFalse(viewerIsEditable(household[0]))
assertTrue(viewerIsEditable(household[1]))
}
/**
* Where focus lands after somebody is removed. Emptying a household of guests is a run
* of presses, and being sent back to the top between each one loses the viewer's place
* every time.
*/
@Test
fun `focus follows a removal down the list`() {
assertEquals(1, viewerFocusIndexAfterRemoval(removedIndex = 1, remainingCount = 3))
// The last row went; the one above it takes the focus.
assertEquals(1, viewerFocusIndexAfterRemoval(removedIndex = 2, remainingCount = 2))
// Nothing left to focus is a real answer, and the caller sends focus to Add.
assertNull(viewerFocusIndexAfterRemoval(removedIndex = 0, remainingCount = 0))
}
}
@@ -0,0 +1,168 @@
package com.ponzischeme89.memby.ui.viewers
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.ui.theme.MembyTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* The two screens that let a household run this feature without an admin console, rendered
* to `build/screenshots/viewers/` with no gateway.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*ViewerManagementScreenshotTest"
* ```
*
* These are the captures worth having because the claims they make are not the kind a unit
* test can check. The name screen has to fit a heading, what has been typed, a keyboard and
* two buttons into 540dp with the bottom of the column where overscan bites; the manage list
* has to make "this person is the Emby account and cannot be changed" read as deliberate
* rather than as two buttons that failed to draw; and the removal question has to make the
* safe answer and the destructive one impossible to confuse at three metres.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class ViewerManagementScreenshotTest {
@get:Rule
val compose = createComposeRule()
private val household = listOf(
MembyViewer(id = "emby-user-1", name = "Matt", kind = MembyViewer.KIND_MAIN),
MembyViewer(id = "v1", name = "Alessandra", kind = MembyViewer.KIND_SHADOW),
MembyViewer(id = "v2", name = "Guest", kind = MembyViewer.KIND_SHADOW),
)
/** A new person, nothing typed. The plate has to read as somewhere to type into. */
@Test
fun `adding somebody, before anything is typed`() {
captureName("viewers-name-empty", ViewerNameTarget.Add, name = "")
}
/** Part-way through. The keyboard must not have moved when the plate filled. */
@Test
fun `adding somebody, part-way through the name`() {
captureName("viewers-name-typed", ViewerNameTarget.Add, name = "ALESS")
}
/**
* A name already in the house. The message sits in the line reserved for it, so
* nothing below it moves which is the whole reason the line is reserved.
*/
@Test
fun `a name the household already has`() {
captureName("viewers-name-clash", ViewerNameTarget.Add, name = "GUEST")
}
/** Renaming: the heading names the person, and the plate opens holding their name. */
@Test
fun `renaming somebody`() {
captureName(
"viewers-name-rename",
ViewerNameTarget.Rename(household[1]),
name = "Alessandra",
)
}
/** The gateway refused. What was typed is still there to be corrected. */
@Test
fun `the gateway refused the name`() {
captureName(
"viewers-name-failed",
ViewerNameTarget.Add,
name = "SAM",
failure = "That could not be saved. Try again.",
)
}
@Test
fun `the manage list`() {
compose.setContent {
MembyTheme {
ViewerManageScreen(
viewers = household,
onRename = {},
onRemove = {},
onAdd = {},
onClose = {},
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/viewers/viewers-manage.png")
}
/**
* At the limit, so Add is gone rather than dimmed the stance the picker takes, and
* worth capturing because removing a control rebalances the row beside it.
*/
@Test
fun `the manage list with the household full`() {
val full = household + (3..7).map {
MembyViewer(id = "v$it", name = "Viewer $it", kind = MembyViewer.KIND_SHADOW)
}
compose.setContent {
MembyTheme {
ViewerManageScreen(
viewers = full,
onRename = {},
onRemove = {},
onAdd = {},
onClose = {},
canAddViewer = false,
busyViewerId = "v1",
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/viewers/viewers-manage-full.png")
}
/**
* The full-stop question. Robolectric's window never takes focus and the focus ring is
* the whole of what says which answer a press would take, so the safe answer is drawn
* lit otherwise the capture would prove nothing about the one thing it exists for.
*/
@Test
fun `removing somebody is asked about first`() {
compose.setContent {
MembyTheme {
ViewerRemovalConfirmation(
viewer = household[1],
onCancel = {},
onConfirm = {},
focusedForCapture = true,
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/viewers/viewers-remove-confirm.png")
}
private fun captureName(
file: String,
target: ViewerNameTarget,
name: String,
failure: String? = null,
) {
compose.setContent {
MembyTheme {
ViewerNameEntry(
target = target,
name = name,
existing = household,
onNameChanged = {},
onConfirm = {},
onCancel = {},
failure = failure,
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/viewers/$file.png")
}
}
@@ -0,0 +1,131 @@
package com.ponzischeme89.memby.ui.viewers
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.data.EmbyProfile
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.ui.UserSwitcherOverlay
import com.ponzischeme89.memby.ui.theme.MembyTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Renders "Who's watching?" to PNGs under `build/screenshots/viewers/`, so the picker can be
* looked at without a gateway or a television.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*ViewerPickerScreenshotTest"
* ```
*
* This is the screen that has to make the whole feature legible in one glance that these
* are people rather than accounts, and that exactly one of them is the one Emby hears about.
* A unit test can check that the "Synced with Emby" caption is produced for the main viewer;
* only a capture says whether a row of near-identical circles reads as a choice at three
* metres.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class ViewerPickerScreenshotTest {
@get:Rule
val compose = createComposeRule()
private val household = listOf(
MembyViewer(id = "emby-user-1", name = "Matt", kind = MembyViewer.KIND_MAIN),
MembyViewer(id = "v1", name = "Alessandra", kind = MembyViewer.KIND_SHADOW),
MembyViewer(id = "v2", name = "Guest", kind = MembyViewer.KIND_SHADOW),
MembyViewer(id = "v3", name = "Kids", kind = MembyViewer.KIND_SHADOW),
)
@Test
fun `a household of four`() {
capture("viewers-household", household, activeViewerId = "v1")
}
/**
* The account's own viewer selected, which is the state every install starts in and the
* one a blank id represents. The capture is the check that the first card reads as
* chosen rather than as nothing being chosen at all.
*/
@Test
fun `the account itself is watching`() {
capture("viewers-account-selected", household, activeViewerId = "")
}
/**
* At the limit, so the Add control is gone rather than dimmed. Worth a capture because
* removing a control changes the balance of the row underneath the cards, which is the
* kind of thing that only looks wrong once it is drawn.
*/
@Test
fun `the household is full`() {
val full = household + (4..7).map {
MembyViewer(id = "v$it", name = "Viewer $it", kind = MembyViewer.KIND_SHADOW)
}
capture("viewers-full", full, activeViewerId = "v1", canAdd = false)
}
/**
* Still fetching. "Loading" and "nobody here" are different things to be told, and this
* is the state a television shows for a moment on every cold start.
*/
@Test
fun `nothing has arrived yet`() {
capture("viewers-loading", emptyList(), activeViewerId = "", loading = true)
}
/**
* The way in, so the two screens can be looked at together. The row names whoever is
* watching rather than repeating the question, which is the whole reason it is worth
* having a label that changes.
*/
@Test
fun `the user menu names the viewer`() {
compose.setContent {
MembyTheme {
UserSwitcherOverlay(
profiles = listOf(
EmbyProfile("a", "https://memby.example", "t", "u1", "Matt"),
),
activeProfileId = "a",
onProfileSelected = {},
onManageProfiles = {},
onDismiss = {},
showViewers = true,
activeViewerId = "v1",
activeViewerName = "Alessandra",
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/viewers/viewers-menu-row.png")
}
private fun capture(
name: String,
viewers: List<MembyViewer>,
activeViewerId: String,
canAdd: Boolean = true,
loading: Boolean = false,
) {
compose.setContent {
MembyTheme {
ViewerPicker(
viewers = viewers,
activeViewerId = activeViewerId,
onViewerSelected = {},
onAddViewer = {},
onManageViewers = {},
canAddViewer = canAdd,
loading = loading,
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/viewers/$name.png")
}
}
@@ -0,0 +1,122 @@
package com.ponzischeme89.memby.ui.viewers
import com.ponzischeme89.memby.data.model.MembyViewer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ViewerSelectionTest {
private fun main(id: String = "emby-user-1", name: String = "Matt") =
MembyViewer(id = id, name = name, kind = MembyViewer.KIND_MAIN)
private fun shadow(id: String, name: String) =
MembyViewer(id = id, name = name, kind = MembyViewer.KIND_SHADOW)
/**
* A blank id means the account's own viewer, because that is what the absence of the
* `X-Memby-Viewer` header means to the gateway. Every rule here has to agree about it
* or the picker will show nobody selected on the state every install starts in.
*/
@Test
fun `a blank active id selects the account's own viewer`() {
val people = listOf(main(), shadow("v1", "Alessandra"))
assertTrue(viewerIsActive(people[0], ""))
assertFalse(viewerIsActive(people[1], ""))
assertEquals(0, viewerPickerInitialIndex(people, ""))
}
@Test
fun `a named viewer is the selected one`() {
val people = listOf(main(), shadow("v1", "Alessandra"), shadow("v2", "Guest"))
assertFalse(viewerIsActive(people[0], "v2"))
assertTrue(viewerIsActive(people[2], "v2"))
assertEquals(2, viewerPickerInitialIndex(people, "v2"))
}
/**
* A viewer deleted on another television leaves this one holding an id nothing
* recognises. Focus must still land on a card that exists rather than off the end of
* the row.
*/
@Test
fun `an unknown viewer still lands focus on a real card`() {
val people = listOf(main(), shadow("v1", "Alessandra"))
assertEquals(0, viewerPickerInitialIndex(people, "v-deleted"))
assertEquals(0, viewerPickerInitialIndex(emptyList(), "v1"))
}
/**
* 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.
*/
@Test
fun `the picker is offered only where there is a choice to make`() {
assertFalse(shouldOfferViewerPicker(gatewayMode = false, viewerCount = 4))
assertFalse(shouldOfferViewerPicker(gatewayMode = true, viewerCount = 1))
assertFalse(shouldOfferViewerPicker(gatewayMode = true, viewerCount = 0))
assertTrue(shouldOfferViewerPicker(gatewayMode = true, viewerCount = 2))
}
/**
* The label names the person once somebody other than the account is watching, and asks
* the question otherwise a household running no viewers must not have a name badge
* appear over its launcher explaining a feature it is not using.
*/
@Test
fun `the menu row names whoever is watching`() {
assertEquals("Who's watching?", viewerMenuLabel("", ""))
assertEquals("Who's watching?", viewerMenuLabel("", "Matt"))
assertEquals("Watching as Alessandra", viewerMenuLabel("v1", "Alessandra"))
assertEquals("Watching as Guest", viewerMenuLabel("v2", " Guest "))
}
@Test
fun `the launcher names only a shadow viewer`() {
assertEquals("", activeViewerLabel("", "Matt"))
assertEquals("Alessandra", activeViewerLabel("v1", "Alessandra"))
}
/**
* The avatar is the only thing telling two people apart at three metres, so a viewer
* with no usable name must not produce a blank circle beside a lettered one.
*/
@Test
fun `initials prefer the short name and survive an empty one`() {
assertEquals("M", main(name = "Matt").initials)
assertEquals("A", shadow("v1", "alessandra").initials)
assertEquals(
"L",
MembyViewer(id = "v2", name = "Alessandra", shortName = "Less").initials,
)
assertEquals("", MembyViewer(id = "v3", name = " ").initials)
}
@Test
fun `kind decides whether watching reaches Emby`() {
assertTrue(main().isMain)
assertFalse(shadow("v1", "Alessandra").isMain)
// A viewer from a gateway that sent no kind is not treated as the publishing one:
// the failure of guessing wrong in that direction is somebody else's watch history.
assertFalse(MembyViewer(id = "v4", name = "Unknown").isMain)
}
/**
* Focus and scroll answer different questions: one is where the remote is, the other is
* what the eye can see. Keeping a card behind the focused one is what stops the picker
* opening on what looks like a list starting at whoever happens to be watching.
*/
@Test
fun `the row keeps one card of context behind the focused one`() {
assertEquals(0, viewerPickerScrollIndex(0))
assertEquals(0, viewerPickerScrollIndex(1))
assertEquals(2, viewerPickerScrollIndex(3))
// Never negative, whatever it is handed.
assertEquals(0, viewerPickerScrollIndex(-4))
}
}
+4
View File
@@ -44,6 +44,10 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("POST /admin/api/accounts/{userID}/preferences/revisions/{revision}/restore",
s.adminAuth(s.handleAdminRestorePreferences))
mux.Handle("DELETE /admin/api/accounts/{userID}/recommendations", s.adminAuth(s.handleAdminResetRecommendations))
mux.Handle("GET /admin/api/accounts/{userID}/viewers", s.adminAuth(s.handleAdminViewers))
mux.Handle("POST /admin/api/accounts/{userID}/viewers", s.adminAuth(s.handleAdminViewers))
mux.Handle("PUT /admin/api/accounts/{userID}/viewers/{viewerID}", s.adminAuth(s.handleAdminViewer))
mux.Handle("DELETE /admin/api/accounts/{userID}/viewers/{viewerID}", s.adminAuth(s.handleAdminViewer))
mux.Handle("PUT /admin/api/accounts/{userID}/recommendations/prompt", s.adminAuth(s.handleAdminPromptRecommendations))
mux.Handle("PUT /admin/api/accounts/{userID}/themes", s.adminAuth(s.handleAdminUserThemes))
mux.Handle("GET /admin/api/recommendations", s.adminAuth(s.handleAdminRecommendations))
+137
View File
@@ -0,0 +1,137 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The people under one account, from the operator's side.
//
// It lives on the **account page** rather than on a rail entry of its own, which is the
// whole of the design decision: a viewer only exists under an account, and a top-level
// page would have to begin by asking which account is being talked about — a question the
// page an operator reached this from has already answered. It is the arrangement the
// per-account preference editor and the device list already take.
//
// A television can now do all of this for itself, so this is the operator's copy rather
// than the only way in: what it is for is a household that has asked for help over the
// phone, and the case a remote genuinely cannot reach — a viewer created on a set that has
// since been unplugged.
type adminViewersResponse struct {
Viewers []store.Viewer `json:"viewers"`
// Whether the household's own switch is on. The page says so rather than quietly
// offering controls whose effect nothing on any television would show: an operator who
// has switched viewers off and then adds one has done something that looks like it
// worked and did nothing.
Enabled bool `json:"enabled"`
// What the gateway will accept, so the console can stop offering Add at the same point
// the television does rather than discovering the limit by being refused.
MaxShadowViewers int `json:"maxShadowViewers"`
}
func (s *Server) handleAdminViewers(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("userID"))
if userID == "" {
writeError(w, http.StatusBadRequest, "user is required")
return
}
if r.Method == http.MethodPost {
s.handleAdminCreateViewer(w, r, userID)
return
}
// The username is only used to name a main viewer that does not exist yet, and an
// operator is not the right person to be naming somebody — an account that has never
// had a request made against it gets the placeholder, and the television replaces it
// with the real Emby name on its first sign-in.
viewers, err := s.store.Viewers(r.Context(), userID, "")
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load viewers")
return
}
writeJSON(w, http.StatusOK, adminViewersResponse{
Viewers: viewers,
Enabled: s.viewersEnabled(r.Context()),
MaxShadowViewers: store.MaxShadowViewers,
})
}
func (s *Server) handleAdminCreateViewer(w http.ResponseWriter, r *http.Request, userID string) {
var req viewerRequest
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req) != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if strings.TrimSpace(req.Name) == "" || len([]rune(req.Name)) > 40 {
writeError(w, http.StatusBadRequest, "a name of up to 40 characters is required")
return
}
viewer, err := s.store.CreateShadowViewer(r.Context(), userID, req.Name, req.ShortName, req.Colour)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// The televisions hold a cached list for viewerListTTL, so the write clears it here for
// the same reason it does on the client-facing route: a person added from the console
// must be pickable on the next request rather than at the end of the window.
s.forgetViewers(userID)
s.loggerFor(r.Context()).Info("viewer added by operator",
"account", userID, "viewer", viewer.ID, "name", viewer.Name)
writeJSON(w, http.StatusOK, viewer)
}
func (s *Server) handleAdminViewer(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("userID"))
viewerID := strings.TrimSpace(r.PathValue("viewerID"))
if userID == "" || viewerID == "" {
writeError(w, http.StatusBadRequest, "user and viewer are required")
return
}
if r.Method == http.MethodDelete {
if err := s.store.DeleteShadowViewer(r.Context(), userID, viewerID); err != nil {
if errors.Is(err, store.ErrViewerNotFound) {
writeError(w, http.StatusNotFound, "no such viewer")
return
}
writeError(w, http.StatusInternalServerError, "could not remove that viewer")
return
}
s.forgetViewers(userID)
// Everything cached under this viewer's own key is now about nobody.
if err := s.cache.InvalidateUser(r.Context(), viewerID); err != nil {
s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err)
}
s.loggerFor(r.Context()).Info("viewer removed by operator",
"account", userID, "viewer", viewerID)
w.WriteHeader(http.StatusNoContent)
return
}
var req viewerRequest
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req) != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
viewer, err := s.store.UpdateShadowViewer(
r.Context(), userID, viewerID, req.Name, req.ShortName, req.Colour,
)
if err != nil {
if errors.Is(err, store.ErrViewerNotFound) {
// The main viewer lands here too, and that is the honest answer: its name is
// the Emby account's, so as a *shadow* viewer to rename it does not exist.
writeError(w, http.StatusNotFound, "no such viewer")
return
}
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.forgetViewers(userID)
s.loggerFor(r.Context()).Info("viewer renamed by operator",
"account", userID, "viewer", viewer.ID, "name", viewer.Name)
writeJSON(w, http.StatusOK, viewer)
}
+1 -1
View File
@@ -177,7 +177,7 @@ func (s *Server) handleRowAnalytics(w http.ResponseWriter, r *http.Request, sess
}
for _, event := range events {
if event.Event == store.RowEventSelect {
_ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID)
_ = s.cache.InvalidateUser(r.Context(), viewerKeyOf(r.Context(), sess))
if s.forYou != nil {
s.forYou.MarkDirty(context.WithoutCancel(r.Context()), sess)
}
+16 -1
View File
@@ -91,6 +91,8 @@ type Server struct {
openSubtitles *opensubtitles.Client
openSubtitlesKey string
mdblistMu sync.Mutex
// viewerLists spares every authenticated request a read of the account's people.
viewerLists viewerListCache
// mdblistSettingsCache spares every row and keystroke a settings read.
mdblistSettingsCache mdblistSettingsCache
// ratingsWarm fills and renews the durable rating cache behind the viewer, so a row
@@ -267,6 +269,13 @@ func (s *Server) Routes() http.Handler {
v1.Handle("PUT /v1/auth/devices/{deviceID}", s.authed(s.handleRenameDevice))
v1.Handle("DELETE /v1/auth/devices/{deviceID}", s.authed(s.handleDeleteDevice))
// Viewers: the people under one account. The list is what the picker draws; every
// other route learns who is watching from the X-Memby-Viewer header instead.
v1.Handle("GET /v1/viewers", s.authed(s.handleViewers))
v1.Handle("POST /v1/viewers", s.authed(s.handleViewers))
v1.Handle("PUT /v1/viewers/{viewerID}", s.authed(s.handleViewer))
v1.Handle("DELETE /v1/viewers/{viewerID}", s.authed(s.handleViewer))
v1.Handle("GET /v1/home", s.authed(s.handleHome))
v1.Handle("GET /v1/heroes/active", s.authed(s.handleActiveHero))
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
@@ -435,6 +444,12 @@ func (s *Server) authed(h authedFunc) http.Handler {
}
sess = s.captureClientIdentity(r, sess)
identify(r.Context(), sess)
// Resolved once, here, and read out of the context by everything downstream. The
// header short-circuits when it is absent, so a household running no viewers pays
// a map lookup and nothing else.
viewer := s.activeViewer(r.Context(), sess, r)
identifyViewer(r.Context(), viewer)
r = r.WithContext(withViewer(r.Context(), viewer))
policy := s.updatePolicy.get()
decision := appupdate.Decide(effectiveUpdatePolicy(policy), clientVersion(r))
retireBelow := destructiveUpdateFloor(policy)
@@ -446,7 +461,7 @@ func (s *Server) authed(h authedFunc) http.Handler {
s.loggerFor(r.Context()).Error("required-update session delete failed", "error", err)
}
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(sess.TokenHash)))
_ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID)
s.invalidateAccountViews(r.Context(), sess)
s.loggerFor(r.Context()).Info("signed out for required update",
"device_id", sess.DeviceID,
"from", clientLogValue(clientVersion(r)),
+1 -1
View File
@@ -232,7 +232,7 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, sess store
s.log.Error("session delete failed", "error", err)
}
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(sess.TokenHash)))
_ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID)
s.invalidateAccountViews(r.Context(), sess)
s.loggerFor(r.Context()).Info("signed out", "device_id", sess.DeviceID)
s.publishAdmin(r.Context(), adminevents.Event{
Type: adminevents.TypeLogout,
+20
View File
@@ -27,6 +27,7 @@ const (
featureGenreBrowser = "genre_browser"
featureTVCalendar = "tv_calendar"
featureWatchTimeDigest = "watch_time_digest"
featureViewers = "viewers"
)
type featureDefinition struct {
@@ -146,6 +147,25 @@ var featureCatalogue = []featureDefinition{
DefaultEnabled: true, MinimumProtocol: 1,
Recovery: "Server-enforced; takes effect before the next summary is due.",
},
{
// 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 that
// arrives already on is one every server running this build starts using before
// anybody has decided to — so it is opted into rather than out of.
//
// Switching it on or off never deletes a viewer or their history: the rows stay
// in Postgres and come back intact. Off, the gateway routes nobody's state
// anywhere but Emby, which is the state a household was in before the feature
// existed; on, a shadow viewer's watching goes to Memby and is picked up exactly
// where they left it.
Key: featureViewers, Name: "Viewers", Area: "Accounts",
Description: "Let one Emby account hold several people, each with their own " +
"Continue Watching, watched history and favourites. Off by default; turning " +
"it off again returns every television to watching as the account itself, " +
"without losing what anybody has watched.",
DefaultEnabled: false, MinimumProtocol: 1, Capability: "viewers_v1",
Recovery: "Takes effect on the next request; nothing a viewer has watched is lost.",
},
{
Key: featureInstallPermission, Name: "Ask TVs for install permission", Area: "Setup",
Description: "Ask a signed-in TV that cannot install its own updates to grant the " +
+2 -2
View File
@@ -76,7 +76,7 @@ func (s *Server) handleBrowseItems(
if genre != "" {
filterKey = "genre:" + genre
}
key := cache.UserKey(sess.EmbyUserID, "browse:"+itemType+":"+filterKey+":"+itoa(offset)+":"+itoa(limit))
key := cache.UserKey(viewerKeyOf(ctx, sess), "browse:"+itemType+":"+filterKey+":"+itoa(offset)+":"+itoa(limit))
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
@@ -111,7 +111,7 @@ func (s *Server) handleBrowseItems(
return
}
items := nonNil(result.Items)
s.decorateItemRatings(ctx, items)
s.decorateItems(ctx, items)
total := genreTotal(result.TotalRecordCount, offset, len(items), limit)
+1 -1
View File
@@ -1109,7 +1109,7 @@ func (s *Server) heroPremiereCandidates(ctx context.Context, now time.Time) []he
// The imported catalogue is shared by the household and deliberately carries no user
// data, so these cards arrive without ratings. Decorating them is one indexed read
// and is what lets a premiere be ranked on the same terms as a film.
s.decorateItemRatings(ctx, payloads)
s.decorateItems(ctx, payloads)
byID := make(map[string]json.RawMessage, len(payloads))
for _, raw := range payloads {
+2 -2
View File
@@ -34,7 +34,7 @@ func (s *Server) handleActiveHero(w http.ResponseWriter, r *http.Request, sess s
// slot. That is what makes an operator's change reachable immediately without dropping
// anything else the household has cached: the old entry is not invalidated, it is
// simply no longer named. See heroRevision.
key := cache.UserKey(sess.EmbyUserID, "hero:active:v2:"+placement+":"+
key := cache.UserKey(viewerKeyOf(r.Context(), sess), "hero:active:v2:"+placement+":"+
heroRevision(s.currentHeroPolicy(r.Context()), sess.EmbyUserID, now, location))
if raw, err := s.cache.Get(r.Context(), key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
@@ -74,7 +74,7 @@ func (s *Server) resolveActiveHero(ctx context.Context, sess store.Session, plac
if err != nil {
return activeHeroResponse{}, err
}
s.decorateItemRatings(ctx, result.Items)
s.decorateItems(ctx, result.Items)
rows := []recommend.Row{{ID: "hero-candidates-" + placement, Kind: "catalogue", Items: result.Items}}
policy := s.currentHeroPolicy(ctx)
+31 -4
View File
@@ -83,7 +83,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
// four cards. See heroRevision.
heroRev := heroRevision(s.currentHeroPolicy(ctx), sess.EmbyUserID, now, s.heroLocation())
key := cache.UserKey(
sess.EmbyUserID,
viewerKeyOf(ctx, sess),
"home:v4:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
":r"+strconv.FormatBool(radarrSchedule)+":h"+strconv.FormatBool(hero)+
":hr"+heroRev+":d"+sess.DeviceID,
@@ -135,7 +135,19 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
}()
}
// The three rows that are answers about a *person* rather than about the library.
// For the account's own viewer they are Emby's, exactly as they always were; for a
// shadow viewer they are built from that viewer's own state, and Emby is asked only to
// describe the titles. The fan-out, the failure counting and the merge below are
// unchanged either way — this is a substitution of one fetch for another, not a second
// code path through the launcher.
viewer := viewerOf(ctx, sess)
shadow := !viewer.IsMain() && s.store != nil
run("resume", &out.ContinueWatching, func(ctx context.Context) (*emby.ItemsResult, error) {
if shadow {
return s.viewerContinueRow(ctx, cred, viewer.ID, limit)
}
return s.emby.ResumeItems(ctx, cred, rowParams(url.Values{
"Recursive": {"true"},
"MediaTypes": {"Video"},
@@ -143,6 +155,9 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
}, fieldsContinue))
})
run("favourites", &out.Favorites, func(ctx context.Context) (*emby.ItemsResult, error) {
if shadow {
return s.viewerFavouritesRow(ctx, cred, viewer.ID, limit)
}
return s.emby.Items(ctx, cred, rowParams(url.Values{
"Filters": {"IsFavorite"},
"IncludeItemTypes": {"Movie,Series"},
@@ -153,6 +168,9 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
}, fieldsRow))
})
run("nextup", &out.NextUp, func(ctx context.Context) (*emby.ItemsResult, error) {
if shadow {
return s.viewerNextUpRow(ctx, cred, viewer.ID, limit)
}
return s.emby.NextUp(ctx, cred, rowParams(url.Values{
"Limit": {itoa(limit)},
}, fieldsContinue))
@@ -163,7 +181,16 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
wg.Add(1)
go func() {
defer wg.Done()
played, err := s.recentlyPlayedSeries(timing.WithLabel(ctx, "emby.recent"), cred)
// What orders the two halves of the merge. For a shadow viewer it is one grouped
// query over their own state rather than a lookback over the account's plays —
// the same question, asked of the system that holds the answer.
var played map[string]time.Time
var err error
if shadow {
played, err = s.store.ViewerWatchedSeries(ctx, viewer.ID, continuePlayLookback)
} else {
played, err = s.recentlyPlayedSeries(timing.WithLabel(ctx, "emby.recent"), cred)
}
if err != nil {
s.loggerFor(ctx).Warn("recently played lookup failed", "error", err)
return
@@ -578,7 +605,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store
return
}
limit := queryInt(r, "limit", 40, 100)
key := cache.UserKey(sess.EmbyUserID, "search:"+itoa(limit)+":"+term+":d"+sess.DeviceID)
key := cache.UserKey(viewerKeyOf(ctx, sess), "search:"+itoa(limit)+":"+term+":d"+sess.DeviceID)
// Every search the tab performs is recorded here, before the cache is consulted, so a
// query answered from Redis counts the same as one that reached Emby. The client also
@@ -606,7 +633,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store
return
}
items := s.personalizeSearch(ctx, sess, term, result.Items, limit)
s.decorateItemRatings(ctx, items)
s.decorateItems(ctx, items)
// Instant search fires a request per keystroke past the second character, so this is
// DEBUG: it is the record of what somebody was looking for when nothing was found,
// not something to carry in the normal log.
+66 -12
View File
@@ -48,7 +48,7 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S
writeError(w, http.StatusBadRequest, "item id is required")
return
}
if raw, err := s.cache.Get(ctx, itemDetailKey(sess.EmbyUserID, itemID)); err == nil {
if raw, err := s.cache.Get(ctx, itemDetailKey(viewerKeyOf(ctx, sess), itemID)); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
@@ -88,7 +88,7 @@ func (s *Server) detailItem(
// than after a second request. Anything not yet stored still arrives on
// /ratings.
decorated := []json.RawMessage{raw}
s.decorateItemRatings(ctx, decorated)
s.decorateItems(ctx, decorated)
return decorated[0], nil
})
return item, err
@@ -255,7 +255,7 @@ func (s *Server) handleSeriesEpisodes(w http.ResponseWriter, r *http.Request, se
// Watching card is focused, and the page asks again when somebody presses. A
// long-running show is a thousand records, so two of them is a real cost on the one
// press that must feel free.
key := cache.UserKey(sess.EmbyUserID, "series-episodes:"+seriesID)
key := cache.UserKey(viewerKeyOf(ctx, sess), "series-episodes:"+seriesID)
body, hit, err := s.cachedRead(ctx, key, s.cfg.ItemTTL,
func(ctx context.Context) (json.RawMessage, error) {
result, err := s.emby.Episodes(
@@ -278,6 +278,11 @@ func (s *Server) handleSeriesEpisodes(w http.ResponseWriter, r *http.Request, se
if items == nil {
items = []json.RawMessage{}
}
// The ticks down the episode list are the clearest statement this app makes
// about what somebody has seen, so they are the last place the account's
// answer may be left standing. The cache key is the viewer's, so this is
// stored per person rather than decorated on the way out.
s.decorateViewerState(ctx, items)
return json.Marshal(seriesEpisodesResponse{Items: items})
})
if err != nil {
@@ -307,33 +312,78 @@ func (s *Server) handleTrailer(w http.ResponseWriter, r *http.Request, sess stor
writeRaw(w, http.StatusOK, result.Items[0])
}
// The four routes below are the whole of what Memby writes back to Emby about a person:
// a favourite, a watched flag, a hidden resume item and a playback report. Each one now
// asks who is watching first, because that is the entire promise of a shadow viewer — the
// Emby account lends them the library and never learns what they did with it.
func (s *Server) handleFavorite(w http.ResponseWriter, r *http.Request, sess store.Session) {
s.setFlag(w, r, sess, func(itemID string, value bool) (json.RawMessage, error) {
s.setFlag(w, r, sess, func(viewer store.Viewer, itemID string, value bool) (json.RawMessage, error) {
if !viewer.IsMain() {
return s.setShadowFlag(r.Context(), viewer, itemID, func() error {
return s.store.SetViewerFavourite(r.Context(), viewer.ID, itemID, value)
})
}
return s.emby.SetFavorite(r.Context(), credentials(sess), itemID, value)
})
}
func (s *Server) handlePlayed(w http.ResponseWriter, r *http.Request, sess store.Session) {
s.setFlag(w, r, sess, func(itemID string, value bool) (json.RawMessage, error) {
s.setFlag(w, r, sess, func(viewer store.Viewer, itemID string, value bool) (json.RawMessage, error) {
if !viewer.IsMain() {
return s.setShadowFlag(r.Context(), viewer, itemID, func() error {
return s.store.SetViewerPlayed(r.Context(), viewer.ID, itemID, value)
})
}
return s.emby.SetPlayed(r.Context(), credentials(sess), itemID, value)
})
}
// setShadowFlag applies a Memby-side mutation and answers in the shape Emby would have.
//
// Re-reading the row rather than describing the write is deliberate: the response is what
// the television draws the card from, and a favourite pressed on a title that is also part
// way through has to come back carrying the position as well as the heart.
func (s *Server) setShadowFlag(
ctx context.Context, viewer store.Viewer, itemID string, apply func() error,
) (json.RawMessage, error) {
if err := apply(); err != nil {
return nil, err
}
state, err := s.store.ViewerStateFor(ctx, viewer.ID, itemID)
if err != nil {
return nil, err
}
return viewerUserData(state), nil
}
func (s *Server) handleHideFromResume(w http.ResponseWriter, r *http.Request, sess store.Session) {
itemID := r.PathValue("id")
if itemID == "" {
writeError(w, http.StatusBadRequest, "item id is required")
return
}
userData, err := s.emby.HideFromResume(r.Context(), credentials(sess), itemID)
viewer := s.activeViewer(r.Context(), sess, r)
var userData json.RawMessage
var err error
if viewer.IsMain() {
userData, err = s.emby.HideFromResume(r.Context(), credentials(sess), itemID)
} else {
userData, err = s.setShadowFlag(r.Context(), viewer, itemID, func() error {
return s.store.HideViewerFromResume(r.Context(), viewer.ID, itemID)
})
}
if err != nil {
s.writeUpstreamError(r.Context(), w, err, "could not remove the item from Continue Watching")
return
}
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
if err := s.cache.InvalidateUser(r.Context(), viewer.ID); err != nil {
s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err)
}
if s.forYou != nil {
// For You is built from the Emby account's own history, so a shadow viewer's press
// says nothing about it. Marking it dirty would rebuild the main viewer's row out of
// somebody else's choice.
if s.forYou != nil && viewer.IsMain() {
s.forYou.MarkDirty(r.Context(), sess)
}
writeRaw(w, http.StatusOK, userData)
@@ -345,8 +395,9 @@ func (s *Server) setFlag(
w http.ResponseWriter,
r *http.Request,
sess store.Session,
apply func(itemID string, value bool) (json.RawMessage, error),
apply func(viewer store.Viewer, itemID string, value bool) (json.RawMessage, error),
) {
viewer := s.activeViewer(r.Context(), sess, r)
itemID := r.PathValue("id")
if itemID == "" {
writeError(w, http.StatusBadRequest, "item id is required")
@@ -358,15 +409,18 @@ func (s *Server) setFlag(
return
}
userData, err := apply(itemID, req.Value)
userData, err := apply(viewer, itemID, req.Value)
if err != nil {
s.writeUpstreamError(r.Context(), w, err, "could not update the item")
return
}
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
if err := s.cache.InvalidateUser(r.Context(), viewer.ID); err != nil {
s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err)
}
if s.forYou != nil {
// For You is built from the Emby account's own history, so a shadow viewer's press
// says nothing about it. Marking it dirty would rebuild the main viewer's row out of
// somebody else's choice.
if s.forYou != nil && viewer.IsMain() {
s.forYou.MarkDirty(r.Context(), sess)
}
writeRaw(w, http.StatusOK, userData)
+19
View File
@@ -24,6 +24,7 @@ type requestIdentity struct {
component string
userID string
user string
viewer string
device string
client string
protocol string
@@ -73,6 +74,21 @@ func identify(ctx context.Context, sess store.Session) {
}
}
// identifyViewer names the person watching, where that is somebody other than the account
// itself. A main viewer is deliberately not recorded: its name is already the "user" field,
// and printing it twice on every line would say nothing.
func identifyViewer(ctx context.Context, viewer store.Viewer) {
identity := identityFrom(ctx)
if identity == nil || viewer.IsMain() {
return
}
if viewer.Name != "" {
identity.viewer = viewer.Name
} else {
identity.viewer = viewer.ID
}
}
func (i *requestIdentity) attrs() []any {
if i == nil {
return nil
@@ -124,6 +140,9 @@ func (i *requestIdentity) viewerAttrs() []any {
if i.user != "" {
attrs = append(attrs, "user", i.user)
}
if i.viewer != "" {
attrs = append(attrs, "viewer", i.viewer)
}
if i.device != "" {
attrs = append(attrs, "device", i.device)
}
+1 -1
View File
@@ -56,7 +56,7 @@ func (s *Server) handlePersonFilmography(w http.ResponseWriter, r *http.Request,
writeError(w, http.StatusBadRequest, "person id is required")
return
}
key := cache.UserKey(sess.EmbyUserID, "person-filmography:v1:"+personID)
key := cache.UserKey(viewerKeyOf(ctx, sess), "person-filmography:v1:"+personID)
body, hit, err := s.cachedRead(ctx, key, s.cfg.ItemTTL,
func(ctx context.Context) (json.RawMessage, error) {
result, err := s.emby.Items(ctx, credentials(sess), url.Values{
+122 -16
View File
@@ -129,6 +129,7 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
return
}
cred := credentials(sess)
viewer := viewerOf(ctx, sess)
item, hinted := playbackHint(r, itemID)
if !hinted {
@@ -148,7 +149,7 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
title := item.Name
if strings.EqualFold(item.Type, "Series") {
episode, err := s.firstPlayableEpisode(ctx, cred, item.ID)
episode, err := s.firstPlayableEpisode(ctx, cred, viewer, item.ID)
if err != nil {
s.writeUpstreamError(ctx, w, err, "could not find an episode to play")
return
@@ -163,6 +164,26 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
}
}
// Where a shadow viewer resumes from is Memby's answer, and it is taken here rather
// than trusted from the card.
//
// The hint the television sends is read off a card this gateway already decorated with
// this 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. This is also the value handed to
// PlaybackInfo below, so taking it here fixes the negotiated stream as well as the
// number sent back.
if !viewer.IsMain() && s.store != nil {
if state, err := s.store.ViewerStateFor(ctx, viewer.ID, target.ID); err == nil {
target.UserData.PlaybackPositionTicks = state.PositionTicks
} else {
// Starting from the beginning is a recoverable disappointment; starting from
// where somebody else got to is not.
s.loggerFor(ctx).Warn("viewer resume position unavailable", "error", err)
target.UserData.PlaybackPositionTicks = 0
}
}
var subtitleIndex *int
if raw := strings.TrimSpace(r.URL.Query().Get("subtitleIndex")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 0 {
@@ -265,7 +286,26 @@ func playbackHint(r *http.Request, itemID string) (emby.Summary, bool) {
}
// firstPlayableEpisode prefers the server's next-up choice and falls back to episode one.
func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials, seriesID string) (*emby.Summary, error) {
// The viewer is threaded in because "where does this series start" is a question about a
// person, and Emby's NextUp answers it for the account. A shadow viewer's answer is their
// own: the first episode they have not finished.
func (s *Server) firstPlayableEpisode(
ctx context.Context, cred emby.Credentials, viewer store.Viewer, seriesID string,
) (*emby.Summary, error) {
if !viewer.IsMain() && s.store != nil {
episode, err := s.firstUnwatchedEpisodeFor(ctx, cred, viewer, seriesID)
if err != nil {
// Falling through to Emby's answer is wrong for this viewer, so it is not
// done: starting somebody at the account's next episode is the leak this
// feature exists to prevent.
return nil, err
}
if episode != nil {
return episode, nil
}
// Nothing recorded for this series yet: fall through and let Emby name its first
// episode, which is the right answer for somebody who has never watched any of it.
}
nextUp, err := s.emby.NextUp(ctx, cred, url.Values{
"SeriesId": {seriesID},
"Limit": {"1"},
@@ -376,6 +416,20 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
title = series + " " + title
}
// Which episode follows is a property of the season and is the same for everybody;
// how far into it *this* viewer already is, is not. The item payload is rewritten as
// well as the summary, because the television draws the next-up banner from it.
viewer := viewerOf(ctx, sess)
if !viewer.IsMain() && s.store != nil {
state, stateErr := s.store.ViewerStateFor(ctx, viewer.ID, next.ID)
if stateErr != nil {
s.loggerFor(ctx).Warn("viewer next-episode position unavailable", "error", stateErr)
state = store.ViewerState{}
}
next.UserData.PlaybackPositionTicks = state.PositionTicks
raw = injectItemUserData(raw, viewerUserData(state))
}
// The metadata-only shape. Everything below this point is a PlaybackInfo negotiation,
// and a client that said it does not want one yet must not be given one anyway.
if !nextEpisodeWantsStream(r) {
@@ -798,6 +852,41 @@ func seriesNameOf(raw json.RawMessage) string {
return parsed.SeriesName
}
// recordShadowPlayback is the other side of the playback report: the same three phases,
// written to Memby instead of to Emby.
//
// Where a title is *finished* is decided here rather than by the television, for the
// reason the gateway decides which subtitle comes on: Emby applies its own completion
// threshold on the main viewer's behalf, and a shadow viewer must be judged by the same
// rule or one household would disagree with itself about whether an episode is watched
// depending on who watched it.
//
// A paused progress report still records the position. Pausing is where somebody leaves a
// film, and the ten seconds between reports is exactly the window a set switched off at
// the wall would otherwise lose.
func (s *Server) recordShadowPlayback(
ctx context.Context, viewer store.Viewer, phase string, report playbackReport,
) error {
if s.store == nil {
return fmt.Errorf("no store for viewer playback")
}
// The pool's own tracer times this; nothing extra is recorded here.
position := max64(report.PositionMs, 0) * ticksPerMillisecond
runtime := max64(report.DurationMs, 0) * ticksPerMillisecond
state := store.ViewerState{
ItemID: report.ItemID,
PositionTicks: position,
RuntimeTicks: runtime,
}
// 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.
if phase == "stopped" {
state.Played = store.PlayedFromPosition(position, runtime)
}
return s.store.RecordViewerPlayback(ctx, viewer.ID, state)
}
// handlePlaybackReport forwards progress to Emby. Stopping invalidates the user's cache
// so Continue Watching reflects the new position on the next home load.
func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, sess store.Session) {
@@ -826,12 +915,24 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
"play_session_id", clientLogValue(report.PlaySessionID),
)
err := s.emby.ReportPlayback(
timing.WithLabel(r.Context(), "emby.report"),
credentials(sess), phase, report.ItemID, report.MediaSourceID,
report.PlaySessionID, report.PlayMethod, report.EventName,
max64(report.PositionMs, 0)*ticksPerMillisecond, report.IsPaused,
)
// Who is watching decides where this goes, and it is the only place that decision is
// made for progress. A shadow viewer's evening is Memby's: nothing below reaches
// /Sessions/Playing, so the Emby account lending them the library never learns what
// they watched or how far they got.
viewer := s.activeViewer(r.Context(), sess, r)
log = log.With("viewer", viewer.ID)
var err error
if viewer.IsMain() {
err = s.emby.ReportPlayback(
timing.WithLabel(r.Context(), "emby.report"),
credentials(sess), phase, report.ItemID, report.MediaSourceID,
report.PlaySessionID, report.PlayMethod, report.EventName,
max64(report.PositionMs, 0)*ticksPerMillisecond, report.IsPaused,
)
} else {
err = s.recordShadowPlayback(r.Context(), viewer, phase, report)
}
if err != nil {
log.Warn("playback report failed", "phase", phase, "error", err)
// Progress is advisory and another reading follows in ten seconds. A final stop is
@@ -876,7 +977,7 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
if phase == "stopped" {
invalidate := timing.Start(r.Context(), "invalidate")
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
if err := s.cache.InvalidateUser(r.Context(), viewer.ID); err != nil {
s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err)
}
invalidate()
@@ -891,10 +992,10 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
// episode stops here rather than at the durable insert four upstream calls later;
// the feature check is a cached read; and only then is anything asked of Emby.
if shouldAutoFollowShow(phase, report.PositionMs, report.DurationMs) &&
s.followChecks.claim(sess.EmbyUserID, report.ItemID) &&
s.followChecks.claim(viewer.ID, report.ItemID) &&
s.featureEnabled(r.Context(), featureAutomaticMyShows) {
follow := timing.Start(r.Context(), "autofollow")
response.AutoFollowedShowTitle = s.autoFollowContinuingShow(r.Context(), sess, report.ItemID)
response.AutoFollowedShowTitle = s.autoFollowContinuingShow(r.Context(), sess, viewer, report.ItemID)
follow()
}
writeJSON(w, http.StatusOK, response)
@@ -907,7 +1008,12 @@ func shouldAutoFollowShow(phase string, positionMs, durationMs int64) bool {
return phase != "started" && durationMs > 0 && positionMs >= (durationMs+1)/2
}
func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Session, episodeID string) string {
// The Emby credential reads the catalogue; the viewer owns the list it is written to.
// Following a show is a Memby preference and belongs to the person, so a shadow viewer
// finishing an episode fills their own My Shows rather than the account's.
func (s *Server) autoFollowContinuingShow(
ctx context.Context, sess store.Session, viewer store.Viewer, episodeID string,
) string {
if s.sonarr == nil || s.store == nil {
return ""
}
@@ -949,7 +1055,7 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio
ItemID: episode.SeriesID, Title: seriesItem.Name, Year: seriesItem.ProductionYear,
ImageTag: seriesItem.ImageTags["Primary"],
}
inserted, err := s.store.SaveUserShowIfAbsent(ctx, sess.EmbyUserID, show)
inserted, err := s.store.SaveUserShowIfAbsent(ctx, viewer.ID, show)
if err != nil {
s.loggerFor(ctx).Warn("auto-follow save failed", "error", err)
return ""
@@ -957,7 +1063,7 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio
if !inserted {
return ""
}
prefs, err := s.store.NotificationPreferences(ctx, sess.EmbyUserID)
prefs, err := s.store.NotificationPreferences(ctx, viewer.ID)
if err != nil {
s.loggerFor(ctx).Warn("auto-follow notification preferences unavailable", "error", err)
return ""
@@ -965,8 +1071,8 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio
notification := notify.Notification{
Kind: "auto-follow",
Source: notifySourceAutoFollow,
UserID: sess.EmbyUserID,
Username: sess.Username,
UserID: viewer.ID,
Username: viewer.Name,
Title: "Added to My Shows",
Body: seriesItem.Name + " was added because you started watching it and it is still continuing.",
ItemID: episode.SeriesID,
+2 -2
View File
@@ -586,7 +586,7 @@ func (s *Server) handleRecommendationAction(
writeError(w, http.StatusBadRequest, err.Error())
return
}
_ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID)
_ = s.cache.InvalidateUser(r.Context(), viewerKeyOf(r.Context(), sess))
if s.forYou != nil {
s.forYou.MarkDirty(context.WithoutCancel(r.Context()), sess)
s.forYou.RefreshAsync(sess, false)
@@ -640,7 +640,7 @@ func (s *Server) handleRecommendationPreferences(
writeError(w, http.StatusInternalServerError, "could not save onboarding preferences")
return
}
_ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID)
_ = s.cache.InvalidateUser(r.Context(), viewerKeyOf(r.Context(), sess))
if s.forYou != nil {
s.forYou.MarkDirty(context.WithoutCancel(r.Context()), sess)
s.forYou.RefreshAsync(sess, false)
+12 -1
View File
@@ -231,7 +231,18 @@ func (s *Server) decorateHomeRatings(ctx context.Context, out *homeResponse) {
}
collections = append(collections,
out.ContinueWatching, out.NextUp, out.Favorites, out.LatestMovies)
s.decorateItems(ctx, collections...)
}
// decorateItems is the one door items leave the gateway through.
//
// It attaches both of the things Memby knows about a title that Emby's payload does not
// carry: the stored review scores, and — for a shadow viewer — whose progress this is. The
// two are separate concerns and stayed separate functions, but every call site wanted both,
// and a decoration added at seven sites is a decoration missing from the eighth.
func (s *Server) decorateItems(ctx context.Context, collections ...[]json.RawMessage) {
s.decorateItemRatings(ctx, collections...)
s.decorateViewerState(ctx, collections...)
}
// decorateItemRatings rewrites each item in place with whatever the database already
@@ -293,7 +304,7 @@ func (s *Server) decorateRowRatings(ctx context.Context, rows []recommend.Row) {
for _, row := range rows {
collections = append(collections, row.Items)
}
s.decorateItemRatings(ctx, collections...)
s.decorateItems(ctx, collections...)
}
// ratingKeysForItems resolves Emby ids to external titles, preferring the index built by
+2 -2
View File
@@ -40,7 +40,7 @@ func (s *Server) handleRelated(w http.ResponseWriter, r *http.Request, sess stor
writeError(w, http.StatusBadRequest, "item id is required")
return
}
key := cache.UserKey(sess.EmbyUserID, "related:v2:"+itemID)
key := cache.UserKey(viewerKeyOf(ctx, sess), "related:v2:"+itemID)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
@@ -64,7 +64,7 @@ func (s *Server) handleRelated(w http.ResponseWriter, r *http.Request, sess stor
return nil, err
}
items := nonNilRaws(recommend.Raws(related))
s.decorateItemRatings(ctx, items)
s.decorateItems(ctx, items)
return json.Marshal(relatedResponse{
Reasons: nonNilStrings(reasons),
Items: items,
+174
View File
@@ -0,0 +1,174 @@
package api
import (
"context"
"encoding/json"
"net/url"
"strings"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/timing"
)
// viewerRowLookupLimit bounds one row's worth of ids. A row is a shelf on a television and
// nothing draws more than a screenful plus what the D-pad can reach.
const viewerRowLookupLimit = 60
// itemsByID fetches a named set of titles and returns them **in the order asked for**.
//
// Emby answers an Ids= query in its own order, and for a shadow viewer the order is the
// whole answer: Continue Watching is "what am I in the middle of, most recent first", and
// that ranking was decided in Postgres out of this viewer's own history. Handing back
// Emby's order would keep the right titles and throw away the reason they were chosen.
//
// The metadata is still Emby's. Only the viewing state belongs to Memby, which is why this
// asks for the ordinary row fields and lets decorateItems replace the UserData afterwards.
func (s *Server) itemsByID(
ctx context.Context, cred emby.Credentials, ids []string, fields string,
) (*emby.ItemsResult, error) {
if len(ids) == 0 {
return &emby.ItemsResult{}, nil
}
if len(ids) > viewerRowLookupLimit {
ids = ids[:viewerRowLookupLimit]
}
result, err := s.emby.Items(ctx, cred, rowParams(url.Values{
"Ids": {strings.Join(ids, ",")},
"Recursive": {"true"},
"Limit": {itoa(len(ids))},
}, fields))
if err != nil {
return nil, err
}
result.Items = orderItemsByID(result.Items, ids)
return result, nil
}
// orderItemsByID puts a set of items back into the order they were asked for.
//
// A title the catalogue still names but Emby no longer answers for is dropped rather than
// left as a gap — the row is drawn from what comes back, and a missing card is better than
// one that cannot be opened. A title Emby volunteers that was not asked for is dropped too:
// the ids are the answer, and anything else in the response is not part of it.
func orderItemsByID(items []json.RawMessage, ids []string) []json.RawMessage {
byID := make(map[string]json.RawMessage, len(items))
for _, raw := range items {
if id := itemIDOf(raw); id != "" {
if _, seen := byID[id]; !seen {
byID[id] = raw
}
}
}
ordered := make([]json.RawMessage, 0, len(ids))
for _, id := range ids {
if raw, ok := byID[id]; ok {
ordered = append(ordered, raw)
// Removed so a repeated id cannot draw the same card twice. The television
// keys its rows by item id and throws on a duplicate.
delete(byID, id)
}
}
return ordered
}
// viewerContinueRow is a shadow viewer's Continue Watching, built from their own playheads.
func (s *Server) viewerContinueRow(
ctx context.Context, cred emby.Credentials, viewerID string, limit int,
) (*emby.ItemsResult, error) {
ids, err := s.store.ViewerResumeItems(ctx, viewerID, limit)
if err != nil {
return nil, err
}
return s.itemsByID(timing.WithLabel(ctx, "emby.viewer_resume"), cred, ids, fieldsContinue)
}
// viewerNextUpRow is the next unwatched episode of each series this viewer is part-way
// through. The ranking is Postgres's; Emby is only asked to describe the titles.
func (s *Server) viewerNextUpRow(
ctx context.Context, cred emby.Credentials, viewerID string, limit int,
) (*emby.ItemsResult, error) {
ids, err := s.store.ViewerNextUp(ctx, viewerID, limit)
if err != nil {
return nil, err
}
return s.itemsByID(timing.WithLabel(ctx, "emby.viewer_nextup"), cred, ids, fieldsContinue)
}
// viewerFavouritesRow is this viewer's own favourites rather than the account's.
//
// It is deliberately *not* re-sorted by name the way the Emby row is. A shadow viewer's
// favourites are the ones they marked, and the order they marked them in is the only
// ordering Memby has that means anything.
func (s *Server) viewerFavouritesRow(
ctx context.Context, cred emby.Credentials, viewerID string, limit int,
) (*emby.ItemsResult, error) {
ids, err := s.store.ViewerFavouriteItems(ctx, viewerID, limit)
if err != nil {
return nil, err
}
return s.itemsByID(timing.WithLabel(ctx, "emby.viewer_favourites"), cred, ids, fieldsRow)
}
// firstUnwatchedEpisodeFor is where a shadow viewer's series starts.
//
// It reads the series' episodes once from Emby and walks them against this viewer's own
// played set, rather than asking Emby's NextUp — which answers for the account and is the
// whole reason a shadow viewer pressing Play on a show they have never seen was being
// dropped into the middle of somebody else's season.
//
// A part-watched episode wins over the first unwatched one: somebody eleven minutes into
// an episode wants that episode, which is the same judgement the Continue Watching merge
// makes.
func (s *Server) firstUnwatchedEpisodeFor(
ctx context.Context, cred emby.Credentials, viewer store.Viewer, seriesID string,
) (*emby.Summary, error) {
states, err := s.store.ViewerPlayedInSeries(ctx, viewer.ID, seriesID)
if err != nil {
return nil, err
}
if len(states) == 0 {
// Never watched. Emby's own first episode is the right answer and costs the
// caller nothing extra to ask for.
return nil, nil
}
episodes, err := s.emby.Episodes(timing.WithLabel(ctx, "emby.viewer_series"), cred, seriesID, url.Values{
"Fields": {"Overview,RunTimeTicks,SeriesName,ParentIndexNumber,IndexNumber"},
"EnableUserData": {"false"},
"EnableTotalRecordCount": {"false"},
})
if err != nil {
return nil, err
}
resume, err := s.store.ViewerStates(ctx, viewer.ID, episodeIDsOf(episodes.Items))
if err != nil {
return nil, err
}
var firstUnplayed *emby.Summary
for _, raw := range episodes.Items {
summary, err := emby.Summarise(raw)
if err != nil || summary.ID == "" {
continue
}
state := resume[summary.ID]
if state.PositionTicks > 0 && !state.Played {
summary.UserData.PlaybackPositionTicks = state.PositionTicks
return &summary, nil
}
if !state.Played && firstUnplayed == nil {
episode := summary
firstUnplayed = &episode
}
}
return firstUnplayed, nil
}
func episodeIDsOf(items []json.RawMessage) []string {
ids := make([]string, 0, len(items))
for _, raw := range items {
if id := itemIDOf(raw); id != "" {
ids = append(ids, id)
}
}
return ids
}
+172
View File
@@ -0,0 +1,172 @@
package api
import (
"context"
"encoding/json"
"github.com/ponzischeme89/memby/server/internal/store"
)
// userDataItemField is the block a television draws a progress bar, a tick and a heart
// from. For a shadow viewer it is written here rather than by Emby.
const userDataItemField = "UserData"
// A launcher is a few hundred cards. This matches the ratings attach limit for the same
// reason: it guards against a future row type asking for a thousand, not against anything
// reached today.
const viewerStateItemLimit = 600
// decorateViewerState replaces the UserData on every item with this viewer's own.
//
// It is the read half of what the four gated mutations are the write half of, and it rides
// exactly where decorateItemRatings rides — one indexed read for a whole launcher, at every
// point items leave the gateway. A card then draws the right progress bar as the row
// appears, and nothing above this line has to know which viewer it is drawing for.
//
// Three things to preserve:
//
// 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 in place on a title this viewer has
// never touched is precisely the leak this feature exists to prevent: Alessandra would see
// Matt's progress bars on everything neither of them had watched together. A title with no
// row gets the zero state, which is the truth about it.
//
// A series and a season are aggregates, so they are rewritten from a *count* rather than
// from a row of their own: Emby fills their block in from their children, and a shadow
// viewer has no children Emby knows about. This was the last place they were still shown
// the account's answer — a series ticked because somebody else had finished it.
func (s *Server) decorateViewerState(ctx context.Context, collections ...[]json.RawMessage) {
// The viewer is read from the context alone. Outside a request there is none, and the
// zero session resolves to a main viewer, so a scheduled task or a test decorates
// nothing rather than blanking what it was given.
viewer := viewerOf(ctx, store.Session{})
if viewer.IsMain() || s.store == nil {
return
}
ids := itemIDsIn(collections, viewerStateItemLimit)
if len(ids) == 0 {
return
}
states, err := s.store.ViewerStates(ctx, viewer.ID, ids)
if err != nil {
// A state read that fails must not hand the viewer the account's watched state,
// so every item is blanked rather than left as it arrived. A launcher with no
// progress bars is a poor answer; one showing somebody else's is a wrong one.
s.loggerFor(ctx).Warn("viewer state read failed", "error", err)
states = map[string]store.ViewerState{}
}
// The aggregate half is a second read and is only paid for by a response that
// actually carries a series or a season card. Its failure is the same failure the leaf
// read has: an empty map, so every container is blanked rather than left carrying
// somebody else's progress.
seriesIDs, seasonIDs := containerIDsIn(collections)
containers := map[string]store.ViewerAggregate{}
if len(seriesIDs) > 0 || len(seasonIDs) > 0 {
found, err := s.store.ViewerContainerStates(ctx, viewer.ID, seriesIDs, seasonIDs)
if err != nil {
s.loggerFor(ctx).Warn("viewer container state read failed", "error", err)
} else {
containers = found
}
}
for _, items := range collections {
for index, raw := range items {
id := itemIDOf(raw)
if id == "" {
continue
}
if isAggregateItem(raw) {
items[index] = injectItemUserData(
raw, viewerAggregateUserData(states[id], containers[id]),
)
continue
}
items[index] = injectItemUserData(raw, viewerUserData(states[id]))
}
}
}
// isAggregateItem reports whether an item's UserData describes its children rather than
// itself. Emby fills a series' and a season's block in from their episodes.
func isAggregateItem(raw json.RawMessage) bool {
return itemTypeOf(raw) == "Series" || itemTypeOf(raw) == "Season"
}
func itemTypeOf(raw json.RawMessage) string {
var item struct {
Type string `json:"Type"`
}
if json.Unmarshal(raw, &item) != nil {
return ""
}
return item.Type
}
// containerIDsIn collects the series and seasons a response is carrying.
//
// A **series** is keyed by its own id, because that is what its episodes carry as their
// series id. A **season** cannot be: its episodes carry its id in their payload, but the
// catalogue's indexed column is the series, so a season is looked up by its own id *and*
// its series is asked for alongside — which is what makes one query answer for a season
// card sitting on a page about a show the response also carries.
func containerIDsIn(collections [][]json.RawMessage) (seriesIDs, seasonIDs []string) {
seenSeries := map[string]bool{}
seenSeasons := map[string]bool{}
for _, items := range collections {
for _, raw := range items {
id := itemIDOf(raw)
if id == "" {
continue
}
switch itemTypeOf(raw) {
case "Series":
if !seenSeries[id] {
seenSeries[id] = true
seriesIDs = append(seriesIDs, id)
}
case "Season":
if !seenSeasons[id] {
seenSeasons[id] = true
seasonIDs = append(seasonIDs, id)
}
if parent := seriesIDOf(raw); parent != "" && !seenSeries[parent] {
seenSeries[parent] = true
seriesIDs = append(seriesIDs, parent)
}
}
}
}
return seriesIDs, seasonIDs
}
func seriesIDOf(raw json.RawMessage) string {
var item struct {
SeriesID string `json:"SeriesId"`
}
if json.Unmarshal(raw, &item) != nil {
return ""
}
return item.SeriesID
}
// injectItemUserData replaces one item's UserData block.
//
// It rewrites rather than merges: a partial overlay would leave whichever fields Memby had
// nothing to say about carrying the account's values, which is the same leak from a
// narrower angle.
func injectItemUserData(raw, userData json.RawMessage) json.RawMessage {
var members map[string]json.RawMessage
if json.Unmarshal(raw, &members) != nil || members == nil {
return raw
}
members[userDataItemField] = userData
out, err := json.Marshal(members)
if err != nil {
return raw
}
return out
}
+414
View File
@@ -0,0 +1,414 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
// viewerHeader names the person watching, as distinct from the account streaming.
//
// It is a header rather than part of the session because a viewer does not belong to a
// television: somebody starts an episode in the lounge and finishes it in the bedroom, and
// switching between two people on one set must not be a re-authentication. The session
// still answers "which Emby account is this and what may it read"; this answers "whose
// evening is it", and the two are separate questions.
const viewerHeader = "X-Memby-Viewer"
// The header is *stated, never inferred* — the stance Credentials.Gateway takes. An app
// that predates viewers sends nothing and resolves to the account's main viewer, which is
// exactly the behaviour it had before this existed; guessing from anything else would file
// a household's ordinary watching under somebody who does not exist.
// How long an account's viewer list is trusted in memory.
//
// Every authenticated request resolves a viewer, and /v1/status alone is every open
// television every ten seconds — a Postgres round trip each, for a list that changes when
// somebody adds a person to the household. A write clears it, so the window is "how long
// until another instance notices" rather than "how long until my change takes effect", the
// bargain featurePolicyCache already makes.
const viewerListTTL = 30 * time.Second
type viewerListCache struct {
mu sync.Mutex
entries map[string]viewerListEntry
}
type viewerListEntry struct {
viewers []store.Viewer
loadedAt time.Time
}
// viewersFor lists an account's viewers, from memory where it can.
func (s *Server) viewersFor(ctx context.Context, sess store.Session) ([]store.Viewer, error) {
if s.store == nil {
return nil, errors.New("no store")
}
c := &s.viewerLists
now := time.Now()
c.mu.Lock()
entry, ok := c.entries[sess.EmbyUserID]
c.mu.Unlock()
if ok && now.Sub(entry.loadedAt) < viewerListTTL {
return entry.viewers, nil
}
viewers, err := s.store.Viewers(ctx, sess.EmbyUserID, sess.Username)
if err != nil {
// A list that will not load is not evidence that the household has no viewers, so
// a stale reading is preferred to none: losing it would silently move a shadow
// viewer's playback back onto the Emby account, which is the one failure this
// feature must never have.
if ok {
return entry.viewers, nil
}
return nil, err
}
c.mu.Lock()
if c.entries == nil {
c.entries = map[string]viewerListEntry{}
}
c.entries[sess.EmbyUserID] = viewerListEntry{viewers: viewers, loadedAt: now}
c.mu.Unlock()
return viewers, nil
}
// forgetViewers drops an account's cached list so a viewer added, renamed or removed is
// live on the next request rather than at the end of the window.
func (s *Server) forgetViewers(embyUserID string) {
c := &s.viewerLists
c.mu.Lock()
delete(c.entries, embyUserID)
c.mu.Unlock()
}
// activeViewer resolves who is watching.
//
// Every unknown case resolves to the main viewer, and that is deliberate: this is on the
// path of every authenticated request, and the failure it is protecting against — a
// television left unable to do anything because a header could not be checked — is far
// worse than a shadow viewer's episode being attributed to the account for one request.
// The one thing it will not do is accept an id it could not confirm belongs to this
// account, because that would let one household's television read another's viewing.
func (s *Server) activeViewer(ctx context.Context, sess store.Session, r *http.Request) store.Viewer {
fallback := store.Viewer{ID: sess.EmbyUserID, Name: sess.Username, Kind: store.ViewerMain}
// The operator's switch is read here rather than at each of the four mutations,
// because this is the one place a request learns who is watching: with it off there is
// no shadow viewer to resolve to, so 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.
if !s.viewersEnabled(ctx) {
return fallback
}
requested := strings.TrimSpace(r.Header.Get(viewerHeader))
if requested == "" || requested == sess.EmbyUserID {
return fallback
}
viewers, err := s.viewersFor(ctx, sess)
if err != nil {
s.loggerFor(ctx).Warn("viewer list unavailable", "error", err)
return fallback
}
for _, viewer := range viewers {
if viewer.ID == requested {
return viewer
}
}
// An id this account does not own. It is logged rather than refused: the ordinary
// cause is a television still holding a viewer somebody has since deleted, and
// answering 403 to every request would leave that set unable to reach the picker that
// would fix it.
s.loggerFor(ctx).Warn("unknown viewer requested", "viewer", requested)
return fallback
}
// viewersEnabled reports whether the household is running viewers at all.
//
// Off is not a deletion. A viewer's rows stay in Postgres untouched and come back intact
// when it is switched on again; what stops is the gateway routing anybody's watching
// anywhere but Emby, which is exactly the state a household was in before this existed.
func (s *Server) viewersEnabled(ctx context.Context) bool {
return s.featureEnabled(ctx, featureViewers)
}
// mainViewerOnly is what an account's list looks like with the feature switched off.
//
// It is 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: one viewer is an
// account nobody has added anybody to, which is the reading that makes a switched-off
// household look like one that never used the feature rather than like one whose picker
// has broken.
func mainViewerOnly(viewers []store.Viewer) []store.Viewer {
for _, viewer := range viewers {
if viewer.IsMain() {
return []store.Viewer{viewer}
}
}
return nil
}
// --- carrying the viewer through one request --------------------------------
type viewerContextKey struct{}
// withViewer installs the resolved viewer for the rest of the request.
//
// It is resolved once, in [Server.authed], and read from the context everywhere else. The
// alternative — every handler that needs a cache key calling activeViewer for itself —
// is fifteen call sites that must each remember to, and the failure of forgetting one is
// silent: the handler simply keys that view under the account, and one viewer is served
// another viewer's rows. Resolving it at the boundary makes forgetting impossible.
func withViewer(ctx context.Context, viewer store.Viewer) context.Context {
return context.WithValue(ctx, viewerContextKey{}, viewer)
}
// viewerOf reports who this request belongs to.
//
// A request with no viewer in its context is one that never passed through authed — a
// scheduled task, a probe, a test — and the account's own id is the honest answer for it,
// which is also the value every one of these keys held before viewers existed.
func viewerOf(ctx context.Context, sess store.Session) store.Viewer {
if viewer, ok := ctx.Value(viewerContextKey{}).(store.Viewer); ok && viewer.ID != "" {
return viewer
}
return store.Viewer{ID: sess.EmbyUserID, Name: sess.Username, Kind: store.ViewerMain}
}
// viewerKeyOf is the shorthand the cache keys use: the id everything about this person is
// filed under. For the main viewer it is the Emby user id, so an existing household's
// cached views keep the names they already had.
func viewerKeyOf(ctx context.Context, sess store.Session) string {
return viewerOf(ctx, sess).ID
}
// viewerID is the key everything about a *person* is stored under — preferences,
// notifications, followed shows, row statistics, recommendation profiles.
//
// For the main viewer it is the Emby user id, which is why this feature needed no
// migration: an existing household's rows are already filed under exactly this value.
func viewerID(viewer store.Viewer) string { return viewer.ID }
// --- the common state layer -------------------------------------------------
// viewerUserData renders one viewer's state in the shape of Emby's UserData block.
//
// This is the seam the client never sees. A television asks for a row and draws a progress
// bar, a tick and a heart from UserData; whether that block came from Emby or from Postgres
// is not a question anything above this line asks, which is what keeps viewers from
// becoming a special case in every screen.
func viewerUserData(state store.ViewerState) json.RawMessage {
payload := map[string]any{
"IsFavorite": state.Favourite,
"Played": state.Played,
"PlaybackPositionTicks": state.PositionTicks,
"PlayCount": state.PlayCount,
}
if state.RuntimeTicks > 0 && state.PositionTicks > 0 {
payload["PlayedPercentage"] = float64(state.PositionTicks) / float64(state.RuntimeTicks) * 100
}
if state.LastPlayedAt != nil {
payload["LastPlayedDate"] = state.LastPlayedAt.UTC().Format(time.RFC3339)
}
raw, err := json.Marshal(payload)
if err != nil {
return json.RawMessage(`{}`)
}
return raw
}
// viewerAggregateUserData renders a series' or a season's block from a count of episodes.
//
// Emby fills those in from an item's children, and a shadow viewer has no children Emby
// has ever heard of — so the count comes from the shared catalogue and this viewer's own
// state. The favourite is the one field that is genuinely the container's own: somebody
// marks a *show* a favourite, not the sum of its episodes, so it is read from the row
// against the series id rather than derived.
//
// Two things are omitted rather than sent as zero, the rule the leaf block follows. A
// container the catalogue cannot count for — a library not yet imported, a show it has
// never seen — has no unwatched count, because "0 left" and "I cannot say" are different
// answers and only one of them is true. And a container nothing has been watched of has no
// last-played date.
func viewerAggregateUserData(state store.ViewerState, aggregate store.ViewerAggregate) json.RawMessage {
payload := map[string]any{
"IsFavorite": state.Favourite,
// A container is never resumable: what resumes is an episode, and Emby reports
// zero here for the same reason.
"PlaybackPositionTicks": 0,
"PlayCount": aggregate.Played,
// Played only where there is something to have finished. An empty catalogue must
// not tick every show in the house.
"Played": aggregate.Total > 0 && aggregate.Played >= aggregate.Total,
}
if aggregate.Total > 0 {
unplayed := aggregate.Total - aggregate.Played
if unplayed < 0 {
unplayed = 0
}
payload["UnplayedItemCount"] = unplayed
payload["PlayedPercentage"] = float64(aggregate.Played) / float64(aggregate.Total) * 100
}
if aggregate.LastPlayedAt != nil {
payload["LastPlayedDate"] = aggregate.LastPlayedAt.UTC().Format(time.RFC3339)
}
raw, err := json.Marshal(payload)
if err != nil {
return json.RawMessage(`{}`)
}
return raw
}
// --- routes -----------------------------------------------------------------
type viewersResponse struct {
Viewers []store.Viewer `json:"viewers"`
Active string `json:"active"`
}
type viewerRequest struct {
Name string `json:"name"`
ShortName string `json:"shortName"`
Colour string `json:"colour"`
}
func (s *Server) handleViewers(w http.ResponseWriter, r *http.Request, sess store.Session) {
if r.Method == http.MethodPost {
s.handleCreateViewer(w, r, sess)
return
}
viewers, err := s.viewersFor(r.Context(), sess)
if err != nil {
s.writeUpstreamError(r.Context(), w, err, "could not load viewers")
return
}
if !s.viewersEnabled(r.Context()) {
viewers = mainViewerOnly(viewers)
}
writeJSON(w, http.StatusOK, viewersResponse{
Viewers: viewers,
Active: s.activeViewer(r.Context(), sess, r).ID,
})
}
func (s *Server) handleCreateViewer(w http.ResponseWriter, r *http.Request, sess store.Session) {
// A refusal rather than a silent success: the television is about to draw a card for
// somebody, and an operator who has switched the feature off has said the household
// does not use it. The wording names the reason, because a television has no log and
// no support channel and that sentence is the whole diagnosis.
if !s.viewersEnabled(r.Context()) {
writeError(w, http.StatusForbidden, "viewers are switched off for this server")
return
}
var req viewerRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if strings.TrimSpace(req.Name) == "" {
writeError(w, http.StatusBadRequest, "a name is required")
return
}
if len([]rune(req.Name)) > 40 {
writeError(w, http.StatusBadRequest, "that name is too long")
return
}
viewer, err := s.store.CreateShadowViewer(
r.Context(), sess.EmbyUserID, req.Name, req.ShortName, req.Colour,
)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.forgetViewers(sess.EmbyUserID)
s.loggerFor(r.Context()).Info("viewer added", "viewer", viewer.ID, "name", viewer.Name)
writeJSON(w, http.StatusOK, viewer)
}
func (s *Server) handleViewer(w http.ResponseWriter, r *http.Request, sess store.Session) {
id := r.PathValue("viewerID")
if id == "" {
writeError(w, http.StatusBadRequest, "viewer id is required")
return
}
if !s.viewersEnabled(r.Context()) {
writeError(w, http.StatusForbidden, "viewers are switched off for this server")
return
}
if r.Method == http.MethodDelete {
if err := s.store.DeleteShadowViewer(r.Context(), sess.EmbyUserID, id); err != nil {
if errors.Is(err, store.ErrViewerNotFound) {
writeError(w, http.StatusNotFound, "no such viewer")
return
}
s.writeUpstreamError(r.Context(), w, err, "could not remove that viewer")
return
}
s.forgetViewers(sess.EmbyUserID)
// Everything cached under this viewer's own key is now about nobody.
if err := s.cache.InvalidateUser(r.Context(), id); err != nil {
s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err)
}
s.loggerFor(r.Context()).Info("viewer removed", "viewer", id)
writeJSON(w, http.StatusOK, map[string]bool{"removed": true})
return
}
var req viewerRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
viewer, err := s.store.UpdateShadowViewer(
r.Context(), sess.EmbyUserID, id, req.Name, req.ShortName, req.Colour,
)
if err != nil {
if errors.Is(err, store.ErrViewerNotFound) {
writeError(w, http.StatusNotFound, "no such viewer")
return
}
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.forgetViewers(sess.EmbyUserID)
writeJSON(w, http.StatusOK, viewer)
}
// invalidateAccountViews drops the cached views of every viewer on an account.
//
// Signing a television out, or an operator resetting somebody, is a statement about the
// account rather than about whoever happened to be watching — so invalidating the account's
// own key alone would leave each shadow viewer's rows behind, to be served intact to the
// next person who signs in on that set.
//
// The list is read directly rather than through the cache, because this is called at
// exactly the moments the cached copy is least trustworthy, and it is best-effort: the
// entries it misses expire on their own TTL, and nothing here is worth failing a sign-out
// over.
func (s *Server) invalidateAccountViews(ctx context.Context, sess store.Session) {
if err := s.cache.InvalidateUser(ctx, sess.EmbyUserID); err != nil {
s.loggerFor(ctx).Warn("cache invalidation failed", "error", err)
}
if s.store == nil {
return
}
viewers, err := s.store.Viewers(ctx, sess.EmbyUserID, sess.Username)
if err != nil {
s.loggerFor(ctx).Warn("viewer list unavailable for invalidation", "error", err)
return
}
for _, viewer := range viewers {
if viewer.IsMain() {
continue
}
if err := s.cache.InvalidateUser(ctx, viewer.ID); err != nil {
s.loggerFor(ctx).Warn("viewer cache invalidation failed",
"viewer", viewer.ID, "error", err)
}
}
}
+364
View File
@@ -0,0 +1,364 @@
package api
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The television has one UserData reader and it must not be able to tell where the block
// came from. This pins the field names against the client's UserItemData, which is the
// contract that keeps viewers from becoming a special case in every screen.
func TestViewerUserDataIsShapedLikeEmbys(t *testing.T) {
played := time.Date(2026, 8, 19, 21, 14, 0, 0, time.UTC)
raw := viewerUserData(store.ViewerState{
ItemID: "982173",
PositionTicks: 15_420_000_000,
RuntimeTicks: 30_840_000_000,
PlayCount: 2,
Favourite: true,
LastPlayedAt: &played,
})
var parsed struct {
IsFavorite bool `json:"IsFavorite"`
Played bool `json:"Played"`
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
PlayCount int `json:"PlayCount"`
PlayedPercentage *float64 `json:"PlayedPercentage"`
LastPlayedDate string `json:"LastPlayedDate"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("decode viewer user data: %v", err)
}
if !parsed.IsFavorite {
t.Error("favourite was not carried")
}
if parsed.Played {
t.Error("a part-watched title was reported as played")
}
if parsed.PlaybackPositionTicks != 15_420_000_000 {
t.Errorf("position = %d", parsed.PlaybackPositionTicks)
}
if parsed.PlayCount != 2 {
t.Errorf("play count = %d", parsed.PlayCount)
}
if parsed.PlayedPercentage == nil || *parsed.PlayedPercentage < 49 || *parsed.PlayedPercentage > 51 {
t.Errorf("played percentage = %v, want about 50", parsed.PlayedPercentage)
}
if parsed.LastPlayedDate != "2026-08-19T21:14:00Z" {
t.Errorf("last played = %q", parsed.LastPlayedDate)
}
}
// A title nobody has touched has to render as untouched rather than as a card claiming a
// zero-length progress bar, so the two optional fields are omitted rather than sent empty.
func TestViewerUserDataOmitsWhatItDoesNotKnow(t *testing.T) {
raw := viewerUserData(store.ViewerState{ItemID: "982173"})
var fields map[string]any
if err := json.Unmarshal(raw, &fields); err != nil {
t.Fatalf("decode viewer user data: %v", err)
}
if _, ok := fields["PlayedPercentage"]; ok {
t.Error("a percentage was claimed for a title with no runtime or position")
}
if _, ok := fields["LastPlayedDate"]; ok {
t.Error("a play date was claimed for a title that has never been played")
}
if fields["Played"] != false || fields["IsFavorite"] != false {
t.Errorf("untouched state rendered as %v", fields)
}
}
// IsMain is what every mutation branches on, so it is worth stating that it reads the
// stored kind rather than guessing from the id.
func TestViewerIsMainReadsTheStoredKind(t *testing.T) {
if !(store.Viewer{ID: "abc", Kind: store.ViewerMain}).IsMain() {
t.Error("a main viewer did not report as main")
}
if (store.Viewer{ID: "abc", Kind: store.ViewerShadow}).IsMain() {
t.Error("a shadow viewer reported as main")
}
if (store.Viewer{ID: "abc"}).IsMain() {
t.Error("a viewer with no kind reported as main")
}
}
// The ids are the answer, not just the selection: a shadow viewer's Continue Watching is
// ordered by their own history in Postgres, and Emby answers an Ids= query in its own
// order. Handing that back would keep the right titles and discard the reason for them.
func TestOrderItemsByIDRestoresTheOrderAskedFor(t *testing.T) {
item := func(id string) json.RawMessage {
return json.RawMessage(`{"Id":"` + id + `","Name":"` + id + `"}`)
}
idsOf := func(items []json.RawMessage) []string {
out := []string{}
for _, raw := range items {
out = append(out, itemIDOf(raw))
}
return out
}
t.Run("emby's order is replaced", func(t *testing.T) {
got := orderItemsByID(
[]json.RawMessage{item("c"), item("a"), item("b")},
[]string{"b", "c", "a"},
)
want := []string{"b", "c", "a"}
if diff := idsOf(got); !equalStrings(diff, want) {
t.Fatalf("order = %v, want %v", diff, want)
}
})
// A title the catalogue still names but Emby will not answer for leaves a card that
// cannot be opened, so it is dropped instead.
t.Run("a missing title is dropped", func(t *testing.T) {
got := orderItemsByID([]json.RawMessage{item("a")}, []string{"a", "gone", "b"})
if diff := idsOf(got); !equalStrings(diff, []string{"a"}) {
t.Fatalf("order = %v, want [a]", diff)
}
})
// Every keyed list on the television throws on a repeated key, and a paging boundary
// or a duplicated row is exactly where an id comes back twice.
t.Run("a repeated id draws one card", func(t *testing.T) {
got := orderItemsByID([]json.RawMessage{item("a"), item("a")}, []string{"a", "a"})
if diff := idsOf(got); !equalStrings(diff, []string{"a"}) {
t.Fatalf("order = %v, want one card", diff)
}
})
// Anything Emby volunteers that was not asked for is not part of the answer.
t.Run("an unasked title is dropped", func(t *testing.T) {
got := orderItemsByID([]json.RawMessage{item("a"), item("z")}, []string{"a"})
if diff := idsOf(got); !equalStrings(diff, []string{"a"}) {
t.Fatalf("order = %v, want [a]", diff)
}
})
}
// The account's UserData must be replaced outright rather than merged: a partial overlay
// leaves whichever fields Memby had nothing to say about carrying the account's values.
func TestInjectItemUserDataReplacesRatherThanMerges(t *testing.T) {
raw := json.RawMessage(
`{"Id":"1","Name":"Anatomy of a Fall",` +
`"UserData":{"Played":true,"PlaybackPositionTicks":9999,"IsFavorite":true,"PlayCount":4}}`)
out := injectItemUserData(raw, viewerUserData(store.ViewerState{ItemID: "1"}))
var parsed struct {
Name string `json:"Name"`
UserData struct {
Played bool `json:"Played"`
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
IsFavorite bool `json:"IsFavorite"`
PlayCount int `json:"PlayCount"`
} `json:"UserData"`
}
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatalf("decode: %v", err)
}
if parsed.Name != "Anatomy of a Fall" {
t.Errorf("the rest of the item was disturbed: name = %q", parsed.Name)
}
if parsed.UserData.Played || parsed.UserData.IsFavorite {
t.Error("the account's watched or favourite state survived")
}
if parsed.UserData.PlaybackPositionTicks != 0 || parsed.UserData.PlayCount != 0 {
t.Errorf("the account's position or play count survived: %+v", parsed.UserData)
}
}
// A series and a season are answered from a count of episodes rather than from a row of
// their own, so they have to be told apart from the leaf items around them.
func TestAggregateItemsAreRecognised(t *testing.T) {
for _, tc := range []struct {
itemType string
want bool
}{
{"Series", true},
{"Season", true},
{"Episode", false},
{"Movie", false},
{"", false},
} {
raw := json.RawMessage(`{"Id":"1","Type":"` + tc.itemType + `"}`)
if got := isAggregateItem(raw); got != tc.want {
t.Errorf("isAggregateItem(%q) = %v, want %v", tc.itemType, got, tc.want)
}
}
}
// Everything downstream keys its cached views on this, so a request that never passed
// through authed must still answer with the value those keys held before viewers existed.
func TestViewerOfFallsBackToTheAccount(t *testing.T) {
sess := store.Session{EmbyUserID: "emby-user-1", Username: "Matt"}
viewer := viewerOf(context.Background(), sess)
if !viewer.IsMain() || viewer.ID != "emby-user-1" {
t.Fatalf("fallback viewer = %+v, want the account as main", viewer)
}
if got := viewerKeyOf(context.Background(), sess); got != "emby-user-1" {
t.Fatalf("cache key = %q, want the Emby user id", got)
}
shadow := store.Viewer{ID: "v0123", Name: "Alessandra", Kind: store.ViewerShadow}
ctx := withViewer(context.Background(), shadow)
if got := viewerKeyOf(ctx, sess); got != "v0123" {
t.Fatalf("cache key = %q, want the shadow viewer", got)
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// The last place a shadow viewer was shown the account's answer. A series card draws its
// tick and its "left to watch" from these fields, and Emby fills them in from children it
// has never heard of for this person.
func TestViewerAggregateUserDataCountsWhatIsLeft(t *testing.T) {
played := time.Date(2026, 8, 18, 20, 5, 0, 0, time.UTC)
raw := viewerAggregateUserData(
store.ViewerState{ItemID: "series-1", Favourite: true},
store.ViewerAggregate{Total: 10, Played: 4, LastPlayedAt: &played},
)
var parsed struct {
IsFavorite bool `json:"IsFavorite"`
Played bool `json:"Played"`
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
UnplayedItemCount *int `json:"UnplayedItemCount"`
PlayedPercentage *float64 `json:"PlayedPercentage"`
LastPlayedDate string `json:"LastPlayedDate"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("decode aggregate user data: %v", err)
}
// The favourite is the container's own — somebody marks a show, not the sum of its
// episodes — so it comes from the row rather than from the count.
if !parsed.IsFavorite {
t.Error("the viewer's own favourite on the series was dropped")
}
if parsed.Played {
t.Error("a part-watched series reported as finished")
}
if parsed.UnplayedItemCount == nil || *parsed.UnplayedItemCount != 6 {
t.Errorf("unplayed = %v, want 6", parsed.UnplayedItemCount)
}
if parsed.PlayedPercentage == nil || *parsed.PlayedPercentage < 39 || *parsed.PlayedPercentage > 41 {
t.Errorf("played percentage = %v, want about 40", parsed.PlayedPercentage)
}
// A container is never resumable; what resumes is an episode.
if parsed.PlaybackPositionTicks != 0 {
t.Errorf("a series carried a resume position: %d", parsed.PlaybackPositionTicks)
}
if parsed.LastPlayedDate != "2026-08-18T20:05:00Z" {
t.Errorf("last played = %q", parsed.LastPlayedDate)
}
finished := viewerAggregateUserData(
store.ViewerState{}, store.ViewerAggregate{Total: 10, Played: 10},
)
var done map[string]any
if err := json.Unmarshal(finished, &done); err != nil {
t.Fatalf("decode: %v", err)
}
if done["Played"] != true {
t.Errorf("a fully watched series did not report as played: %v", done)
}
if count, ok := done["UnplayedItemCount"].(float64); !ok || count != 0 {
t.Errorf("unplayed on a finished series = %v, want 0", done["UnplayedItemCount"])
}
}
// "Nothing left to watch" and "I cannot say how much there is" are different answers, and
// only one of them is true for a library the catalogue has not imported yet. Ticking every
// show in the house is the worst thing this could do.
func TestViewerAggregateUserDataSaysNothingItCannotCount(t *testing.T) {
raw := viewerAggregateUserData(store.ViewerState{}, store.ViewerAggregate{})
var fields map[string]any
if err := json.Unmarshal(raw, &fields); err != nil {
t.Fatalf("decode: %v", err)
}
if _, ok := fields["UnplayedItemCount"]; ok {
t.Error("a count was claimed for a series the catalogue cannot count")
}
if _, ok := fields["PlayedPercentage"]; ok {
t.Error("a percentage was claimed with nothing to divide by")
}
if _, ok := fields["LastPlayedDate"]; ok {
t.Error("a play date was claimed for a series nobody has watched")
}
if fields["Played"] != false {
t.Errorf("an uncountable series reported as watched: %v", fields)
}
}
// A season is looked up by its own id, and its series is asked for alongside it, because
// the catalogue's indexed column is the series. Getting that wrong costs the season card
// its count on exactly the page — a series detail page — where seasons appear.
func TestContainerIDsCollectSeriesAndSeasons(t *testing.T) {
items := []json.RawMessage{
json.RawMessage(`{"Id":"ep-1","Type":"Episode","SeriesId":"show-1"}`),
json.RawMessage(`{"Id":"show-1","Type":"Series"}`),
json.RawMessage(`{"Id":"season-2","Type":"Season","SeriesId":"show-2"}`),
json.RawMessage(`{"Id":"season-2","Type":"Season","SeriesId":"show-2"}`),
json.RawMessage(`{"Id":"film-1","Type":"Movie"}`),
}
seriesIDs, seasonIDs := containerIDsIn([][]json.RawMessage{items})
if !equalStrings(seasonIDs, []string{"season-2"}) {
t.Errorf("seasons = %v, want one season and no repeat", seasonIDs)
}
// show-2 is there because the season named it; show-1 because it is a card in its own
// right. Neither the episode nor the film contributes a container.
if !equalStrings(seriesIDs, []string{"show-1", "show-2"}) {
t.Errorf("series = %v, want the series card and the season's parent", seriesIDs)
}
}
// Switched off, an account looks like one nobody has added anybody to — which is how the
// television decides not to offer the picker. It must never look like an account whose
// list failed to load.
func TestMainViewerOnlyLeavesTheAccount(t *testing.T) {
viewers := []store.Viewer{
{ID: "emby-user-1", Name: "Matt", Kind: store.ViewerMain},
{ID: "v01", Name: "Alessandra", Kind: store.ViewerShadow},
{ID: "v02", Name: "Guest", Kind: store.ViewerShadow},
}
got := mainViewerOnly(viewers)
if len(got) != 1 || !got[0].IsMain() || got[0].ID != "emby-user-1" {
t.Fatalf("switched-off list = %+v, want the account alone", got)
}
if mainViewerOnly(nil) != nil {
t.Error("a list with no main viewer invented one")
}
}
// The switch is the operator's and rides the ordinary feature machinery, so what is worth
// pinning is that it is *in* the catalogue and gated on a capability — a household half of
// whose televisions cannot choose between people must not be offered it.
func TestViewersIsAnOperatorFeature(t *testing.T) {
definition, ok := knownFeature(featureViewers)
if !ok {
t.Fatal("viewers is not in the feature catalogue")
}
if definition.Capability != "viewers_v1" {
t.Errorf("capability = %q, want viewers_v1", definition.Capability)
}
// Off by default, and deliberately so: this is the switch deciding where a household's
// watched state is written, and a feature that arrives already on is one every server
// running this build starts using before anybody decided to.
if definition.DefaultEnabled {
t.Error("viewers defaults on; it is opted into rather than out of")
}
}
+14 -11
View File
@@ -111,17 +111,20 @@ func appendField(target []field, groups []string, attr slog.Attr) []field {
// Anything unranked keeps the order the caller wrote it in, which is usually the order
// that reads best for that particular event.
var fieldRank = map[string]int{
"component": 1,
"user": 2,
"device": 3,
"client": 4,
"correlation": 5,
"play_session_id": 6,
"protocol": 7,
"method": 8,
"path": 9,
"status": 10,
"duration": 11,
"component": 1,
"user": 2,
// The person watching sits beside the account they watch through, because on a
// household running viewers those are different answers and the line has to give both.
"viewer": 3,
"device": 4,
"client": 5,
"correlation": 6,
"play_session_id": 7,
"protocol": 8,
"method": 9,
"path": 10,
"status": 11,
"duration": 12,
// Constant per process, so it belongs at the end of the line rather than in front
// of the fields that differ between events.
"version": 900,
+79
View File
@@ -108,6 +108,12 @@ CREATE INDEX IF NOT EXISTS library_items_genres_idx ON library_items USING GIN (
CREATE INDEX IF NOT EXISTS library_items_type_created_idx ON library_items (type, date_created DESC);
CREATE INDEX IF NOT EXISTS library_items_synced_idx ON library_items (synced_at);
-- Every episode of one series, which is what a viewer's Next Up walks and what a series
-- card's watched count is computed from. Both run on the tail of an ordinary request, and
-- without this each is a scan of every episode in the library.
CREATE INDEX IF NOT EXISTS library_items_series_episodes_idx
ON library_items (series_id) WHERE type = 'Episode';
-- Durable raw MDBList responses. Source selection and display formatting happen at read
-- time, so changing the visible sources does not require another external API request.
CREATE TABLE IF NOT EXISTS external_media_ratings (
@@ -903,3 +909,76 @@ CREATE INDEX IF NOT EXISTS notification_log_user_idx
ON notification_log (emby_user_id, occurred_at DESC) WHERE emby_user_id <> '';
CREATE INDEX IF NOT EXISTS notification_log_status_idx ON notification_log (status, occurred_at DESC);
CREATE INDEX IF NOT EXISTS notification_log_kind_idx ON notification_log (kind, occurred_at DESC);
-- Viewers: the people using one Memby account.
--
-- 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
-- which behaves exactly as the account did before viewers existed, and any number of
-- SHADOW viewers whose state is Memby's alone.
--
-- The main viewer's id IS the Emby user id, and that is the whole of why this feature
-- needed no migration. Every table in this schema keys a person by a bare emby_user_id
-- with no foreign key behind it, so substituting a viewer id for it leaves an existing
-- household's preferences, notifications, followed shows, search history and row stats
-- exactly where they were. A shadow id is prefixed 'v' and is therefore distinguishable
-- from Emby's 32-hex GUIDs by inspection, which is what makes that substitution safe.
CREATE TABLE IF NOT EXISTS viewers (
id TEXT PRIMARY KEY,
emby_user_id TEXT NOT NULL,
name TEXT NOT NULL,
short_name TEXT NOT NULL DEFAULT '',
colour TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL, -- main | shadow
pin_hash BYTEA,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS viewers_account_idx ON viewers (emby_user_id, created_at);
-- One main viewer per account, enforced rather than assumed: the main viewer is what a
-- request falls back to, so an account with two of them would resolve differently
-- depending on which row a query happened to return first.
CREATE UNIQUE INDEX IF NOT EXISTS viewers_account_main_idx
ON viewers (emby_user_id) WHERE kind = 'main';
-- A shadow viewer's own viewing state, in the shape of the Emby UserData block it stands
-- in for. Only the fields Memby actually renders are here: 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 lives in Emby, and a copy
-- of it here would be a second answer free to disagree with the one the household's other
-- Emby clients see.
CREATE TABLE IF NOT EXISTS viewer_playback_state (
viewer_id TEXT NOT NULL,
item_id TEXT NOT NULL,
series_id TEXT NOT NULL DEFAULT '',
season_id TEXT NOT NULL DEFAULT '',
position_ticks BIGINT NOT NULL DEFAULT 0,
runtime_ticks BIGINT NOT NULL DEFAULT 0,
played BOOLEAN NOT NULL DEFAULT false,
play_count INT NOT NULL DEFAULT 0,
favourite BOOLEAN NOT NULL DEFAULT false,
hidden_from_resume BOOLEAN NOT NULL DEFAULT false,
last_played_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (viewer_id, item_id)
);
-- Continue Watching for a shadow viewer is this index: what they are part-way through,
-- most recent first. The partial predicate keeps it to the rows that row can draw from
-- rather than to everything they have ever pressed Play on.
CREATE INDEX IF NOT EXISTS viewer_playback_resume_idx
ON viewer_playback_state (viewer_id, last_played_at DESC)
WHERE position_ticks > 0 AND NOT played AND NOT hidden_from_resume;
-- Next Up walks a series' episodes for the newest completion; favourites are their own
-- row, and both are asked for per viewer.
CREATE INDEX IF NOT EXISTS viewer_playback_series_idx
ON viewer_playback_state (viewer_id, series_id, last_played_at DESC)
WHERE series_id <> '';
CREATE INDEX IF NOT EXISTS viewer_playback_favourite_idx
ON viewer_playback_state (viewer_id, updated_at DESC) WHERE favourite;
+508
View File
@@ -0,0 +1,508 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// ViewerState is one viewer's answer about one title, in the shape of the Emby UserData
// block it stands in for. Zero values are the honest answer for a title nobody has
// touched, which is what lets a caller decorate an item it found nothing stored for
// without a special case.
type ViewerState struct {
ItemID string
SeriesID string
SeasonID string
PositionTicks int64
RuntimeTicks int64
Played bool
PlayCount int
Favourite bool
HiddenFromResume bool
LastPlayedAt *time.Time
}
// PlayedFraction is the share of a title that must be behind the viewer for it to count as
// watched. It matches Emby's own default so a household cannot come to disagree with itself
// about whether an episode is finished depending on which viewer watched it.
const PlayedFraction = 0.9
// PlayedFromPosition decides whether a stop report completed the title.
//
// A runtime of zero means the length was not known rather than that the title is zero
// long, so it can never complete anything — the alternative is that every report with a
// missing duration marks something watched at the first second.
func PlayedFromPosition(positionTicks, runtimeTicks int64) bool {
if runtimeTicks <= 0 || positionTicks <= 0 {
return false
}
return float64(positionTicks) >= float64(runtimeTicks)*PlayedFraction
}
// RecordViewerPlayback writes a progress or stop report for a shadow viewer.
//
// 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 sitting at its last frame is
// one the row keeps offering to resume four seconds from the end. play_count only moves on
// the transition into played, so the ten-second reports either side of the threshold cannot
// count one viewing several times.
func (s *Store) RecordViewerPlayback(ctx context.Context, viewerID string, state ViewerState) error {
if viewerID == "" || state.ItemID == "" {
return fmt.Errorf("store: viewer playback: viewer and item are required")
}
position := state.PositionTicks
if position < 0 {
position = 0
}
runtime := state.RuntimeTicks
if runtime < 0 {
runtime = 0
}
if state.Played {
position = 0
}
_, err := s.pool.Exec(ctx, `
INSERT INTO viewer_playback_state (
viewer_id, item_id, series_id, season_id,
position_ticks, runtime_ticks, played, play_count, last_played_at, updated_at
) VALUES (
$1, $2,
-- The series is read out of the shared catalogue rather than asked of Emby or
-- carried by the television: it is already there, it is what orders this
-- viewer's Continue Watching, and a report arrives every ten seconds.
COALESCE(NULLIF($3, ''), (SELECT series_id FROM library_items WHERE id = $2), ''),
$4, $5, $6, $7, CASE WHEN $7 THEN 1 ELSE 0 END, now(), now())
ON CONFLICT (viewer_id, item_id) DO UPDATE SET
series_id = CASE WHEN excluded.series_id <> '' THEN excluded.series_id
ELSE viewer_playback_state.series_id END,
season_id = CASE WHEN excluded.season_id <> '' THEN excluded.season_id
ELSE viewer_playback_state.season_id END,
position_ticks = excluded.position_ticks,
runtime_ticks = CASE WHEN excluded.runtime_ticks > 0 THEN excluded.runtime_ticks
ELSE viewer_playback_state.runtime_ticks END,
played = excluded.played,
play_count = viewer_playback_state.play_count
+ CASE WHEN excluded.played AND NOT viewer_playback_state.played
THEN 1 ELSE 0 END,
last_played_at = now(),
updated_at = now()`,
viewerID, state.ItemID, state.SeriesID, state.SeasonID,
position, runtime, state.Played,
)
if err != nil {
return fmt.Errorf("store: record viewer playback: %w", err)
}
return nil
}
// SetViewerPlayed marks a title watched or unwatched by hand.
//
// Marking unwatched clears the position for the same reason marking watched does: the two
// are one statement about where this viewer stands with the title, and a cleared flag over
// a retained playhead would put it straight back into Continue Watching at the closing
// credits.
func (s *Store) SetViewerPlayed(ctx context.Context, viewerID, itemID string, played bool) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO viewer_playback_state (
viewer_id, item_id, position_ticks, played, play_count, last_played_at, updated_at
) VALUES ($1, $2, 0, $3, CASE WHEN $3 THEN 1 ELSE 0 END,
CASE WHEN $3 THEN now() ELSE NULL END, now())
ON CONFLICT (viewer_id, item_id) DO UPDATE SET
position_ticks = 0,
played = excluded.played,
play_count = viewer_playback_state.play_count
+ CASE WHEN excluded.played AND NOT viewer_playback_state.played
THEN 1 ELSE 0 END,
last_played_at = CASE WHEN excluded.played
THEN COALESCE(viewer_playback_state.last_played_at, now())
ELSE viewer_playback_state.last_played_at END,
updated_at = now()`,
viewerID, itemID, played)
if err != nil {
return fmt.Errorf("store: set viewer played: %w", err)
}
return nil
}
// SetViewerFavourite records a favourite that belongs to the person rather than to the
// Emby account, so one viewer's heart cannot appear on everybody else's launcher.
func (s *Store) SetViewerFavourite(ctx context.Context, viewerID, itemID string, favourite bool) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO viewer_playback_state (viewer_id, item_id, favourite, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (viewer_id, item_id) DO UPDATE SET
favourite = excluded.favourite, updated_at = now()`,
viewerID, itemID, favourite)
if err != nil {
return fmt.Errorf("store: set viewer favourite: %w", err)
}
return nil
}
// HideViewerFromResume takes a title off this viewer's Continue Watching without claiming
// they watched it. The position is kept: hiding is a statement about the row, not about
// where they got to, and pressing Play again should still resume.
func (s *Store) HideViewerFromResume(ctx context.Context, viewerID, itemID string) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO viewer_playback_state (viewer_id, item_id, hidden_from_resume, updated_at)
VALUES ($1, $2, true, now())
ON CONFLICT (viewer_id, item_id) DO UPDATE SET
hidden_from_resume = true, updated_at = now()`,
viewerID, itemID)
if err != nil {
return fmt.Errorf("store: hide from resume: %w", err)
}
return nil
}
// ViewerStateFor reads one title's state. A title with no row is not an error: it is a
// title this viewer has never touched, which is the ordinary case.
func (s *Store) ViewerStateFor(ctx context.Context, viewerID, itemID string) (ViewerState, error) {
state := ViewerState{ItemID: itemID}
err := s.pool.QueryRow(ctx, `
SELECT series_id, season_id, position_ticks, runtime_ticks,
played, play_count, favourite, hidden_from_resume, last_played_at
FROM viewer_playback_state WHERE viewer_id = $1 AND item_id = $2`,
viewerID, itemID,
).Scan(
&state.SeriesID, &state.SeasonID, &state.PositionTicks, &state.RuntimeTicks,
&state.Played, &state.PlayCount, &state.Favourite, &state.HiddenFromResume,
&state.LastPlayedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return state, nil
}
if err != nil {
return ViewerState{}, fmt.Errorf("store: viewer state: %w", err)
}
return state, nil
}
// ViewerStates reads a whole launcher's worth in one query.
//
// This is the read behind every decorated row, so it is one indexed lookup for several
// hundred cards rather than a request per card — the economy decorateItemRatings already
// makes for scores.
func (s *Store) ViewerStates(
ctx context.Context, viewerID string, itemIDs []string,
) (map[string]ViewerState, error) {
states := map[string]ViewerState{}
if viewerID == "" || len(itemIDs) == 0 {
return states, nil
}
rows, err := s.pool.Query(ctx, `
SELECT item_id, series_id, season_id, position_ticks, runtime_ticks,
played, play_count, favourite, hidden_from_resume, last_played_at
FROM viewer_playback_state
WHERE viewer_id = $1 AND item_id = ANY($2)`, viewerID, itemIDs)
if err != nil {
return nil, fmt.Errorf("store: viewer states: %w", err)
}
defer rows.Close()
for rows.Next() {
var state ViewerState
if err := rows.Scan(
&state.ItemID, &state.SeriesID, &state.SeasonID,
&state.PositionTicks, &state.RuntimeTicks,
&state.Played, &state.PlayCount, &state.Favourite, &state.HiddenFromResume,
&state.LastPlayedAt,
); err != nil {
return nil, fmt.Errorf("store: scan viewer state: %w", err)
}
states[state.ItemID] = state
}
return states, rows.Err()
}
// ViewerResumeItems is this viewer's Continue Watching, most recently played first.
//
// It answers in item ids alone: the catalogue is shared by the household and is read from
// library_items or Emby, so duplicating a single field of metadata here would be a second
// copy free to go stale.
func (s *Store) ViewerResumeItems(ctx context.Context, viewerID string, limit int) ([]string, error) {
if viewerID == "" {
return nil, nil
}
if limit <= 0 {
limit = 20
}
rows, err := s.pool.Query(ctx, `
SELECT item_id FROM viewer_playback_state
WHERE viewer_id = $1 AND position_ticks > 0 AND NOT played AND NOT hidden_from_resume
ORDER BY last_played_at DESC NULLS LAST
LIMIT $2`, viewerID, limit)
if err != nil {
return nil, fmt.Errorf("store: viewer resume items: %w", err)
}
defer rows.Close()
ids := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("store: scan resume item: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// ViewerFavouriteItems is this viewer's favourites, most recently marked first.
func (s *Store) ViewerFavouriteItems(ctx context.Context, viewerID string, limit int) ([]string, error) {
if viewerID == "" {
return nil, nil
}
if limit <= 0 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT item_id FROM viewer_playback_state
WHERE viewer_id = $1 AND favourite
ORDER BY updated_at DESC
LIMIT $2`, viewerID, limit)
if err != nil {
return nil, fmt.Errorf("store: viewer favourites: %w", err)
}
defer rows.Close()
ids := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("store: scan favourite: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// ViewerWatchedSeries reports, per series, when this viewer last finished or watched an
// episode of it. It is what orders a shadow viewer's Continue Watching, which merges
// resumable items with the next unwatched episode of a series they are part-way through —
// and a Next Up episode has no time of its own, so it is placed by its series.
func (s *Store) ViewerWatchedSeries(
ctx context.Context, viewerID string, limit int,
) (map[string]time.Time, error) {
watched := map[string]time.Time{}
if viewerID == "" {
return watched, nil
}
if limit <= 0 {
limit = 40
}
rows, err := s.pool.Query(ctx, `
SELECT series_id, max(last_played_at) AS played_at
FROM viewer_playback_state
WHERE viewer_id = $1 AND series_id <> '' AND last_played_at IS NOT NULL
GROUP BY series_id
ORDER BY played_at DESC
LIMIT $2`, viewerID, limit)
if err != nil {
return nil, fmt.Errorf("store: viewer watched series: %w", err)
}
defer rows.Close()
for rows.Next() {
var seriesID string
var playedAt time.Time
if err := rows.Scan(&seriesID, &playedAt); err != nil {
return nil, fmt.Errorf("store: scan watched series: %w", err)
}
watched[seriesID] = playedAt
}
return watched, rows.Err()
}
// ViewerPlayedInSeries reports which of a series' episodes this viewer has finished, which
// is what Next Up walks to find the first one they have not.
func (s *Store) ViewerPlayedInSeries(
ctx context.Context, viewerID, seriesID string,
) (map[string]bool, error) {
played := map[string]bool{}
if viewerID == "" || seriesID == "" {
return played, nil
}
rows, err := s.pool.Query(ctx, `
SELECT item_id, played FROM viewer_playback_state
WHERE viewer_id = $1 AND series_id = $2`, viewerID, seriesID)
if err != nil {
return nil, fmt.Errorf("store: viewer played in series: %w", err)
}
defer rows.Close()
for rows.Next() {
var itemID string
var done bool
if err := rows.Scan(&itemID, &done); err != nil {
return nil, fmt.Errorf("store: scan played episode: %w", err)
}
played[itemID] = done
}
return played, rows.Err()
}
// ViewerNextUp is the next unwatched episode of every series this viewer is part-way
// through, the series they watched most recently first.
//
// It is computed entirely in Postgres, out of the shared catalogue and this viewer's own
// state, because Emby's own NextUp answers for the *account* and there is nobody else to
// ask. That also makes it cheap: the alternative — walking each series' episode list over
// the wire — is one Emby request per show on the tail of the launcher.
//
// Three rules, each of which Emby's own answer also applies:
//
// An episode already resumable is left out, because it is in Continue Watching already and
// the merge would otherwise offer the same show twice.
//
// Specials are not next episodes. Season 0 is a real season and a perfectly good thing to
// watch, but it is not what "next" means, and a show whose specials sort first would never
// offer anything else.
//
// A series with nothing unwatched left simply contributes no row rather than an empty one.
func (s *Store) ViewerNextUp(ctx context.Context, viewerID string, limit int) ([]string, error) {
if viewerID == "" {
return nil, nil
}
if limit <= 0 {
limit = 20
}
rows, err := s.pool.Query(ctx, `
WITH watched AS (
SELECT series_id, max(last_played_at) AS played_at
FROM viewer_playback_state
WHERE viewer_id = $1 AND series_id <> '' AND last_played_at IS NOT NULL
GROUP BY series_id
),
episodes AS (
SELECT li.id,
li.series_id,
COALESCE((li.payload->>'ParentIndexNumber')::int, 0) AS season,
COALESCE((li.payload->>'IndexNumber')::int, 0) AS episode,
w.played_at
FROM library_items li
JOIN watched w ON w.series_id = li.series_id
WHERE li.type = 'Episode'
AND COALESCE((li.payload->>'ParentIndexNumber')::int, 0) > 0
),
unplayed AS (
SELECT e.id, e.played_at,
row_number() OVER (
PARTITION BY e.series_id ORDER BY e.season, e.episode, e.id
) AS rank
FROM episodes e
LEFT JOIN viewer_playback_state vps
ON vps.viewer_id = $1 AND vps.item_id = e.id
WHERE COALESCE(vps.played, false) = false
AND COALESCE(vps.position_ticks, 0) = 0
)
SELECT id FROM unplayed WHERE rank = 1
ORDER BY played_at DESC
LIMIT $2`, viewerID, limit)
if err != nil {
return nil, fmt.Errorf("store: viewer next up: %w", err)
}
defer rows.Close()
ids := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("store: scan next up: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// ViewerAggregate is what a series or a season card says about a viewer: how much of it
// there is, how much of it is behind them, and when they last watched any of it.
//
// It stands in for the block Emby fills in from an item's children, which is the one place
// a shadow viewer was still shown the *account's* answer — a series card ticked because
// somebody else had finished it. Emby computes it per Emby user and there is nobody to ask
// for a person Emby has never heard of, so it is computed here out of the shared catalogue
// and this viewer's own state.
type ViewerAggregate struct {
// Total is how many episodes the catalogue holds. Zero means the catalogue cannot
// answer — a library not yet imported, or a series it has never seen — which is a
// different thing from a series with nothing in it, and the caller must not print a
// count for it.
Total int
// Played is how many of those this viewer has finished.
Played int
// LastPlayedAt is the most recent episode of it they touched, finished or not, which
// is what orders a shelf.
LastPlayedAt *time.Time
}
// ViewerContainerStates aggregates a viewer's episode state per series *and* per season.
//
// One map keyed by container id serves both, because a series id and a season id are both
// Emby GUIDs and cannot collide — so the caller looks an item up by its own id and does not
// have to know which of the two it is holding.
//
// The query groups by the **pair** and the two rollups are done here rather than in SQL.
// That is a deliberately dull query — no grouping sets, no second pass over the same rows —
// and it is exact for both answers because a season belongs to exactly one series, so
// summing a series' seasons is summing its episodes. It runs on the tail of every request
// that serves a series card, which is why the index it reads
// (library_items_series_episodes_idx) exists.
//
// One thing to know about it: a series is only counted completely if it was *asked* for.
// A season whose series was not in seriesIDs contributes to a partial series total, which
// is harmless only because nothing looks that series up — containerIDsIn asks for a
// season's series alongside it precisely so the case cannot arise for anything drawn.
func (s *Store) ViewerContainerStates(
ctx context.Context, viewerID string, seriesIDs, seasonIDs []string,
) (map[string]ViewerAggregate, error) {
aggregates := map[string]ViewerAggregate{}
if viewerID == "" || (len(seriesIDs) == 0 && len(seasonIDs) == 0) {
return aggregates, nil
}
rows, err := s.pool.Query(ctx, `
SELECT li.series_id,
COALESCE(li.payload->>'SeasonId', '') AS season_id,
count(*) AS total,
count(*) FILTER (WHERE COALESCE(vps.played, false)) AS played,
max(vps.last_played_at) AS last_played_at
FROM library_items li
LEFT JOIN viewer_playback_state vps
ON vps.viewer_id = $1 AND vps.item_id = li.id
WHERE li.type = 'Episode'
AND (li.series_id = ANY($2) OR COALESCE(li.payload->>'SeasonId', '') = ANY($3))
GROUP BY li.series_id, COALESCE(li.payload->>'SeasonId', '')`,
viewerID, seriesIDs, seasonIDs)
if err != nil {
return nil, fmt.Errorf("store: viewer container states: %w", err)
}
defer rows.Close()
for rows.Next() {
var seriesID, seasonID string
var total, played int
var lastPlayedAt *time.Time
if err := rows.Scan(&seriesID, &seasonID, &total, &played, &lastPlayedAt); err != nil {
return nil, fmt.Errorf("store: scan container state: %w", err)
}
// An episode filed under no series or no season contributes to neither rather than
// to a row keyed on the empty string, which would be an aggregate about nothing.
addViewerAggregate(aggregates, seriesID, total, played, lastPlayedAt)
addViewerAggregate(aggregates, seasonID, total, played, lastPlayedAt)
}
return aggregates, rows.Err()
}
// addViewerAggregate folds one season's worth of counting into a container's total.
func addViewerAggregate(
into map[string]ViewerAggregate, key string, total, played int, lastPlayedAt *time.Time,
) {
if key == "" {
return
}
aggregate := into[key]
aggregate.Total += total
aggregate.Played += played
if lastPlayedAt != nil &&
(aggregate.LastPlayedAt == nil || lastPlayedAt.After(*aggregate.LastPlayedAt)) {
aggregate.LastPlayedAt = lastPlayedAt
}
into[key] = aggregate
}
+238
View File
@@ -0,0 +1,238 @@
package store
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// ViewerKind separates the one viewer whose state is Emby's from the ones whose state is
// Memby's. It is stated on the row rather than derived from whether an id looks like an
// Emby GUID: the id shape is a safety property, not a source of truth, and a household
// that arrived at an odd id must not silently change which viewer publishes.
const (
ViewerMain = "main"
ViewerShadow = "shadow"
)
// ErrViewerNotFound is returned when an id names no viewer of the account that asked.
var ErrViewerNotFound = errors.New("store: viewer not found")
// MaxShadowViewers bounds an account's list. A picker is a row of cards on a television
// and the D-pad has to reach the end of it; this is a limit on the UI, not on the schema.
const MaxShadowViewers = 7
type Viewer struct {
ID string `json:"id"`
Name string `json:"name"`
ShortName string `json:"shortName,omitempty"`
Colour string `json:"colour,omitempty"`
Kind string `json:"kind"`
HasPIN bool `json:"hasPin"`
CreatedAt time.Time `json:"createdAt"`
}
// IsMain reports whether this viewer's state is published to Emby.
func (v Viewer) IsMain() bool { return v.Kind == ViewerMain }
// NewShadowViewerID mints an id that cannot be mistaken for an Emby user id.
//
// Emby's are 32 hex characters. This is a "v" followed by 32 more, so the two are
// distinguishable by inspection anywhere one is read out of a log line or a cache key —
// which matters because a viewer id is substituted for an emby_user_id in twenty tables
// that cannot tell the difference themselves.
func NewShadowViewerID() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("store: viewer id: %w", err)
}
return "v" + hex.EncodeToString(buf), nil
}
// IsShadowViewerID reports whether an id belongs to the shadow namespace. Callers holding
// no viewer record use it to answer "is this Emby's user or Memby's" cheaply.
func IsShadowViewerID(id string) bool {
return strings.HasPrefix(id, "v") && len(id) == 33
}
// Viewers lists an account's viewers, main first and the rest in the order they were
// added. The main viewer is created on demand: an account that predates this feature has
// no row, and its first request must still resolve to something rather than to an error.
func (s *Store) Viewers(ctx context.Context, embyUserID, username string) ([]Viewer, error) {
if err := s.ensureMainViewer(ctx, embyUserID, username); err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `
SELECT id, name, short_name, colour, kind, pin_hash IS NOT NULL, created_at
FROM viewers WHERE emby_user_id = $1
ORDER BY kind = 'main' DESC, created_at, id`, embyUserID)
if err != nil {
return nil, fmt.Errorf("store: list viewers: %w", err)
}
defer rows.Close()
viewers := []Viewer{}
for rows.Next() {
var v Viewer
if err := rows.Scan(
&v.ID, &v.Name, &v.ShortName, &v.Colour, &v.Kind, &v.HasPIN, &v.CreatedAt,
); err != nil {
return nil, fmt.Errorf("store: scan viewer: %w", err)
}
viewers = append(viewers, v)
}
return viewers, rows.Err()
}
// ensureMainViewer records the account's own viewer if it has none.
//
// The insert is ON CONFLICT DO NOTHING on the primary key, so two televisions signing in
// at once cannot both create it, and the name is only ever set on the way in: the viewer
// may have been renamed since, and an Emby username arriving on every request must not
// overwrite that.
func (s *Store) ensureMainViewer(ctx context.Context, embyUserID, username string) error {
if strings.TrimSpace(embyUserID) == "" {
return fmt.Errorf("store: main viewer: no account")
}
name := strings.TrimSpace(username)
if name == "" {
name = "Me"
}
_, err := s.pool.Exec(ctx, `
INSERT INTO viewers (id, emby_user_id, name, kind)
VALUES ($1, $1, $2, 'main')
ON CONFLICT (id) DO NOTHING`, embyUserID, name)
if err != nil {
return fmt.Errorf("store: ensure main viewer: %w", err)
}
return nil
}
// ViewerFor resolves one viewer *of this account*.
//
// The account is part of the query rather than checked afterwards: the id arrives in a
// request header, so this is the boundary at which one household's television is stopped
// from naming another household's viewer.
func (s *Store) ViewerFor(ctx context.Context, embyUserID, viewerID string) (Viewer, error) {
var v Viewer
err := s.pool.QueryRow(ctx, `
SELECT id, name, short_name, colour, kind, pin_hash IS NOT NULL, created_at
FROM viewers WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID,
).Scan(&v.ID, &v.Name, &v.ShortName, &v.Colour, &v.Kind, &v.HasPIN, &v.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Viewer{}, ErrViewerNotFound
}
if err != nil {
return Viewer{}, fmt.Errorf("store: viewer: %w", err)
}
return v, nil
}
// CreateShadowViewer adds a person to an account.
//
// The count is taken inside the transaction, because the limit is the only thing standing
// between a held D-pad on the add button and an unbounded picker.
func (s *Store) CreateShadowViewer(
ctx context.Context, embyUserID, name, shortName, colour string,
) (Viewer, error) {
name = strings.TrimSpace(name)
if name == "" {
return Viewer{}, fmt.Errorf("store: viewer name is required")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return Viewer{}, fmt.Errorf("store: begin create viewer: %w", err)
}
defer tx.Rollback(ctx)
var shadows int
if err := tx.QueryRow(ctx, `
SELECT count(*) FROM viewers WHERE emby_user_id = $1 AND kind = 'shadow'`,
embyUserID,
).Scan(&shadows); err != nil {
return Viewer{}, fmt.Errorf("store: count viewers: %w", err)
}
if shadows >= MaxShadowViewers {
return Viewer{}, fmt.Errorf("store: %d viewers is the limit", MaxShadowViewers)
}
id, err := NewShadowViewerID()
if err != nil {
return Viewer{}, err
}
var v Viewer
if err := tx.QueryRow(ctx, `
INSERT INTO viewers (id, emby_user_id, name, short_name, colour, kind)
VALUES ($1, $2, $3, $4, $5, 'shadow')
RETURNING id, name, short_name, colour, kind, pin_hash IS NOT NULL, created_at`,
id, embyUserID, name, strings.TrimSpace(shortName), strings.TrimSpace(colour),
).Scan(&v.ID, &v.Name, &v.ShortName, &v.Colour, &v.Kind, &v.HasPIN, &v.CreatedAt); err != nil {
return Viewer{}, fmt.Errorf("store: create viewer: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return Viewer{}, fmt.Errorf("store: commit create viewer: %w", err)
}
return v, nil
}
// UpdateShadowViewer renames or re-colours a viewer. The main viewer is deliberately not
// updatable here: its name is the Emby account's and belongs to Emby.
func (s *Store) UpdateShadowViewer(
ctx context.Context, embyUserID, viewerID, name, shortName, colour string,
) (Viewer, error) {
name = strings.TrimSpace(name)
if name == "" {
return Viewer{}, fmt.Errorf("store: viewer name is required")
}
var v Viewer
err := s.pool.QueryRow(ctx, `
UPDATE viewers SET name = $3, short_name = $4, colour = $5, updated_at = now()
WHERE emby_user_id = $1 AND id = $2 AND kind = 'shadow'
RETURNING id, name, short_name, colour, kind, pin_hash IS NOT NULL, created_at`,
embyUserID, viewerID, name, strings.TrimSpace(shortName), strings.TrimSpace(colour),
).Scan(&v.ID, &v.Name, &v.ShortName, &v.Colour, &v.Kind, &v.HasPIN, &v.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Viewer{}, ErrViewerNotFound
}
if err != nil {
return Viewer{}, fmt.Errorf("store: update viewer: %w", err)
}
return v, nil
}
// DeleteShadowViewer removes a viewer and everything Memby held on their behalf.
//
// A main viewer can never be deleted through this route: it is the account's own, and an
// account with no main viewer would have nothing to fall back to. The playback state goes
// with the row rather than being left to a housekeeping task, because the whole of what it
// describes is a person who no longer exists.
func (s *Store) DeleteShadowViewer(ctx context.Context, embyUserID, viewerID string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("store: begin delete viewer: %w", err)
}
defer tx.Rollback(ctx)
tag, err := tx.Exec(ctx, `
DELETE FROM viewers WHERE emby_user_id = $1 AND id = $2 AND kind = 'shadow'`,
embyUserID, viewerID)
if err != nil {
return fmt.Errorf("store: delete viewer: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrViewerNotFound
}
if _, err := tx.Exec(ctx,
`DELETE FROM viewer_playback_state WHERE viewer_id = $1`, viewerID); err != nil {
return fmt.Errorf("store: delete viewer state: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("store: commit delete viewer: %w", err)
}
return nil
}
+100
View File
@@ -0,0 +1,100 @@
package store
import (
"testing"
"time"
)
// A shadow id must never be mistaken for an Emby user id: the two are substituted for one
// another in twenty tables that cannot tell the difference, so telling them apart by
// inspection is the safety property the whole scheme rests on.
func TestShadowViewerIDIsDistinguishableFromEmbyUserID(t *testing.T) {
id, err := NewShadowViewerID()
if err != nil {
t.Fatalf("mint shadow id: %v", err)
}
if !IsShadowViewerID(id) {
t.Fatalf("minted id %q not recognised as a shadow id", id)
}
// Emby's are 32 hex characters with no prefix.
if IsShadowViewerID("8f14e45fceea167a5a36dedd4bea2543") {
t.Fatal("an Emby user id was read as a shadow viewer")
}
if IsShadowViewerID("") || IsShadowViewerID("v") || IsShadowViewerID("viewer") {
t.Fatal("a short string was read as a shadow viewer")
}
other, err := NewShadowViewerID()
if err != nil {
t.Fatalf("mint second shadow id: %v", err)
}
if other == id {
t.Fatal("two minted ids collided")
}
}
func TestPlayedFromPosition(t *testing.T) {
const hour = int64(36_000_000_000) // one hour in Emby ticks
for _, tc := range []struct {
name string
position int64
runtime int64
want bool
}{
{"finished", hour, hour, true},
{"at the threshold", hour * 9 / 10, hour, true},
{"just short of it", hour*9/10 - 1, hour, false},
{"barely started", hour / 100, hour, false},
// A runtime of zero means the length was not known, not that the title is zero
// long. Reading it the other way marks everything watched at the first second.
{"unknown runtime", hour, 0, false},
{"nothing watched", 0, hour, false},
{"negative position", -hour, hour, false},
// Playing past the stated runtime is ordinary — a container whose duration is a
// little short of its own last frame.
{"past the end", hour * 2, hour, true},
} {
t.Run(tc.name, func(t *testing.T) {
if got := PlayedFromPosition(tc.position, tc.runtime); got != tc.want {
t.Fatalf("PlayedFromPosition(%d, %d) = %v, want %v",
tc.position, tc.runtime, got, tc.want)
}
})
}
}
// The rollup a series card's tick and count are built from. It is done in Go rather than in
// SQL — the query groups by the season/series pair and this folds it twice — so it is worth
// pinning that both directions add up and that the date is the latest of them.
func TestViewerAggregateRollup(t *testing.T) {
earlier := time.Date(2026, 8, 1, 20, 0, 0, 0, time.UTC)
later := time.Date(2026, 8, 18, 21, 30, 0, 0, time.UTC)
aggregates := map[string]ViewerAggregate{}
// Two seasons of one show, folded into the series and kept apart per season.
addViewerAggregate(aggregates, "show-1", 10, 10, &earlier)
addViewerAggregate(aggregates, "season-1", 10, 10, &earlier)
addViewerAggregate(aggregates, "show-1", 8, 3, &later)
addViewerAggregate(aggregates, "season-2", 8, 3, &later)
series := aggregates["show-1"]
if series.Total != 18 || series.Played != 13 {
t.Errorf("series rollup = %d of %d, want 13 of 18", series.Played, series.Total)
}
if series.LastPlayedAt == nil || !series.LastPlayedAt.Equal(later) {
t.Errorf("series last played = %v, want the later of the two", series.LastPlayedAt)
}
if got := aggregates["season-1"]; got.Total != 10 || got.Played != 10 {
t.Errorf("season one = %d of %d, want 10 of 10", got.Played, got.Total)
}
if got := aggregates["season-2"]; got.Total != 8 || got.Played != 3 {
t.Errorf("season two = %d of %d, want 3 of 8", got.Played, got.Total)
}
// An episode filed under no series or no season contributes to neither, rather than to
// a row keyed on the empty string — which would be an aggregate about nothing.
addViewerAggregate(aggregates, "", 5, 5, &later)
if _, ok := aggregates[""]; ok {
t.Error("an unfiled episode produced an aggregate")
}
}