Files
jellytau/docs/specs/multi-user-profiles.md
T
dtourolle da762da55d 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).
2026-08-30 19:03:59 +02:00

20 KiB

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 — new "Who's watching" section Destination on completion:

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 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) 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); 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

-- 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.

pub struct OfflineRepository {
    db_service: Arc<RusqliteService>,
    server_id: String,
    user_id: String,     // already there, already used for user_data joins
}

offline.rs:87

Every row enters the cache because some specific user's request returned it, and save_to_cache (offline.rs:395) — the single write choke point, called only from 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) 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) 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 mints one UUID per installation, sent as DeviceId on every request (client.rs:60). 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 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 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.

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)
#[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 actually writes access_token:{user_id}. Fix the doc, not the code — changing the key format would strand every existing token.