0.2.82
This commit is contained in:
+468
@@ -0,0 +1,468 @@
|
||||
# Viewers
|
||||
|
||||
**A Memby account is the household's relationship with an Emby user. A viewer is one
|
||||
person under it.** Every account has exactly one **main** viewer, whose state is Emby's,
|
||||
and any number of **shadow** viewers, whose state is Memby's alone. A shadow viewer
|
||||
watches through the account's Emby credentials and Emby never learns what they watched.
|
||||
|
||||
```
|
||||
Emby user
|
||||
└── Memby account (Living Room)
|
||||
├── Matt main → watched state, progress and favourites go to Emby
|
||||
├── Alessandra shadow → state lives in Memby
|
||||
└── Guest shadow → state lives in Memby
|
||||
```
|
||||
|
||||
A viewer does **not** belong to a television. Somebody starts an episode in the lounge and
|
||||
finishes it in the bedroom, because the state is the gateway's.
|
||||
|
||||
## What was verified before any of this was written
|
||||
|
||||
**Every write of Emby viewer state passes through four methods** on `emby.Client`:
|
||||
`ReportPlayback`, `SetFavorite`, `SetPlayed` and `HideFromResume`. All four are called
|
||||
only from `api` handlers holding a `store.Session`. Nothing else writes viewer state, so
|
||||
keeping a shadow viewer out of Emby is four branches rather than a sweep of the codebase.
|
||||
That is the property this feature is built on, and it is the one to re-check before
|
||||
adding any new call into `emby.Client`.
|
||||
|
||||
**Two things still reach Emby, and both are accepted deliberately.** `PlaybackInfo` is
|
||||
sent with `IsPlayback: true`, which registers a play session — so shadow playback appears
|
||||
in Emby's Now Playing and activity log as the account. And `tracearr` identifies viewers
|
||||
by *Emby username* (`tracearr/client.go`), so a shadow viewer's watching feeds the
|
||||
account's Tracearr history, watch-time figures and genre affinity. Neither touches watched
|
||||
state or resume position, which is what the feature promises. Both are v2 problems.
|
||||
|
||||
**There is no second implementation on the direct path.** On the direct-to-Emby path the
|
||||
television reports straight to Emby itself, so there is nothing in between to route a
|
||||
viewer's state to — the stance the TV calendar and genre affinity already take. Viewers
|
||||
are gateway-only, and a set with no gateway has one viewer, which is the account.
|
||||
|
||||
## The seam: a viewer id, not a second user
|
||||
|
||||
Before this, `sess.EmbyUserID` did two unrelated jobs — it was the Emby credential *and*
|
||||
Memby's key for "who is watching". Viewers separate them:
|
||||
|
||||
- **The credential stays the account's.** `credentials(sess)` is unchanged, and every
|
||||
library read, image, stream URL and subtitle still goes out as the Emby user. A shadow
|
||||
viewer has no Emby identity and needs none.
|
||||
- **The person is `viewer.ID`.** Preferences, notifications, followed shows, row
|
||||
statistics, search history, recommendation profiles and cache keys belong to whoever is
|
||||
watching.
|
||||
|
||||
**The main viewer's id *is* the Emby user id**, which is the whole reason this needed no
|
||||
migration. Every table in the schema keys a person by a bare `emby_user_id TEXT` with no
|
||||
foreign key behind it, so substituting a viewer id leaves an existing household's rows
|
||||
exactly where they were, and the main viewer behaves as the account did by construction
|
||||
rather than by care. A shadow id is `"v"` plus 32 hex characters, so it can be told apart
|
||||
from Emby's 32-hex GUIDs by inspection in a log line or a cache key
|
||||
(`store.IsShadowViewerID`).
|
||||
|
||||
`Viewer.Kind` is still stored and is what every branch reads. The id shape is a safety
|
||||
property, not a source of truth.
|
||||
|
||||
## How a request learns who is watching
|
||||
|
||||
`X-Memby-Viewer`, resolved by `s.activeViewer` (`api/viewers.go`).
|
||||
|
||||
It is a header rather than part of the session because switching between two people on one
|
||||
television must not be a re-authentication, and because the same person is one viewer on
|
||||
every set in the house. The session still answers "which account is this and what may it
|
||||
read".
|
||||
|
||||
Things to preserve:
|
||||
|
||||
- **It is stated, never inferred** — the stance `Credentials.Gateway` takes. An app that
|
||||
predates viewers sends nothing and resolves to the main viewer, which is exactly its
|
||||
previous behaviour.
|
||||
- **Every unknown case falls back to the main viewer**, including a list that will not
|
||||
load. This is on the path of every authenticated request, and a television unable to do
|
||||
anything because a header could not be checked is a far worse failure than one request
|
||||
attributed to the account. The one thing it will not do is accept an id it could not
|
||||
confirm belongs to this account.
|
||||
- **An unknown id is logged, not refused.** The ordinary cause is a set still holding a
|
||||
viewer somebody has since deleted, and a 403 on every request would leave it unable to
|
||||
reach the picker that would fix it.
|
||||
- **The list is cached for `viewerListTTL`** and the write clears it, because `/v1/status`
|
||||
alone is every open television every ten seconds — the bargain `featurePolicyCache`
|
||||
already makes. A failed refresh keeps the previous reading rather than dropping to the
|
||||
main viewer, which would silently move a shadow viewer's playback back onto Emby.
|
||||
|
||||
## The common state layer
|
||||
|
||||
`viewerUserData` renders a viewer's state in the shape of Emby's `UserData` block, and the
|
||||
television never learns which it received. That is what keeps viewers from becoming a
|
||||
special case in every screen: a card draws its progress bar, tick and heart from
|
||||
`UserData`, and where the block came from is not a question anything above that line asks.
|
||||
|
||||
The client reads exactly five fields (`UserItemData` in `EmbyModels.kt`), and
|
||||
`viewers_test.go` pins them. Two are omitted rather than sent empty — a title with no
|
||||
runtime has no percentage, and one never played has no date — because a card claiming a
|
||||
zero-length progress bar is worse than one claiming nothing.
|
||||
|
||||
## Where state is written
|
||||
|
||||
`viewer_playback_state`, keyed `(viewer_id, item_id)`. The Emby item id is the common
|
||||
identifier, so no library metadata is duplicated and nothing here needs invalidating when
|
||||
the catalogue changes. **There is deliberately no row for a main viewer**: their state is
|
||||
Emby's, and a copy here would be a second answer free to disagree with what the
|
||||
household's other Emby clients see.
|
||||
|
||||
- **A completed title is stored at position zero**, the way Emby stores one. The position
|
||||
is what Continue Watching reads, and a finished episode left at its last frame is one the
|
||||
row keeps offering to resume four seconds from the end.
|
||||
- **Only a stop can complete a title.** A progress report crossing the threshold is
|
||||
somebody still watching the closing minutes, and marking it played there would take the
|
||||
episode out of Continue Watching underneath them.
|
||||
- **`PlayedFromPosition` is the completion rule and it matches Emby's own** (90%), so a
|
||||
household cannot disagree with itself about whether an episode is finished depending on
|
||||
who watched it. A runtime of zero means the length was not known rather than that the
|
||||
title is zero long, so it can never complete anything.
|
||||
- **`play_count` moves only on the transition into played**, so the ten-second reports
|
||||
either side of the threshold cannot count one viewing several times.
|
||||
- **The series is read out of `library_items` inside the upsert**, not asked of Emby and
|
||||
not carried by the television. It is already there, it is what will order this viewer's
|
||||
Continue Watching, and a report arrives every ten seconds.
|
||||
- **Hiding keeps the position.** Hiding is a statement about the row, not about where
|
||||
somebody got to, and pressing Play again should still resume.
|
||||
|
||||
## What a viewer reads
|
||||
|
||||
The write side is four branches. The read side is the larger half, and it is deliberately
|
||||
**one substitution rather than a second code path**: the fan-out, the failure counting and
|
||||
the Continue Watching merge in `handleHome` are identical for both kinds of viewer, and
|
||||
only the fetch behind three rows changes.
|
||||
|
||||
**Everything is keyed on `viewerKeyOf(ctx, sess)`.** The viewer is resolved once, in
|
||||
`authed`, and carried in the request context (`withViewer` / `viewerOf`). That is the
|
||||
point: the alternative is calling `activeViewer` at each of the fifteen sites that build a
|
||||
cache key, and the failure of forgetting one is silent — that view is keyed under the
|
||||
account, and one viewer is served another's rows. Resolving at the boundary makes
|
||||
forgetting impossible. A request that never passed through `authed` — a scheduled task, a
|
||||
probe, a test — resolves to the account, which is the value every one of these keys held
|
||||
before viewers existed.
|
||||
|
||||
**Cache keys that moved to the viewer**: home, search, genre and library browse, item
|
||||
detail, series episodes, person filmography, related, the active hero. **Keys that
|
||||
deliberately did not**: genre affinity, trailers, the screensaver, recommendations and the
|
||||
Magic pool. Those answer for the household or are computed from the account's Tracearr
|
||||
history, and keying them per viewer would multiply the misses without changing the answer.
|
||||
They are the v2 list.
|
||||
|
||||
**`decorateItems` is the one door items leave the gateway through.** It attaches the
|
||||
stored review scores and, for a shadow viewer, replaces the `UserData` block. It rides
|
||||
exactly where `decorateItemRatings` rode — one indexed read for a whole launcher, at every
|
||||
point items are served. The two remained separate functions because they are separate
|
||||
concerns, but every call site wanted both, and *a decoration added at seven sites is a
|
||||
decoration missing from the eighth*.
|
||||
|
||||
Things to preserve in `decorateViewerState`:
|
||||
|
||||
- **A main viewer returns immediately.** Their state is Emby's and is already on the
|
||||
payload, so a household running no viewers pays one comparison for the whole launcher.
|
||||
- **Every item is rewritten, not only the ones with something stored.** The `UserData`
|
||||
that arrived from Emby is the *account's*, and leaving it on a title this viewer has
|
||||
never touched is precisely the leak the feature exists to prevent. A title with no row
|
||||
gets the zero state, which is the truth about it. A failed state read blanks everything
|
||||
for the same reason: a launcher with no progress bars is a poor answer, one showing
|
||||
somebody else's is a wrong one.
|
||||
- **The block is replaced, never merged.** A partial overlay leaves whichever fields Memby
|
||||
had nothing to say about carrying the account's values — the same leak from a narrower
|
||||
angle.
|
||||
- **A series and a season are answered from a count**, not from a row of their own. Emby
|
||||
fills their block in from children it has never heard of for this person, so
|
||||
`ViewerContainerStates` counts the episodes the shared catalogue holds against this
|
||||
viewer's own played set and `viewerAggregateUserData` renders the result. This was the
|
||||
last place a shadow viewer was shown the account's answer — a series ticked because
|
||||
somebody else had finished it.
|
||||
|
||||
## What a series card says
|
||||
|
||||
`UnplayedItemCount` and `PlayedPercentage` are **omitted rather than sent as zero** when the
|
||||
catalogue cannot count the title, the rule the leaf block follows: a library not yet
|
||||
imported, and a show the import has never seen, would otherwise tick every series in the
|
||||
house. `Played` is only true where there is something to have finished, for the same reason.
|
||||
|
||||
The **favourite is the container's own** and comes from the row against the series id, not
|
||||
from the count: somebody marks a *show* a favourite, not the sum of its episodes. And a
|
||||
container is never resumable — what resumes is an episode — so `PlaybackPositionTicks` is
|
||||
zero, which is what Emby reports too.
|
||||
|
||||
The query groups by the **season/series pair** and the two rollups are done in Go
|
||||
(`addViewerAggregate`). That is deliberately dull — no grouping sets, no second pass — and
|
||||
it is exact for both answers because a season belongs to exactly one series. `containerIDsIn`
|
||||
asks for a season's series alongside it, which is what keeps a partial series total from
|
||||
belonging to anything drawn. `library_items_series_episodes_idx` is what makes it affordable;
|
||||
without it, a series card costs a scan of every episode in the library, and it is also what
|
||||
`ViewerNextUp` walks.
|
||||
|
||||
## The three rows that are about a person
|
||||
|
||||
`resume`, `favourites` and `nextup` are substituted in the home fan-out. The ranking is
|
||||
Postgres's and Emby is asked only to *describe* the titles — which is why `orderItemsByID`
|
||||
exists: Emby answers an `Ids=` query in its own order, and for these rows the order **is**
|
||||
the answer. Handing Emby's order back would keep the right titles and throw away the reason
|
||||
they were chosen. It also drops a title Emby will not answer for (a card that cannot be
|
||||
opened is worse than a missing one) and collapses a repeated id, because every keyed list
|
||||
on the television throws on a duplicate.
|
||||
|
||||
**Next Up is computed entirely in Postgres** (`store.ViewerNextUp`), out of the shared
|
||||
catalogue and this viewer's own state. Emby's `NextUp` answers for the account and there is
|
||||
nobody else to ask — and the alternative, walking each series' episode list over the wire,
|
||||
is one Emby request per show on the tail of the launcher. It applies the same three rules
|
||||
Emby's own answer does: an episode already resumable is left out (it is in Continue
|
||||
Watching, and the merge would offer the show twice), specials are not next episodes, and a
|
||||
series with nothing unwatched left contributes no row rather than an empty one.
|
||||
|
||||
**The merge is unchanged.** `ViewerWatchedSeries` returns exactly the
|
||||
`map[string]time.Time` that `recentlyPlayedSeries` does, so `mergeContinueWatching` never
|
||||
learns which viewer it is ordering for.
|
||||
|
||||
## Where a shadow viewer's playback starts
|
||||
|
||||
Taken from the store in `handlePlayback`, not trusted from the card. The hint the
|
||||
television sends is read off a card this gateway already decorated with the viewer's own
|
||||
state, so the two normally agree — but only normally: the store has heard about the episode
|
||||
they were part-way through on the *other* television, and a card is only as fresh as the
|
||||
last home refresh. It is also the value handed to `PlaybackInfo`, so taking it here fixes
|
||||
the negotiated stream as well as the number sent back. A failed read starts from the
|
||||
beginning, because that is a recoverable disappointment where starting from where somebody
|
||||
else got to is not.
|
||||
|
||||
**Pressing Play on a series** resolves through `firstUnwatchedEpisodeFor` rather than
|
||||
Emby's `NextUp`, which is what would otherwise drop a shadow viewer into the middle of
|
||||
somebody else's season. A part-watched episode wins over the first unwatched one — somebody
|
||||
eleven minutes in wants that episode, the same judgement the Continue Watching merge makes.
|
||||
A viewer with nothing recorded for the series falls through to Emby's first episode, which
|
||||
is the right answer for somebody who has never watched any of it.
|
||||
|
||||
**Auto-advance** keeps Emby's answer for *which* episode follows — that is a property of
|
||||
the season and is the same for everybody — and replaces only how far into it this viewer
|
||||
already is.
|
||||
|
||||
## Signing out is about the account
|
||||
|
||||
`invalidateAccountViews` drops every viewer's cached views, not only the account's.
|
||||
Invalidating one key would leave each shadow viewer's rows behind, to be served intact to
|
||||
the next person who signs in on that set. It is best-effort: what it misses expires on its
|
||||
own TTL, and nothing there is worth failing a sign-out over.
|
||||
|
||||
## The television
|
||||
|
||||
**`X-Memby-Viewer` is sent by `GatewayAuthInterceptor`**, read on each request rather than
|
||||
captured — the bargain the audio capability tokens already make. Switching between two
|
||||
people must not rebuild the HTTP client, and the answer can change between any two
|
||||
requests. The header is **omitted rather than sent empty** when nobody has been chosen,
|
||||
because that is precisely what an app predating viewers sends and what the gateway reads as
|
||||
the account's own viewer. The read is guarded, like the audio tokens, because the
|
||||
interceptor also runs before the service locator exists in a screenshot context.
|
||||
|
||||
`viewers_v1` joins `MEMBY_CAPABILITIES`, so an operator cannot switch the feature on for a
|
||||
household half of whose televisions have no way of choosing between people.
|
||||
|
||||
**The active viewer is device state**, and it is the one thing about viewers that is: a
|
||||
viewer follows the person to every set in the house, but which of them is sitting in front
|
||||
of *this* one is that set's own answer — the lounge and the bedroom are commonly two
|
||||
different people at the same moment. `activeViewerId` and `activeViewerName` are written in
|
||||
**one** edit (DataStore rewrites the whole file per edit, and a name landing apart from the
|
||||
id it labels would leave the launcher greeting one person while every request named
|
||||
another), and both are cleared with the session and on any account switch: the people under
|
||||
one Emby account are not the people under another.
|
||||
|
||||
**The home cache is keyed per viewer**, `home_cache::<userId>@<serverUrl>#<viewerId>`. A
|
||||
cold start draws the cache before the first refresh lands, so without this a shadow viewer
|
||||
would open on the account's evening. **The account's own viewer keys exactly as it always
|
||||
did, with no suffix**, so every existing install keeps the cache it already has. Removing a
|
||||
profile now clears *every* viewer's key for it (`profileHomeCacheKeys`), or the largest
|
||||
values this store holds would be orphaned.
|
||||
|
||||
`switchViewer` clears the playable, series-episode, local-resume and genre-affinity caches —
|
||||
the same set a profile switch clears, for the same reason: they hold one person's watched
|
||||
state, resume positions and reasons. It deliberately does **not** clear the persisted home
|
||||
cache, which is keyed per viewer, so switching back is instant.
|
||||
|
||||
**The gateway's `active` field is adopted.** `viewers()` compares what the server resolved
|
||||
the request to against what this set is sending, and takes the server's answer when the two
|
||||
disagree and the id is not in the list. The case it exists for is a viewer deleted on
|
||||
another television: this set is still sending an id nothing recognises, the gateway has
|
||||
quietly fallen back, and without adopting that the picker would go on showing somebody who
|
||||
no longer exists as selected.
|
||||
|
||||
## The picker
|
||||
|
||||
`ui/viewers/ViewerPicker.kt` — "Who's watching?", a full screen rather than another row in
|
||||
the user switcher. That panel lists *accounts* and the actions beside them; a viewer is a
|
||||
different grain of thing, and a household picks a person the way they pick one on any
|
||||
television service, by looking at a row of faces. Folding them into the same 292dp column
|
||||
would have made two unrelated questions look like one list.
|
||||
|
||||
Stateless, the stance `SignInContent` and the detail panes take, so
|
||||
`ViewerPickerScreenshotTest` renders it with no gateway → `build/screenshots/viewers/`.
|
||||
Things to preserve:
|
||||
|
||||
- **Focus opens on whoever is watching**, not on the first card: a television is switched on
|
||||
by the person who last used it far more often than not, so the common case is one confirm
|
||||
press rather than a walk along the row.
|
||||
- **The scroll keeps one card of context behind the focused one**
|
||||
(`viewerPickerScrollIndex`). Scrolling straight to the focused card pins it against the
|
||||
left edge and the people before it vanish with nothing saying they are there — the capture
|
||||
is what caught it. Focus and scroll are separate functions because they answer different
|
||||
questions: where the remote is, and what the eye can see.
|
||||
- **"Synced with Emby" is a reserved line, not a conditional one**, or a card without it
|
||||
sits taller than the one beside it — the rule the cast grid's character line follows. It
|
||||
is most of the difference between a household understanding this feature and being puzzled
|
||||
by it.
|
||||
- **The scale is read only inside `graphicsLayer`**, so travelling the row redraws two cards
|
||||
rather than recomposing every card in it.
|
||||
- **A blank active id means the account's own viewer**, because that is what the header's
|
||||
absence means to the gateway. Writing the main viewer's id instead would work on the wire
|
||||
and would be worse in one way: an app never told the account's Emby user id could not then
|
||||
express "nobody in particular", which is the state every existing install starts in.
|
||||
- **The Add control is removed at the limit, never dimmed** — the stance the two optional
|
||||
transport controls take.
|
||||
- `MAX_SHADOW_VIEWERS` mirrors `store.MaxShadowViewers`; the gateway is what enforces it,
|
||||
and this copy only decides whether to offer a button whose one possible outcome would be a
|
||||
refusal.
|
||||
|
||||
## The way in
|
||||
|
||||
A row in `UserSwitcherOverlay`, above Notifications, because it changes *whose* menu that
|
||||
is — the notifications and requests below belong to whichever viewer it selects.
|
||||
`viewerMenuLabel` names the person once somebody other than the account is watching
|
||||
("Watching as Alessandra") and asks the question otherwise: a household running no viewers
|
||||
must not have a badge appear over its launcher explaining a feature it is not using. The
|
||||
panel's own subtitle becomes "Choose an account" when the row is present, since the list
|
||||
above it is no longer the thing that answers "who is watching".
|
||||
|
||||
`shouldOfferViewerPicker` gates it on two conditions and both matter: there is nobody to ask
|
||||
on the direct path, and an account nobody has added a viewer to would be offered a question
|
||||
with one answer, which reads as a fault rather than as a feature waiting to be used.
|
||||
|
||||
**The switcher's rows are now a list** (`userSwitcherMenuItems`) rather than four
|
||||
hand-written offsets (`profiles.size + 1`, `if (showRequests) 2 else 1`,
|
||||
`actionCount - 1`). A count that disagrees with the rows actually drawn is how the last item
|
||||
in a menu becomes unreachable, and a fifth conditional row is exactly the change that breaks
|
||||
it — the shape `QuickAction` already moved to for the same reason. The D-pad test now runs
|
||||
over every combination of optional rows.
|
||||
|
||||
## Built
|
||||
|
||||
- `viewers` and `viewer_playback_state` in `store/schema.sql`, with the main viewer created
|
||||
on demand so an account predating the feature resolves on its first request.
|
||||
- `store/viewers.go`, `store/viewer_playback.go` — the viewer list, the state layer,
|
||||
Continue Watching, Next Up, favourites and the series-recency ordering.
|
||||
- `api/viewers.go` — header resolution, the cached list, the context plumbing,
|
||||
`viewerUserData`, `invalidateAccountViews`, and the four CRUD routes.
|
||||
- `api/viewer_state_attach.go`, `api/viewer_rows.go` — the read side.
|
||||
- **The four mutations are gated**, and For You is marked dirty only for the main viewer.
|
||||
- Every log line names the viewer when it is somebody other than the account.
|
||||
- **The television**: the header and its capability, the active viewer as device state, the
|
||||
per-viewer home cache, `EmbyRepository`'s viewer operations, the picker, the row in the
|
||||
user menu that opens it, and the name-entry and manage screens behind it.
|
||||
- **The operator's switch**, and the Viewers card on the console's account page.
|
||||
- **Series and season aggregates**, so a shadow viewer no longer sees the account's progress
|
||||
anywhere.
|
||||
|
||||
## Naming somebody, from the television
|
||||
|
||||
`ui/viewers/ViewerNameEntry.kt` reuses the **search keyboard** rather than growing a second
|
||||
one — the note `TvKeyboard` already carried, written before there was a second caller to
|
||||
prove it. Two on-screen keyboards is two focus contracts to keep in step, and where the
|
||||
letters are is the one thing a viewer must never have to relearn. Things to preserve:
|
||||
|
||||
- **The keyboard is the child that gives way, never the buttons.** A `Column` hands each
|
||||
child what the ones before it left over, so the confirm row — being last — was measured
|
||||
from the remainder and drew as two squeezed slivers with their labels pressed out. The
|
||||
keyboard takes `weight(1f, fill = false)`, and weighted children are measured from what
|
||||
the unweighted ones leave: the same inversion the home hero makes for its Play chip, and
|
||||
it broke here in exactly the same way. `viewers-name-empty.png` is the capture it is
|
||||
answerable to.
|
||||
- **The rules are pure and refuse early** (`ViewerEditing.kt`). A blank name is refused
|
||||
because the gateway refuses it. A **repeated** name is refused by the app alone — the
|
||||
gateway is happy to hold two people called Sam — because the picker is a row of faces with
|
||||
a name under each, and two identical names is a choice nobody in the household can make.
|
||||
A rename skips the person being renamed, so correcting somebody's capitalisation is not
|
||||
refused as a duplicate of themselves.
|
||||
- **The limit is enforced by refusing the keypress**, not by rejecting the save. A remote
|
||||
types one character at a time, and a limit that only announces itself at the end is one
|
||||
somebody discovers after typing a sentence.
|
||||
- **A refusal is not drawn in the accent.** Every affirmative thing on a Memby screen is
|
||||
green, and a refusal wearing the confirmation colour reads at a glance as the name having
|
||||
been accepted. The message line is *reserved* rather than conditional, or it would push
|
||||
the keyboard down by a line at the moment somebody is typing into it.
|
||||
- **A local rule and a server refusal are separate parameters.** One is true before anything
|
||||
is sent and the other is what came back; showing them in one slot would leave a stale
|
||||
refusal sitting under a name that has since been corrected. A refused save keeps what was
|
||||
typed — retyping a name somebody has just entered is the worst possible answer to a
|
||||
request that failed for a reason the television does not know.
|
||||
|
||||
`ViewerManageScreen` is the list beside it. It is a *list* where the picker is a row of
|
||||
faces, because the two answer different questions — "who is watching" is a glance and one
|
||||
press, "who is here" is read a line at a time and acted on per person. A row holds **two
|
||||
focus targets** rather than opening a menu, the shape the alerts page settled on: a remote
|
||||
has one confirm key, and a press that opened a list of actions would make renaming somebody
|
||||
three presses deep for nothing. The **main viewer has neither** and is still listed, because
|
||||
a list of the people here that omitted the one whose watching actually reaches Emby would be
|
||||
the more confusing of the two. Removing somebody is a full-stop question following
|
||||
`ExitConfirmation`'s rules — the two answers do not look alike, the safe one takes focus
|
||||
first, and **Back means keep**.
|
||||
|
||||
Both open **over** the picker rather than instead of it, the arrangement the add-a-user
|
||||
sign-in already takes over the manage-users page: Back is one step out of each and the row
|
||||
of faces is still underneath, with nothing to restore because nothing was unmounted.
|
||||
`ViewerActionButton` is the one button all three screens are built from — there were nearly
|
||||
three of it, the same shape at the same size in the same green written on three different
|
||||
days.
|
||||
|
||||
## The operator's switch
|
||||
|
||||
`viewers` in `featureCatalogue`, capability `viewers_v1`. It is read in **`activeViewer`**
|
||||
and nowhere else: that is the one place a request learns who is watching, so with it off
|
||||
every branch downstream — the gated writes, the substituted rows, the per-viewer cache keys
|
||||
— falls back to the account by construction rather than by fifteen separate checks.
|
||||
|
||||
**Off is not a deletion.** A viewer's rows stay in Postgres untouched and come back intact;
|
||||
what stops is the gateway routing anybody's watching anywhere but Emby, which is the state a
|
||||
household was in before this existed. `/v1/viewers` answers with `mainViewerOnly`, a
|
||||
*shortened list* rather than an error or an empty one, because the television decides whether
|
||||
to offer the picker by counting what it was sent — so a switched-off household looks like one
|
||||
that never used the feature rather than like one whose picker has broken. The four mutations
|
||||
answer 403 with a sentence naming the reason, since a television has no log and no support
|
||||
channel.
|
||||
|
||||
**Default off**, the stance the genre browser takes. This is the switch that decides where a
|
||||
household's watched state is written, and a feature arriving already on is one every server
|
||||
running the build starts using before anybody has decided to — so it is opted into rather
|
||||
than out of. An account with no shadow viewers behaves identically either way, which is what
|
||||
makes switching it on a safe thing to try rather than a migration.
|
||||
|
||||
## The operator's copy
|
||||
|
||||
`/admin/accounts/{userID}` carries a Viewers card (`admin_viewers.go`,
|
||||
`admin-ui/src/pages/Account.tsx`). It is on the **account page** rather than a rail entry of
|
||||
its own because a viewer only exists under an account, and a top-level page would open by
|
||||
asking which account — the question the page an operator reached this from has answered. It
|
||||
is the arrangement the per-account preference editor and the device list already take.
|
||||
|
||||
The televisions can do all of this themselves now, so this is the operator's copy rather than
|
||||
the only way in: what it is for is a household asking for help, and the case a remote cannot
|
||||
reach — a viewer created on a set that has since been unplugged. Two things to preserve: the
|
||||
write clears the cached list (`forgetViewers`) for the same reason the client-facing route
|
||||
does, and the card **states** that the feature is switched off rather than quietly offering
|
||||
controls whose effect nothing on any television would show.
|
||||
|
||||
The list is its own request rather than a field on `/admin/api/accounts`: that response is the
|
||||
whole household and this is a list per person, so folding it in would make every accounts poll
|
||||
read one table per account for a page showing one of them.
|
||||
|
||||
## Not built yet, in the order it should be
|
||||
|
||||
1. **The personalisation layer** keyed on the viewer: recommendations, the Magic pool, genre
|
||||
affinity, journeys, notifications and watch time all still answer for the account.
|
||||
2. **PINs**, and the Tracearr attribution problem.
|
||||
|
||||
## Untested
|
||||
|
||||
`ViewerContainerStates` is the one piece of this with no test behind its SQL: the store's
|
||||
tests are pure and there is no Postgres in the build, so the query has been read but not run.
|
||||
The Go either side of it — the rollup and the block it renders — is pinned. It is the first
|
||||
thing to exercise against a real database.
|
||||
@@ -65,6 +65,24 @@ interface AccountDevice {
|
||||
versions: DeviceVersion[] | null;
|
||||
}
|
||||
|
||||
/* One person under this account. A viewer is not an account: the credential and the
|
||||
library permissions stay the Emby user's, and only whose evening it is changes. */
|
||||
interface Viewer {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName?: string;
|
||||
colour?: string;
|
||||
kind: string;
|
||||
hasPin?: boolean;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
interface ViewersPayload {
|
||||
viewers: Viewer[];
|
||||
enabled: boolean;
|
||||
maxShadowViewers: number;
|
||||
}
|
||||
|
||||
interface RecommendationState {
|
||||
prompted?: boolean;
|
||||
completed?: boolean;
|
||||
@@ -144,7 +162,8 @@ type Pending =
|
||||
| { kind: 'reset-recommendations' }
|
||||
| { kind: 'cancel-prompt' }
|
||||
| { kind: 'reset-preferences' }
|
||||
| { kind: 'no-themes' };
|
||||
| { kind: 'no-themes' }
|
||||
| { kind: 'remove-viewer'; viewer: Viewer };
|
||||
|
||||
export function AccountPage() {
|
||||
const { userId = '' } = useParams();
|
||||
@@ -166,8 +185,23 @@ export function AccountPage() {
|
||||
const [notifications, setNotifications] = useState<NotificationPreferences | null>(null);
|
||||
const [pending, setPending] = useState<Pending | null>(null);
|
||||
const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null);
|
||||
/* Renaming or adding a viewer. One piece of state for both, because the dialog is the
|
||||
same question either way and a null id is what says there is nobody to rename yet. */
|
||||
const [namingViewer, setNamingViewer] = useState<{ id: string | null; name: string } | null>(null);
|
||||
|
||||
/* Viewers are their own request rather than a field on the accounts payload: that
|
||||
response is the whole household and this is a list per person, so folding it in would
|
||||
make every accounts poll read one table per account for a page showing one of them. */
|
||||
const viewersQuery = useQuery<ViewersPayload>(`${base}/viewers`);
|
||||
|
||||
const account = (data?.accounts ?? []).find((entry) => entry.id === userId);
|
||||
const shadowViewers = (viewersQuery.data?.viewers ?? []).filter((viewer) => viewer.kind !== 'main');
|
||||
/* The gateway is what enforces the limit; this only decides whether to offer a button
|
||||
whose one possible outcome would be a refusal — the rule the television's own picker
|
||||
follows. */
|
||||
const canAddViewer =
|
||||
Boolean(viewersQuery.data?.enabled) &&
|
||||
shadowViewers.length < (viewersQuery.data?.maxShadowViewers ?? 0);
|
||||
const catalogue = data?.catalogue ?? [];
|
||||
const themeCatalogue = data?.themes ?? [];
|
||||
|
||||
@@ -319,6 +353,92 @@ export function AccountPage() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* The people under this account. It sits beside the devices rather than on a page
|
||||
of its own because a viewer only exists under an account, and a top-level page
|
||||
would open by asking which account — the question this page has answered.
|
||||
|
||||
The televisions can do all of this themselves now, so this is the operator's
|
||||
copy: for a household asking for help, and for the case a remote cannot reach,
|
||||
such as a viewer created on a set that has since been unplugged. */}
|
||||
<Card
|
||||
title="Viewers"
|
||||
intro="Several people under one Emby account, each with their own Continue Watching, watched history and favourites. Only the account itself is synced with Emby; a shadow viewer's watching is kept by Memby and never reported."
|
||||
icon="people"
|
||||
tone="note"
|
||||
actions={
|
||||
viewersQuery.data && !viewersQuery.data.enabled ? (
|
||||
<Tag tone="warn">switched off</Tag>
|
||||
) : (
|
||||
<Tag tone="ok">{num(shadowViewers.length)} beside the account</Tag>
|
||||
)
|
||||
}
|
||||
footer={
|
||||
canAddViewer ? (
|
||||
<Button variant="primary" onClick={() => setNamingViewer({ id: null, name: '' })}>
|
||||
Add viewer
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled>
|
||||
{viewersQuery.data ? `${num(viewersQuery.data.maxShadowViewers)} is the limit` : 'Add viewer'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* Stated rather than implied. An operator who has switched viewers off and then
|
||||
adds one has done something that looks as though it worked and changes nothing
|
||||
on any television, because the gateway resolves every request to the account. */}
|
||||
{viewersQuery.data && !viewersQuery.data.enabled ? (
|
||||
<Banner
|
||||
message={
|
||||
'Viewers are switched off for this server, so every television watches as the account. ' +
|
||||
'Nothing here is deleted — turn the feature on under Features to bring these people back.'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{viewersQuery.loading ? (
|
||||
<Loading />
|
||||
) : (viewersQuery.data?.viewers ?? []).length === 0 ? (
|
||||
<Empty>Nobody is set up yet, so this account has one viewer: itself.</Empty>
|
||||
) : (
|
||||
<div className="list">
|
||||
{(viewersQuery.data?.viewers ?? []).map((viewer) => (
|
||||
<div className="list-item" key={viewer.id}>
|
||||
<div className="list-body">
|
||||
<b>{viewer.name}</b>
|
||||
<p>
|
||||
{viewer.kind === 'main'
|
||||
? 'The account itself — named by Emby, and the only one whose watching Emby hears about'
|
||||
: 'Watches privately; nothing reaches Emby'}
|
||||
{viewer.createdAt && viewer.kind !== 'main' ? ` · added ${when(viewer.createdAt)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="list-actions">
|
||||
{viewer.kind === 'main' ? (
|
||||
<Tag tone="info">synced with Emby</Tag>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setNamingViewer({ id: viewer.id, name: viewer.name })}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => setPending({ kind: 'remove-viewer', viewer })}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Recommendation setup"
|
||||
intro="The prompt appears the next time this person opens Memby on any of their televisions."
|
||||
@@ -687,6 +807,29 @@ export function AccountPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{namingViewer ? (
|
||||
<ViewerNameDialog
|
||||
initial={namingViewer.name}
|
||||
renaming={namingViewer.id !== null}
|
||||
busy={busy === 'viewer-name'}
|
||||
onCancel={() => setNamingViewer(null)}
|
||||
onConfirm={(name) =>
|
||||
void act(
|
||||
'viewer-name',
|
||||
() =>
|
||||
namingViewer.id
|
||||
? api.put(`${base}/viewers/${encodeURIComponent(namingViewer.id)}`, { name })
|
||||
: api.post(`${base}/viewers`, { name }),
|
||||
namingViewer.id ? 'Viewer renamed.' : 'Viewer added.',
|
||||
() => {
|
||||
setNamingViewer(null);
|
||||
void viewersQuery.reload();
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{pending ? (
|
||||
<PendingDialog
|
||||
pending={pending}
|
||||
@@ -701,6 +844,13 @@ export function AccountPage() {
|
||||
() => api.del(`${base}/devices/${encodeURIComponent(pending.deviceId)}`),
|
||||
'Device signed out.',
|
||||
);
|
||||
case 'remove-viewer':
|
||||
return void act(
|
||||
'remove-viewer',
|
||||
() => api.del(`${base}/viewers/${encodeURIComponent(pending.viewer.id)}`),
|
||||
'Viewer removed.',
|
||||
() => void viewersQuery.reload(),
|
||||
);
|
||||
case 'remove-account':
|
||||
return void act(
|
||||
'remove-account',
|
||||
@@ -929,6 +1079,53 @@ function RenameDialog({
|
||||
);
|
||||
}
|
||||
|
||||
/* Naming a viewer, added or renamed. It is the same question either way, so it is one
|
||||
dialog with one field rather than two that would drift apart — the shape RenameDialog
|
||||
above already takes for a device. */
|
||||
function ViewerNameDialog({
|
||||
initial,
|
||||
renaming,
|
||||
busy,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
initial: string;
|
||||
renaming: boolean;
|
||||
busy: boolean;
|
||||
onConfirm: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState(initial);
|
||||
return (
|
||||
<div className="scrim" onPointerDown={(event) => event.target === event.currentTarget && onCancel()}>
|
||||
<div className="dialog" role="dialog" aria-modal="true">
|
||||
<h2>{renaming ? 'Rename this viewer' : 'Add a viewer'}</h2>
|
||||
<p>
|
||||
The name shown on the television’s “Who’s watching?” screen. Everything
|
||||
they watch is kept by Memby and never reported to Emby.
|
||||
</p>
|
||||
<Field label="Name">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
autoFocus
|
||||
maxLength={40}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="dialog-actions">
|
||||
<Button variant="quiet" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" busy={busy} disabled={!name.trim()} onClick={() => onConfirm(name.trim())}>
|
||||
{renaming ? 'Rename' : 'Add viewer'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingDialog({
|
||||
pending,
|
||||
busy,
|
||||
@@ -973,6 +1170,12 @@ function PendingDialog({
|
||||
label: 'Restore defaults',
|
||||
destructive: true,
|
||||
},
|
||||
'remove-viewer': {
|
||||
title: 'Remove this viewer?',
|
||||
body: 'What they were part-way through, what they had watched and their favourites are deleted, on every television in the house. The Emby account is untouched.',
|
||||
label: 'Remove viewer',
|
||||
destructive: true,
|
||||
},
|
||||
'no-themes': {
|
||||
title: 'Allow this person no colour schemes?',
|
||||
body: 'They will be left on Midnight with nothing to choose between.',
|
||||
|
||||
@@ -63,7 +63,7 @@ val projectNoticeText =
|
||||
|
||||
// A release workflow can derive the app version from its Git tag without editing the
|
||||
// source tree. Local builds keep using the checked-in default.
|
||||
val defaultVersionName = "0.2.81"
|
||||
val defaultVersionName = "0.2.82"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -8,6 +8,8 @@ import com.ponzischeme89.memby.data.model.GatewayCalendar
|
||||
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayDevice
|
||||
import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.data.model.MembyViewerRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
|
||||
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
|
||||
@@ -556,6 +558,98 @@ class EmbyRepository internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Viewers -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The people under this account, or an empty list where there is nobody to ask.
|
||||
*
|
||||
* Gateway-only, like the TV calendar and genre affinity: a viewer's state lives on the
|
||||
* server, so with no gateway there is exactly one viewer and it is the account. Every
|
||||
* failure answers empty rather than throwing — the picker is a convenience, and a
|
||||
* household that cannot reach the gateway has larger problems than not being able to
|
||||
* change who is watching.
|
||||
*/
|
||||
suspend fun viewers(): List<MembyViewer> {
|
||||
if (!ServerConfig.isGateway) return emptyList()
|
||||
val response = runCatching { requireGateway().viewers() }.getOrNull() ?: return emptyList()
|
||||
// The gateway says whom it resolved this request to. A viewer deleted on another
|
||||
// television is the case this exists for: this set is still sending an id nothing
|
||||
// recognises, the gateway has quietly fallen back to the account, and without
|
||||
// adopting that answer the picker would go on showing somebody who no longer
|
||||
// exists as selected.
|
||||
if (response.active != snapshot.activeViewerId &&
|
||||
response.viewers.none { it.id == snapshot.activeViewerId }
|
||||
) {
|
||||
val resolved = response.viewers.firstOrNull { it.id == response.active }
|
||||
settings.setActiveViewer(resolved?.id.orEmpty(), resolved?.name.orEmpty())
|
||||
observedSettings = settings.snapshot()
|
||||
}
|
||||
return response.viewers
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes who is watching on this television.
|
||||
*
|
||||
* Everything cleared here is cleared for the same reason a profile switch clears it:
|
||||
* these caches hold one person's watched state, resume positions and reasons, and the
|
||||
* next person must not inherit them. The persisted home cache is *not* cleared — it is
|
||||
* keyed per viewer, so switching back is instant and each viewer keeps their own rows
|
||||
* for the next cold start.
|
||||
*
|
||||
* Passing a blank id selects the account's own viewer, which is what the header's
|
||||
* absence means to the gateway.
|
||||
*/
|
||||
suspend fun switchViewer(viewer: MembyViewer?) {
|
||||
val id = if (viewer == null || viewer.isMain) "" else viewer.id
|
||||
if (id == snapshot.activeViewerId) return
|
||||
settings.setActiveViewer(id, viewer?.name.orEmpty())
|
||||
observedSettings = settings.snapshot()
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
clearLocalResume()
|
||||
clearGenreAffinity()
|
||||
}
|
||||
|
||||
suspend fun createViewer(name: String, shortName: String = "", colour: String = ""): MembyViewer? {
|
||||
if (!ServerConfig.isGateway) return null
|
||||
val trimmed = name.trim()
|
||||
require(trimmed.isNotEmpty() && trimmed.length <= 40) { "Invalid viewer name" }
|
||||
return runCatching {
|
||||
requireGateway().createViewer(MembyViewerRequest(trimmed, shortName.trim(), colour.trim()))
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
suspend fun renameViewer(viewer: MembyViewer, name: String): MembyViewer? {
|
||||
if (!ServerConfig.isGateway) return null
|
||||
val trimmed = name.trim()
|
||||
require(trimmed.isNotEmpty() && trimmed.length <= 40) { "Invalid viewer name" }
|
||||
return runCatching {
|
||||
requireGateway().updateViewer(
|
||||
viewer.id,
|
||||
MembyViewerRequest(trimmed, viewer.shortName, viewer.colour),
|
||||
)
|
||||
}.getOrNull().also { updated ->
|
||||
// The label this television prints follows the rename immediately rather than
|
||||
// waiting for the next list request.
|
||||
if (updated != null && viewer.id == snapshot.activeViewerId) {
|
||||
settings.setActiveViewer(updated.id, updated.name)
|
||||
observedSettings = settings.snapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a viewer and everything the gateway held for them. Removing whoever is
|
||||
* watching returns this television to the account's own viewer — leaving it naming a
|
||||
* person who no longer exists would send a header the gateway refuses on every request.
|
||||
*/
|
||||
suspend fun removeViewer(viewer: MembyViewer): Boolean {
|
||||
if (!ServerConfig.isGateway || viewer.isMain) return false
|
||||
val removed = runCatching { requireGateway().deleteViewer(viewer.id) }.isSuccess
|
||||
if (removed && viewer.id == snapshot.activeViewerId) switchViewer(null)
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* False only when the gateway explicitly rejects the restored token with 401.
|
||||
*
|
||||
|
||||
@@ -431,6 +431,25 @@ data class Settings(
|
||||
* what is here rather than wait to be told.
|
||||
*/
|
||||
val preferencesRevision: Long = 0,
|
||||
/**
|
||||
* Which person under this account is watching on *this* television.
|
||||
*
|
||||
* Device state, deliberately, and the one thing about viewers that is: a viewer follows
|
||||
* the person to every set in the house, but which of them is sitting in front of this
|
||||
* one is that set's own answer — the lounge and the bedroom are commonly two different
|
||||
* people at the same moment. Blank means the account's own viewer, which is what every
|
||||
* television reported before viewers existed, so the header is simply not sent.
|
||||
*
|
||||
* Cleared with the session, because the people under one Emby account are not the
|
||||
* people under another.
|
||||
*/
|
||||
val activeViewerId: String = "",
|
||||
/**
|
||||
* The active viewer's name, kept so the launcher can say whose evening it is without
|
||||
* waiting on a request. Purely a label: [activeViewerId] is the identity, and the
|
||||
* gateway is what validates it.
|
||||
*/
|
||||
val activeViewerName: String = "",
|
||||
val profiles: List<EmbyProfile> = emptyList(),
|
||||
) {
|
||||
val isSignedIn: Boolean
|
||||
@@ -591,6 +610,8 @@ class SettingsStore(private val context: Context) {
|
||||
val UPDATE_ALERT_READ = booleanPreferencesKey("update_alert_read")
|
||||
val REQUIRED_UPDATE_VERSION = stringPreferencesKey("required_update_version")
|
||||
val PREFERENCES_REVISION = longPreferencesKey("preferences_revision")
|
||||
val ACTIVE_VIEWER_ID = stringPreferencesKey("active_viewer_id")
|
||||
val ACTIVE_VIEWER_NAME = stringPreferencesKey("active_viewer_name")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -647,6 +668,32 @@ class SettingsStore(private val context: Context) {
|
||||
context.dataStore.edit { it[Keys.CONFIRM_EXIT_MEMBY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Records who is watching on this television.
|
||||
*
|
||||
* The id and the name are written in **one** edit, the rule
|
||||
* [applyRemotePreferences] follows and for the same reason: DataStore rewrites the
|
||||
* whole file per edit, and a name landing apart from the id it labels would leave the
|
||||
* launcher greeting one person while every request named another.
|
||||
*
|
||||
* A blank id is how the account's own viewer is chosen — the header is then omitted
|
||||
* entirely, which is what an app predating viewers sends and what the gateway reads as
|
||||
* the main viewer.
|
||||
*/
|
||||
suspend fun setActiveViewer(viewerId: String, viewerName: String) {
|
||||
val id = viewerId.trim()
|
||||
val name = viewerName.trim().take(40)
|
||||
context.dataStore.edit {
|
||||
if (id.isEmpty()) {
|
||||
it.remove(Keys.ACTIVE_VIEWER_ID)
|
||||
it.remove(Keys.ACTIVE_VIEWER_NAME)
|
||||
} else {
|
||||
it[Keys.ACTIVE_VIEWER_ID] = id
|
||||
it[Keys.ACTIVE_VIEWER_NAME] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids of alerts already shown on this TV. The gateway keeps offering an alert for as
|
||||
* long as it is current, so without this an "aired" banner would return every poll —
|
||||
@@ -1009,6 +1056,7 @@ class SettingsStore(private val context: Context) {
|
||||
val profileKey = profileHomeCacheKey(
|
||||
userId = preferences[Keys.USER_ID],
|
||||
serverUrl = preferences[Keys.SERVER_URL],
|
||||
viewerId = preferences[Keys.ACTIVE_VIEWER_ID],
|
||||
)
|
||||
if (profileKey == null) {
|
||||
// No active profile to key against; the flat slot is all there is.
|
||||
@@ -1028,21 +1076,48 @@ class SettingsStore(private val context: Context) {
|
||||
/**
|
||||
* Where a given profile's home cache is stored. Null when there is no active profile
|
||||
* to key it against, in which case only the flat [Keys.HOME_CACHE] is written.
|
||||
*
|
||||
* The viewer is part of the key because the rows are theirs — a shadow viewer's
|
||||
* Continue Watching is a different shelf from the account's, and a cold start drawing
|
||||
* the cache before the first refresh lands would otherwise open on somebody else's
|
||||
* evening. **The account's own viewer keys exactly as it always did**, with no suffix
|
||||
* at all, so every existing install keeps the cache it already has.
|
||||
*/
|
||||
private fun profileHomeCacheKey(userId: String?, serverUrl: String?): Preferences.Key<String>? {
|
||||
private fun profileHomeCacheKey(
|
||||
userId: String?,
|
||||
serverUrl: String?,
|
||||
viewerId: String? = null,
|
||||
): Preferences.Key<String>? {
|
||||
if (userId.isNullOrBlank() || serverUrl.isNullOrBlank()) return null
|
||||
return stringPreferencesKey("home_cache::$userId@$serverUrl")
|
||||
val base = "home_cache::$userId@$serverUrl"
|
||||
return stringPreferencesKey(
|
||||
if (viewerId.isNullOrBlank()) base else "$base#$viewerId",
|
||||
)
|
||||
}
|
||||
|
||||
/** Every home-cache key belonging to one profile, across all of its viewers. */
|
||||
private fun profileHomeCacheKeys(
|
||||
preferences: Preferences,
|
||||
userId: String?,
|
||||
serverUrl: String?,
|
||||
): List<Preferences.Key<String>> {
|
||||
if (userId.isNullOrBlank() || serverUrl.isNullOrBlank()) return emptyList()
|
||||
val base = "home_cache::$userId@$serverUrl"
|
||||
return preferences.asMap().keys
|
||||
.filter { it.name == base || it.name.startsWith("$base#") }
|
||||
.map { stringPreferencesKey(it.name) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The active profile's cached rows. The per-profile key is authoritative; the flat
|
||||
* [Keys.HOME_CACHE] slot survives only for installs written before the split and for
|
||||
* the case where there is no profile to key against.
|
||||
* The active profile's cached rows, for whoever is watching. The per-profile key is
|
||||
* authoritative; the flat [Keys.HOME_CACHE] slot survives only for installs written
|
||||
* before the split and for the case where there is no profile to key against.
|
||||
*/
|
||||
private fun activeHomeCache(preferences: Preferences): String? =
|
||||
profileHomeCacheKey(
|
||||
userId = preferences[Keys.USER_ID],
|
||||
serverUrl = preferences[Keys.SERVER_URL],
|
||||
viewerId = preferences[Keys.ACTIVE_VIEWER_ID],
|
||||
)?.let { preferences[it] } ?: preferences[Keys.HOME_CACHE]
|
||||
|
||||
suspend fun setForYouMinutes(minutes: Int) {
|
||||
@@ -1314,9 +1389,11 @@ class SettingsStore(private val context: Context) {
|
||||
val profiles = profilesFrom(preferences)
|
||||
val removed = profiles.firstOrNull { it.id == profileId } ?: return@edit
|
||||
writeProfiles(preferences, profiles.filterNot { it.id == profileId })
|
||||
// Forget the departing profile's cached rows too; nothing will read that key
|
||||
// again and it is the largest single value this store holds.
|
||||
profileHomeCacheKey(removed.userId, removed.serverUrl)?.let(preferences::remove)
|
||||
// Forget the departing profile's cached rows too — every viewer's, not only
|
||||
// the account's; nothing will read those keys again and they are the largest
|
||||
// single values this store holds.
|
||||
profileHomeCacheKeys(preferences, removed.userId, removed.serverUrl)
|
||||
.forEach(preferences::remove)
|
||||
if (
|
||||
preferences[Keys.USER_ID] == removed.userId &&
|
||||
preferences[Keys.SERVER_URL] == removed.serverUrl
|
||||
@@ -1346,7 +1423,8 @@ class SettingsStore(private val context: Context) {
|
||||
it.userId == activeUserId && it.serverUrl == activeServer
|
||||
}
|
||||
writeProfiles(preferences, remaining)
|
||||
profileHomeCacheKey(activeUserId, activeServer)?.let(preferences::remove)
|
||||
profileHomeCacheKeys(preferences, activeUserId, activeServer)
|
||||
.forEach(preferences::remove)
|
||||
clearActiveSession(preferences)
|
||||
}
|
||||
}
|
||||
@@ -1384,9 +1462,27 @@ class SettingsStore(private val context: Context) {
|
||||
preferences.remove(Keys.PROFILE_INITIALS)
|
||||
preferences.remove(Keys.SHORT_NAME)
|
||||
preferences.remove(Keys.USERNAME)
|
||||
// The people under one Emby account are not the people under another, and an id
|
||||
// carried across would name somebody this account has never heard of. The gateway
|
||||
// refuses it and falls back to the main viewer, so the consequence is cosmetic
|
||||
// rather than a leak — but a television claiming to be Alessandra when it is
|
||||
// signed into a different household is still wrong on its face.
|
||||
clearActiveViewer(preferences)
|
||||
}
|
||||
|
||||
private fun clearActiveViewer(preferences: MutablePreferences) {
|
||||
preferences.remove(Keys.ACTIVE_VIEWER_ID)
|
||||
preferences.remove(Keys.ACTIVE_VIEWER_NAME)
|
||||
}
|
||||
|
||||
private fun applyProfile(preferences: MutablePreferences, profile: EmbyProfile) {
|
||||
// Switching account resets who is watching, for the same reason clearing the
|
||||
// session does: the viewer list belongs to the account being left.
|
||||
if (preferences[Keys.USER_ID] != profile.userId ||
|
||||
preferences[Keys.SERVER_URL] != profile.serverUrl
|
||||
) {
|
||||
clearActiveViewer(preferences)
|
||||
}
|
||||
preferences[Keys.SERVER_URL] = profile.serverUrl
|
||||
preferences[Keys.TOKEN] = profile.token
|
||||
preferences[Keys.USER_ID] = profile.userId
|
||||
@@ -1558,6 +1654,8 @@ class SettingsStore(private val context: Context) {
|
||||
updateAlertRead = preferences[Keys.UPDATE_ALERT_READ] ?: false,
|
||||
requiredUpdateVersion = preferences[Keys.REQUIRED_UPDATE_VERSION],
|
||||
preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
|
||||
activeViewerId = preferences[Keys.ACTIVE_VIEWER_ID].orEmpty(),
|
||||
activeViewerName = preferences[Keys.ACTIVE_VIEWER_NAME].orEmpty(),
|
||||
profiles = profiles,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -87,6 +87,60 @@ data class GatewayTrailerReport(
|
||||
@Serializable
|
||||
data class GatewayDeviceNameRequest(val deviceName: String)
|
||||
|
||||
/**
|
||||
* One person under this Memby account.
|
||||
*
|
||||
* A device is a television and a profile is a signed-in Emby account; a *viewer* is one of
|
||||
* the people using them. The main viewer's state is Emby's and its id is the Emby user id,
|
||||
* which is why nothing here needs a separate notion of "the account's own viewer" — it is
|
||||
* simply the one whose [kind] is `main`.
|
||||
*/
|
||||
@Serializable
|
||||
data class MembyViewer(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val shortName: String = "",
|
||||
val colour: String = "",
|
||||
val kind: String = "",
|
||||
val hasPin: Boolean = false,
|
||||
val createdAt: String = "",
|
||||
) {
|
||||
/** True when this viewer's watching is published to Emby. */
|
||||
val isMain: Boolean get() = kind == KIND_MAIN
|
||||
|
||||
/**
|
||||
* What the picker draws in the avatar. Emby usernames are commonly one word, so the
|
||||
* first letter alone is what distinguishes them at three metres; a short name the
|
||||
* household set is preferred because it is the thing they chose to be called.
|
||||
*/
|
||||
val initials: String
|
||||
get() = (shortName.takeIf(String::isNotBlank) ?: name)
|
||||
.trim()
|
||||
.takeIf(String::isNotEmpty)
|
||||
?.take(1)
|
||||
?.uppercase()
|
||||
.orEmpty()
|
||||
|
||||
companion object {
|
||||
const val KIND_MAIN = "main"
|
||||
const val KIND_SHADOW = "shadow"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MembyViewers(
|
||||
val viewers: List<MembyViewer> = emptyList(),
|
||||
/** Whom the gateway resolved this request to, which is the answer this TV adopts. */
|
||||
val active: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MembyViewerRequest(
|
||||
val name: String,
|
||||
val shortName: String = "",
|
||||
val colour: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* One horizontal strip, described entirely by the server.
|
||||
*
|
||||
|
||||
@@ -24,6 +24,9 @@ import com.ponzischeme89.memby.data.model.GatewayRows
|
||||
import com.ponzischeme89.memby.data.model.GatewayRequestLookup
|
||||
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.data.model.MembyViewerRequest
|
||||
import com.ponzischeme89.memby.data.model.MembyViewers
|
||||
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
|
||||
import com.ponzischeme89.memby.data.model.RecommendationPreferences
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
@@ -66,6 +69,26 @@ interface GatewayApi {
|
||||
@Body body: GatewayDeviceNameRequest,
|
||||
)
|
||||
|
||||
/**
|
||||
* The people under this account. `active` is the gateway's own answer about whom this
|
||||
* request resolved to, which is what makes a viewer this television no longer has —
|
||||
* deleted on another set — visibly fall back to the main viewer rather than silently.
|
||||
*/
|
||||
@GET("v1/viewers")
|
||||
suspend fun viewers(): MembyViewers
|
||||
|
||||
@POST("v1/viewers")
|
||||
suspend fun createViewer(@Body body: MembyViewerRequest): MembyViewer
|
||||
|
||||
@PUT("v1/viewers/{viewerId}")
|
||||
suspend fun updateViewer(
|
||||
@Path("viewerId") viewerId: String,
|
||||
@Body body: MembyViewerRequest,
|
||||
): MembyViewer
|
||||
|
||||
@DELETE("v1/viewers/{viewerId}")
|
||||
suspend fun deleteViewer(@Path("viewerId") viewerId: String)
|
||||
|
||||
@GET("v1/home")
|
||||
suspend fun home(@Query("limit") limit: Int): GatewayHome
|
||||
|
||||
|
||||
@@ -119,12 +119,31 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) :
|
||||
audioCapabilityTokens()
|
||||
).joinToString(","),
|
||||
)
|
||||
activeViewerId()?.let { builder.header(VIEWER_HEADER, it) }
|
||||
tokenProvider()?.takeIf { it.isNotBlank() }?.let {
|
||||
builder.header("Authorization", "Bearer $it")
|
||||
}
|
||||
return chain.proceed(builder.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Who is watching, as distinct from which account is streaming.
|
||||
*
|
||||
* Read on each request rather than captured, for the reason the audio tokens are:
|
||||
* switching between two people on one television must not rebuild the HTTP client, and
|
||||
* the answer can change at any moment between two requests. The header is *omitted*
|
||||
* rather than sent empty when nobody has been chosen — the gateway states that an
|
||||
* absent header means the account's own viewer, and an empty one would be a value it
|
||||
* has to have an opinion about.
|
||||
*
|
||||
* Guarded like the audio tokens, because this interceptor also runs before the service
|
||||
* locator exists in a screenshot or instrumentation context, where throwing here would
|
||||
* fail the request rather than merely decline to name a viewer.
|
||||
*/
|
||||
private fun activeViewerId(): String? = runCatching {
|
||||
ServiceLocator.settings.current?.activeViewerId?.takeIf(String::isNotBlank)
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* What this set can put through its speakers, resolved against the viewer's own
|
||||
* passthrough choice — so a manual override reaches the *server's* device profile too,
|
||||
@@ -160,6 +179,9 @@ private object RequiredUpdateInterceptor : Interceptor {
|
||||
}
|
||||
}
|
||||
|
||||
/** Names the person watching. See `X-Memby-Viewer` in the gateway's `api/viewers.go`. */
|
||||
internal const val VIEWER_HEADER = "X-Memby-Viewer"
|
||||
|
||||
internal const val MEMBY_PROTOCOL_VERSION = 1
|
||||
|
||||
internal val MEMBY_CAPABILITIES = listOf(
|
||||
@@ -190,6 +212,10 @@ internal val MEMBY_CAPABILITIES = listOf(
|
||||
// "processing", the single word the whole span used to be, so an older television reads
|
||||
// its request page exactly as it always did.
|
||||
"request_progress_v1",
|
||||
// Declares that this build can draw the viewer picker and sends X-Memby-Viewer. An
|
||||
// older app never receives the feature, so the operator cannot switch on a household
|
||||
// of people that half its televisions have no way of choosing between.
|
||||
"viewers_v1",
|
||||
)
|
||||
|
||||
internal const val HEVC_DECODE_CAPABILITY = "video_hevc_decode"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.ui.viewers.viewerMenuLabel
|
||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.mark
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
@@ -481,13 +482,24 @@ fun UserSwitcherOverlay(
|
||||
*/
|
||||
showRequests: Boolean = false,
|
||||
onOpenRequests: () -> Unit = {},
|
||||
/**
|
||||
* Whether this household has anybody to choose between. False hides the row entirely,
|
||||
* for the same reason [showRequests] does: on the direct path there is nobody to ask,
|
||||
* and an account nobody has added a viewer to would be offered a question with one
|
||||
* answer. See `shouldOfferViewerPicker`.
|
||||
*/
|
||||
showViewers: Boolean = false,
|
||||
activeViewerId: String = "",
|
||||
activeViewerName: String = "",
|
||||
onOpenViewers: () -> Unit = {},
|
||||
) {
|
||||
val profileIds = profiles.map(EmbyProfile::id)
|
||||
val actionCount = userSwitcherActionCount(showRequests)
|
||||
val menuItems = userSwitcherMenuItems(showRequests, showViewers)
|
||||
val actionCount = menuItems.size
|
||||
// Re-keyed on the action count as well as the profiles: a permission arriving on a poll
|
||||
// while this menu is open changes how many rows there are, and a requester list of the
|
||||
// old length would leave the new row unfocusable.
|
||||
val focusRequesters = remember(profileIds, actionCount) {
|
||||
val focusRequesters = remember(profileIds, menuItems) {
|
||||
List(profiles.size + actionCount) { FocusRequester() }
|
||||
}
|
||||
val profileListState = remember(profileIds) { LazyListState() }
|
||||
@@ -571,7 +583,11 @@ fun UserSwitcherOverlay(
|
||||
modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 5.dp),
|
||||
)
|
||||
Text(
|
||||
"Choose 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(
|
||||
// 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
|
||||
.focusRequester(focusRequesters[profiles.size])
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) focusedIndex = profiles.size
|
||||
},
|
||||
modifier = modifier,
|
||||
onClick = onOpenAlerts,
|
||||
)
|
||||
// Requests sits beside Notifications because both are personal activity.
|
||||
if (showRequests) {
|
||||
UserSwitcherAction(
|
||||
UserSwitcherMenuItem.REQUESTS -> UserSwitcherAction(
|
||||
label = "Requests",
|
||||
icon = MembyIcon.PlaylistAdd.mark,
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequesters[profiles.size + 1])
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) focusedIndex = profiles.size + 1
|
||||
},
|
||||
modifier = modifier,
|
||||
onClick = onOpenRequests,
|
||||
)
|
||||
}
|
||||
val settingsIndex = profiles.size + if (showRequests) 2 else 1
|
||||
UserSwitcherAction(
|
||||
UserSwitcherMenuItem.SETTINGS -> UserSwitcherAction(
|
||||
label = "Settings",
|
||||
icon = MembyIcon.Settings.mark,
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequesters[settingsIndex])
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) focusedIndex = settingsIndex
|
||||
},
|
||||
modifier = modifier,
|
||||
onClick = onOpenSettings,
|
||||
)
|
||||
val manageIndex = profiles.size + actionCount - 1
|
||||
UserSwitcherAction(
|
||||
UserSwitcherMenuItem.MANAGE_USERS -> UserSwitcherAction(
|
||||
label = "Manage users",
|
||||
icon = MembyIcon.Person.mark,
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequesters[manageIndex])
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) focusedIndex = manageIndex
|
||||
},
|
||||
icon = MembyIcon.Grid.mark,
|
||||
modifier = modifier,
|
||||
onClick = onManageProfiles,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifications, then Requests when this viewer may make them, Settings, then Manage users.
|
||||
*
|
||||
* Pure and derived in one place because three things read it — the requester list's length,
|
||||
* the D-pad's lower bound and Manage users' own index — and a count that disagreed with the
|
||||
* rows actually drawn is how the last item in a menu becomes unreachable.
|
||||
*/
|
||||
internal fun userSwitcherActionCount(showRequests: Boolean): Int = if (showRequests) 4 else 3
|
||||
|
||||
@Composable
|
||||
private fun UserSwitcherProfileItem(
|
||||
profile: EmbyProfile,
|
||||
|
||||
@@ -127,6 +127,7 @@ import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||
import com.ponzischeme89.memby.data.analytics.PlaybackJourney
|
||||
import com.ponzischeme89.memby.data.analytics.playbackEntryPointFor
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||
import com.ponzischeme89.memby.data.model.HomeRow
|
||||
import com.ponzischeme89.memby.data.model.MyShow
|
||||
@@ -169,6 +170,13 @@ import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOutline
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySplashTint
|
||||
import com.ponzischeme89.memby.ui.viewers.MAX_SHADOW_VIEWERS
|
||||
import com.ponzischeme89.memby.ui.viewers.ViewerManageScreen
|
||||
import com.ponzischeme89.memby.ui.viewers.ViewerNameEntry
|
||||
import com.ponzischeme89.memby.ui.viewers.ViewerNameTarget
|
||||
import com.ponzischeme89.memby.ui.viewers.ViewerPicker
|
||||
import com.ponzischeme89.memby.ui.viewers.shouldOfferViewerPicker
|
||||
import com.ponzischeme89.memby.ui.viewers.viewerNameFor
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
||||
@@ -1975,6 +1983,35 @@ private fun HomeScreen(
|
||||
) + notificationState.notifications
|
||||
var showNotifications by remember { mutableStateOf(false) }
|
||||
var showRequests by remember { mutableStateOf(false) }
|
||||
// The people under this account. Fetched once the launcher is up rather than on the
|
||||
// critical path: nothing on the signed-in path may block on a request, and until the
|
||||
// answer lands this television watches as whoever it watched as last — which is the
|
||||
// right answer far more often than not.
|
||||
var viewers by remember { mutableStateOf<List<MembyViewer>>(emptyList()) }
|
||||
var showViewerPicker by remember { mutableStateOf(false) }
|
||||
// Managing viewers is a stack over the picker rather than a replacement for it — the
|
||||
// arrangement the "add another user" sign-in already takes over the manage-users page:
|
||||
// cancelling a name comes straight back to the list the button was pressed from, with
|
||||
// nothing to restore because nothing was ever unmounted.
|
||||
var showViewerManage by remember { mutableStateOf(false) }
|
||||
var viewerNameTarget by remember { mutableStateOf<ViewerNameTarget?>(null) }
|
||||
var viewerName by remember { mutableStateOf("") }
|
||||
var viewerNameFailure by remember { mutableStateOf<String?>(null) }
|
||||
var viewerSaving by remember { mutableStateOf(false) }
|
||||
var viewerBusyId by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(settings.userId, settings.serverUrl) {
|
||||
viewers = if (settings.isSignedIn) repo.viewers() else emptyList()
|
||||
}
|
||||
// One place the list is re-read, so every mutation ends the same way and none of them
|
||||
// has to work out what the answer should now be. The gateway is the thing that knows.
|
||||
val refreshViewers: suspend () -> Unit = {
|
||||
viewers = runCatching { repo.viewers() }.getOrDefault(viewers)
|
||||
}
|
||||
val openViewerName: (ViewerNameTarget) -> Unit = { target ->
|
||||
viewerName = viewerNameFor(target)
|
||||
viewerNameFailure = null
|
||||
viewerNameTarget = target
|
||||
}
|
||||
// Two different things: [launchingItem] is the gate that stops a second Play press
|
||||
// stacking a second player, and stays shut until one comes back. [resolvingItem] is
|
||||
// the loading screen, and belongs only to a launch that is waiting on the server.
|
||||
@@ -3398,6 +3435,14 @@ private fun HomeScreen(
|
||||
notificationsLoading = false
|
||||
}
|
||||
},
|
||||
showViewers = shouldOfferViewerPicker(ServerConfig.isGateway, viewers.size),
|
||||
activeViewerId = settings.activeViewerId,
|
||||
activeViewerName = settings.activeViewerName,
|
||||
onOpenViewers = {
|
||||
userSwitcherVisible = false
|
||||
navigationExpanded = false
|
||||
showViewerPicker = true
|
||||
},
|
||||
showRequests = requestsAllowed,
|
||||
onOpenRequests = {
|
||||
homeViewModel.trackJourney(
|
||||
@@ -3417,6 +3462,106 @@ private fun HomeScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showViewerPicker) {
|
||||
val closeViewerPicker: () -> Unit = {
|
||||
showViewerPicker = false
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(16L)
|
||||
runCatching { navigationFocusRequester.requestFocus() }
|
||||
}
|
||||
}
|
||||
BackHandler(onBack = closeViewerPicker)
|
||||
Box(Modifier.fillMaxSize().zIndex(20f).background(MembySurface)) {
|
||||
ViewerPicker(
|
||||
viewers = viewers,
|
||||
activeViewerId = settings.activeViewerId,
|
||||
onViewerSelected = { viewer ->
|
||||
closeViewerPicker()
|
||||
scope.launch {
|
||||
// Everything on the launcher belongs to the outgoing viewer, so
|
||||
// the journey is closed and the rows are refreshed rather than
|
||||
// left standing under a different person's name.
|
||||
homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen)
|
||||
repo.switchViewer(viewer)
|
||||
homeViewModel.refreshAll()
|
||||
}
|
||||
},
|
||||
// Both open over the picker rather than instead of it, so Back is one
|
||||
// step out of each and the row of faces is still underneath.
|
||||
onAddViewer = { openViewerName(ViewerNameTarget.Add) },
|
||||
onManageViewers = { showViewerManage = true },
|
||||
canAddViewer = viewers.count { !it.isMain } < MAX_SHADOW_VIEWERS,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showViewerManage) {
|
||||
BackHandler(onBack = { showViewerManage = false })
|
||||
Box(Modifier.fillMaxSize().zIndex(21f).background(MembySurface)) {
|
||||
ViewerManageScreen(
|
||||
viewers = viewers,
|
||||
onRename = { openViewerName(ViewerNameTarget.Rename(it)) },
|
||||
onRemove = { viewer ->
|
||||
viewerBusyId = viewer.id
|
||||
scope.launch {
|
||||
// Removing whoever is watching returns this set to the account,
|
||||
// which the repository does; the launcher has to be told, or it
|
||||
// goes on drawing the removed person's rows.
|
||||
val watching = viewer.id == settings.activeViewerId
|
||||
val removed = repo.removeViewer(viewer)
|
||||
if (removed && watching) {
|
||||
homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen)
|
||||
homeViewModel.refreshAll()
|
||||
}
|
||||
refreshViewers()
|
||||
viewerBusyId = null
|
||||
}
|
||||
},
|
||||
onAdd = { openViewerName(ViewerNameTarget.Add) },
|
||||
onClose = { showViewerManage = false },
|
||||
canAddViewer = viewers.count { !it.isMain } < MAX_SHADOW_VIEWERS,
|
||||
busyViewerId = viewerBusyId,
|
||||
)
|
||||
}
|
||||
}
|
||||
viewerNameTarget?.let { target ->
|
||||
val closeViewerName = { viewerNameTarget = null }
|
||||
BackHandler(onBack = closeViewerName)
|
||||
Box(Modifier.fillMaxSize().zIndex(22f).background(MembySurface)) {
|
||||
ViewerNameEntry(
|
||||
target = target,
|
||||
name = viewerName,
|
||||
existing = viewers,
|
||||
onNameChanged = { viewerName = it; viewerNameFailure = null },
|
||||
onCancel = closeViewerName,
|
||||
onConfirm = {
|
||||
if (!viewerSaving) {
|
||||
viewerSaving = true
|
||||
scope.launch {
|
||||
val saved = when (target) {
|
||||
ViewerNameTarget.Add -> repo.createViewer(viewerName)
|
||||
is ViewerNameTarget.Rename ->
|
||||
repo.renameViewer(target.viewer, viewerName)
|
||||
}
|
||||
viewerSaving = false
|
||||
if (saved == null) {
|
||||
// The one thing the television can say about a refusal
|
||||
// it has no wording for. The screen stays up holding
|
||||
// what was typed, because retyping a name somebody has
|
||||
// just entered is the worst possible answer to a
|
||||
// request that failed for a reason nothing here knows.
|
||||
viewerNameFailure = "That could not be saved. Try again."
|
||||
} else {
|
||||
viewerNameTarget = null
|
||||
refreshViewers()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
failure = viewerNameFailure,
|
||||
saving = viewerSaving,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (userQuickActionsVisible) {
|
||||
val closeUserQuickActions: () -> Unit = {
|
||||
userQuickActionsVisible = false
|
||||
|
||||
@@ -26,3 +26,32 @@ internal fun userSwitcherNextIndex(
|
||||
UserSwitcherDirection.DOWN -> (current + 1).coerceAtMost(lastIndex)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pinned rows below the profile list, in the order they are drawn.
|
||||
*
|
||||
* It is a list rather than four pieces of arithmetic because five things read it — the
|
||||
* requester list's length, the D-pad's lower bound, and each row's own index — and every
|
||||
* one of those was previously written out by hand (`profiles.size + 1`,
|
||||
* `if (showRequests) 2 else 1`, `actionCount - 1`). A count that disagreed with the rows
|
||||
* actually drawn is how the last item in a menu becomes unreachable, and a fifth
|
||||
* conditional row is exactly the change that breaks it. This is the shape `QuickAction`
|
||||
* already moved to for the same reason.
|
||||
*/
|
||||
internal enum class UserSwitcherMenuItem { VIEWERS, NOTIFICATIONS, REQUESTS, SETTINGS, MANAGE_USERS }
|
||||
|
||||
/**
|
||||
* Who's watching first, because it changes *whose* menu this is: the notifications and
|
||||
* requests below it belong to whichever viewer it selects, so offering it after them would
|
||||
* put the answer below the things that depend on it.
|
||||
*/
|
||||
internal fun userSwitcherMenuItems(
|
||||
showRequests: Boolean,
|
||||
showViewers: Boolean = false,
|
||||
): List<UserSwitcherMenuItem> = buildList {
|
||||
if (showViewers) add(UserSwitcherMenuItem.VIEWERS)
|
||||
add(UserSwitcherMenuItem.NOTIFICATIONS)
|
||||
if (showRequests) add(UserSwitcherMenuItem.REQUESTS)
|
||||
add(UserSwitcherMenuItem.SETTINGS)
|
||||
add(UserSwitcherMenuItem.MANAGE_USERS)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.ponzischeme89.memby.ui.theme
|
||||
import com.composables.icons.fontawesome.FontAwesome
|
||||
import com.composables.icons.fontawesome.solid.AngleDoubleLeft
|
||||
import com.composables.icons.fontawesome.solid.ArrowDown
|
||||
import com.composables.icons.fontawesome.solid.Pen
|
||||
import com.composables.icons.fontawesome.solid.TrashAlt
|
||||
import com.composables.icons.fontawesome.solid.ArrowLeft
|
||||
import com.composables.icons.fontawesome.solid.ArrowRight
|
||||
import com.composables.icons.fontawesome.solid.ArrowUp
|
||||
@@ -100,6 +102,8 @@ internal val fontAwesomeIconPack = MembyIconPack(
|
||||
MembyIcon.Add to { FontAwesome.Solid.Plus },
|
||||
MembyIcon.Close to { FontAwesome.Solid.Times },
|
||||
MembyIcon.Refresh to { FontAwesome.Solid.Sync },
|
||||
MembyIcon.Rename to { FontAwesome.Solid.Pen },
|
||||
MembyIcon.Remove to { FontAwesome.Solid.TrashAlt },
|
||||
MembyIcon.ChevronLeft to { FontAwesome.Solid.ChevronLeft },
|
||||
MembyIcon.ChevronRight to { FontAwesome.Solid.ChevronRight },
|
||||
MembyIcon.ChevronDown to { FontAwesome.Solid.ChevronDown },
|
||||
|
||||
@@ -13,6 +13,8 @@ import com.composables.icons.lucide.Building2
|
||||
import com.composables.icons.lucide.CalendarClock
|
||||
import com.composables.icons.lucide.CalendarDays
|
||||
import com.composables.icons.lucide.Check
|
||||
import com.composables.icons.lucide.Pencil
|
||||
import com.composables.icons.lucide.Trash2
|
||||
import com.composables.icons.lucide.ChevronDown
|
||||
import com.composables.icons.lucide.ChevronLeft
|
||||
import com.composables.icons.lucide.ChevronRight
|
||||
@@ -100,6 +102,8 @@ internal val lucideIconPack = MembyIconPack(
|
||||
MembyIcon.Add to { Lucide.Plus },
|
||||
MembyIcon.Close to { Lucide.X },
|
||||
MembyIcon.Refresh to { Lucide.RefreshCw },
|
||||
MembyIcon.Rename to { Lucide.Pencil },
|
||||
MembyIcon.Remove to { Lucide.Trash2 },
|
||||
MembyIcon.ChevronLeft to { Lucide.ChevronLeft },
|
||||
MembyIcon.ChevronRight to { Lucide.ChevronRight },
|
||||
MembyIcon.ChevronDown to { Lucide.ChevronDown },
|
||||
|
||||
@@ -24,8 +24,10 @@ import androidx.compose.material.icons.filled.ChevronLeft
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Devices
|
||||
import androidx.compose.material.icons.filled.DeleteOutline
|
||||
import androidx.compose.material.icons.filled.DoneAll
|
||||
import androidx.compose.material.icons.filled.Event
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.FirstPage
|
||||
@@ -118,6 +120,8 @@ object MaterialIconPack {
|
||||
MembyIcon.Add to { Icons.Default.Add },
|
||||
MembyIcon.Close to { Icons.Default.Close },
|
||||
MembyIcon.Refresh to { Icons.Default.Refresh },
|
||||
MembyIcon.Rename to { Icons.Default.Edit },
|
||||
MembyIcon.Remove to { Icons.Default.DeleteOutline },
|
||||
MembyIcon.ChevronLeft to { Icons.Default.ChevronLeft },
|
||||
MembyIcon.ChevronRight to { Icons.Default.ChevronRight },
|
||||
MembyIcon.ChevronDown to { Icons.Default.KeyboardArrowDown },
|
||||
|
||||
@@ -53,6 +53,12 @@ enum class MembyIcon {
|
||||
Close,
|
||||
Refresh,
|
||||
|
||||
// Editing a thing rather than acting on media: the manage-viewers list is what needed
|
||||
// them, and they are named for the job so a pack answering with a pencil and a pack
|
||||
// answering with a pen both sit under a name that stays true.
|
||||
Rename,
|
||||
Remove,
|
||||
|
||||
// Movement
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.ponzischeme89.memby.ui.viewers
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyDisabledText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOutline
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.mark
|
||||
|
||||
/**
|
||||
* The one button the three viewer screens are built from.
|
||||
*
|
||||
* There were nearly three of it — the picker's Add and Manage, the manage list's Rename and
|
||||
* Remove, and the name screen's confirm and cancel — all the same shape at the same size in
|
||||
* the same green, written on three different days. This is the rule the rest of the app
|
||||
* already follows for Play (`ui/MembyButtons.kt`) and for detail-page cards
|
||||
* (`Modifier.detailCardFocus`): one language per kind of control, or the copies drift and a
|
||||
* viewer notices before anybody else does.
|
||||
*
|
||||
* [emphasised] marks the answer the screen is *for* — adding the person, saving the name —
|
||||
* so that on a row of two the destructive or the neutral one is never the one wearing the
|
||||
* accent. It is a quiet outline the rest of the time, which is the same distinction
|
||||
* `ExitConfirmation` draws between staying and closing.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ViewerActionButton(
|
||||
label: String,
|
||||
icon: MembyIcon,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
emphasised: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
/**
|
||||
* Draws the button as though the remote were on it. Robolectric's window never takes
|
||||
* focus and the ring is the whole of what says which answer a press would take, so a
|
||||
* capture of a two-answer row would otherwise prove nothing — the flag `ExitConfirmation`
|
||||
* carries, for the same reason.
|
||||
*/
|
||||
focusedForCapture: Boolean = false,
|
||||
) {
|
||||
var hasFocus by remember { mutableStateOf(false) }
|
||||
val focused = hasFocus || focusedForCapture
|
||||
val shape = RoundedCornerShape(MembyPanelCorner)
|
||||
// Disabled is drawn rather than removed only where the control is the point of the
|
||||
// screen: the confirm button on the name screen is what explains what to do next, and
|
||||
// a button that appeared once enough had been typed would move the row under a thumb.
|
||||
// Everywhere the control is optional it is removed instead — see the picker's Add.
|
||||
val background = when {
|
||||
!enabled -> Color.Transparent
|
||||
focused -> MembyAccent
|
||||
emphasised -> MembyAccent.copy(alpha = 0.16f)
|
||||
else -> MembyControlSurface
|
||||
}
|
||||
val content = when {
|
||||
!enabled -> MembyDisabledText
|
||||
focused -> MembyAccentInk
|
||||
emphasised -> MembyAccent
|
||||
else -> MembyMutedText
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(background)
|
||||
// The outline survives being disabled: without it a dimmed button has no fill and
|
||||
// no edge, and reads as a label somebody forgot to finish rather than as a control
|
||||
// waiting for a name to be typed.
|
||||
.border(1.dp, if (focused) Color.Transparent else MembyOutline, shape)
|
||||
.onFocusChanged { hasFocus = it.isFocused }
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon.mark,
|
||||
contentDescription = null,
|
||||
tint = content,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(text = label, color = content, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.ponzischeme89.memby.ui.viewers
|
||||
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
|
||||
/**
|
||||
* The rules behind naming a person, kept pure so they can be tested without a television.
|
||||
*
|
||||
* Naming is the whole of what this feature needed and did not have: the gateway's routes
|
||||
* and the repository's calls have existed since viewers shipped, and both the Add and the
|
||||
* Manage controls opened the *account* list instead, because a name has to be typed and
|
||||
* this app has one text-entry idiom — the search keyboard — that nothing else reused.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The longest name the gateway will accept, in characters rather than bytes, because that
|
||||
* is what it counts in runes on the other side.
|
||||
*
|
||||
* It is enforced here by *refusing the keypress* rather than by rejecting the save. A
|
||||
* remote types one character at a time and a limit that only announces itself at the end
|
||||
* is one somebody discovers after typing a sentence.
|
||||
*/
|
||||
internal const val MAX_VIEWER_NAME_LENGTH = 40
|
||||
|
||||
/** What the name-entry screen is being opened for. */
|
||||
internal sealed interface ViewerNameTarget {
|
||||
/** A new person under this account. */
|
||||
data object Add : ViewerNameTarget
|
||||
|
||||
/** Renaming somebody who is already here. */
|
||||
data class Rename(val viewer: MembyViewer) : ViewerNameTarget
|
||||
}
|
||||
|
||||
/** The name the screen opens holding: empty for a new person, their own for a rename. */
|
||||
internal fun viewerNameFor(target: ViewerNameTarget): String = when (target) {
|
||||
ViewerNameTarget.Add -> ""
|
||||
is ViewerNameTarget.Rename -> target.viewer.name
|
||||
}
|
||||
|
||||
internal fun viewerNameHeading(target: ViewerNameTarget): String = when (target) {
|
||||
ViewerNameTarget.Add -> "Who is watching?"
|
||||
is ViewerNameTarget.Rename -> "Rename ${target.viewer.name}"
|
||||
}
|
||||
|
||||
/**
|
||||
* The button names the *outcome*, not the screen — "Add viewer" rather than "Save",
|
||||
* because on a television the label under the focus ring is commonly the only thing
|
||||
* saying what a confirm press is about to do.
|
||||
*/
|
||||
internal fun viewerNameAction(target: ViewerNameTarget): String = when (target) {
|
||||
ViewerNameTarget.Add -> "Add viewer"
|
||||
is ViewerNameTarget.Rename -> "Save name"
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one typed character, or refuses it at the limit.
|
||||
*
|
||||
* Returning the unchanged name is what makes the refusal silent, which is the right
|
||||
* failure here: the keyboard is on screen, the name is on screen above it, and a key that
|
||||
* simply stops writing is self-explanatory in a way an error message about a maximum is
|
||||
* not.
|
||||
*/
|
||||
internal fun viewerNameWith(current: String, typed: String): String =
|
||||
if (current.length + typed.length > MAX_VIEWER_NAME_LENGTH) current else current + typed
|
||||
|
||||
/**
|
||||
* What is wrong with this name, or null when nothing is.
|
||||
*
|
||||
* Two refusals and they fail for different reasons. A blank name is refused because the
|
||||
* gateway refuses it, and a card with no name on it is not a person anybody could pick.
|
||||
* A **repeated** name is refused by this app alone — the gateway is perfectly happy to
|
||||
* hold two people called Sam — because the picker is a row of faces with a name under
|
||||
* each, and two identical names is a choice nobody in the household can make.
|
||||
*
|
||||
* The comparison ignores case and surrounding space, since "sam" and "Sam " are the same
|
||||
* answer to "who is this", and a rename skips the person being renamed so that correcting
|
||||
* somebody's capitalisation is not refused as a duplicate of themselves.
|
||||
*/
|
||||
internal fun viewerNameError(
|
||||
name: String,
|
||||
existing: List<MembyViewer>,
|
||||
target: ViewerNameTarget,
|
||||
): String? {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return "Type a name first."
|
||||
val renaming = (target as? ViewerNameTarget.Rename)?.viewer?.id
|
||||
val clash = existing.any { it.id != renaming && it.name.trim().equals(trimmed, ignoreCase = true) }
|
||||
return if (clash) "There is already somebody called $trimmed." else null
|
||||
}
|
||||
|
||||
/** Whether the confirm button does anything yet. */
|
||||
internal fun viewerNameSubmittable(
|
||||
name: String,
|
||||
existing: List<MembyViewer>,
|
||||
target: ViewerNameTarget,
|
||||
): Boolean = viewerNameError(name, existing, target) == null
|
||||
|
||||
/**
|
||||
* The people the manage screen can actually change.
|
||||
*
|
||||
* The main viewer is listed but never editable: its name is the Emby account's and belongs
|
||||
* to Emby, which is why `store.UpdateShadowViewer` refuses it and why removing it is not
|
||||
* offered at all — an account with no main viewer has nothing left to fall back to.
|
||||
*/
|
||||
internal fun viewerIsEditable(viewer: MembyViewer): Boolean = !viewer.isMain
|
||||
|
||||
/**
|
||||
* Where the manage list puts focus after somebody is removed.
|
||||
*
|
||||
* The row that took their place, falling back to the last row when the one removed was at
|
||||
* the end, and to nothing at all when the list has emptied — the rule
|
||||
* [profileFocusIndexAfterRemoval][com.ponzischeme89.memby.ui.profileFocusIndexAfterRemoval]
|
||||
* already follows, for the same reason: a viewer who has just deleted three people in a
|
||||
* row must not be sent back to the top of the list between each one.
|
||||
*/
|
||||
internal fun viewerFocusIndexAfterRemoval(removedIndex: Int, remainingCount: Int): Int? =
|
||||
if (remainingCount <= 0) null else removedIndex.coerceIn(0, remainingCount - 1)
|
||||
@@ -0,0 +1,324 @@
|
||||
package com.ponzischeme89.memby.ui.viewers
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.ui.distinctForKeys
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOutline
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
|
||||
/**
|
||||
* The people under this account, and what can be done about them.
|
||||
*
|
||||
* It is a *list* where the picker is a row of faces, because the two answer different
|
||||
* questions. The picker asks "who is watching", which is a glance and one press; this asks
|
||||
* "who is here", which is read one line at a time and acted on per person — the same
|
||||
* distinction the alerts page draws between a badge and its inbox.
|
||||
*
|
||||
* Stateless but for the remote's own business: which row a removal is aimed at, and where
|
||||
* focus goes when that row disappears. The caller owns the list and the requests.
|
||||
*/
|
||||
@Composable
|
||||
fun ViewerManageScreen(
|
||||
viewers: List<MembyViewer>,
|
||||
onRename: (MembyViewer) -> Unit,
|
||||
onRemove: (MembyViewer) -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
canAddViewer: Boolean = true,
|
||||
/** The viewer a request is in flight for, so a second press cannot start a second one. */
|
||||
busyViewerId: String? = null,
|
||||
) {
|
||||
// Ids come off a wire and a keyed list throws on a repeat — see ui/ListKeys.kt.
|
||||
val people = remember(viewers) { viewers.distinctForKeys(MembyViewer::id) }
|
||||
var pendingRemoval by remember { mutableStateOf<MembyViewer?>(null) }
|
||||
var removalReturnIndex by remember { mutableStateOf<Int?>(null) }
|
||||
val renameFocus = remember(people) { List(people.size) { FocusRequester() } }
|
||||
val addFocus = remember { FocusRequester() }
|
||||
|
||||
// Focus lands on the row that took the removed one's place rather than back at the top,
|
||||
// because emptying a household of guests is a run of presses and being sent to the top
|
||||
// between each one loses the viewer's place every time.
|
||||
LaunchedEffect(people) {
|
||||
val index = removalReturnIndex?.let { viewerFocusIndexAfterRemoval(it, people.size) }
|
||||
removalReturnIndex = null
|
||||
val target = index ?: people.indexOfFirst(::viewerIsEditable).takeIf { it >= 0 }
|
||||
runCatching {
|
||||
target?.let { renameFocus.getOrNull(it)?.requestFocus() } ?: addFocus.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier.fillMaxSize().background(MembySurface)) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 72.dp, vertical = 40.dp),
|
||||
) {
|
||||
Text("VIEWERS", color = MembyAccent, fontSize = 11.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Who is under this account",
|
||||
color = MembyOnSurface,
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Everybody here keeps their own Continue Watching, watched history " +
|
||||
"and favourites. Only the account itself is synced with Emby.",
|
||||
color = MembyQuietText,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
Spacer(Modifier.height(22.dp))
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
contentPadding = PaddingValues(bottom = 12.dp),
|
||||
) {
|
||||
itemsIndexed(people, key = { _, viewer -> viewer.id }) { index, viewer ->
|
||||
ViewerManageRow(
|
||||
viewer = viewer,
|
||||
busy = viewer.id == busyViewerId,
|
||||
onRename = { onRename(viewer) },
|
||||
onRemove = {
|
||||
removalReturnIndex = index
|
||||
pendingRemoval = viewer
|
||||
},
|
||||
renameModifier = Modifier.focusRequester(renameFocus[index]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
if (canAddViewer) {
|
||||
ViewerActionButton(
|
||||
label = "Add viewer",
|
||||
icon = MembyIcon.Add,
|
||||
onClick = onAdd,
|
||||
emphasised = true,
|
||||
modifier = Modifier.focusRequester(addFocus),
|
||||
)
|
||||
}
|
||||
ViewerActionButton(label = "Done", icon = MembyIcon.Check, onClick = onClose)
|
||||
}
|
||||
}
|
||||
|
||||
pendingRemoval?.let { viewer ->
|
||||
ViewerRemovalConfirmation(
|
||||
viewer = viewer,
|
||||
onCancel = {
|
||||
removalReturnIndex = null
|
||||
pendingRemoval = null
|
||||
},
|
||||
onConfirm = {
|
||||
pendingRemoval = null
|
||||
onRemove(viewer)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One person and the two things that can be done to them.
|
||||
*
|
||||
* The row holds **two focus targets** rather than opening a menu, the shape the alerts page
|
||||
* settled on for the same reason: a remote has one confirm key, and a press that opened a
|
||||
* list of actions would make renaming somebody three presses deep for no gain. Down still
|
||||
* reaches the next row from either, so the second target costs nothing to somebody who only
|
||||
* ever renames.
|
||||
*
|
||||
* The **main viewer has neither**. Its name is the Emby account's — `UpdateShadowViewer`
|
||||
* refuses to touch it — and removing it would leave the account with nothing to fall back
|
||||
* to. It is still listed, because a list of the people here that omitted the person whose
|
||||
* watching actually reaches Emby would be the more confusing of the two.
|
||||
*/
|
||||
@Composable
|
||||
private fun ViewerManageRow(
|
||||
viewer: MembyViewer,
|
||||
busy: Boolean,
|
||||
onRename: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
renameModifier: Modifier = Modifier,
|
||||
) {
|
||||
var hasFocus by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(MembyPanelCorner)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// The lit surface is the *row's*, driven by hasFocus rather than isFocused, or
|
||||
// the row goes dark the moment the remote steps sideways into its own control.
|
||||
.onFocusChanged { hasFocus = it.hasFocus }
|
||||
.clip(shape)
|
||||
.background(if (hasFocus) MembyControlSurfaceRaised else MembyControlSurface)
|
||||
.border(1.dp, if (hasFocus) MembyAccent else Color.Transparent, shape)
|
||||
.padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(46.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MembySurface)
|
||||
.border(1.dp, MembyOutline, CircleShape),
|
||||
) {
|
||||
Text(
|
||||
text = viewer.initials,
|
||||
color = MembyOnSurface,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = viewer.name,
|
||||
color = MembyOnSurface,
|
||||
fontSize = 18.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = when {
|
||||
busy -> "Working…"
|
||||
viewer.isMain -> "The account itself — named by Emby, and synced with it"
|
||||
else -> "Watches privately; nothing reaches Emby"
|
||||
},
|
||||
color = MembyQuietText,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (viewerIsEditable(viewer)) {
|
||||
Spacer(Modifier.width(16.dp))
|
||||
ViewerActionButton(
|
||||
label = "Rename",
|
||||
icon = MembyIcon.Rename,
|
||||
onClick = onRename,
|
||||
enabled = !busy,
|
||||
modifier = renameModifier,
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
ViewerActionButton(
|
||||
label = "Remove",
|
||||
icon = MembyIcon.Remove,
|
||||
onClick = onRemove,
|
||||
enabled = !busy,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The full-stop question, because removing somebody deletes everything Memby kept for them
|
||||
* and there is nothing to undo it with.
|
||||
*
|
||||
* It follows `ExitConfirmation`'s rules, which are the app's rules for a question asked over
|
||||
* the thing it is about: the two answers **do not look alike**, the safe one takes focus
|
||||
* first, and **Back means keep** — it is the key that raised the panel, and pressing it
|
||||
* again must not be what deletes a person.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ViewerRemovalConfirmation(
|
||||
viewer: MembyViewer,
|
||||
onCancel: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
/** Draws the keep button lit, so a capture states which answer a press would take. */
|
||||
focusedForCapture: Boolean = false,
|
||||
) {
|
||||
val keepFocus = remember { FocusRequester() }
|
||||
BackHandler(onBack = onCancel)
|
||||
LaunchedEffect(viewer.id) { runCatching { keepFocus.requestFocus() } }
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.78f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(520.dp)
|
||||
.background(MembyControlSurface, RoundedCornerShape(18.dp))
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Remove ${viewer.name}?",
|
||||
color = MembyOnSurface,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = "What they were part-way through, what they had watched and their " +
|
||||
"favourites are deleted, on every television in the house. Nothing on " +
|
||||
"the Emby account changes.",
|
||||
color = MembyMutedText,
|
||||
fontSize = 15.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
ViewerActionButton(
|
||||
label = "Keep ${viewer.name}",
|
||||
icon = MembyIcon.Close,
|
||||
onClick = onCancel,
|
||||
emphasised = true,
|
||||
focusedForCapture = focusedForCapture,
|
||||
modifier = Modifier.focusRequester(keepFocus),
|
||||
)
|
||||
ViewerActionButton(
|
||||
label = "Remove",
|
||||
icon = MembyIcon.Remove,
|
||||
onClick = onConfirm,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||
|
||||
package com.ponzischeme89.memby.ui.viewers
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.input.key.utf16CodePoint
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.ui.search.BACKSPACE_CODE
|
||||
import com.ponzischeme89.memby.ui.search.FIRST_PRINTABLE_CODE
|
||||
import com.ponzischeme89.memby.ui.search.TvKeyboard
|
||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOutline
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
|
||||
/**
|
||||
* What a refusal is printed in. A literal rather than a token, the stance every status
|
||||
* colour in this app takes: it carries meaning of its own, and a palette that could repaint
|
||||
* it could make a refusal look like a confirmation.
|
||||
*/
|
||||
private val ViewerRefusal = Color(0xFFFF8A80)
|
||||
|
||||
/**
|
||||
* Naming a person, which is the one thing this feature could not do from a television.
|
||||
*
|
||||
* It reuses the **search keyboard** rather than growing a second one. Two on-screen
|
||||
* keyboards in one app is two focus contracts to keep in step, and the one thing a viewer
|
||||
* must never have to relearn is where the letters are — which is the note [TvKeyboard]
|
||||
* already carries, written before there was a second caller to prove it.
|
||||
*
|
||||
* Stateless, the stance the picker and `SignInContent` take: the caller owns the name, the
|
||||
* request and what happens after it, so the screenshot test can render every state of it
|
||||
* with no gateway.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ViewerNameEntry(
|
||||
target: ViewerNameTarget,
|
||||
name: String,
|
||||
existing: List<MembyViewer>,
|
||||
onNameChanged: (String) -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
/**
|
||||
* What the gateway said when it refused. It is kept apart from the local rules in
|
||||
* [viewerNameError] on purpose: one is a sentence about what is typed and is true
|
||||
* before anything is sent, the other is what came back, and showing them in one slot
|
||||
* would let a stale server refusal sit under a name that has since been corrected.
|
||||
*/
|
||||
failure: String? = null,
|
||||
saving: Boolean = false,
|
||||
) {
|
||||
val keyboardEntry = remember { FocusRequester() }
|
||||
val keyboardReturn = remember { FocusRequester() }
|
||||
val confirmFocus = remember { FocusRequester() }
|
||||
var lastKeyIndex by remember { mutableStateOf(0) }
|
||||
|
||||
// The keyboard, not the confirm button: somebody who opened this screen came to type.
|
||||
LaunchedEffect(target) { runCatching { keyboardEntry.requestFocus() } }
|
||||
|
||||
val localError = viewerNameError(name, existing, target)
|
||||
// The local rule is only worth printing once there is something to be wrong about — a
|
||||
// screen that opens by telling somebody their empty name is empty is scolding them for
|
||||
// not having started.
|
||||
val message = failure ?: localError.takeIf { name.isNotEmpty() }
|
||||
val canConfirm = localError == null && !saving
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MembySurface)
|
||||
.padding(horizontal = 48.dp, vertical = 24.dp)
|
||||
// A USB keyboard, or a phone remote app sending key events, types into the same
|
||||
// name the on-screen keys do — the Search tab's rule, and the same limits: only
|
||||
// printable characters and backspace are consumed, so D-pad and Back fall
|
||||
// through untouched and the screen can still be left.
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
val code = event.utf16CodePoint
|
||||
when {
|
||||
code == BACKSPACE_CODE -> {
|
||||
onNameChanged(name.dropLast(1)); true
|
||||
}
|
||||
code >= FIRST_PRINTABLE_CODE -> {
|
||||
onNameChanged(viewerNameWith(name, code.toChar().toString())); true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = viewerNameHeading(target),
|
||||
color = MembyOnSurface,
|
||||
fontSize = 26.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "They keep their own Continue Watching, watched history and favourites. " +
|
||||
"Nothing they watch reaches Emby.",
|
||||
color = MembyQuietText,
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
NamePlate(name = name)
|
||||
|
||||
// Reserved rather than conditional, the rule the picker's "Synced with Emby" line
|
||||
// follows: a message appearing under the plate would push the keyboard down by a
|
||||
// line at the moment somebody is typing into it.
|
||||
//
|
||||
// Deliberately not the accent: every affirmative thing on a Memby screen is green,
|
||||
// and a refusal wearing the confirmation colour reads at a glance as the name
|
||||
// having been accepted. This is the red the search tab's own refusals use.
|
||||
Text(
|
||||
text = message ?: " ",
|
||||
color = ViewerRefusal,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
// The keyboard is the child that gives way, never the buttons under it.
|
||||
//
|
||||
// A Column hands each child what the ones before it left over, so the confirm row —
|
||||
// being last — was measured from the remainder and rendered as two squeezed slivers
|
||||
// with their labels pressed out. Weighted children are measured from what the
|
||||
// *unweighted* ones leave, so this inverts it: the buttons take their natural size
|
||||
// first and the letters give up a row if a set is short of height. It is the same
|
||||
// inversion the home hero makes for its Play chip, and it broke here in the same way.
|
||||
Box(Modifier.width(340.dp).weight(1f, fill = false)) {
|
||||
TvKeyboard(
|
||||
// There is no rail beside this screen and no results grid to its right, so
|
||||
// both edges are dead ends rather than jumps to a distant control: a screen
|
||||
// this small has nowhere for focus to go sideways that would not be a
|
||||
// surprise.
|
||||
navigationFocusRequester = FocusRequester.Cancel,
|
||||
resultsEntry = FocusRequester.Cancel,
|
||||
keyboardEntry = keyboardEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
lastKeyIndex = lastKeyIndex,
|
||||
hasResultsTarget = false,
|
||||
onKeyFocused = { lastKeyIndex = it },
|
||||
onCharacter = { onNameChanged(viewerNameWith(name, it)) },
|
||||
onBackspace = { onNameChanged(name.dropLast(1)) },
|
||||
onClear = { onNameChanged("") },
|
||||
// No submit key. The keyboard's own Search key is a lookup that costs
|
||||
// something; here the confirm is a decision about a person and belongs
|
||||
// beside the way out of the screen, not in the middle of the letters.
|
||||
onSearch = null,
|
||||
compact = true,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
ViewerActionButton(
|
||||
label = "Cancel",
|
||||
icon = MembyIcon.Close,
|
||||
onClick = onCancel,
|
||||
)
|
||||
ViewerActionButton(
|
||||
label = if (saving) "Saving…" else viewerNameAction(target),
|
||||
icon = MembyIcon.Check,
|
||||
onClick = { if (canConfirm) onConfirm() },
|
||||
emphasised = true,
|
||||
enabled = canConfirm,
|
||||
modifier = Modifier.focusRequester(confirmFocus),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What has been typed, shown at the size the room can read.
|
||||
*
|
||||
* The caret is drawn rather than blinking: nothing on this screen may animate, because an
|
||||
* animation here would run for as long as somebody takes to type a name, and this app ships
|
||||
* to boxes with nothing spare. A steady mark says the same thing.
|
||||
*/
|
||||
@Composable
|
||||
private fun NamePlate(name: String) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.width(340.dp)
|
||||
.height(56.dp)
|
||||
.background(MembySurface, RoundedCornerShape(MembyPanelCorner))
|
||||
.border(1.dp, MembyOutline, RoundedCornerShape(MembyPanelCorner))
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = if (name.isEmpty()) "Type a name" else name + "|",
|
||||
color = if (name.isEmpty()) MembyQuietText else MembyOnSurface,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = if (name.isEmpty()) FontWeight.Normal else FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package com.ponzischeme89.memby.ui.viewers
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.ui.distinctForKeys
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOutline
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
|
||||
/**
|
||||
* "Who's watching?" — the people under one Memby account.
|
||||
*
|
||||
* It is deliberately a full screen rather than another row in the user switcher. That panel
|
||||
* lists *accounts* and the actions beside them, and a viewer is a different grain of thing:
|
||||
* a household picks a person the way they pick one on any television service, by looking at
|
||||
* a row of faces. Folding them into the same 292dp column would have made two unrelated
|
||||
* questions look like one list.
|
||||
*
|
||||
* Stateless on purpose, the stance `SignInContent` and the detail panes take: everything it
|
||||
* needs is a parameter, so [ViewerPickerScreenshotTest] can render it with no gateway, and
|
||||
* the caller owns the requests.
|
||||
*/
|
||||
@Composable
|
||||
fun ViewerPicker(
|
||||
viewers: List<MembyViewer>,
|
||||
activeViewerId: String,
|
||||
onViewerSelected: (MembyViewer) -> Unit,
|
||||
onAddViewer: () -> Unit,
|
||||
onManageViewers: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
/**
|
||||
* Whether an "Add viewer" card is offered. False once the household has reached the
|
||||
* gateway's limit — the control is *removed* rather than dimmed, the stance the two
|
||||
* optional transport controls take: a remote is driven by a D-pad, and a dead stop on
|
||||
* the way to the next control is worse than no control at all.
|
||||
*/
|
||||
canAddViewer: Boolean = true,
|
||||
loading: Boolean = false,
|
||||
) {
|
||||
// Ids come off a wire and a keyed LazyRow throws on a repeat, taking the screen with
|
||||
// it. Deduplicate, never disambiguate — see ui/ListKeys.kt.
|
||||
val people = remember(viewers) { viewers.distinctForKeys(MembyViewer::id) }
|
||||
val cardFocusers = remember(people) { List(people.size) { FocusRequester() } }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// Focus opens on whoever is watching rather than on the first card. A television is
|
||||
// switched on by the person who last used it far more often than not, so the common
|
||||
// case is one confirm press instead of a walk along the row.
|
||||
val initialIndex = remember(people, activeViewerId) {
|
||||
viewerPickerInitialIndex(people, activeViewerId)
|
||||
}
|
||||
LaunchedEffect(people, initialIndex) {
|
||||
if (people.isEmpty()) return@LaunchedEffect
|
||||
listState.scrollToItem(viewerPickerScrollIndex(initialIndex))
|
||||
runCatching { cardFocusers[initialIndex].requestFocus() }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MembySurface)
|
||||
.padding(horizontal = 48.dp, vertical = 40.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "Who's watching?",
|
||||
color = MembyOnSurface,
|
||||
fontSize = 34.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "Everyone keeps their own Continue Watching and their own watched history.",
|
||||
color = MembyQuietText,
|
||||
fontSize = 15.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(34.dp))
|
||||
|
||||
if (people.isEmpty()) {
|
||||
// "Still fetching" and "nobody here" are different things to be told — the
|
||||
// distinction the player's cast panel makes with its own `loaded` flag.
|
||||
Text(
|
||||
text = if (loading) "Loading viewers…" else "No viewers yet.",
|
||||
color = MembyMutedText,
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
} else {
|
||||
LazyRow(
|
||||
state = listState,
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||
) {
|
||||
itemsIndexed(people, key = { _, viewer -> viewer.id }) { index, viewer ->
|
||||
ViewerCard(
|
||||
viewer = viewer,
|
||||
selected = viewerIsActive(viewer, activeViewerId),
|
||||
onClick = { onViewerSelected(viewer) },
|
||||
modifier = Modifier.focusRequester(cardFocusers[index]),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(30.dp))
|
||||
Row {
|
||||
if (canAddViewer) {
|
||||
ViewerActionButton(
|
||||
label = "Add viewer",
|
||||
icon = MembyIcon.Add,
|
||||
onClick = onAddViewer,
|
||||
)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
}
|
||||
ViewerActionButton(
|
||||
label = "Manage",
|
||||
icon = MembyIcon.Settings,
|
||||
onClick = onManageViewers,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One person, as a face and a name.
|
||||
*
|
||||
* The animated scale is read only inside [graphicsLayer], never in the composable body, so
|
||||
* travelling the row redraws two cards rather than recomposing every card in it — the rule
|
||||
* every focus treatment in this app follows.
|
||||
*/
|
||||
@Composable
|
||||
private fun ViewerCard(
|
||||
viewer: MembyViewer,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(if (focused) 1.06f else 1f, label = "viewerCardScale")
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
.width(150.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(112.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (focused) MembyAccent else MembyControlSurface)
|
||||
.border(
|
||||
width = if (selected) 3.dp else 1.dp,
|
||||
color = when {
|
||||
focused -> Color.Transparent
|
||||
selected -> MembyAccent
|
||||
else -> MembyOutline
|
||||
},
|
||||
shape = CircleShape,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = viewer.initials,
|
||||
color = if (focused) MembyAccentInk else MembyOnSurface,
|
||||
fontSize = 40.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = viewer.name,
|
||||
color = if (focused) MembyOnSurface else MembyMutedText,
|
||||
fontSize = 16.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
// The main viewer is the one whose watching reaches Emby, and saying so is most of
|
||||
// the difference between a household understanding this feature and being puzzled
|
||||
// by it. The line is *reserved* rather than conditional, or a card without it would
|
||||
// sit taller than the one beside it — the rule the cast grid's character line
|
||||
// follows.
|
||||
Text(
|
||||
text = if (viewer.isMain) "Synced with Emby" else " ",
|
||||
color = MembyQuietText,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.ponzischeme89.memby.ui.viewers
|
||||
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
|
||||
/**
|
||||
* The rules the picker is built on, kept pure so they can be tested without a television.
|
||||
*
|
||||
* All three are about the same awkwardness: a blank active id means "the account's own
|
||||
* viewer", because that is what the absence of the `X-Memby-Viewer` header means to the
|
||||
* gateway. Writing the main viewer's id into the setting instead would work equally well
|
||||
* on the wire and would be worse in one specific way — an app that had never been told the
|
||||
* account's Emby user id could not then express "nobody in particular", which is the state
|
||||
* every existing install starts in.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How many shadow viewers an account may hold. Mirrors `store.MaxShadowViewers` on the
|
||||
* gateway, which is the one that enforces it — this copy only decides whether the picker
|
||||
* offers the control, so that a household at the limit is never handed a button whose only
|
||||
* possible outcome is a refusal.
|
||||
*/
|
||||
internal const val MAX_SHADOW_VIEWERS = 7
|
||||
|
||||
/** Whether [viewer] is the one this television is currently watching as. */
|
||||
internal fun viewerIsActive(viewer: MembyViewer, activeViewerId: String): Boolean =
|
||||
if (activeViewerId.isEmpty()) viewer.isMain else viewer.id == activeViewerId
|
||||
|
||||
/**
|
||||
* Where the picker opens focus.
|
||||
*
|
||||
* On whoever is watching, falling back to the first card. A television is switched on by
|
||||
* the person who last used it far more often than not, so the common case should be one
|
||||
* confirm press rather than a walk along the row — and an id this account no longer
|
||||
* recognises must still land somewhere real rather than off the end of the list.
|
||||
*/
|
||||
internal fun viewerPickerInitialIndex(viewers: List<MembyViewer>, activeViewerId: String): Int {
|
||||
if (viewers.isEmpty()) return 0
|
||||
val index = viewers.indexOfFirst { viewerIsActive(it, activeViewerId) }
|
||||
return if (index >= 0) index else 0
|
||||
}
|
||||
|
||||
/**
|
||||
* What the launcher calls whoever is watching.
|
||||
*
|
||||
* Empty for the account's own viewer, which is the whole point: a household that has never
|
||||
* added anybody must not have a name badge appear over its launcher explaining a feature it
|
||||
* is not using. Only a *shadow* viewer is somebody worth naming, because only then is there
|
||||
* a question of whose evening it is.
|
||||
*/
|
||||
internal fun activeViewerLabel(activeViewerId: String, activeViewerName: String): String =
|
||||
if (activeViewerId.isEmpty()) "" else activeViewerName.trim()
|
||||
|
||||
/**
|
||||
* Whether this television should offer the picker at all.
|
||||
*
|
||||
* Two conditions, and both matter. There is no gateway to ask on the direct path, so there
|
||||
* is exactly one viewer and it is the account. And an account with a single viewer is one
|
||||
* nobody has added anybody to — offering "Who's watching?" there is a question with one
|
||||
* answer, which reads as a fault rather than as a feature waiting to be used. The entry
|
||||
* point that *adds* the first viewer therefore lives with the other account management,
|
||||
* not behind this.
|
||||
*/
|
||||
internal fun shouldOfferViewerPicker(gatewayMode: Boolean, viewerCount: Int): Boolean =
|
||||
gatewayMode && viewerCount > 1
|
||||
|
||||
/**
|
||||
* What the user menu's row is called.
|
||||
*
|
||||
* It names the *person* once somebody other than the account is watching, because that is
|
||||
* the one thing a household needs to be able to check at a glance — "am I about to add this
|
||||
* to Alessandra's Continue Watching or to mine?" — and the menu is where they would look.
|
||||
* With the account's own viewer selected there is nobody to name, so it asks the question
|
||||
* instead.
|
||||
*/
|
||||
internal fun viewerMenuLabel(activeViewerId: String, activeViewerName: String): String {
|
||||
val name = activeViewerLabel(activeViewerId, activeViewerName)
|
||||
return if (name.isEmpty()) "Who's watching?" else "Watching as $name"
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the row is scrolled so the picker opens with context.
|
||||
*
|
||||
* Scrolling straight to the focused card pins it against the left edge, and the people
|
||||
* before it disappear with nothing on screen saying they are there — a household of six
|
||||
* whose fourth viewer is watching would open on what looks like a list starting at them.
|
||||
* Leaving one card visible behind the focused one is the cheapest possible answer: it says
|
||||
* "there is more this way" without a chevron, a fade or anything else to maintain.
|
||||
*
|
||||
* It is separate from [viewerPickerInitialIndex] because they answer different questions —
|
||||
* one is where the remote is, the other is what the eye can see — and conflating them would
|
||||
* mean focus landing on the wrong person to make the scroll look right.
|
||||
*/
|
||||
internal fun viewerPickerScrollIndex(focusedIndex: Int): Int =
|
||||
(focusedIndex - 1).coerceAtLeast(0)
|
||||
@@ -1,7 +1,8 @@
|
||||
package com.ponzischeme89.memby.ui.requests
|
||||
|
||||
import com.ponzischeme89.memby.ui.userSwitcherActionCount
|
||||
import com.ponzischeme89.memby.ui.UserSwitcherDirection
|
||||
import com.ponzischeme89.memby.ui.UserSwitcherMenuItem
|
||||
import com.ponzischeme89.memby.ui.userSwitcherMenuItems
|
||||
import com.ponzischeme89.memby.ui.userSwitcherNextIndex
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -146,38 +147,49 @@ class RequestPresentationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the switcher's action count matches the rows actually drawn`() {
|
||||
assertEquals(3, userSwitcherActionCount(showRequests = false))
|
||||
assertEquals(4, userSwitcherActionCount(showRequests = true))
|
||||
fun `the switcher's rows are exactly the ones its conditions ask for`() {
|
||||
assertEquals(3, userSwitcherMenuItems(showRequests = false).size)
|
||||
assertEquals(4, userSwitcherMenuItems(showRequests = true).size)
|
||||
assertEquals(4, userSwitcherMenuItems(showRequests = false, showViewers = true).size)
|
||||
assertEquals(5, userSwitcherMenuItems(showRequests = true, showViewers = true).size)
|
||||
|
||||
// Who is watching comes first, because the rows below it belong to whichever
|
||||
// viewer it selects; Manage users stays last, where a menu's escape hatch belongs.
|
||||
val full = userSwitcherMenuItems(showRequests = true, showViewers = true)
|
||||
assertEquals(UserSwitcherMenuItem.VIEWERS, full.first())
|
||||
assertEquals(UserSwitcherMenuItem.MANAGE_USERS, full.last())
|
||||
|
||||
// An optional row is absent rather than present-and-dead, so nothing below it
|
||||
// shifts under a D-pad that has already started travelling.
|
||||
assertFalse(UserSwitcherMenuItem.VIEWERS in userSwitcherMenuItems(showRequests = true))
|
||||
assertFalse(UserSwitcherMenuItem.REQUESTS in userSwitcherMenuItems(showRequests = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the D-pad reaches the last switcher row whether or not Requests is shown`() {
|
||||
// The bug this guards is the one that makes Manage users unreachable: an action
|
||||
// count that disagrees with the number of rows caps the D-pad one row short.
|
||||
fun `the D-pad reaches the last switcher row whatever is shown`() {
|
||||
// The bug this guards is the one that makes Manage users unreachable: a row count
|
||||
// that disagrees with the rows actually drawn caps the D-pad one row short. It is
|
||||
// run over every combination because each new optional row is a fresh chance to
|
||||
// reintroduce it.
|
||||
val profiles = listOf("p1", "p2")
|
||||
val withRequests = userSwitcherActionCount(showRequests = true)
|
||||
for (requests in listOf(false, true)) {
|
||||
for (people in listOf(false, true)) {
|
||||
val rows = userSwitcherMenuItems(requests, people).size
|
||||
var index = 0
|
||||
repeat(10) {
|
||||
repeat(12) {
|
||||
index = userSwitcherNextIndex(
|
||||
currentIndex = index,
|
||||
profileCount = profiles.size,
|
||||
direction = UserSwitcherDirection.DOWN,
|
||||
actionCount = withRequests,
|
||||
actionCount = rows,
|
||||
)
|
||||
}
|
||||
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(
|
||||
"requests=$requests viewers=$people",
|
||||
profiles.size + rows - 1,
|
||||
index,
|
||||
)
|
||||
}
|
||||
assertEquals(profiles.size + withoutRequests - 1, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.ponzischeme89.memby.ui.viewers
|
||||
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The rules behind naming a person. They are pure so they can be pinned here, which matters
|
||||
* because the failures they prevent are all the same shape: a name that reached the gateway
|
||||
* and came back refused, after somebody had typed it one character at a time with a remote.
|
||||
*/
|
||||
class ViewerEditingTest {
|
||||
|
||||
private val household = listOf(
|
||||
MembyViewer(id = "emby-user-1", name = "Matt", kind = MembyViewer.KIND_MAIN),
|
||||
MembyViewer(id = "v1", name = "Alessandra", kind = MembyViewer.KIND_SHADOW),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a blank name is refused`() {
|
||||
assertNotNull(viewerNameError("", household, ViewerNameTarget.Add))
|
||||
assertNotNull(viewerNameError(" ", household, ViewerNameTarget.Add))
|
||||
assertFalse(viewerNameSubmittable("", household, ViewerNameTarget.Add))
|
||||
}
|
||||
|
||||
/**
|
||||
* The gateway is perfectly happy to hold two people called Sam. This app is not: the
|
||||
* picker is a row of faces with a name under each, and two identical names is a choice
|
||||
* nobody in the household can make.
|
||||
*/
|
||||
@Test
|
||||
fun `a name already in the house is refused, whatever its case`() {
|
||||
assertNotNull(viewerNameError("Alessandra", household, ViewerNameTarget.Add))
|
||||
assertNotNull(viewerNameError(" alessandra ", household, ViewerNameTarget.Add))
|
||||
// The account's own name counts too — it is a card in the same row.
|
||||
assertNotNull(viewerNameError("Matt", household, ViewerNameTarget.Add))
|
||||
assertNull(viewerNameError("Sam", household, ViewerNameTarget.Add))
|
||||
}
|
||||
|
||||
/**
|
||||
* Correcting somebody's own capitalisation must not be refused as a duplicate of
|
||||
* themselves, which is the one case a plain "is this name taken" check gets wrong.
|
||||
*/
|
||||
@Test
|
||||
fun `a rename may keep the name it started with`() {
|
||||
val target = ViewerNameTarget.Rename(household[1])
|
||||
assertNull(viewerNameError("Alessandra", household, target))
|
||||
assertNull(viewerNameError("alessandra", household, target))
|
||||
// Somebody else's name is still taken.
|
||||
assertNotNull(viewerNameError("Matt", household, target))
|
||||
}
|
||||
|
||||
/**
|
||||
* The limit is enforced by refusing the keypress rather than by rejecting the save. A
|
||||
* remote types one character at a time, and a limit that only announces itself at the
|
||||
* end is one somebody discovers after typing a sentence.
|
||||
*/
|
||||
@Test
|
||||
fun `typing stops at the limit rather than overrunning it`() {
|
||||
val full = "x".repeat(MAX_VIEWER_NAME_LENGTH)
|
||||
assertEquals(full, viewerNameWith(full, "y"))
|
||||
val nearly = "x".repeat(MAX_VIEWER_NAME_LENGTH - 1)
|
||||
assertEquals(nearly + "y", viewerNameWith(nearly, "y"))
|
||||
// A paste-sized addition that would overrun is refused whole rather than truncated:
|
||||
// half of what somebody dictated is worse than none of it.
|
||||
assertEquals(nearly, viewerNameWith(nearly, "yz"))
|
||||
}
|
||||
|
||||
/** The screen opens holding the name being changed, and nothing for a new person. */
|
||||
@Test
|
||||
fun `the screen opens on the right name`() {
|
||||
assertEquals("", viewerNameFor(ViewerNameTarget.Add))
|
||||
assertEquals("Alessandra", viewerNameFor(ViewerNameTarget.Rename(household[1])))
|
||||
}
|
||||
|
||||
/**
|
||||
* The button names the outcome rather than the screen, because on a television the
|
||||
* label under the focus ring is commonly the only thing saying what a press will do.
|
||||
*/
|
||||
@Test
|
||||
fun `the confirm button names what it will do`() {
|
||||
assertEquals("Add viewer", viewerNameAction(ViewerNameTarget.Add))
|
||||
assertEquals("Save name", viewerNameAction(ViewerNameTarget.Rename(household[1])))
|
||||
}
|
||||
|
||||
/**
|
||||
* The main viewer's name is the Emby account's and belongs to Emby —
|
||||
* `store.UpdateShadowViewer` refuses to touch it — so the manage list must not offer
|
||||
* a control whose only possible outcome is a refusal.
|
||||
*/
|
||||
@Test
|
||||
fun `the account itself cannot be renamed or removed`() {
|
||||
assertFalse(viewerIsEditable(household[0]))
|
||||
assertTrue(viewerIsEditable(household[1]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Where focus lands after somebody is removed. Emptying a household of guests is a run
|
||||
* of presses, and being sent back to the top between each one loses the viewer's place
|
||||
* every time.
|
||||
*/
|
||||
@Test
|
||||
fun `focus follows a removal down the list`() {
|
||||
assertEquals(1, viewerFocusIndexAfterRemoval(removedIndex = 1, remainingCount = 3))
|
||||
// The last row went; the one above it takes the focus.
|
||||
assertEquals(1, viewerFocusIndexAfterRemoval(removedIndex = 2, remainingCount = 2))
|
||||
// Nothing left to focus is a real answer, and the caller sends focus to Add.
|
||||
assertNull(viewerFocusIndexAfterRemoval(removedIndex = 0, remainingCount = 0))
|
||||
}
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
package com.ponzischeme89.memby.ui.viewers
|
||||
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* The two screens that let a household run this feature without an admin console, rendered
|
||||
* to `build/screenshots/viewers/` with no gateway.
|
||||
*
|
||||
* ```powershell
|
||||
* .\gradlew.bat :app:testDebugUnitTest --tests "*ViewerManagementScreenshotTest"
|
||||
* ```
|
||||
*
|
||||
* These are the captures worth having because the claims they make are not the kind a unit
|
||||
* test can check. The name screen has to fit a heading, what has been typed, a keyboard and
|
||||
* two buttons into 540dp with the bottom of the column where overscan bites; the manage list
|
||||
* has to make "this person is the Emby account and cannot be changed" read as deliberate
|
||||
* rather than as two buttons that failed to draw; and the removal question has to make the
|
||||
* safe answer and the destructive one impossible to confuse at three metres.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class ViewerManagementScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private val household = listOf(
|
||||
MembyViewer(id = "emby-user-1", name = "Matt", kind = MembyViewer.KIND_MAIN),
|
||||
MembyViewer(id = "v1", name = "Alessandra", kind = MembyViewer.KIND_SHADOW),
|
||||
MembyViewer(id = "v2", name = "Guest", kind = MembyViewer.KIND_SHADOW),
|
||||
)
|
||||
|
||||
/** A new person, nothing typed. The plate has to read as somewhere to type into. */
|
||||
@Test
|
||||
fun `adding somebody, before anything is typed`() {
|
||||
captureName("viewers-name-empty", ViewerNameTarget.Add, name = "")
|
||||
}
|
||||
|
||||
/** Part-way through. The keyboard must not have moved when the plate filled. */
|
||||
@Test
|
||||
fun `adding somebody, part-way through the name`() {
|
||||
captureName("viewers-name-typed", ViewerNameTarget.Add, name = "ALESS")
|
||||
}
|
||||
|
||||
/**
|
||||
* A name already in the house. The message sits in the line reserved for it, so
|
||||
* nothing below it moves — which is the whole reason the line is reserved.
|
||||
*/
|
||||
@Test
|
||||
fun `a name the household already has`() {
|
||||
captureName("viewers-name-clash", ViewerNameTarget.Add, name = "GUEST")
|
||||
}
|
||||
|
||||
/** Renaming: the heading names the person, and the plate opens holding their name. */
|
||||
@Test
|
||||
fun `renaming somebody`() {
|
||||
captureName(
|
||||
"viewers-name-rename",
|
||||
ViewerNameTarget.Rename(household[1]),
|
||||
name = "Alessandra",
|
||||
)
|
||||
}
|
||||
|
||||
/** The gateway refused. What was typed is still there to be corrected. */
|
||||
@Test
|
||||
fun `the gateway refused the name`() {
|
||||
captureName(
|
||||
"viewers-name-failed",
|
||||
ViewerNameTarget.Add,
|
||||
name = "SAM",
|
||||
failure = "That could not be saved. Try again.",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the manage list`() {
|
||||
compose.setContent {
|
||||
MembyTheme {
|
||||
ViewerManageScreen(
|
||||
viewers = household,
|
||||
onRename = {},
|
||||
onRemove = {},
|
||||
onAdd = {},
|
||||
onClose = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/viewers/viewers-manage.png")
|
||||
}
|
||||
|
||||
/**
|
||||
* At the limit, so Add is gone rather than dimmed — the stance the picker takes, and
|
||||
* worth capturing because removing a control rebalances the row beside it.
|
||||
*/
|
||||
@Test
|
||||
fun `the manage list with the household full`() {
|
||||
val full = household + (3..7).map {
|
||||
MembyViewer(id = "v$it", name = "Viewer $it", kind = MembyViewer.KIND_SHADOW)
|
||||
}
|
||||
compose.setContent {
|
||||
MembyTheme {
|
||||
ViewerManageScreen(
|
||||
viewers = full,
|
||||
onRename = {},
|
||||
onRemove = {},
|
||||
onAdd = {},
|
||||
onClose = {},
|
||||
canAddViewer = false,
|
||||
busyViewerId = "v1",
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/viewers/viewers-manage-full.png")
|
||||
}
|
||||
|
||||
/**
|
||||
* The full-stop question. Robolectric's window never takes focus and the focus ring is
|
||||
* the whole of what says which answer a press would take, so the safe answer is drawn
|
||||
* lit — otherwise the capture would prove nothing about the one thing it exists for.
|
||||
*/
|
||||
@Test
|
||||
fun `removing somebody is asked about first`() {
|
||||
compose.setContent {
|
||||
MembyTheme {
|
||||
ViewerRemovalConfirmation(
|
||||
viewer = household[1],
|
||||
onCancel = {},
|
||||
onConfirm = {},
|
||||
focusedForCapture = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/viewers/viewers-remove-confirm.png")
|
||||
}
|
||||
|
||||
private fun captureName(
|
||||
file: String,
|
||||
target: ViewerNameTarget,
|
||||
name: String,
|
||||
failure: String? = null,
|
||||
) {
|
||||
compose.setContent {
|
||||
MembyTheme {
|
||||
ViewerNameEntry(
|
||||
target = target,
|
||||
name = name,
|
||||
existing = household,
|
||||
onNameChanged = {},
|
||||
onConfirm = {},
|
||||
onCancel = {},
|
||||
failure = failure,
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/viewers/$file.png")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.ponzischeme89.memby.ui.viewers
|
||||
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.data.EmbyProfile
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.ui.UserSwitcherOverlay
|
||||
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* Renders "Who's watching?" to PNGs under `build/screenshots/viewers/`, so the picker can be
|
||||
* looked at without a gateway or a television.
|
||||
*
|
||||
* ```powershell
|
||||
* .\gradlew.bat :app:testDebugUnitTest --tests "*ViewerPickerScreenshotTest"
|
||||
* ```
|
||||
*
|
||||
* This is the screen that has to make the whole feature legible in one glance — that these
|
||||
* are people rather than accounts, and that exactly one of them is the one Emby hears about.
|
||||
* A unit test can check that the "Synced with Emby" caption is produced for the main viewer;
|
||||
* only a capture says whether a row of near-identical circles reads as a choice at three
|
||||
* metres.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class ViewerPickerScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private val household = listOf(
|
||||
MembyViewer(id = "emby-user-1", name = "Matt", kind = MembyViewer.KIND_MAIN),
|
||||
MembyViewer(id = "v1", name = "Alessandra", kind = MembyViewer.KIND_SHADOW),
|
||||
MembyViewer(id = "v2", name = "Guest", kind = MembyViewer.KIND_SHADOW),
|
||||
MembyViewer(id = "v3", name = "Kids", kind = MembyViewer.KIND_SHADOW),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a household of four`() {
|
||||
capture("viewers-household", household, activeViewerId = "v1")
|
||||
}
|
||||
|
||||
/**
|
||||
* The account's own viewer selected, which is the state every install starts in and the
|
||||
* one a blank id represents. The capture is the check that the first card reads as
|
||||
* chosen rather than as nothing being chosen at all.
|
||||
*/
|
||||
@Test
|
||||
fun `the account itself is watching`() {
|
||||
capture("viewers-account-selected", household, activeViewerId = "")
|
||||
}
|
||||
|
||||
/**
|
||||
* At the limit, so the Add control is gone rather than dimmed. Worth a capture because
|
||||
* removing a control changes the balance of the row underneath the cards, which is the
|
||||
* kind of thing that only looks wrong once it is drawn.
|
||||
*/
|
||||
@Test
|
||||
fun `the household is full`() {
|
||||
val full = household + (4..7).map {
|
||||
MembyViewer(id = "v$it", name = "Viewer $it", kind = MembyViewer.KIND_SHADOW)
|
||||
}
|
||||
capture("viewers-full", full, activeViewerId = "v1", canAdd = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Still fetching. "Loading" and "nobody here" are different things to be told, and this
|
||||
* is the state a television shows for a moment on every cold start.
|
||||
*/
|
||||
@Test
|
||||
fun `nothing has arrived yet`() {
|
||||
capture("viewers-loading", emptyList(), activeViewerId = "", loading = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* The way in, so the two screens can be looked at together. The row names whoever is
|
||||
* watching rather than repeating the question, which is the whole reason it is worth
|
||||
* having a label that changes.
|
||||
*/
|
||||
@Test
|
||||
fun `the user menu names the viewer`() {
|
||||
compose.setContent {
|
||||
MembyTheme {
|
||||
UserSwitcherOverlay(
|
||||
profiles = listOf(
|
||||
EmbyProfile("a", "https://memby.example", "t", "u1", "Matt"),
|
||||
),
|
||||
activeProfileId = "a",
|
||||
onProfileSelected = {},
|
||||
onManageProfiles = {},
|
||||
onDismiss = {},
|
||||
showViewers = true,
|
||||
activeViewerId = "v1",
|
||||
activeViewerName = "Alessandra",
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/viewers/viewers-menu-row.png")
|
||||
}
|
||||
|
||||
private fun capture(
|
||||
name: String,
|
||||
viewers: List<MembyViewer>,
|
||||
activeViewerId: String,
|
||||
canAdd: Boolean = true,
|
||||
loading: Boolean = false,
|
||||
) {
|
||||
compose.setContent {
|
||||
MembyTheme {
|
||||
ViewerPicker(
|
||||
viewers = viewers,
|
||||
activeViewerId = activeViewerId,
|
||||
onViewerSelected = {},
|
||||
onAddViewer = {},
|
||||
onManageViewers = {},
|
||||
canAddViewer = canAdd,
|
||||
loading = loading,
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/viewers/$name.png")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.ponzischeme89.memby.ui.viewers
|
||||
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ViewerSelectionTest {
|
||||
|
||||
private fun main(id: String = "emby-user-1", name: String = "Matt") =
|
||||
MembyViewer(id = id, name = name, kind = MembyViewer.KIND_MAIN)
|
||||
|
||||
private fun shadow(id: String, name: String) =
|
||||
MembyViewer(id = id, name = name, kind = MembyViewer.KIND_SHADOW)
|
||||
|
||||
/**
|
||||
* A blank id means the account's own viewer, because that is what the absence of the
|
||||
* `X-Memby-Viewer` header means to the gateway. Every rule here has to agree about it
|
||||
* or the picker will show nobody selected on the state every install starts in.
|
||||
*/
|
||||
@Test
|
||||
fun `a blank active id selects the account's own viewer`() {
|
||||
val people = listOf(main(), shadow("v1", "Alessandra"))
|
||||
|
||||
assertTrue(viewerIsActive(people[0], ""))
|
||||
assertFalse(viewerIsActive(people[1], ""))
|
||||
assertEquals(0, viewerPickerInitialIndex(people, ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a named viewer is the selected one`() {
|
||||
val people = listOf(main(), shadow("v1", "Alessandra"), shadow("v2", "Guest"))
|
||||
|
||||
assertFalse(viewerIsActive(people[0], "v2"))
|
||||
assertTrue(viewerIsActive(people[2], "v2"))
|
||||
assertEquals(2, viewerPickerInitialIndex(people, "v2"))
|
||||
}
|
||||
|
||||
/**
|
||||
* A viewer deleted on another television leaves this one holding an id nothing
|
||||
* recognises. Focus must still land on a card that exists rather than off the end of
|
||||
* the row.
|
||||
*/
|
||||
@Test
|
||||
fun `an unknown viewer still lands focus on a real card`() {
|
||||
val people = listOf(main(), shadow("v1", "Alessandra"))
|
||||
|
||||
assertEquals(0, viewerPickerInitialIndex(people, "v-deleted"))
|
||||
assertEquals(0, viewerPickerInitialIndex(emptyList(), "v1"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Two conditions, and both matter. There is nobody to ask on the direct path, and an
|
||||
* account nobody has added a viewer to would be offered a question with one answer —
|
||||
* which reads as a fault rather than as a feature waiting to be used.
|
||||
*/
|
||||
@Test
|
||||
fun `the picker is offered only where there is a choice to make`() {
|
||||
assertFalse(shouldOfferViewerPicker(gatewayMode = false, viewerCount = 4))
|
||||
assertFalse(shouldOfferViewerPicker(gatewayMode = true, viewerCount = 1))
|
||||
assertFalse(shouldOfferViewerPicker(gatewayMode = true, viewerCount = 0))
|
||||
assertTrue(shouldOfferViewerPicker(gatewayMode = true, viewerCount = 2))
|
||||
}
|
||||
|
||||
/**
|
||||
* The label names the person once somebody other than the account is watching, and asks
|
||||
* the question otherwise — a household running no viewers must not have a name badge
|
||||
* appear over its launcher explaining a feature it is not using.
|
||||
*/
|
||||
@Test
|
||||
fun `the menu row names whoever is watching`() {
|
||||
assertEquals("Who's watching?", viewerMenuLabel("", ""))
|
||||
assertEquals("Who's watching?", viewerMenuLabel("", "Matt"))
|
||||
assertEquals("Watching as Alessandra", viewerMenuLabel("v1", "Alessandra"))
|
||||
assertEquals("Watching as Guest", viewerMenuLabel("v2", " Guest "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the launcher names only a shadow viewer`() {
|
||||
assertEquals("", activeViewerLabel("", "Matt"))
|
||||
assertEquals("Alessandra", activeViewerLabel("v1", "Alessandra"))
|
||||
}
|
||||
|
||||
/**
|
||||
* The avatar is the only thing telling two people apart at three metres, so a viewer
|
||||
* with no usable name must not produce a blank circle beside a lettered one.
|
||||
*/
|
||||
@Test
|
||||
fun `initials prefer the short name and survive an empty one`() {
|
||||
assertEquals("M", main(name = "Matt").initials)
|
||||
assertEquals("A", shadow("v1", "alessandra").initials)
|
||||
assertEquals(
|
||||
"L",
|
||||
MembyViewer(id = "v2", name = "Alessandra", shortName = "Less").initials,
|
||||
)
|
||||
assertEquals("", MembyViewer(id = "v3", name = " ").initials)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kind decides whether watching reaches Emby`() {
|
||||
assertTrue(main().isMain)
|
||||
assertFalse(shadow("v1", "Alessandra").isMain)
|
||||
// A viewer from a gateway that sent no kind is not treated as the publishing one:
|
||||
// the failure of guessing wrong in that direction is somebody else's watch history.
|
||||
assertFalse(MembyViewer(id = "v4", name = "Unknown").isMain)
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus and scroll answer different questions: one is where the remote is, the other is
|
||||
* what the eye can see. Keeping a card behind the focused one is what stops the picker
|
||||
* opening on what looks like a list starting at whoever happens to be watching.
|
||||
*/
|
||||
@Test
|
||||
fun `the row keeps one card of context behind the focused one`() {
|
||||
assertEquals(0, viewerPickerScrollIndex(0))
|
||||
assertEquals(0, viewerPickerScrollIndex(1))
|
||||
assertEquals(2, viewerPickerScrollIndex(3))
|
||||
// Never negative, whatever it is handed.
|
||||
assertEquals(0, viewerPickerScrollIndex(-4))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 " +
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
+117
-11
@@ -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(
|
||||
// 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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -113,15 +113,18 @@ func appendField(target []field, groups []string, attr slog.Attr) []field {
|
||||
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,
|
||||
// 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user