feat(profiles): multi-user profiles with PIN switching
A shared device can hold several accounts from the same server and switch between them in a couple of taps. A profile can be locked behind a 4-8 digit PIN; one without a PIN is one tap away. Forgetting a PIN falls through to the account's own Jellyfin password, so there is no reset flow and no recovery secret to store. Opt-in by construction: a single account with no PIN starts, plays and downloads exactly as before, and never sees a picker. Two decisions worth keeping: - Switching is not logging out. auth_logout invalidates the token server-side, which is precisely what a switch must not do, or every switch back would cost a password. The switch runs as a plan (profiles/switch.rs) so the teardown *ordering* is unit-testable with no player and no server -- a straggler reporting after the active user flips would attribute one account's viewing to another, silently. - The PIN gates switching, not the token at rest. Wrapping each token with its PIN would leave a locked profile unable to resume its own downloads or drain its own sync queue until somebody typed the code, which on a device that reboots nightly costs more than it defends against a four-digit secret. auth_initialize does refuse to restore a PIN-protected session, so the gate is on the session rather than on which screen is shown. "Child account" is not modelled anywhere -- a child's profile is simply one with no PIN. The frontend renders an opaque unlockMethod and never compares a PIN, counts an attempt or infers a role. Migration 024 adds user_pins, user_item_visibility, user_libraries and download_grants, and backfills the existing user so an upgrade does not blank its library. The visibility and grant tables are the schema half of the cache-scoping and shared-download work; the read-path enforcement is still to come (see docs/specs/multi-user-profiles.md).
This commit is contained in:
@@ -91,6 +91,9 @@ For a narrative overview of the system design, see
|
||||
| UR-079 | The app decides *what stream to play* and says so. Playing a video used to mean asking the server to re-encode it, always — a decision made nowhere, written down nowhere, and re-derived downstream by whoever needed it: the player worked out whether it had been handed a playlist by looking for `.m3u8` in the URL. So a viewer paid for a transcode of a file their device could have played untouched, and the app could not tell them which it was. Now one negotiation produces one self-describing answer — direct play, remux, or transcode; over a playlist, a plain HTTP file, or a local one — and every renderer consumes that same answer instead of guessing from a string. On Android, where the player decodes almost everything the library holds, this stops around 85% of plays from starting a transcode nobody needed | Medium | Done |
|
||||
| UR-080 | Video on the desktop plays as itself. The picture was drawn by a webview `<video>` element, which decodes little beyond h264 — so the app told the server it could accept only h264, and the server re-encoded almost everything before sending it. That was never a statement about the machine: the same machine already runs mpv for audio, which decodes essentially the whole library. Measured against a real library, 93% of desktop playback was a transcode nobody needed, against 15% on Android where a real decoder does the work. mpv now draws the picture, the app claims what it can genuinely decode, and video is sent as it was stored wherever that is possible — sparing the server the work, the network the bitrate, and the picture a generation of re-encoding | Medium | Proposed |
|
||||
| UR-081 | Playback behaves the same whichever engine renders it | High | In Progress |
|
||||
| UR-082 | A shared device holds more than one account from the same server, and changing who is using it takes a couple of taps rather than a password. Switching away leaves the account it left able to come straight back, and each account sees only its own library, its own progress and its own downloads — including offline, where the server is not there to filter | Medium | Proposed |
|
||||
| UR-083 | An account can be locked behind a short numeric code, so that on a family device the accounts that need protecting are protected and the ones that do not are one tap away. The code gates switching to that account, not what the account may watch. Repeated wrong guesses stop being answered | Medium | Proposed |
|
||||
| UR-084 | Forgetting the code is not a lockout: the account's ordinary password gets in, and a new code can be set from there | Medium | Proposed |
|
||||
| UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done |
|
||||
|
||||
---
|
||||
@@ -136,6 +139,7 @@ External system integrations and platform-specific implementations.
|
||||
| IR-031 | Android `WindowInsets` bridge: an `OnApplyWindowInsetsListener` on the decor view reports `systemBars() | displayCutout()` in CSS pixels, pushed into the WebView as `jt-inset` CSS custom properties plus a `jellytau-insets-changed` event, and pullable via the `AndroidInsets` JS bridge | Platform | UR-066 | Done (pending device verification) |
|
||||
| IR-032 | Whole-file background download of the item being played, reusing the existing resumable download worker and the Range-capable `/Videos/{id}/stream.mp4` endpoint; plus per-platform read-through caching hooks (ExoPlayer `CacheDataSource`, mpv `stream-record`) for direct-play sessions only | Storage | UR-071 | Proposed |
|
||||
| IR-033 | libmpv render-API integration for video: `vo=libmpv` driving an OpenGL FBO bound by the host toolkit, with GL entry points resolved through libepoxy. Note that libepoxy exports them as *data* symbols — there is no `glFoo` function, only an `epoxy_glFoo` variable holding a lazily-resolving pointer — so `get_proc_address` must return the pointer stored **at** that symbol; returning the symbol's own address makes mpv jump into non-executable data and take SIGSEGV on the first GL call. The `epoxy` crate resolves this correctly but is unusable, its `gl_generator` dependency pulling a yanked `xml-rs` | Playback | UR-080 | Proposed |
|
||||
| IR-034 | One downloaded file serves every account that asked for it: the download row owns the bytes, a per-user grant owns the claim, and the file is unlinked only when the last grant goes. The on-disk layout is already content-derived rather than user-derived, so this formalises what the paths already imply and stops two accounts clobbering one file | Storage | UR-082 | Proposed |
|
||||
|
||||
> **Where a UR is met by a different mechanism than its IR anticipated.** Several
|
||||
> integration requirements were written when libmpv was expected to be the single
|
||||
@@ -460,6 +464,16 @@ Internal architecture, components, and application logic.
|
||||
| DR-264 | The episode a viewer *just finished* is no longer offered as the one they are up to. Nothing records completion locally: the stop report writes a position through `storage_update_playback_progress` (which never sets `is_played`), and the cache mirror carried the server's flag not at all — so on a cache hit every episode read back as unwatched. Leaving the player with Back reloads the series page within a second of the stop report, inside the window where Jellyfin's Next Up still names the episode that just ended, and `pick_current_episode` handed it straight back: the season view kept the yellow ring and the "Up next" badge on the episode the viewer had just watched, and scrolled to it. Two halves. (a) `is_finished` — the played flag **or** a position at or past `MAX_PROGRESS_FRACTION` of the runtime, the same 95% threshold that already disqualifies an episode from counting as in-progress — replaces the bare `is_played` in the furthest-watched scan and the first-unwatched fallback, and screens the Next Up candidate: the server is one stop-report behind for a moment, the local position is not. (b) `OfflineRepository::mirror_user_data` carries `is_played` alongside the favourite flag and the position, under the same `pending_sync = 0` conflict rule, so watched state survives a cache write instead of being dropped — that flag was previously written by nothing but an explicit local toggle | Repository | UR-062 | Done |
|
||||
| DR-265 | The player's position variable keeps advancing behind a picture-in-picture window. `VideoPlayer` tracks the absolute position in its own `currentTime` rather than reading `videoElement.currentTime` at the point of use — transcoded HLS resets the element to 0 on every segment rebuild, so only the running total is meaningful — and that variable had exactly one writer while playing: a `requestAnimationFrame` loop. RAF is driven by the document being rendered, and an Android activity behind a PiP window is paused, so the loop stops while the element plays on. The `timeupdate` handler that would have covered the gap was written as a fallback "for when RAF isn't running" and gated itself on `!isPlaying`, switching itself off at precisely the moment it was the only source left. `currentTime` therefore froze at the instant PiP was entered, and every consumer froze with it: the seek bar, the ten-second progress reports, the position mirrored into Rust through `html5Adapter`, and — the reported symptom — the background-audio handoff, which resumed the audio-only stream at the PiP-entry position while the picture carried on where it really was. The gate is now `shouldApplyTimeUpdate` and turns only on the things that genuinely own the position instead: an in-flight seek, a seek-bar drag, and an element whose `readyState` is below `HAVE_CURRENT_DATA` (which reads 0 and would rewind). Both writers producing the same derived value costs nothing — the element is the authority either way | Player | UR-004, UR-041 | Done |
|
||||
| DR-266 | PiP and the background-audio handoff can no longer be armed at once, and neither can a single stale boolean end the picture. They are alternatives — one keeps the video on screen, the other throws it away — but exclusivity was enforced from one side only: arming the toggle called `setAutoEnterEnabled(false)`, while the PiP *button* stayed ungated and still worked, so pressing it left both live. What then decided between them was `isInPictureInPictureMode`, sampled once inside `MainActivity.onStop()` and passed to `background_action`. That sample is not reliable: there are orderings — the keyguard dismissing the window, the window being stashed, OEM variance in when `onPictureInPictureModeChanged(false)` lands relative to `onStop` — where the activity is stopped with a PiP window still on screen and the flag reads false. Backgrounding then meant "the app is gone" and handed a video the user was watching in the window off to audio-only. Two halves. (a) `enteringPictureInPicture` disarms background audio, because pressing PiP is an unambiguous request to keep the picture; both directions now go through one `BackgroundBehaviour` pair rather than two ad-hoc call sites. (b) `inPictureInPicture` accepts either witness — the native sample or the frontend's own latch over `jellytau-pip-entered`/`jellytau-pip-exited`. The latch cannot report a window that has closed, because both events reach the WebView through the same message queue in dispatch order, so a genuine exit is always known before the background signal that follows it. The decision itself stays in Rust; the frontend only supplies a fact it can establish more reliably than the activity can | Player | UR-040, UR-041 | Done |
|
||||
| DR-267 | `profiles_*` commands expose the accounts already stored in `users` — list, add, remove, and a startup target that decides between resuming the last account and showing the picker. `storage_get_users` and `storage_set_active_user` have existed and gone uncalled since the schema was written; what was missing was never the storage but the decision of who may switch to what, which is domain logic and stays in Rust. Adding an account authenticates against the *current* server and takes no URL, which is how the same-server constraint is enforced rather than by omitting a form field | Auth | UR-082 | Proposed |
|
||||
| DR-268 | A profile's PIN is an Argon2id hash in `user_pins` with the failure count and lockout deadline beside it, both read and written only in Rust. The PIN deliberately does **not** encrypt the access token: wrapping it would leave a locked profile unable to resume its own downloads, drain its own `sync_queue` or poll its own sessions until someone typed the code, which on a device that reboots nightly costs more than it defends against a four-digit secret. The gate is against a member of the household, and the security doc says so rather than implying at-rest protection it does not provide | Auth | UR-083 | Proposed |
|
||||
| DR-269 | A forgotten PIN falls through to the ordinary password login against the same server, after which a new PIN can be set. There is no reset token, no recovery secret and no administrator approval path — the account's own password is already the authority, and inventing a second one would be a weaker credential guarding the same thing | Auth | UR-084 | Proposed |
|
||||
| DR-270 | Switching profiles is its own operation, not a logout. `auth_logout` calls Jellyfin's logout endpoint and invalidates the token server-side, which is exactly the behaviour a switch must not have. The switch runs as a state machine over a `ProfileSession` — stop playback and drop the queue, park the outgoing user's sync queue, stop the poller, destroy the repository, flip `is_active`, rebuild — so the teardown *ordering* can be unit-tested with no player and no server. The queue cannot outlive its owner: a straggler reporting after the flip would attribute one account's viewing to another, which is silent and unrecoverable | Auth | UR-082 | Proposed |
|
||||
| DR-271 | Cache visibility is a byproduct of the write path rather than a maintained index. `save_to_cache` is the single choke point through which every cached item passes and it already holds the `user_id` it fetched for, so it stamps `user_item_visibility` in the same transaction; reads join through it. The stamp cannot disagree with the server because it *is* the record of what the server returned, and there is nothing to reconcile. It does not shrink when permissions tighten — that drift is bounded by re-deriving from `/UserViews` on unlock while online, and is stale-permissive offline by design | Repository | UR-082 | Proposed |
|
||||
| DR-272 | `offline_is_available` answers per user instead of per item. It counted completed `downloads` rows for an `item_id` with no user predicate, so every profile on the device saw every other profile's downloads as its own — the same class of leak as the shared metadata cache, in the one place where the server is not present to filter | Storage | UR-082 | Proposed |
|
||||
| DR-273 | Each profile derives its own `DeviceId` as `uuid5(device_uuid, user_id)` rather than sharing the installation's. Jellyfin identifies a *session* by device, so a shared id makes a family look like one device that keeps changing user — playback history, the Devices dashboard and the remote-control target all collapse together | Auth | UR-082 | Proposed |
|
||||
| DR-274 | Startup shows the picker only when the last-used profile has a PIN, or when more than one profile exists and the setting asks for it; otherwise it resumes exactly as before. The feature is invisible to a single-account install, which is what makes it safe to ship without a migration anyone has to think about | Auth | UR-082 | Proposed |
|
||||
| DR-275 | Idle re-lock separates "the UI is locked" from "who is the active profile", so audio keeps playing and keeps reporting as the account that started it while the screen is locked. Lockscreen transport controls keep working untouched, because nothing on a lockscreen browses or starts new content — the locked UI refuses only what reaches past the current queue. The timer starts when playback stops rather than when the UI goes quiet, and unlocking to a *different* profile stops playback. It lives in Rust beside the player state machine: it needs authoritative playback state, and a frontend timer dies with the WebView on Android | Player | UR-083 | Proposed |
|
||||
| DR-276 | The picker and PIN pad render an opaque `unlock_method` and an `UnlockOutcome` union the backend returns; the frontend never compares a PIN, counts an attempt, or infers that an account without a PIN is a child's. "Child account" is not modelled at all — a child profile is simply one with no PIN — so no role taxonomy is invented on either side of a boundary that has leaked taxonomy before | Frontend | UR-082, UR-083 | Proposed |
|
||||
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
|
||||
|
||||
---
|
||||
@@ -549,6 +563,9 @@ Internal architecture, components, and application logic.
|
||||
| UR-078 | - | DR-218 |
|
||||
| UR-079 | - | DR-225, DR-226, DR-227, DR-228, DR-229, DR-230 |
|
||||
| UR-080 | IR-033 | DR-231, DR-232, DR-233, DR-234, DR-235, DR-236, DR-237 |
|
||||
| UR-082 | IR-034 | DR-267, DR-270, DR-271, DR-272, DR-273, DR-274, DR-276 |
|
||||
| UR-083 | - | DR-268, DR-275, DR-276 |
|
||||
| UR-084 | - | DR-269 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
# Spec: Multi-user profiles with PIN switching
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** UR-082, UR-083, UR-084 → IR-034, DR-267 … DR-276
|
||||
**UX spec:** [ux-flows.md](../ux-flows.md) — new "Who's watching" section
|
||||
**Destination on completion:**
|
||||
- [09-security.md](../architecture/09-security.md) — new "Profile locking" section beside *Authentication Token Storage* (PIN gate, what it does and does not protect)
|
||||
- [01-rust-backend.md](../architecture/01-rust-backend.md) — profile switch orchestration beside the session state machine
|
||||
- [02-svelte-frontend.md](../architecture/02-svelte-frontend.md) — profile picker + lock state in the nav guard
|
||||
- [08-database-design.md](../architecture/08-database-design.md) — `user_pins`, `user_item_visibility`, `download_grants`, per-user vs device settings
|
||||
- [06-downloads-and-offline.md](../architecture/06-downloads-and-offline.md) — shared files, per-user grants, refcounted deletion
|
||||
|
||||
## Summary
|
||||
|
||||
A shared device (family TV, tablet) can hold several Jellyfin accounts **from the
|
||||
same server** and switch between them in a couple of taps. Adult accounts can set
|
||||
a numeric PIN that gates the switch; child accounts have no PIN and are one tap
|
||||
away. An adult who forgets their PIN signs in with their Jellyfin password
|
||||
instead — there is no separate reset flow.
|
||||
|
||||
The feature is **opt-in and invisible until used**: one account with no PIN
|
||||
behaves exactly as the app does today.
|
||||
|
||||
## Motivation
|
||||
|
||||
JellyTau already stores users per server ([schema.rs](../../src-tauri/src/storage/schema.rs)
|
||||
`users`), keeps a token per user in the keyring, and exposes
|
||||
`storage_get_users` / `storage_set_active_user` — none of which any UI calls. The
|
||||
only way to change account today is `auth_logout`, which calls Jellyfin's logout
|
||||
endpoint and **invalidates the token server-side**, forcing a full password login
|
||||
every time. On a living-room device shared by a family that is the difference
|
||||
between "switch to the kids' profile" and "find the password".
|
||||
|
||||
Two defects block simply exposing the existing commands, and both are the real
|
||||
work of this spec:
|
||||
|
||||
1. **The metadata cache is server-scoped, not user-scoped.** `items`, `libraries`,
|
||||
`genres` and `thumbnails` carry `server_id` but no user. Jellyfin's parental
|
||||
controls filter *server responses*, but the cache-first read path returns local
|
||||
rows before the server answers — so a child profile on a device a parent has
|
||||
browsed sees the parent's titles and artwork.
|
||||
2. **Download availability is answered per item, not per user.**
|
||||
`offline_is_available` ([offline.rs](../../src-tauri/src/commands/offline.rs))
|
||||
counts completed rows for an `item_id` with no user predicate, so a child's UI
|
||||
marks a parent's download as available and can play it offline.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Which profiles exist, and each one's unlock method | Rust | Derived from the `users` table + PIN presence. The frontend must never infer "this is a child account" from anything; it renders an opaque `unlock_method` |
|
||||
| PIN verification, attempt counting, lockout window | Rust | A gate the frontend could skip is not a gate. The counter and the clock must live where the webview cannot reach them |
|
||||
| PIN hashing (KDF, salt, cost) | Rust | Security primitive; changes with threat model, never with UI |
|
||||
| Switch orchestration (stop player, drain sync queue, swap repository, restart poller) | Rust | Owns every piece of state being torn down; ordering is a correctness invariant |
|
||||
| Cache visibility stamping and filtering | Rust | Domain data access control. Any leak here is a content-safety bug |
|
||||
| Download grants, refcounted file deletion | Rust | Storage domain; the frontend has no concept of a file refcount |
|
||||
| Same-server constraint on adding a profile | Rust | Domain rule about what a profile *is*, not a form-validation nicety |
|
||||
| Whether to show the picker at startup | Rust | Depends on profile count + PIN presence + a stored setting, all backend state |
|
||||
| Profile picker grid, avatars, transitions | Frontend | Pure presentation |
|
||||
| PIN pad layout, digit entry, shake-on-wrong | Frontend | Input handling; changes only if the UI is redesigned |
|
||||
| "Use password instead" form | Frontend | Presentation over the existing `auth_login` |
|
||||
| Ordering of tiles (last used first) | Frontend | Presentation preference over data Rust already returns |
|
||||
|
||||
Borderline: *ordering of tiles* could be argued into Rust since `last_used_at`
|
||||
comes from the DB. Rust returns the timestamp; the frontend decides it means
|
||||
"leftmost". Tie-breaker: it changes only if the UI is redesigned.
|
||||
|
||||
## Design
|
||||
|
||||
### Threat model — state it plainly
|
||||
|
||||
The PIN is a **switching gate against a member of the household**, not at-rest
|
||||
protection against an attacker with the disk. Tokens stay in the keyring exactly
|
||||
as they are today ([credentials.rs](../../src-tauri/src/credentials.rs)); the PIN
|
||||
does **not** encrypt them.
|
||||
|
||||
This is a deliberate choice, and the rejected alternative matters enough to
|
||||
record: wrapping each token with a key derived from its PIN would resist an
|
||||
offline attacker, but a locked profile would then be *unable to act as itself* —
|
||||
no resuming its downloads after a restart, no draining its `sync_queue`, no
|
||||
session polling — until someone walked past and typed four digits. On a device
|
||||
that reboots nightly that is a worse product for a threat this feature does not
|
||||
face. A four-digit code was never going to resist an offline attack anyway.
|
||||
|
||||
Consequences to document in 09-security.md rather than discover later:
|
||||
|
||||
- Anyone with the SQLite file and keyring access has every profile's token,
|
||||
PIN or not.
|
||||
- The PIN stops a child *becoming a parent*. It does not restrict content. Content
|
||||
restriction is Jellyfin's server-side parental controls, which most self-hosters
|
||||
have never configured — the UI must say so when a PIN-less profile is created.
|
||||
|
||||
### Schema
|
||||
|
||||
```sql
|
||||
-- Migration 024
|
||||
CREATE TABLE user_pins (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
pin_hash TEXT NOT NULL, -- Argon2id PHC string; salt is embedded
|
||||
failed_count INTEGER DEFAULT 0,
|
||||
locked_until TEXT, -- RFC3339; NULL when not locked out
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- What the server has actually shown to this user. NOT a maintained index:
|
||||
-- written as a byproduct of the cache write path, so it cannot disagree with
|
||||
-- what the server returned.
|
||||
CREATE TABLE user_item_visibility (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_id TEXT NOT NULL,
|
||||
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, item_id)
|
||||
);
|
||||
CREATE INDEX idx_visibility_user ON user_item_visibility(user_id);
|
||||
|
||||
-- Same, at library granularity, from each user's /UserViews.
|
||||
CREATE TABLE user_libraries (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
library_id TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, library_id)
|
||||
);
|
||||
|
||||
-- Downloads: one file, many claimants.
|
||||
CREATE TABLE download_grants (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
|
||||
granted_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, download_id)
|
||||
);
|
||||
```
|
||||
|
||||
**Migration of existing installs is not optional.** Every current cache row and
|
||||
download predates the concept of a user. Migration 024 backfills
|
||||
`user_item_visibility` and `download_grants` for the single existing user (and,
|
||||
if somehow several `users` rows exist, for the one with `is_active = 1`).
|
||||
Without the backfill an upgrading user's library goes blank.
|
||||
|
||||
### Cache scoping — a byproduct, not an index
|
||||
|
||||
The reason this is tractable: the repository **already carries the user**.
|
||||
|
||||
```rust
|
||||
pub struct OfflineRepository {
|
||||
db_service: Arc<RusqliteService>,
|
||||
server_id: String,
|
||||
user_id: String, // already there, already used for user_data joins
|
||||
}
|
||||
```
|
||||
[offline.rs:87](../../src-tauri/src/repository/offline.rs#L87)
|
||||
|
||||
Every row enters the cache because *some specific user's* request returned it, and
|
||||
`save_to_cache` ([offline.rs:395](../../src-tauri/src/repository/offline.rs#L395))
|
||||
— the single write choke point, called only from
|
||||
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) — knows who that is. It
|
||||
stamps `user_item_visibility` in the same transaction as the item. There is no
|
||||
reconciliation job and no way for the stamp to drift from what the server said,
|
||||
because the stamp *is* the record of what the server said.
|
||||
|
||||
Reads join through it. 43 of the 64 `FROM items` sites live in
|
||||
`repository/offline.rs`, where `self.user_id` is already in scope.
|
||||
|
||||
**The 21 sites outside the repository are triaged, not blanket-scoped:**
|
||||
|
||||
| Group | Disposition |
|
||||
|-------|-------------|
|
||||
| Browse/query paths returning lists to the UI | Must scope |
|
||||
| By-id lookups from an already-authorised context (download worker resolving an item it holds a grant for; queued-row stream URL lookup) | Not scoped — authorisation happened upstream |
|
||||
| Maintenance (`smart_cache` eviction, `pinning`) | Not scoped — deliberately device-wide |
|
||||
|
||||
The triage result is recorded in 08-database-design.md, because "why isn't this
|
||||
one scoped?" is exactly what a future change gets wrong.
|
||||
|
||||
**Known limit — revocation drift.** The stamp can never show *more* than the
|
||||
server showed, but it does not shrink when a parent tightens permissions. Mitigation:
|
||||
on unlock while online, re-derive `user_libraries` from `/UserViews` and drop
|
||||
visibility rows for libraries that disappeared. Offline, the cache stays
|
||||
stale-permissive. This is a stated property, not a bug.
|
||||
|
||||
**Enforcement.** `scripts/check-cache-scope.sh` fails if `FROM items` appears
|
||||
outside an allowlist of modules. With writes funnelled and 43 reads in one file
|
||||
the allowlist is short enough to mean something — unlike `check:boundary`, which
|
||||
had to pattern-match literals. It will not catch a missed join *inside* the
|
||||
repository; it will catch a new query appearing in a random command file, which
|
||||
is the realistic drift.
|
||||
|
||||
### Downloads — shared files, per-user grants
|
||||
|
||||
The file layout already assumes sharing: paths are content-derived
|
||||
(`{base}/{series}/{S01E02 - Name}`,
|
||||
[download/mod.rs:1052](../../src-tauri/src/commands/download/mod.rs#L1052)) while
|
||||
rows are keyed `UNIQUE(item_id, user_id)` — so two profiles downloading the same
|
||||
episode already aim at one path and clobber each other. Formalising:
|
||||
|
||||
- A second profile requesting an already-downloaded item inserts a **grant**. No
|
||||
bytes transferred; immediately available.
|
||||
- `offline_is_available` joins through grants instead of counting rows per item.
|
||||
- `download_cancel` ([download/mod.rs:1300](../../src-tauri/src/commands/download/mod.rs#L1300))
|
||||
drops the caller's grant and unlinks the file **only when the last grant goes**.
|
||||
It currently deletes unconditionally, which under sharing would yank a file from
|
||||
under another profile.
|
||||
- Budget is naturally shared: the file is counted once. Eviction picks files with
|
||||
no recent access across *any* grant.
|
||||
- A grant is not an entitlement. On unlock while online, grants for items the
|
||||
profile can no longer see are dropped, alongside the visibility re-derivation.
|
||||
|
||||
### Device ID
|
||||
|
||||
[device_get_id](../../src-tauri/src/commands/device.rs#L27) mints one UUID per
|
||||
installation, sent as `DeviceId` on every request
|
||||
([client.rs:60](../../src-tauri/src/jellyfin/client.rs#L60)). Jellyfin uses it to
|
||||
identify a *session* — the Dashboard → Devices row, and the target the
|
||||
remote-control feature casts to.
|
||||
|
||||
**Decision pending an empirical test** (see Open questions). Shipping default is a
|
||||
per-profile derived ID, `uuid5(device_uuid, user_id)`, which makes each family
|
||||
member a distinct device entry so playback history attributes cleanly and the
|
||||
sessions list can tell "this TV, Dad" from "this TV, Kid". If the test shows
|
||||
Jellyfin tolerates a shared ID *and* the merged view is preferred, one line
|
||||
changes.
|
||||
|
||||
### Switch orchestration
|
||||
|
||||
`profiles_switch` is **not** `auth_logout`. Logout invalidates the token
|
||||
server-side; a switch must leave the outgoing profile able to come back with one
|
||||
tap. Ordering, in Rust, as a state machine over a `ProfileSession` so it is
|
||||
unit-testable without a player or a server:
|
||||
|
||||
1. Pause playback and tear down the queue (the queue cannot outlive its owner —
|
||||
a straggler would report the outgoing profile's episode against the incoming one).
|
||||
2. Drain or park `sync_queue` for the outgoing user.
|
||||
3. Stop the session poller; unregister MPRIS / MediaSession metadata.
|
||||
4. Destroy the repository handle.
|
||||
5. Flip `users.is_active`.
|
||||
6. Build the new repository, restart the poller, re-derive visibility and grants
|
||||
if online.
|
||||
7. Emit `profile-switched`.
|
||||
|
||||
Two hazards, both already documented in CLAUDE.md and both reached from a new
|
||||
direction here: never call blocking APIs from player event callbacks, and never
|
||||
hold a lock across a `match` scrutinee. Teardown touches every one of those paths
|
||||
at once.
|
||||
|
||||
### Lock state vs. playback
|
||||
|
||||
"Locked" and "who is the active profile" are **different state**. Re-lock (idle
|
||||
timeout, off by default) flips only the first:
|
||||
|
||||
- Audio keeps playing and keeps reporting as the profile that started it.
|
||||
- The lockscreen / MediaSession keeps full transport control over the **existing
|
||||
queue** — play, pause, seek, next, prev. Nothing on the lockscreen browses or
|
||||
starts new content, so [MediaSessionCompat](../architecture/05-platform-backends.md)
|
||||
needs no changes at all.
|
||||
- The locked UI refuses anything reaching past the current queue: browsing,
|
||||
search, new playback, downloads, settings, switching profile without the PIN.
|
||||
|
||||
Two rules keep it coherent:
|
||||
|
||||
- **Never re-lock while something is playing.** The idle timer starts when
|
||||
playback stops, not when the UI goes quiet. This removes almost all of the
|
||||
conflict on its own.
|
||||
- **Unlocking to a *different* profile stops playback.** Unlocking to the same
|
||||
profile leaves everything running.
|
||||
|
||||
The timer lives in Rust beside the player state machine: it needs authoritative
|
||||
playback state, and a frontend timer dies with the webview on Android.
|
||||
|
||||
### Startup
|
||||
|
||||
The picker appears only when the last-used profile has a PIN, **or** more than one
|
||||
profile exists and "ask who's watching" is on. Otherwise startup resumes the last
|
||||
account exactly as [auth_initialize](../../src-tauri/src/commands/auth.rs#L19)
|
||||
does today. One account, no PIN → the user never sees any of this.
|
||||
|
||||
### Commands
|
||||
|
||||
Names match the Rust fns; top-level params auto-convert to camelCase.
|
||||
|
||||
```rust
|
||||
profiles_list() -> Vec<Profile>
|
||||
profiles_startup_target() -> StartupTarget // Resume{user_id} | Picker
|
||||
profiles_unlock(user_id: String, pin: Option<String>) -> UnlockOutcome
|
||||
profiles_unlock_with_password(user_id: String, password: String) -> UnlockOutcome
|
||||
profiles_add(username: String, password: String, pin: Option<String>) -> Profile
|
||||
profiles_set_pin(user_id: String, current_pin: Option<String>, new_pin: Option<String>)
|
||||
profiles_remove(user_id: String, forget_downloads: bool)
|
||||
```
|
||||
|
||||
```rust
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Profile {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub avatar_tag: Option<String>,
|
||||
pub unlock_method: UnlockMethod,
|
||||
pub last_used_at: Option<String>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum UnlockMethod { None, Pin }
|
||||
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum UnlockOutcome {
|
||||
Ok { user_id: String },
|
||||
WrongPin { attempts_remaining: u32 },
|
||||
LockedOut { until: String },
|
||||
NeedsPassword,
|
||||
}
|
||||
```
|
||||
|
||||
Event: `profile-switched` (kebab-case), payload `{ userId }`.
|
||||
|
||||
`profiles_add` takes no server URL — it authenticates against the *current*
|
||||
server. That is the same-server constraint, enforced in Rust rather than by
|
||||
omitting a form field.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Multiple servers. The schema already supports it (`users.server_id`); only the
|
||||
flow is constrained. Not a schema change to undo later.
|
||||
- Per-profile content restriction. That is Jellyfin's, server-side.
|
||||
- Profile avatars uploaded locally — use the server's `avatar_tag`.
|
||||
- Biometric unlock.
|
||||
- Idle re-lock is specified above but ships **off by default** and last.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] One account with no PIN: startup, playback and downloads are byte-identical to today.
|
||||
- [ ] A second profile can be added with a password, against the current server only.
|
||||
- [ ] A PIN-less profile switches in one tap; a PIN profile requires the PIN.
|
||||
- [ ] Wrong PIN decrements attempts, then locks out with a stated window; the counter survives an app restart.
|
||||
- [ ] "Use password instead" signs in and offers to set a new PIN.
|
||||
- [ ] Switching does **not** invalidate the outgoing profile's token — switching back needs no password.
|
||||
- [ ] A child profile does not see cached items or downloads belonging to another profile, online or offline.
|
||||
- [ ] Two profiles requesting the same item produce one file and two grants; removing one grant keeps the file.
|
||||
- [ ] Upgrading an existing install shows the same library it showed before (backfill works).
|
||||
- [ ] Playback survives an idle re-lock; lockscreen transport still works; unlocking to a different profile stops it.
|
||||
- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy -D warnings` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check:boundary` and the new `check:cache-scope` pass.
|
||||
- [ ] New code carries `// TRACES:` comments; `bun run traces:validate` passes.
|
||||
- [ ] `bindings.ts` regenerated.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust**
|
||||
- PIN: correct/incorrect/lockout/expiry-of-lockout; counter persists across a
|
||||
service restart; a cleared PIN removes the row.
|
||||
- Switch orchestration as a pure state machine over `ProfileSession` — assert the
|
||||
teardown *ordering*, no player or server needed. This is the part that would
|
||||
otherwise only ever be hand-tested.
|
||||
- Visibility: `save_to_cache` stamps; a second user's read of the same item
|
||||
returns nothing; migration backfill populates the existing user.
|
||||
- Grants: second grant transfers no bytes; cancel with two grants keeps the file;
|
||||
cancel of the last grant unlinks it.
|
||||
- Same-server: `profiles_add` against a different URL is rejected.
|
||||
|
||||
**Frontend**
|
||||
- Picker renders from `profiles_list` with no unlock-method inference of its own.
|
||||
- PIN pad calls `profiles_unlock` and renders each `UnlockOutcome` variant; it
|
||||
never compares a PIN or counts an attempt locally.
|
||||
- `tauriIntegration.test.ts` gains the new commands (camelCase param guard).
|
||||
|
||||
**Manual — the honest gap.** End-to-end multi-user needs two real accounts with
|
||||
differing library permissions on a real server; CI has neither. The state-machine
|
||||
extraction above is what keeps the risky half testable. The rest is a documented
|
||||
manual pass in the release checklist.
|
||||
|
||||
## TRACES
|
||||
|
||||
| Piece | Tag |
|
||||
|-------|-----|
|
||||
| `profiles_*` commands | `UR-082 \| DR-267` |
|
||||
| PIN hash + lockout | `UR-083 \| DR-268` |
|
||||
| Password fallback | `UR-084 \| DR-269` |
|
||||
| Switch orchestration | `UR-082 \| DR-270` |
|
||||
| Visibility stamp/filter | `UR-082 \| DR-271` |
|
||||
| Download grants | `UR-082 \| IR-034, DR-272` |
|
||||
| Per-profile device ID | `UR-082 \| DR-273` |
|
||||
| Startup target | `UR-082 \| DR-274` |
|
||||
| Idle re-lock | `UR-083 \| DR-275` |
|
||||
| Picker + PIN pad UI | `UR-082, UR-083 \| DR-276` |
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Does authenticating a second user with an in-use `DeviceId` invalidate the
|
||||
first user's token?** Two minutes with two accounts: log in as A, log in as B
|
||||
with the same DeviceId, then call `/Sessions` with A's token. Decides whether
|
||||
the per-profile device ID is a preference or a requirement.
|
||||
2. Should removing a profile default to deleting its exclusive downloads, or
|
||||
keeping them? Spec currently makes it an explicit flag.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes.
|
||||
- `auth_logout` stays exactly as it is. Do not refactor switching through it; the
|
||||
server-side invalidation is the whole reason it is unsuitable.
|
||||
- The docs say the keyring key is `jellytau::{server_id}::{user_id}::access_token`;
|
||||
[credentials.rs:244](../../src-tauri/src/credentials.rs#L244) actually writes
|
||||
`access_token:{user_id}`. Fix the doc, not the code — changing the key format
|
||||
would strand every existing token.
|
||||
Reference in New Issue
Block a user