diff --git a/VIEWERS.md b/VIEWERS.md new file mode 100644 index 0000000..dda8ec4 --- /dev/null +++ b/VIEWERS.md @@ -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::@#`. 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. diff --git a/admin-ui/src/pages/Account.tsx b/admin-ui/src/pages/Account.tsx index 2def472..39685cd 100644 --- a/admin-ui/src/pages/Account.tsx +++ b/admin-ui/src/pages/Account.tsx @@ -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(null); const [pending, setPending] = useState(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(`${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() { )} + {/* 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. */} + switched off + ) : ( + {num(shadowViewers.length)} beside the account + ) + } + footer={ + canAddViewer ? ( + + ) : ( + + ) + } + > + {/* 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 ? ( + + ) : null} + {viewersQuery.loading ? ( + + ) : (viewersQuery.data?.viewers ?? []).length === 0 ? ( + Nobody is set up yet, so this account has one viewer: itself. + ) : ( +
+ {(viewersQuery.data?.viewers ?? []).map((viewer) => ( +
+
+ {viewer.name} +

+ {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)}` : ''} +

+
+
+ {viewer.kind === 'main' ? ( + synced with Emby + ) : ( + <> + + + + )} +
+
+ ))} +
+ )} +
+ ) : null} + {namingViewer ? ( + 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 ? ( 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 ( +
event.target === event.currentTarget && onCancel()}> +
+

{renaming ? 'Rename this viewer' : 'Add a viewer'}

+

+ The name shown on the television’s “Who’s watching?” screen. Everything + they watch is kept by Memby and never reported to Emby. +

+ + setName(event.target.value)} + /> + +
+ + +
+
+
+ ); +} + 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.', diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6e486f6..196b9fb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index 3b06bb7..d38478c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -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 { + 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. * diff --git a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt index cc0dd54..d5b11ac 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt @@ -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 = 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? { + private fun profileHomeCacheKey( + userId: String?, + serverUrl: String?, + viewerId: String? = null, + ): Preferences.Key? { 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> { + 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, ) } diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt index 0477ad3..d5ea569 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt @@ -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 = 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. * diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt index 6f3203c..d561cf4 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt index 0f32d4a..f87473f 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt @@ -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" diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt index dd1032a..a6cf528 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -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 who’s 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 who’s 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, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index 2b5d8a4..ccc5d21 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -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>(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(null) } + var viewerName by remember { mutableStateOf("") } + var viewerNameFailure by remember { mutableStateOf(null) } + var viewerSaving by remember { mutableStateOf(false) } + var viewerBusyId by remember { mutableStateOf(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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt b/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt index 99961a7..e22e816 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt @@ -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 = buildList { + if (showViewers) add(UserSwitcherMenuItem.VIEWERS) + add(UserSwitcherMenuItem.NOTIFICATIONS) + if (showRequests) add(UserSwitcherMenuItem.REQUESTS) + add(UserSwitcherMenuItem.SETTINGS) + add(UserSwitcherMenuItem.MANAGE_USERS) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPackFontAwesome.kt b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPackFontAwesome.kt index 7e4de10..af4e308 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPackFontAwesome.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPackFontAwesome.kt @@ -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 }, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPackLucide.kt b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPackLucide.kt index 8fb0c98..61534d5 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPackLucide.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPackLucide.kt @@ -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 }, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPacks.kt b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPacks.kt index 2e780c6..4f8077e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPacks.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPacks.kt @@ -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 }, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIcons.kt b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIcons.kt index 2fe6322..22d2bba 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIcons.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIcons.kt @@ -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, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerControls.kt b/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerControls.kt new file mode 100644 index 0000000..e0f91bc --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerControls.kt @@ -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) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerEditing.kt b/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerEditing.kt new file mode 100644 index 0000000..96c5f5e --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerEditing.kt @@ -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, + 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, + 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) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerManageScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerManageScreen.kt new file mode 100644 index 0000000..3039800 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerManageScreen.kt @@ -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, + 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(null) } + var removalReturnIndex by remember { mutableStateOf(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, + ) + } + } + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerNameEntry.kt b/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerNameEntry.kt new file mode 100644 index 0000000..91bec3d --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerNameEntry.kt @@ -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, + 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, + ) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerPicker.kt b/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerPicker.kt new file mode 100644 index 0000000..2eca7a7 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerPicker.kt @@ -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, + 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, + ) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerSelection.kt b/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerSelection.kt new file mode 100644 index 0000000..8b761f2 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerSelection.kt @@ -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, 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) diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/requests/RequestPresentationTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/requests/RequestPresentationTest.kt index c580e0f..0c0bbf2 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/requests/RequestPresentationTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/requests/RequestPresentationTest.kt @@ -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) } } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/viewers/ViewerEditingTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/viewers/ViewerEditingTest.kt new file mode 100644 index 0000000..4312210 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/viewers/ViewerEditingTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/viewers/ViewerManagementScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/viewers/ViewerManagementScreenshotTest.kt new file mode 100644 index 0000000..5e14557 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/viewers/ViewerManagementScreenshotTest.kt @@ -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") + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/viewers/ViewerPickerScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/viewers/ViewerPickerScreenshotTest.kt new file mode 100644 index 0000000..df67430 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/viewers/ViewerPickerScreenshotTest.kt @@ -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, + 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") + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/viewers/ViewerSelectionTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/viewers/ViewerSelectionTest.kt new file mode 100644 index 0000000..29453c5 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/viewers/ViewerSelectionTest.kt @@ -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)) + } +} diff --git a/server/internal/api/admin.go b/server/internal/api/admin.go index 8e75175..0c65cce 100644 --- a/server/internal/api/admin.go +++ b/server/internal/api/admin.go @@ -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)) diff --git a/server/internal/api/admin_viewers.go b/server/internal/api/admin_viewers.go new file mode 100644 index 0000000..e401c27 --- /dev/null +++ b/server/internal/api/admin_viewers.go @@ -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) +} diff --git a/server/internal/api/analytics.go b/server/internal/api/analytics.go index a460630..de738ca 100644 --- a/server/internal/api/analytics.go +++ b/server/internal/api/analytics.go @@ -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) } diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 79ec833..d5ae671 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -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)), diff --git a/server/internal/api/auth.go b/server/internal/api/auth.go index aa1f23c..2fcc7a6 100644 --- a/server/internal/api/auth.go +++ b/server/internal/api/auth.go @@ -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, diff --git a/server/internal/api/features.go b/server/internal/api/features.go index d55d6c3..206c606 100644 --- a/server/internal/api/features.go +++ b/server/internal/api/features.go @@ -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 " + diff --git a/server/internal/api/genres.go b/server/internal/api/genres.go index 8a0d14b..76ca740 100644 --- a/server/internal/api/genres.go +++ b/server/internal/api/genres.go @@ -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) diff --git a/server/internal/api/hero.go b/server/internal/api/hero.go index 5d6a2e0..44b7463 100644 --- a/server/internal/api/hero.go +++ b/server/internal/api/hero.go @@ -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 { diff --git a/server/internal/api/hero_active.go b/server/internal/api/hero_active.go index acf68ba..a63264b 100644 --- a/server/internal/api/hero_active.go +++ b/server/internal/api/hero_active.go @@ -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) diff --git a/server/internal/api/home.go b/server/internal/api/home.go index e3dc38a..ed0fa74 100644 --- a/server/internal/api/home.go +++ b/server/internal/api/home.go @@ -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. diff --git a/server/internal/api/items.go b/server/internal/api/items.go index 9b191ea..48a4bcd 100644 --- a/server/internal/api/items.go +++ b/server/internal/api/items.go @@ -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) diff --git a/server/internal/api/logcontext.go b/server/internal/api/logcontext.go index f650736..729534b 100644 --- a/server/internal/api/logcontext.go +++ b/server/internal/api/logcontext.go @@ -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) } diff --git a/server/internal/api/people.go b/server/internal/api/people.go index 8d06789..b868fc9 100644 --- a/server/internal/api/people.go +++ b/server/internal/api/people.go @@ -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{ diff --git a/server/internal/api/playback.go b/server/internal/api/playback.go index f94add3..2175276 100644 --- a/server/internal/api/playback.go +++ b/server/internal/api/playback.go @@ -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, diff --git a/server/internal/api/ranking.go b/server/internal/api/ranking.go index 23588f5..d8825f8 100644 --- a/server/internal/api/ranking.go +++ b/server/internal/api/ranking.go @@ -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) diff --git a/server/internal/api/ratings_attach.go b/server/internal/api/ratings_attach.go index b8f80ae..3d3184f 100644 --- a/server/internal/api/ratings_attach.go +++ b/server/internal/api/ratings_attach.go @@ -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 diff --git a/server/internal/api/related.go b/server/internal/api/related.go index 1230e9c..1c95bd6 100644 --- a/server/internal/api/related.go +++ b/server/internal/api/related.go @@ -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, diff --git a/server/internal/api/viewer_rows.go b/server/internal/api/viewer_rows.go new file mode 100644 index 0000000..2b8b960 --- /dev/null +++ b/server/internal/api/viewer_rows.go @@ -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 +} diff --git a/server/internal/api/viewer_state_attach.go b/server/internal/api/viewer_state_attach.go new file mode 100644 index 0000000..2ef7cc7 --- /dev/null +++ b/server/internal/api/viewer_state_attach.go @@ -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 +} diff --git a/server/internal/api/viewers.go b/server/internal/api/viewers.go new file mode 100644 index 0000000..180ef6f --- /dev/null +++ b/server/internal/api/viewers.go @@ -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) + } + } +} diff --git a/server/internal/api/viewers_test.go b/server/internal/api/viewers_test.go new file mode 100644 index 0000000..912d413 --- /dev/null +++ b/server/internal/api/viewers_test.go @@ -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") + } +} diff --git a/server/internal/logging/console.go b/server/internal/logging/console.go index 9b71244..4b04121 100644 --- a/server/internal/logging/console.go +++ b/server/internal/logging/console.go @@ -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, diff --git a/server/internal/store/schema.sql b/server/internal/store/schema.sql index 3022ab2..4d95afe 100644 --- a/server/internal/store/schema.sql +++ b/server/internal/store/schema.sql @@ -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; diff --git a/server/internal/store/viewer_playback.go b/server/internal/store/viewer_playback.go new file mode 100644 index 0000000..8cff078 --- /dev/null +++ b/server/internal/store/viewer_playback.go @@ -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 +} diff --git a/server/internal/store/viewers.go b/server/internal/store/viewers.go new file mode 100644 index 0000000..af879f8 --- /dev/null +++ b/server/internal/store/viewers.go @@ -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 +} diff --git a/server/internal/store/viewers_test.go b/server/internal/store/viewers_test.go new file mode 100644 index 0000000..f17ff1e --- /dev/null +++ b/server/internal/store/viewers_test.go @@ -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") + } +}