feat(settings): add the cross-platform settings contract and its manifest (#479)
* docs(settings): define the cross-platform settings contract Turns the audit in #376 into a decision-complete design for how user settings work across the server, bundled web client, Apple clients, and Android clients. Today there are three partial contracts - the server registry, the web client's own manifest, and independently owned key constants in each native client - and they have measurably drifted. The root enabler is that keyUsesUserScope returns true for any unregistered key, so a client can invent a production setting unilaterally and the server stores it as an unvalidated string. The design decides: Ownership. Every production user-facing setting needs a server-owned manifest entry, even when the value is stored only on one client. The single exception is private local.<client>.* diagnostics, bounded by five conditions. Types and scopes. Native JSON values instead of strings. Five remote scopes plus client_local, and each definition declares its own resolution order rather than inheriting a global precedence. Preferences versus restrictions. internal/policy already resolves max_playback_quality and metadata-language limits over the same controls this contract resolves preferences for. Definitions declare constrained_by, the effective response reports the permitted value alongside the user's stored one, and a mutation exceeding a restriction is stored rather than rejected - a capped 4K preference should take effect the day the cap lifts, not be destroyed by it. Compatibility. Widening a scope, adding an enum member, or widening a range is additive and revision-tagged; narrowing anything needs a new key. introduced_in is a manifest revision attached to individual enum members and scopes, not just whole definitions, so a newer client never offers a choice an older server will reject. Rollout. One coordinated breaking release, with no compatibility shim, projection, or client fallback. After the cutover no future setting requires coordination. No settings version check goes in the authenticated middleware and nothing returns 426: deleting the old routes already produces the break, and a gate would be more code in four repos for the same outcome while permanently coupling every endpoint to one subsystem's versioning. Scope placement. Appearance and date/time move from account to profile scope. Account scope was an artifact of pre-profile storage; leaving it there means a household shares one theme and text size, and any non-child profile can restyle everyone else. Read path. Batched context resolution, index requirements, a session-snapshot rule, and a no-regression benchmark gating storage consolidation - profile_series resolution is per-item, so a season view would otherwise issue one request per episode. Verified against the current server, Apple, and Android implementations. Two findings shape it: the unknown-key extension bag is real, and v1 scope reads NOT LOCKED, so removing the legacy surface needs no amendment if it lands before lock. Related to #376. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): add the canonical settings contract manifest First implementation step for the cross-platform settings contract (#376). Adds the artifact everything else depends on: the manifest, its JSON Schema, the object value schemas, and a Go loader that validates the whole thing at load time. No routes, no storage, no behavior change — nothing reads this yet. contracts/settings/v1/ holds the artifact at a stable path because clients vendor it and generate bindings from it. The embed directive has to sit beside it (go:embed cannot reach outside its own directory), so that directory is a tiny Go package containing nothing else; loading and validation live in internal/settingscontract. 38 definitions: 35 remote, 3 contract-known client_local. That covers every key the legacy registry accepts, every unregistered key the extension bag was silently accepting from the web client, every unregistered device key Android writes, and the profile preference columns that become settings. Registering the previously-unregistered keys is where the drift shows up, and the manifest records each case in a notes field: - ui_theme, ui_text_scale, ui_text_weight, ui_high_contrast, ui_custom_theme_vars, and ui_custom_css reached the server only because keyUsesUserScope returns true for any unregistered key. They are now typed, renamed to the dotted convention every other key uses, and moved to profile scope per the design. - player.match_frame_rate and player.sleep_timer_default_minutes are written by Android against a server that does not register them, so every write and reset is currently rejected. Registered. - player.next_up_prompt_seconds is Android's alias for playback.next_up_prompt_seconds and does not become a definition; the test matrix pins it as a migration alias. - player.playback_speed is capped at 3.0, matching the server rather than Android's 4.0. - subtitle_appearance becomes playback.subtitle_appearance. Every other canonical key carries a domain prefix, and preserving accidental key names is an explicit non-goal of the design. Validation is deliberately stricter than the schema can express. Beyond shape, it enforces that a resolution order ends in "default", that it only resolves scopes the definition allows, and — the one most likely to bite — that every writable scope is actually read, so a setting cannot accept writes at a scope it will never honor. Defaults are validated against their own value schema, so a default that violates its own range or enum fails at load. Revision tags are checked to never run ahead of the manifest revision, which is what makes revision-aware client filtering trustworthy. Ceiling and floor policy constraints are rejected on unordered types, where capping would silently do nothing; playback.preferred_quality's enum is therefore ordered ascending. ValidateValue is the single validation path, so the mutation endpoint, the migration, and the manifest's own default checks cannot diverge later. Numbers decode through json.Number so an integer setting rejects 30.5 rather than truncating, and object values validate against their referenced JSON Schema instead of accepting arbitrary JSON the way validateJSONSetting does today. Canonicalization implements RFC 8785 over the value domain the contract uses: sorted keys, no insignificant whitespace, ECMAScript number formatting. The digest is the ETag, and PublicBytes strips maintainer notes so the served manifest never carries internal commentary. Promotes santhosh-tekuri/jsonschema/v6 from indirect to direct. Verification: 124 tests pass across 16 cases; golangci-lint clean; make verify-local-paths passes. Two failures in internal/api/handlers (TestRemoveJellyfinCompatWebDisablesWebSetting, the playback v3 seek recovery test) reproduce unchanged on main and are unrelated. Part of #376. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): give ui.theme a device override Theme joins text scale, text weight, and high contrast as a profile default with an optional per-device override, resolving profile_device -> profile -> default. The right theme is partly a function of the screen and the room — a light theme on a phone in daylight, a dark one on a TV at night — which is the same reasoning the other three appearance keys already used. All four appearance settings now cascade consistently, which also means one rule to explain in the UI rather than "these three follow the device, that one does not". ui.custom_theme_vars and ui.custom_css stay profile-wide. They are authored styling rather than a contextual preference, so a profile's custom tokens still apply on top of whichever theme a device resolves to. Recorded in the definition notes because it is a visible consequence: vars tuned against a dark theme will sit on top of a light one if a device overrides the theme. Widening those to profile_device later is an additive revision bump if it turns out to matter. Part of #376. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(web): tag local appearance caches with their owning account The theme, text scale, text weight, high contrast, custom theme variable and custom CSS caches in localStorage were untagged, so on a shared browser a second account inherited the first account's appearance: with no server value of its own, every fallback resolved to whatever the previous account had stored, and the leftover `silo-theme` key also suppressed the admin-configured default theme for the new account. DateTimeFormatProvider already solved this by stamping its cache with the authenticated user id and refusing another account's values. Extract that mechanism into `createOwnedCache` in utils/storage.ts (where key namespacing lives) and put all three groups behind it, so appearance and custom theme get the same protection instead of a third copy of the rule. - Each group carries its own owner stamp. A shared stamp would be unsafe: the groups are written by hooks nested inside each other, and effects run inner-first, so whichever hook stamped first would vouch for the other's still-stale values. - A null owner (auth bootstrapping, or signed out) still trusts the cache, which keeps the warm start and the login screen's last look. - An unstamped cache is not trusted once an account is known, so existing users take a one-time appearance reset on first load rather than a chance of seeing someone else's settings. - When a foreign cache is detected the values are dropped and the empty cache is handed to the new account, so a later single save cannot re-trust the rest of the previous account's state. Owner is the user id because /settings is user-scoped server side; it lives in one helper (`appearanceCacheOwner`) so it can be widened if appearance moves to profile scope. `shouldLoadApiTheme` is gone: it had become a synonym for `appearanceCacheOwner(...) !== null` with no callers left. Part of #376 AI-use disclosure: implemented with Claude Code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(settings): make the settings contract enforceable and fix the appearance cache The contract manifest landed as a document nothing checked. This makes it a mechanism, and fixes the one defect in the change set that hurt users on merge rather than at cutover. Web appearance cache. useTheme cleared the cache for any account whose stamp did not match and never repopulated it — the only writers were the four user-action setters — so every upgrading user lost their warm start on every load, not once, and x-large-text and high-contrast users lost theirs too. The owner-stamp protocol is replaced with per-account key namespacing (`silo-theme:7`): a foreign value is absent rather than present-and-distrusted, so nothing has to be deleted, the first account keeps its warm start, and there is no shared stamp for a second tab, a stale debounce timer, or an out-of-order effect to race on. Widening ownership to profile scope, which this manifest requires, is now a change to appearanceCacheOwner alone. Adds the API-to-cache mirror useTheme was missing, cancels pending debounced writes across an account change, and re-seeds provider state during render so no frame paints the previous account's look. Canonicalization. writeCanonical used json.Marshal, which HTML-escapes < > and &, and canonicalNumber used Go's 'g' format — both diverge from RFC 8785, so the first label containing an ampersand or bound below 1e-4 would have forked the server's ETag from every conforming client. Output is now byte-identical to ECMAScript String() across the edge cases, verified against node. The ETag also covers the value schemas, which decide what the server accepts and previously could change while the tag stood still. All four derived representations are memoized; a conditional GET no longer costs a full parse and re-serialize. Validation. strictUnmarshal's decoder.More() answered false for a stray ] or }, so `true]` validated as a boolean. Enum matching compared fmt.Sprintf tokens, so the string "3" satisfied an integer member. Declared steps were never enforced. The language pattern rejected tags both mobile platforms emit unprompted (en_US, ca-ES-valencia, ar-EG-u-nu-latn) and never normalized case, so en-US and en-us were two rows for one preference; NormalizeValue now canonicalizes on the shared path. Manifest. show_forced_subtitles defaulted false where the server column is NOT NULL DEFAULT true, which would have turned forced subtitles off for every profile that never touched it. preferred_quality declared 13 members where the planner speaks 6 and collapses the rest to auto. metadata_language's allowlist was bound to the very column it migrates from. subtitle-appearance pinned fontFamily to three families while Apple stores any installed system font. Registers five user-facing settings the clients already ship, and corrects three notes that described Android behaviour that was not true. Enforcement. The package had no non-test callers, so MustLoad never ran; it now loads and logs at startup. The inventory test compared the manifest against a hand-copied map and could not see the drift it named; it now iterates settingsRegistry and checks defaults too — both verified to fail on injected drift. Adds .github/workflows/ci.yml, the repo's first CI that runs go test, go vet, gofmt, and the frontend suite. Known pre-existing failures are named individually in the Makefile so everything else stays gated and the list can only shrink. Part of #135 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(settings): align the sleep timer default and range with the shipped client Android is the only client that implements this setting. It clamps to 0..240 and defaults to 30. The manifest said 0..480 with a default of 0, so a manifest-driven UI would have offered durations no client can store, and every user who never opened the picker would have had the preset silently turned off at cutover. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci: give the new workflow the deps it actually needs The first run exposed two gaps in the workflow itself. go build ./... fails without libvips headers, because h2non/bimg binds libvips through cgo and pkg-config; the Dockerfile installs the same package. And pnpm/action-setup resolves its version from package.json, but there is no package.json at the repo root — the packageManager field lives in web/package.json, and a job's defaults.run.working-directory does not apply to an action's inputs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(web): stop the diagnostics download test depending on the Node version new Response(blob) reads the body through blob.stream(), which jsdom's Blob does not implement on Node 22 — the version the Dockerfile builds with. The test passed locally on Node 24 and threw "object.stream is not a function" in CI. Nothing in it asserts on the body, only that the object URL and filename reach the anchor, so a string body is equivalent and works on both. Surfaced by the CI workflow added in this branch, which is the first thing in this repo to run the frontend suite anywhere but a developer's machine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(build): copy the settings contract into the container build context Both Dockerfiles copy cmd/, internal/, migrations/ and web/embed.go, but the manifest lives in contracts/settings/v1 — an embedded Go package that sits outside internal/ because clients vendor those files. The image build therefore fails with "no required module provides package .../contracts/settings/v1". Caught deploying to the dev box. Nothing had built an image since the manifest landed: the Docker workflow only runs on pushes to main and workflow_dispatch, and CI's go build runs against a full checkout, so neither gate covers the container context. This would have broken the published image on merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(settings): enforce the language-tag and step constraints the manifest declares A sweep of all 43 manifest definitions against the running server (160 checks: declared default, both boundaries, and deliberate violations for each remote key) found two places where the live registry accepts what the contract forbids. Both are fixed by calling the contract's own validators rather than adding a second implementation. playback.audio_language was checked as "32 characters or fewer", so the server stored "!!!" for a field the manifest declares as language_tag — a value track matching would then silently never match. It now requires a well-formed tag via settingscontract.NormalizeLanguageTag. The empty string is still accepted: the string-only endpoint has no way to send null, and both Android and web send "" to clear the choice, so rejecting it would break clearing the preference. player.playback_speed declared step 0.05 and nothing enforced it, so 0.26 was stored — a value no client's stepper can represent and that every client would silently snap on the next write. settingscontract.StepAligned is now exported and used by both the contract validator and the registry, so there is one definition of "on step" rather than two that can drift. This gives the contract its first production consumer beyond the startup load, which is the direction Phase 2 continues in. Also fixes a genuinely flaky test that the new CI gate would have hit intermittently: TestRemoveJellyfinCompatWebDisablesWebSetting used t.TempDir as the install root, but the endpoint returns 202 and its goroutine keeps writing there after the test body returns, so cleanup tripped "directory not empty" roughly one run in four. Confirmed pre-existing and unrelated to settings; the suite now passes six consecutive full-package runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(settings): keep widened numeric bounds resolvable at older revisions A bound was one scalar plus the revision that introduced it, which discards the value it replaced. Widening a maximum from 240 to 480 at revision 3 left a revision-3 client with no correct answer against a revision-1 server: honoring 480 offers values that server rejects, and filtering the tagged bound out leaves the setting unbounded. Since clients are specified to filter their pinned contract against the server's advertised revision, the bound has to carry what it used to be. Bounds now hold their full history, oldest first, and AtRevision hands back the limit a given peer actually enforces. A bound nobody has widened still serializes as a bare number, so the manifest reads the same and untouched entries do not churn the ETag. Validation gains the rules the representation makes checkable: a maximum may only grow and a minimum may only shrink, history is strictly ordered, later entries must say when they arrived, and the first entry cannot predate the definition. That last rule is the lower bound allowed_scopes already enforced; the same gap is closed for enum members, which could previously claim to predate the definition containing them. Reported by Codex review on #479. * fix(settings): accept the partial subtitle appearance objects already stored The schema required all nine properties, but the current API accepts and round-trips sparse objects — settings_device_test.go stores {"fontSize":"xxlarge"} and reads it back — and the web client has always merged whatever it gets over DEFAULT_SUBTITLE_APPEARANCE. Requiring the full object would have made the cutover migration quarantine preferences users really set, or block on them. Every property is now optional and a stored value is documented as a sparse override merged over the definition's complete default. An empty object is still rejected: an override that overrides nothing is the same state as no override, which the contract represents as unset. Cross-scope resolution is deliberately unchanged. A device override still replaces the profile's object rather than merging into it, because a device override means "draw subtitles this way on this screen", not "amend the profile" — and that is what the server does today. Reported by Codex review on #479. * fix(jellycompat): scan the parent directory when a sidecar changes Autoscan matched scantrigger rejections by comparing RequestError.Message against literal strings. One of those messages became "Unsupported media file extension for library type" and the copy in handlers_autoscan.go did not, so the comparison silently stopped matching. The effect is user-visible: a Jellyfin client posting a change for Movie.nfo or poster.jpg gets a 400 and the batch is abandoned, when the sidecar should have resolved to a scan of the directory containing it. Three tests covered exactly this and had been excluded rather than read. RequestError now carries a Reason the caller can switch on. Message stays prose for the client reading the response — it is meant to be reworded, and nothing should break when it is. Also makes two tests honest about asynchronous work. The Jellyfin Web teardown deleted its install root while the operation goroutine was still writing to it, where a late write recreates a path RemoveAll already walked past; it now waits for the operation's terminal state, which required exporting CurrentWebOperation. And the direct-play If-Range test pinned size and mtime so ctime was the only remaining validator, then read it back inside a single coarse-clock tick — it failed about 85% of the time on main for a reason unrelated to what it tests, and now rewrites until the stamp moves. With those fixed, GOTEST_KNOWN_FAILURES is empty and gone: make test-go runs the whole Go suite. The one test that cannot pass yet — TestHandleReplanPlaybackV3SeekFailureRecoveryNeverChangesMediaVersion, which has failed since the commit that introduced it and describes unimplemented v3 planner behavior — carries a t.Skip explaining that where the test is, rather than a regex in the Makefile. Reported by CodeRabbit review on #479. * fix(settings): reject JSON the decoder would otherwise rewrite Two cases where encoding/json accepts input by quietly changing it, which is the one thing a contract promising byte-identical agreement between peers cannot tolerate. Duplicate object properties. jsonschema.UnmarshalJSON keeps the last occurrence, so {"fontSize":"small","fontSize":"large"} validated and stored "large". Which one wins is a property of the parser, not of the contract: a client generated against a different JSON library can disagree about what it just sent, and the canonical form cannot represent the duplicate at all. Lone surrogates. An unpaired \ud800 became U+FFFD and canonicalization reported success, so the server would issue canonical bytes and an ETag for an artifact a conforming implementation must refuse — RFC 8785 requires terminating here. Substitution also means the value read back is not the value written. Both checks run before the decode that would hide them, on the shared decodeJSON path that the manifest, its public projection and every value schema go through, and again on the object branch of ValidateValue, which uses a different decoder. Reported by Codex review on #479. * ci: gate Go lint on the lines a branch changes AGENTS.md told contributors CI ran the same checks as `make lint`, and the Go job ran only gofmt and vet. A change failing the documented Go lint gate passed all three jobs. Running the linter as-is is not an option: the tree has ~296 findings today, which is why this half of `make lint` was never enforced. Blocking every PR on a cleanup nobody has scheduled gets the gate deleted again, so CI runs with --new-from-merge-base and only the lines a branch touches have to be clean. The count can then only fall. golangci-lint is built from source at a pinned version rather than downloaded. A released binary refuses to run against a Go newer than the one it was built with, and go.mod here tracks Go closely enough that the current release already fails that way on 1.26.4. .golangci.yml declared version 2 while still using v1's issues.exclude-rules key. Current golangci-lint ignores it, so the "allow repeated strings and unchecked cleanup errors in tests" exclusions silently did not apply — 16 findings in test files that the config says to skip. Moved to linters.exclusions, which `golangci-lint config verify` accepts. The four lines this surfaced in scantrigger are fixed rather than excluded: its repeated status codes and messages are now named constants, so one condition cannot end up worded two ways. Also drops the workflow token to contents:read and stops persisting credentials in the three checkouts, neither of which any job needs. Reported by CodeRabbit and Codex review on #479. * docs(v1): record the settings removal as a pre-lock exception The design removes the legacy /api/v1/settings routes and the profile DTO preference fields, while AGENTS.md states /api/v1 is additive-only and removals go through Deprecation/Sunset. Read together those contradict. They do not actually conflict: v1-scope.md scopes the additive-only rule to "when the scope locks", and the scope is still open, so a removal taken now is in scope and there is no amendment process to invoke yet. But that reasoning lived only in the settings design, where nobody checking the API policy would find it. v1-scope.md now carries a pre-lock removals table naming what goes and why waiting is worse, and states the deadline the argument depends on: a removal listed there must ship before lock or fall back to Deprecation/Sunset. AGENTS.md points at the table and says to treat an unlisted removal as a mistake. Reported by CodeRabbit review on #479. * fix(settings): clear the remaining review findings Small, unrelated except that each was raised on #479. compileObjectSchemas parsed every non-directory file under schemas/ as a JSON Schema, so a stray editor backup or .DS_Store would panic the server at startup through MustLoad. schema_ref can only name a .json file; anything else is skipped. cmd/silo used MustLoad while the ETag check beside it and every other startup failure use log.Fatalf. It now fails the same way, so a bad contract prints an error instead of a stack trace. TestRegistryDefaultsMatchTheContract called scalarDefault before handling null, and scalarDefault rejects null as non-scalar — so the subtest skipped and the comparison after it was unreachable. A nullable contract default could disagree with a non-empty registry default and nothing failed. Confirmed by injecting that drift, which now reports it. The three appearance providers each adapted the auth context to AppearanceAuth with identical code, putting the shape of auth back in three places that widening cache ownership would have to find. useAppearanceCacheOwner now does it once. useTheme.test.ts cleared storage.KEYS between cases, but appearanceCache writes namespaced keys and an owner pointer that are not in that list, so both survived and the suite was order-dependent. It clears the store, as storage.test.ts already did. The abs_smart_collection_store comment is reworded rather than given back its SQL quotes: gofmt folds a pair of apostrophes in a doc comment into a typographic quote, which is how it became one in the first place. Reported by CodeRabbit review on #479. * feat(settings): add canonical typed storage for the settings contract The cross-platform settings contract needs one typed store behind it before a resolver, routes or a migration can exist. This adds that storage to both user-store backends and holds them to identical behavior. PostgreSQL gets user_setting_values with the scope CHECK constraints, the five partial unique indexes that enforce one explicit value per identity, and the covering indexes the one-query read path needs, plus user_setting_mutations for mutation_id idempotency and the inert user_setting_migration_rejects audit table. The per-user SQLite store gets the same shape minus user_id, since that database is already user-scoped. The UserStore interface grows the typed operations: read one explicit value at one scope, collect every candidate row for a resolution request in a single query, upsert with a revision increment, unset, and the idempotency receipt operations. The resolution read deliberately returns unranked candidates so the resolver can rank in Go — one query per request, never one per scope, which the pgx query-count test pins. Delete behavior is application-enforced. Neither backend can inherit it from constraints: the SQLite store declares no foreign keys, and library, series and device columns are not FK targets in Postgres either. Profile deletion cascades to profile-anchored values while account scope survives, forgetting a device clears its profile_device values alongside the legacy overrides, and the library/series purges remove only what is scoped to that entity. The shared conformance suite covers all of it, including the set-versus-unset distinction for false, 0, "" and null, so a divergence between the two backends fails a test rather than reaching a client. Part of #376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(settings): pin the settings-value schema constraints in both backends Completes the storage track. The conformance suite exercises the store API, which validates identities in Go before any SQL runs — so nothing noticed whether the CHECK constraints and partial unique indexes actually existed. The one-time migration writes these rows in bulk without going through the per-request path, so the schema is the only thing guarding it. Adds constraint tests to both backends covering every scope's column requirements, rejection of an unknown scope, a profile that does not exist, non-JSON values, and each of the five partial unique indexes. Also clears the lint the storage commit did not get to: sql.ErrNoRows and pgx.ErrNoRows compared with == rather than errors.Is (which fails on a wrapped error), an unchecked rows.Close, and repeated fixture literals in the shared suite now named so a backend that confuses two scope columns fails on the assertion rather than on a typo. * fix(settings): close the review findings in the validator and the theme cache Four defects the existing tests did not reach. The web theme resolver compared the server's value against the appearance cache and fell back when they agreed, but the mirroring effect writes the server's value into that same cache — so the comparison held on the first render and stopped holding on the second, reverting an explicitly chosen theme to the default. The server's value is this account's own stored choice, so it now simply wins. The regression test re-renders rather than asserting on the first paint, which is why the original one passed. golangci-lint's exclusions.paths is a path regex, not a directory list, so a bare `web` also excluded internal/jellycompat/web_component.go, internal/webhooksync/, internal/notifications/webhook*.go and eleven other non-test files that were being linted before. Anchored. json.Number is a string kind, so `"1.5"` unmarshalled into it happily and Float64 parsed the quoted digits: a numeric setting validated as a JSON string and NormalizeValue stored the quoted form into jsonb. Rejected. The lone-surrogate check ran only on the object branch, so a lone surrogate in ui.custom_css decoded to U+FFFD on SQLite and was refused outright by Postgres jsonb — the two backends disagreeing about whether the same value could be stored. Hoisted to cover every type. The strict language-tag validation this branch added is correct, but it rejects what the shipped Android client sends; the companion fix is silo-android 4aeb78b4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(auth): stop TestJWT_TamperedToken passing a valid signature The test overwrote the last character of the signature with "X". An HMAC-SHA256 signature is 32 bytes, so its base64url encoding is 43 characters and the final one carries only four significant bits — U, V, W and X all decode to the same trailing byte. Roughly one token in sixteen was therefore left byte-identical and validly signed, and the test failed because ValidateToken correctly accepted it. Measured at 3098/50000 (6.2%) over distinct signatures; it just failed the Go job on this branch for reasons unrelated to the branch. Flipping a character in the middle of the signature is 0/50000. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(settings): reject raw invalid UTF-8, not just escaped surrogates The previous commit hoisted the lone-surrogate check to cover every value type, but that only closes the escaped path. A raw 0xff byte inside a quoted string — what an HTTP body carries when a client encodes text in the wrong charset — is not an escape, so the surrogate scan never sees it, while encoding/json still substitutes U+FFFD and reports success. NormalizeValue then stores the original bytes, which SQLite's json_valid accepts and Postgres jsonb refuses: the same backend divergence, reached the other way. Found by the Codex review bot on the previous commit's own diff. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(settings): size the library page state bound to what the web client writes ui.library_page_state's `search` was bounded at 256 characters. The web client serializes an advanced library view as URLSearchParams, encoding each filter rule as three groups[i][rules][j][field|op|value] keys — measured at 216 characters for one rule, 518 for three, 820 for five. The current endpoint validates this key by checking only that it parses, so those oversized values are already stored in production. Typing them at the declared bound would have failed the migration for anyone who had saved a view with more than one filter rule, and rejected the equivalent write afterwards. Raised to 4096, which clears ten rules with room to spare while staying a real bound. The test pins it against the key shapes libraryPageSearchParams.ts actually emits rather than a round number. Reported by the Codex review bot; the lengths above were measured by calling serializeLibraryPageSearchParams, not estimated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): split quality into two axes and register the orphan keys Two manifest changes the cutover needs. **Quality becomes resolution + bitrate.** The legacy ladder values (1080p-high, 720p-medium, 1080p-8, 420p, 328p) were never a third dimension — they are a bitrate spelled into the resolution string. The web player already decomposes them: useTranscodeQuality.ts defines 1080p-high as {resolution: 1080p, bitrate: 10000} and sends the two separately, so the compound form never reached the wire. Downloads went further and kept only a bitrate ladder. So playback.preferred_quality keeps the six clean resolutions and playback.max_bitrate_kbps becomes the second axis, nullable because "uncapped" is a real answer and a numeric sentinel would need widening every time hardware improves. Clients compose their own presets from the pair, which means retuning what "High" means is a client release rather than a contract break. Migration decomposes each legacy value losslessly, so none of them lands in the rejects table. **The five extension-bag keys are now definitions.** card_overlays, next_up_mode, sidebar_pins, disabled_library_ids and library_order reached the server only through the unknown-key path, stored as unvalidated strings. Two of them the server reads back — next_up_mode decides home section assembly and card_overlays falls back to an admin default — so they cannot be demoted to client-local. Registering them is what lets the extension bag close. Adds three schemas for their shapes and a test that exercises every schema_ref against a real value: each of these is nullable with a null default, so the existing default-validation test returns at the null branch without ever compiling the reference. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): add the canonical resolution engine One answer to "what is this setting, for this profile, on this device, for this content". Before this, each caller carried its own ladder: catalog/detail.go resolved subtitles across four levels by hand and audio across three, handlers/settings.go had a two-level device/user resolution with a lazy write-back inside a GET, and jellycompat read profile columns directly. Those disagreed about precedence, which is the drift the contract exists to remove. Resolution is one batched read regardless of how many keys, libraries, or series are in play — ranking happens in Go against each definition's declared resolution_order. Five sequential index lookups per key per item is the implementation the design rejects, and a season view is exactly where it would have shown up. An absent identity drops its scope rather than erroring, so one code path serves an identified client, an anonymous jellycompat seed, and a batch spanning many series. Rows for a foreign profile, device, library or series are ignored even though the batched read returns them. Constraints narrow without destroying: a capped 4K preference resolves to the cap, reports itself constrained, and keeps the authored value so it takes effect the day the cap lifts. Two cases needed care — null on a nullable numeric means unbounded, so a ceiling must cap it rather than rank it equal and let the value that most needs capping slip past; and an allowlist falls back to a permitted member rather than the definition's default, which may itself be outside the list. Adds ValueSchema.CompareValues to the contract package, since ordering values is what makes a ceiling or floor mean anything and value semantics belong with the schema that declares them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): add the one-time migration planner The conversion rules from legacy settings storage to canonical values, as ordinary Go rather than twice in two SQL dialects. Both backends read their own rows, hand them to Plan, and write what comes back — so the decisions are testable without a database and SQLite and Postgres cannot drift apart in what they decide. The rules that needed care, each pinned by a test: Column defaults are not choices. quality_preference is NOT NULL DEFAULT '1080p' while the contract defaults to auto, so migrating the column unconditionally would pin every profile in the install to 1080p having never chosen it — and that stored value would then outrank the contract default forever. Same for language 'en', subtitle_mode 'auto', and show_forced_subtitles true. The empty string is unset, not a value. The legacy string API had no way to send null, so both Android and web spell "clear my choice" as "". Storing that would make a cleared setting outrank the default. Legacy quality decomposes rather than rejects. Every compound value maps to a resolution and a bitrate from the ladder in useTranscodeQuality.ts, so nothing lands in the rejects table. Account rows fan out to every profile, which is the account-to-profile move the contract makes for appearance and search scope: a household that shared one theme each end up owning theirs. Legacy strings become typed JSON — "true" to true, "30" to 30 — or every generated binding would fail to decode what the migration wrote. Nullability differs per backend, so profile columns arrive as pointers and the caller resolves "chose the default" versus "never written" when it reads. jellycompat's DisplayPreferences blobs ride the same table under synthetic keys and are left alone; they are that subsystem's storage. Everything that cannot convert is recorded with a reason rather than dropped, and a final test asserts every planned row would be accepted by the mutation endpoint's own validation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): run the one-time migration on the SQLite backend Wires the planner to real storage as userdb migration V15. V14 created the tables; this fills them. It runs inside runMigrations' existing transaction, so a database either comes out fully migrated or untouched — a partial migration is the one state neither the operator's backup nor a rollback covers. Pinned by a test that rolls back and asserts nothing was left behind. Two things the wiring had to get right that the planner could not see: Reject identities are JSON. Postgres declares that column jsonb NOT NULL and SQLite guards it with a json_valid CHECK, so the free-form "profile=p1 device=d1" the planner emitted would have failed to insert — on exactly the rows the table exists to record. They are structured documents now, which is also queryable. Subtitle and audio preferences are two tables keyed the same way, so they merge into one per-series record before planning. Converting them independently would have produced two rows racing for the same identity. Every legacy read tolerates a missing table, since this runs against databases created at any schema version, and preferred_metadata_language is deliberately absent: that column exists only in the Postgres schema. Tested end to end against a real database rather than only through the planner — the rows land, satisfy the scope CHECK and the partial unique indexes, and hold valid JSON. Also covers the empty-install case and asserts a second run fails rather than silently doubling every value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): run the one-time migration on the Postgres backend The mirror of userdb V15, registered with goose as a Go migration rather than SQL: the conversion validates every value against its own definition and re-encodes it as typed JSON, and one legacy quality string becomes two rows — neither is expressible in SQL without duplicating the manifest. The rules stay in internal/settingsmigrate, so the two backends cannot disagree. RunTx, so the whole backfill lands in goose's transaction. The down migration empties the canonical tables; the legacy ones are never touched by the up, which is what keeps the cutover reversible until the follow-up migration drops the superseded columns. preferred_metadata_language is read here and only here — the column exists in this schema and not in SQLite's, so this is the sole source for catalog.metadata_language. Verified against a real Postgres: the full goose chain runs, 1080p-high decomposes to ("1080p", 10000), values land as typed jsonb rather than strings (jsonb_typeof reports number), rejects carry a queryable jsonb identity, and the composite profile foreign key refuses a row naming a profile that does not exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): add the canonical settings API The routes that make the typed storage reachable. Until now the manifest, the resolver and the migration all existed with nothing able to call them. GET /settings/contract serves the public manifest behind an ETag — clients vendor a pinned copy and generate bindings from it, so the common request asks "still the same contract?" rather than transferring it. Its capabilities sibling reports revision and supported scopes for feature detection instead of version sniffing. /settings/values/{key} reads, writes and clears an explicit value at one named scope, which is what a reset affordance needs: "did I set this here" is a different question from "what applies", and the old endpoint could only answer a blurred version of both. Scope comes from the query while profile and device come from session headers, so one profile cannot address another's settings by naming it. /settings/values/effective resolves any number of keys in one request, with the resolution ladder and the source of each answer reported so a client can offer "reset this device's override" against the exact row holding it. Asking for no keys returns every remote setting, which is what a settings screen wants. Writes are idempotent when a client sends X-Silo-Mutation-Id: a retry after a dropped response replays the receipt, and reusing an id with different content is a conflict rather than a silent overwrite of the wrong thing. Three things the string-only endpoint could not do, each pinned by a test: an unknown key is refused rather than stored in the extension bag, values are checked against their declared type and range, and a write to a scope the definition does not allow is rejected. Registered before the catch-all /{key} routes, which would otherwise swallow "contract" and "values" as setting names. The legacy endpoints stay live for now; deleting them is the next commit, once their consumers move. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): generate typed bindings for all four languages One generator rather than one per repo. The point of the contract is that four codebases agree on keys, types, scopes and defaults, and four independently written generators would be four chances to disagree. Go and TypeScript land in this repo; Kotlin and Swift are written into the sibling client checkouts, skipped with a note when they are not present so a server-only developer can still run it. Output is sorted by key so an unrelated manifest edit does not produce spurious diffs. The Kotlin output is the interesting one: it generates the DeviceSettings allowlist Android maintained by hand, plus the BOOLEAN_KEYS/INT_KEYS/ DOUBLE_KEYS classification it kept as a *second* hand-maintained table that had to agree with the first. Both are manifest questions now, so the whole class of "wrote a local key to the server" and "flushed a value the store could not parse" bugs stops being possible by construction. The TypeScript output carries the full definition table — labels, controls, enum members, bounds — so web/src/lib/settingsManifest.ts can be deleted rather than kept in sync: it declared 17 definitions against the contract's 49, with its own two-scope model that does not match the contract's five. make verify-settings-bindings fails when the committed output disagrees with the manifest, wired into CI, so a manifest change cannot merge leaving every client reading stale keys. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(web): add the two-axis quality picker and typed settings hooks Quality becomes one picker over two stored values. The server holds a resolution cap and a bandwidth cap independently, which is what the player has always sent on the wire — useTranscodeQuality.ts has decomposed 1080p-high into {resolution, bitrate} for as long as it has existed. Presets live in the client rather than the contract so retuning what "High" means is a one-line edit here instead of a contract change four codebases have to agree on, and an older server keeps working because it only ever sees the two axes it already understands. A combination no preset covers still gets a truthful label rather than a picker showing the wrong entry: reachable by setting the axes separately through the API, or from a legacy value whose bitrate is off this ladder. Choosing an uncapped preset clears the bitrate rather than storing a sentinel, so "no cap" stays the absence of a value at every layer. Adds hooks over the canonical API alongside the legacy ones rather than replacing them wholesale — a key that is not in the manifest cannot be expressed, because SettingKey is generated from it, and the default for an unset value comes from the generated table rather than a literal at the call site. That last part is what stops the flip-off bug the Apple client carries a hand-written guard for. A test asserts every preset composes values the contract actually accepts, so a preset naming a resolution outside the enum or a bitrate outside the declared bounds fails here rather than 400ing when a user picks it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): resolve catalog playback preferences through the contract catalog/detail.go held the two hardest ladders in the codebase: subtitles resolved across four levels by hand, audio across three, each partially overriding the last through Has* flags. Both now call the canonical resolver, so the precedence lives in the manifest and this file cannot disagree with the contract about which override wins. Adding a scope is a manifest change rather than another branch here. The subtitle track signature stays on its specialized table — it identifies a concrete track rather than expressing a preference, so it is not a setting. Resolution keeps the memoization the old lookups had: the audio resolver still reads once per profile and once per library rather than once per file, which is what kept a many-track audiobook detail page fast. The test that guards it now counts resolver reads instead of GetProfile calls, since the guarantee is about scaling with file count rather than about which method does the reading. Four tests seeded the profile column directly. That column is a migration source now, not a read path, so they seed the canonical value instead — they were passing against storage nothing reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): close the unknown-key extension bag keyUsesUserScope returned true for any key the registry did not know, so a client could invent a production setting unilaterally and the server stored it as an unvalidated string. That is how six ui.* settings and five orphan keys reached production untyped, and it is the root enabler the design names. An unknown key is no longer a user setting, so the legacy write path rejects it and the canonical API — which validates every value against its own definition — is the only way to store something new. jellycompat's DisplayPreferences blobs ride the same table under synthetic keys and keep working: they are that subsystem's storage rather than user settings, and they move to dedicated storage in the follow-up rather than being dropped here. Also repoints the DisplayPreferences seed at the canonical resolver. Resolved at profile scope with no device on purpose — Jellyfin clients do not carry Silo's device identity, so a device override leaking into the seed would hand one device's settings to every Jellyfin client on the account. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): enforce viewer quality caps through resolver constraints constraintsFor was the unwired half of the preferences-versus-restrictions seam: it returned nil, so a profile capped at 1080p by policy still resolved its stored 2160p preference at face value through the effective endpoint. The settings routes are mounted inside RequireViewerAccess, so the resolved access scope is already on the request context. Scope.MaxPlaybackQuality holds a literal member of the contract's quality enum ("1080p"/"2160p"), which is exactly what the manifest binds playback.preferred_quality's ceiling to under policy_input "max_playback_quality" — so the wiring is a direct map with no translation table. An empty value means the policy sets no cap, expressed by returning nil so the resolver leaves the preference alone. catalog.metadata_language deliberately stays unconstrained: the manifest notes record that the allowlist draft was circular (the policy input it would bind to is populated from the very preference it would narrow). The handler test covers both halves of the seam: a 2160p preference under a 1080p cap resolves to the cap with constrained:true/ceiling and the authored value reported in stored_value, the stored row itself is not rewritten, and an uncapped viewer gets the preference unchanged with no constraint noise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(settings): publish user_settings change events Add a user_settings realtime channel so clients learn when a setting changed on another device without polling. The channel is modeled on user_state: non-admin subscribable, per-user addressed envelopes, null snapshot. SettingValuesHandler gains an EventsHub and publishes user_settings.changed after every successful PUT and DELETE on /settings/values/{key}. The payload carries only key, scope and profile_id — never the value. Admins receive every user's user-scoped events, so a value in the payload would leak private settings to admins; interested clients re-fetch over the scoped REST API instead. The payload is always non-empty because an empty Data falls back to a null snapshot in the hub. A nil hub (tests) skips publishing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(settings): sweep expired mutation receipts daily Setting-mutation idempotency receipts were written with an expires_at that nothing enforced, so the table grew forever. Add a hidden daily system task (05:00) that walks every login account, opens its user store, and calls DeleteExpiredSettingMutations. A user whose store fails to open or sweep is logged and skipped so one broken store cannot stall retention for everyone else; the delete is idempotent, so the next run repairs anything missed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(settings): resolve metadata language canonically in access and policy Repoint the last legacy column readers onto canonical contract resolution (settings cutover task A4a): - access.Resolver and policy.ViewerResolver now resolve catalog.metadata_language through settingsresolve (profile scope -> contract default) via a shared access.PreferredMetadataLanguage helper, instead of reading user_profiles.preferred_metadata_language. Resolution is deliberately unconstrained: the policy input this preference feeds is the one a constraint would have to reference, which is circular — see the key's manifest notes. - playback start now resolves playback.audio_language canonically for the profile default instead of reading user_profiles.language, matching the catalog detail path. Series and library override handling is unchanged. - items.go needed no change: it already consumes the resolver-produced scope.PreferredMetadataLanguage. The legacy columns keep their values but are no longer read on these paths; a profile with only a column value now resolves to the contract default, and a stored canonical value wins. Tests pin both directions in access, policy (including scope parity, where the column is now a decoy), and the playback handler. Read cost is one batched store read per resolution, same as the profile-row read it replaces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(jellycompat): give DisplayPreferences its own table The Jellyfin DisplayPreferences blobs rode the legacy user_settings key/value table under synthetic jellycompat:* keys, which forced the legacy settings API to carry a prefix carve-out in its otherwise-closed unknown-key gate. They are the compat subsystem's storage, not user settings: the contract neither validates nor resolves them. Move them to a dedicated jellycompat_displayprefs table in both backends, keyed by (prefs id, client) per user, with the blob stored as opaque text served back byte-for-byte (deliberately not jsonb, which would re-serialize it). The data-copy migrations — per-user SQLite V16 and a paired SQL + Go goose migration for Postgres — are transactional and harmless to re-run, and both drive their key parsing and row classification from the new internal/jellycompat/displayprefs package so the backends cannot diverge, following the internal/settingsmigrate precedent. A jellycompat:* row that does not parse as a DisplayPrefs key (only ever writable through the removed carve-out) is recorded in user_setting_migration_rejects rather than silently deleted. With the last non-settings tenant gone, the jellycompatSettingPrefix carve-out is deleted: the legacy settings endpoints now refuse jellycompat:* keys like any other unknown key and never surface them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(settings): serve admin user-settings through the canonical API Replace the ten string-registry /admin/users/{id}/settings* and device-settings* routes with the canonical contract surface: one list of every explicit value the target user has stored across all scopes, and set/delete at an explicit scope named in the query string. The admin handlers live on SettingValuesHandler and share the session routes' implementation rather than duplicating it — the same key/scope parsing, identity validation, contract scope allowance, value normalization and mutation-receipt idempotency, factored into keyedScopeFromRequest/completeIdentity and setValueAt/deleteValueAt. The only admin-specific parts are the target user coming from the path, profile and device ids coming from the query (an admin holds no session claim to the user being inspected, so its named profile is checked to exist), and change events attributed to the target user so their clients refresh. The list is a new UserStore read, ListAllSettingValues, implemented in both backends and pinned by the shared storetest conformance suite: the admin surface wants the stored truth (which overrides exist, for a per-row reset affordance), which no resolution-shaped read answers. The ten removed routes are recorded in the pre-lock removals table in docs/architecture/v1-scope.md per the v1 API rules; the web admin device-overrides page moves onto the new surface in the Phase B rewrite inside this same unmerged PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(settings): add the cross-platform conformance fixture and its Go and web runners contracts/settings/v1/conformance.json is the spec's named drift gate: 21 hand-authored cases of {keys, stored rows, context, constraints, expected effective value + source}, every one executable against the shipped manifest. They pin the semantics most likely to drift across four resolver implementations: the full resolution ladder (series > library > device > profile > default), an absent identity dropping its scopes, foreign-identity rows never resolving, ceiling caps that report the authored value with constrained:true, the ordered-enum sentinels (auto below every cap, original above), null-on-a-nullable-numeric meaning unbounded and being brought down by a ceiling but ignored by a floor, allowlist falling back to the first allowed member rather than the (possibly forbidden) default, and playback.subtitle_appearance resolving device > profile only with the sparse device object replacing, not merging. Cases may inject a constraint binding onto a copy of a real definition so constraint kinds no shipped definition carries stay testable. The Go runner (internal/settingsresolve/conformance_test.go) resolves each case through the real resolver against the embedded manifest. The web runner (web/src/lib/settingsConformance.test.ts) runs the same cases through a new client-side resolver, web/src/lib/settingsResolve.ts, which mirrors the server's semantics; the TypeScript bindings now carry each definition's ordered flag and constrained_by binding so that resolver derives constraint behavior from the contract instead of hardcoding it. Both runners reject unknown fixture fields — schema drift in the fixture itself is drift — and both refuse a fixture authored against a different manifest revision. The fixture travels with the bindings: make settings-bindings vendors the copy the web runner reads, and make verify-settings-bindings fails CI when that copy goes stale. The Kotlin and Swift copies land together with their runners in the client repos, which will pick their own test-resource paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): review pass over the phase A stack Fixes the eight adversarially-confirmed defects the review of the unpushed phase A stack (40e0f77a..1f2c7fe4) found, each with a test that fails without its fix. Writers left behind by the language cutover (high).22e9d7f1made access, policy and playback start resolve catalog.metadata_language and playback.audio_language exclusively from user_setting_values, but POST/PUT /profiles — the write path the shipped web UI uses — still wrote only the legacy columns, so a language change after the one-time backfill never took effect (a stale backfilled row, or the contract default, won forever). Profile mutations now mirror their preference fields into the canonical profile-scope rows through the same contract validation /settings/values applies (audio, subtitle and metadata language, subtitle mode, forced subtitles; the empty string clears the row, matching the migration's unset spelling), publish user_settings.changed for each row moved, and 400 on a value the canonical endpoint would refuse. quality_preference is deliberately not mirrored: the server never resolves the legacy column and the two-axis picker already writes canonically. Web admin settings 404s (high + medium).facad78dremoved the ten /admin/users/{id}/settings* and device-settings* routes but shipped no web changes, so the user-detail settings and device-overrides tabs and the devices-page override editor were dead. The seven admin hooks now speak the canonical values API: one list across all scopes feeds both tabs, mutations address an explicit scope identity, values re-type through the generated contract (display stringifies for the registry-era controls), device rows are enriched with device and profile names client-side, and the removed bulk device reset becomes per-key deletes that treat 404 as already-reset. Silent metadata-language degrade (medium). PreferredMetadataLanguage now logs a warning with the profile and error when contract load or store resolution fails, so pool exhaustion is distinguishable from "no preference"; the healthy paths stay quiet. Displayprefs move data loss (medium). Under READ COMMITTED the blanket pattern DELETEs in moveDisplayPrefs/unmoveDisplayPrefs could destroy a row an old-binary instance committed between the SELECT and the DELETE during a rolling deploy — reproduced against real Postgres. Both directions now delete only the exact rows they read (rejects restore by primary key), leaving a late row stranded for a re-run to pick up. Coverage the review proved missing (medium x3): admin mutations are now tested to attribute change events to the target user, not the acting admin (the exact regression passed the whole suite before); the user_settings websocket channel is subscribed through the real events websocket, failing if the channel is dropped from either allowedChannelsForRole or AllChannels; and the conformance fixture gains three locked-constraint cases (replace, equal-value pass-through, locked default) so the Go and TypeScript locked branches — previously executable by no test on either platform — are pinned by the shared drift gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): read and write appearance and format preferences through the settings contract Move the four identity-sensitive preference hooks — useTheme, useCustomTheme, useDateTimeFormat, useSearchMediaScope — off the legacy string-only /settings endpoints and onto the canonical settings API. Each surface now reads through one batched useEffectiveSettings call and writes via useSetSettingValue at scope "profile", matching what the generated manifest declares: ui.theme / ui.text_scale / ui.text_weight / ui.high_contrast are profile-scoped with a profile_device override the effective read already resolves (no device-override UI exists, so writes stay profile-wide), and ui.custom_theme_vars / ui.custom_css / ui.date_format / ui.time_format / search.media_scope are profile-wide. Keys come from the generated SETTING_KEYS table, so a typo'd or unmanifested key can no longer be expressed. Because the canonical effective endpoint always answers — resolving unset keys to the contract default with source "default" — the hooks now use the source to distinguish "the profile chose this" from "nobody stored anything". That preserves the admin-default theme layering and keeps resolved-but-unchosen values out of the warm-start mirror. ui.theme moving account→profile scope means the appearance warm-start cache must not be shared by sibling profiles on one account, so appearanceCacheOwner widens its token from the user id to user id plus active profile id. Every cache read/write already resolves through that one function, so no call site could be left behind; the API→cache mirror, the render-time re-seed on identity change, and the debounced write cancellation all follow automatically. The ownership tests now cover profile switches within one account: no theme/text-scale/CSS leaks between profiles, each profile's warm start survives the switch, and a debounce armed by one profile never persists under its sibling. Part of the Phase B settings-contract cutover; the legacy hooks in queries/settings.ts keep their remaining callers until B4 deletes them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): store library, sidebar, and overlay preferences through the settings contract Phase B2 of the settings-contract cutover: the query-layer preference stores — sidebar pins, library page state, disabled libraries, library order, and card overlay prefs — move off the legacy string-valued /settings endpoints onto the canonical values API, using generated SETTING_KEYS and each definition's declared scope (profile for pins, visibility, order, and overlays; profile_device for page state and the remember toggle). Values are now written as typed JSON matching the contract schemas (sidebar-pins.json, library-page-state.json, library-id-list.json, card-overlays.json) instead of JSON-encoded strings, so the encoding the migration produced keeps validating. Every parser accepts both the canonical object value and the legacy string encoding, so nothing breaks while caches or older rows still hold strings. Semantics preserved deliberately: - Sidebar pin toggles keep their optimistic update with the revision-guarded rollback, now layered on the effective-settings cache entry (effectiveSettingsQueryKey is exported for exactly this). - The remember-library-pages toggle clears the device override to inherit again rather than storing the default, via useClearSettingValue; the canonical DELETE's 404 for "nothing stored" is treated as already-done, matching the legacy delete's idempotency. - Overlay prefs keep the admin default / kill-switch layering: the contract default null means "no preference expressed", which is what lets /settings/overlay-config defaults apply, and only a stored value overrides them. - Library visibility/order keep their optimistic local state with rollback on error; ids are normalized client-side with the same rules library-id-list.json enforces. parseDisabledLibraryIDs/parseLibraryOrder collapse into one parseLibraryIDList (they were byte-identical), and the serialize helpers disappear with the string encoding. Legacy hooks in queries/settings.ts stay for the remaining consumers until B4. Part of the settings-contract cutover (see docs/superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): write playback, subtitle, and library preferences at canonical scopes The settings screens and the player panels were the last web surfaces still speaking the legacy string API, and each carried its own idea of where a preference lives. Playback and subtitle behavior wrote profile columns through PUT /profiles; auto-play and next-up wrote untyped strings; subtitle appearance went through three bespoke routes that existed only because the string API had no way to express an object-valued setting per device. All of them now read one batched effective resolution and write typed JSON at an explicit scope. Where each preference lands follows the manifest rather than the endpoint that happened to hold it: - Playback and subtitle defaults, and next-up mode, write at profile. - Subtitle appearance writes playback.subtitle_appearance at profile_device, replacing /settings/subtitle_appearance/effective and the PUT/DELETE pair on /settings/device/subtitle_appearance. One hook now owns that value for the settings screen, the in-player panel, and the cue renderer, which before each parsed the effective response separately. - Per-library edits write at profile_library with the library identity, one key at a time. The legacy endpoint replaced a composite row, so clearing one field meant re-sending the other three and losing any concurrent change to them; independent per-key writes have no such coupling, and "inherit" is a delete rather than a sentinel. - The in-player series choice splits along the line the contract draws: language and mode are preferences and move to profile_series, while the track index and signature stay on /subtitle-prefs because they identify a concrete track rather than expressing a preference. Controls render from the generated SETTING_DEFINITIONS. The hand-written registry beside it had drifted — it declared several profile-only keys as device overrides, and disagreed with the manifest about the bounds of two sliders — so the display helpers now derive control shape, options, bounds, and the device-overridable key list from the contract. (Deleting settingsManifest.ts itself is B4; nothing outside its own test imports it any more.) Two follow-on fixes fell out of reading the contract rather than the registry. playback.auto_skip_recap and playback.auto_play_next_preview are declared at profile_device but only the intro override was ever consulted, so a device override on either silently did nothing; the player resolves all three now. And per-library "Original Language" is gone: the contract types these as BCP 47 tags, and the phase-A migration already rejects "original" at profile_library, so offering it would have written a value the server refuses. Risk worth naming: LibrarySettings decides "overrides" from the resolved source rather than by comparing values, which is what keeps three distinct cases apart — a library row holding the same value as the profile is still an override, and a library row holding null is an explicit "no subtitles" rather than an absent choice. A screen that compared values would collapse the first into "inherits" and the second into "unset". Part of #135 AI-assisted: authored with Claude Code; reviewed and verified by the committer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): refresh settings from the user_settings channel A canonical settings write reached only the tab that made it. The server already publishes user_settings.changed on every write and delete, but no web client subscribed, so a preference changed on a phone or by an admin sat stale here until a manual reload or the 5-minute staleTime expired. Subscribe the channel and treat the frame purely as an invalidation signal. The payload carries the key, the scope and the profile — never a value, because admins receive other accounts' user-scoped events and a value there would leak private settings. Marking the value queries stale lets react-query refetch only what a mounted screen is reading, and a burst of writes coalesces into one fetch per key rather than one per event. A profile-addressed change to a profile other than the signed-in one is dropped: it cannot alter what this tab resolves. Account-scoped changes carry no profile and always invalidate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(web): render settings from the generated contract web/src/lib/settingsManifest.ts was a hand-written table of labels, controls, defaults and bounds sitting beside the generated contract, and it had already drifted: it declared profile-scoped keys as device overrides, disagreed with the server on the type and range of several keys, and enumerated a language subset narrower than the one the player speaks. lib/settingsDisplay.ts has derived all of that from SETTING_DEFINITIONS since the contract landed, and nothing but the manifest's own test still imported it. Delete the manifest and its test. The one piece it owned that the contract cannot express is the language list — language settings are typed as BCP 47 rather than as an enum, so there is no member list to render — which moves to lib/languageOptions.ts and is now derived from the shared player language list. Two shapes ship: NAMED_LANGUAGE_OPTIONS for a control that spells its own unset entry, and LANGUAGE_OPTIONS with the leading "no preference" row for a nullable setting. The per-library editor's LANGUAGE_OPTIONS re-export goes with it, so every language dropdown in settings now iterates one list in one shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web): show canonical device overrides in admin devices The device detail panel read its override rows from GET /admin/devices/{user}/{device}, whose `settings` array still comes out of the legacy user_device_settings table. The settings-contract migration folded that table into user_setting_values and nothing writes to it any more, so an override created since the cutover — including one the admin had just saved through this very panel — was invisible here, while the migrated rows stayed visible. The panel's own writes go to the canonical route, which made the list look like it silently dropped edits. Read the overrides from the canonical values API instead, filtered to device scope and to this device. Both storage generations show, because the migration moved the legacy rows into the same table. The detail endpoint is still the source for registration metadata — device name, owner, which profiles have used it — which is not a setting and has no canonical equivalent. The override count and last-updated readouts move to the canonical rows for the same reason: override_count is computed over the legacy table and would disagree with the rows rendered underneath it. "Reset all for device" has no bulk canonical route, so it keeps issuing one delete per key, now over the keys that actually exist. The reset button also takes the profile id from the tab rather than from its first row, which a profile registered on the device with no override yet does not have. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(web): delete the legacy settings hooks hooks/queries/settings.ts spoke the string-only registry API: every value a string, scope implied by which function you called, and an unknown key silently accepted. Phase B moved every consumer onto the canonical value hooks, and the last importer left was the file's own test — so both go together, along with the client functions they were the only callers of. hooks/queries/libraryPlaybackPreferences.ts goes with them. It wrapped GET/PUT/DELETE /library-playback-prefs, which LibrarySettings replaced with profile_library-scoped canonical writes; nothing in web has called it since. The server route stays for now — the Android and Apple clients may still use it — but the web type and query keys have no reason to linger. settingsKeys keeps only `all` (the prefix the canonical invalidation targets) and the plugin entries, which are a different system. The list/detail/deviceDetail/effective builders described the registry's cache layout and had no remaining callers; effectiveSettingsQueryKey in settingValues.ts owns the canonical shape. hooks/useSettingsForm.ts is deliberately untouched: it edits admin server_settings through /admin/settings, which is a separate surface from the per-user contract, and has more than twenty live consumers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web): review pass over the phase B adoption Phase B moved the web client onto the canonical settings surface. Three scope mistakes slipped in, all of the same shape: a value written at a scope no UI can reach, shadowing the one the user can edit. Auto-play next. The post-roll toggle wrote profile_device while Settings → Playback wrote profile, and the contract resolves the device row above the profile row. Turning auto-play off in the player therefore made the settings switch permanently inert — it saved a profile value the device row kept shadowing and snapped straight back, with no web affordance able to clear the device row. Both surfaces now share useAutoPlayNextSetting, which writes the profile and clears any device row (also the only way a migrated per-device override becomes reachable). Before Phase B both writers used useSetDeviceSetting, so they could not disagree; this restores that invariant at the scope the rest of the Playback screen edits. In-player subtitle picks. handleSubtitleChanged wrote three canonical keys at profile_series, the top of the resolution ladder, while "Auto" on the item page still deleted only the legacy /subtitle-prefs row — so the reset silently stopped working and the abandoned language kept resolving for every episode of the series, forever. One of the three, show_forced_subtitles, was worse: the player has no forced-subtitle control, so the value it wrote back was the *resolved* one, which for a viewer who never expressed a preference is the contract default. That pinned the default above the profile-scope toggle on the Subtitles screen. The written set now comes from SERIES_SUBTITLE_SETTING_KEYS — language and mode only, both derived from the user's actual choice — and useDeleteSubtitlePreference clears exactly that list, so the writer and the reset cannot drift. show_forced_subtitles still rides the legacy composite row, which is keyed to a concrete track selection and is not part of the canonical ladder. Admin user settings. The tab now lists every non-device canonical row, which includes the object-valued profile settings (sidebar pins, card overlays, disabled libraries, library order, custom theme vars). It gated only on `definition`, and controlKindFor has no `object` branch, so those fell through to RegistrySettingControl's select — rendering a user's pins as a one-entry "Unset" dropdown whose only option nulls them. It now uses the same isStructuredSetting guard the device tab got, routing them to a raw JSON editor. Tests: each fix has a test that fails without it, verified by reverting the fix in place. The auto-play and subtitle tests resolve through lib/settingsResolve rather than a canned answer, so the scope-precedence assertions exercise the real ladder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): serve profile preference fields from canonical resolution PUT /settings/values?scope=profile writes only user_setting_values, but GET /profiles still served the legacy user_profiles columns. A preference saved through the canonical API was therefore invisible in every profile DTO reader on every platform — Apple's shipped build reads exactly those fields — while profiles_settings_sync.go mirrored one way only, legacy column write to canonical row. Serve those five fields (language, preferred_metadata_language, subtitle_language, subtitle_mode, show_forced_subtitles) by resolving their canonical keys through the settingsresolve seam at profile scope, falling back to the contract default rather than to the stale column. This matches the cutover direction taken everywhere else: the legacy columns stay written but stop being read, so "clear this preference" cannot resurface a pre-cutover value the one-time backfill already converted. The write paths that accept these fields and mirror them are unchanged; this is read-side only, and the DTO's field names and types are untouched. Resolution is batched. A profile list serves the whole household, so SettingResolutionQuery.ProfileID becomes ProfileIDs and the new Resolver.ResolveProfiles ranks every profile against one candidate set — one store read per list request instead of one per profile. Both backends carry the widened predicate and the shared storetest conformance suite gains a household case, so they cannot drift on it. quality_preference stays column-backed: the legacy column is one compound value while the contract splits it across playback.preferred_quality and playback.max_bitrate_kbps, so there is no lossless read. The auto_skip_* and auto_play_next_preview fields stay column-backed too — the sync path never mirrored them, so their canonical rows can lag the columns. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web): repair CI findings after the main merge CI runs checks the local loop does not: golangci-lint (not installed here) flagged two unchecked Close errors in the new websocket test, and tsc -b (the tests were only vitest-run locally) rejected strict indexed-access in four test files touched by the review passes. The merge also brought main's onboarding tour, whose SettingControl wrote through the legacy useSetSetting hook this branch deletes — it now writes the canonical scoped mutation, re-typing the tour's string values through the generated contract like the admin surface does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style(settings): satisfy the incremental lint pass golangci-lint reports findings incrementally, so these three surfaced only after the previous fix: errors.Is for the pgx.ErrNoRows compare (wrapped errors), and named constants for the repeated "values" response key and the "usersettings" prefs id goconst flagged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(database): pin the read value when deleting moved displayprefs rows Under READ COMMITTED the move's DELETE takes its own snapshot, so during a rolling deploy an old-binary instance could update a jellycompat row between the migration's SELECT and its delete — and the (user_id, key) predicate would destroy the newer value after copying only the older one. Naming the value the transaction actually read makes such a row survive as a stranded legacy row instead, the same disposition a late-inserted row already had. Extends the concurrent-write migration test to commit an update to an already-read row during the stall and assert the newer value survives. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): make canonical mutation writes honest under failure Three review findings on the canonical settings endpoints: - Idempotency receipts were recorded via defer, so a failed upsert still left a receipt and the client's retry replayed a success for a write that never happened. The receipt is now written only after the upsert lands, and it stores the actual response — revision and updated_at included — so a replay is byte-identical instead of a reconstruction of the input with revision 0. - The mutation envelope accepted trailing JSON after the first document, leaving the interpreted mutation parser-dependent. The decoder now requires EOF after the envelope. - Resolving a device-aware key without X-Silo-Device-Id silently skipped every stored device override and passed the profile fallback off as the effective value. The effective endpoint now fails closed with 400, matching the write path's existing requirement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): stop the contract rejecting values shipped clients store Four bounds in the contract were narrower than what a shipped client already produces, so real stored preferences would fail validation or be quarantined at migration: - The BCP 47 grammar rejected extlang tags (zh-cmn) and private-use-only tags (x-private) the legacy length-only validator accepted, turning an existing 204 into a 400. The pattern now covers both, and NormalizeLanguageTag cases a script correctly after an extlang and leaves private-use content lowercase. - subtitle_appearance.fontFamily allowlisted ASCII, contradicting its own description: Apple clients store CTFontManager family names verbatim and those are routinely CJK. The pattern now excludes unsafe characters instead of allowlisting ASCII. - theme-var-overrides capped CSS values at 128 characters, which real multi-stop gradients exceed; the web importer stores them unchecked. Raised to 1024. Plus one tightening the review asked for: card-overlays.order now declares uniqueItems, matching library-id-list, so an overlay cannot be rendered twice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(userstore): reject non-canonical identities and bound resolution batches Three review findings on the canonical settings storage layer: - SettingIdentity.Validate trimmed ids only to check emptiness, so a padded id like " p1 " validated, persisted verbatim, and was then invisible to resolution queries, which bind trimmed forms — a silently orphaned row. Validation now rejects any id that is not in canonical trimmed form, pinned in the shared conformance suite so both backends hold the line. - The effective-values endpoint accepted unbounded library_ids and series_ids lists; the SQLite backend expands each id into a bound parameter, so a crafted batch could exhaust the host-parameter budget and fail the whole resolution. The request boundary now caps the combined content ids at 200. - pickForScope's doc comment promised ties broken "by the most specific id in the request order" while the implementation sorts by ascending library then series id; the comment now describes the actual (deliberately deterministic-only) behavior. Plus: the pgstore conformance cleanups now assert the ON DELETE CASCADE they rely on instead of discarding the delete error, so a dropped FK can no longer leak seeded rows into the shared test database silently. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): close the discovery gaps around the canonical API Three review findings: - Canonical profile_device writes never touched the device registry, so a device that only ever wrote through /settings/values was invisible to ListDevices and the admin device surfaces — undiscoverable and unforgettable. Device-scope writes now refresh the registry from the request's device headers, throttled the same way the legacy route is. - The contract spec tells clients to probe GET /settings/manifest (and /settings/capability), and to read a 404 as "pre-contract server"; the router only exposed /settings/contract*. The documented paths now alias the same handlers. - The plugin proxy's X-Silo-Theme header came from the legacy account-level user_settings.ui_theme row, so a profile's theme change through the canonical API never reached plugins and profiles sharing an account were indistinguishable. The lookup now resolves the canonical profile-scoped ui.theme row (falling back to the legacy row for stores the backfill has not covered) using the request's active profile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(settings): emit revision metadata in generated bindings and verify the TS one Two review findings on the generator surface: - The bindings dropped every introduced_in tag, so a client generated from revision N could not filter its pinned contract down to an older server's advertised revision — the promised negotiation had no data. The TypeScript definitions now carry introducedIn per definition, per scope, per enum member, and the full history of any widened numeric bound. (Go/Kotlin/Swift emit keys, not definition tables, so they only need the Revision constant they already have.) - make verify-settings-bindings compared only the generated Go file and the conformance fixture, so a manifest change could merge with a stale web/src/lib/settingsContract.ts. The target now regenerates and diffs the TypeScript binding too, through the same prettier config the bindings target applies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: drop the accidentally committed settingsgen binary24ee9952checked in a 5.5 MB compiled settingsgen alongside its source. The binary is a local build artifact — cmd/settingsgen is the source of truth and make settings-bindings runs it with go run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): close the migration planner's data-loss and crash findings Four review findings on the one-time legacy-to-canonical migration: - A device holding both player.next_up_prompt_seconds and its playback.* rename canonicalized to one identity, and both backends insert bare — a unique violation that failed NewUserDB (SQLite) or aborted the goose migration (Postgres). Plan now ends with a deterministic dedup keyed on the canonical identity; a canonically keyed row beats a renamed alias, since the runtime writes the canonical spelling first and only best-effort-deletes the alias. - The four auto-skip profile columns (auto_skip_intro/credits/recap, auto_play_next_preview) were never read, so an explicit true silently became the contract default false. They now migrate — explicit true only, so an untouched false column does not become a choice. - Profiles with language 'en' emitted no playback.audio_language row because the column default was suppressed, but that default WAS the effective behavior: the old playback path preferred English, while the canonical null default skips language matching entirely. English now migrates as an explicit row. The other suppressed defaults stay suppressed — their empty-string defaults already meant unset. - Stored v1 card_overlays documents were quarantined because the planner validated them against the v2-only schema; the web parser has upgraded v1 at read time all along. The planner now applies the same v1-to-v2 upgrade before validation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): delete canonical library-scoped values with the library The canonical settings schema deliberately has no FK on library_id or series_id, and the migration comment promised the owning delete paths would clean these rows up — but nothing called DeleteSettingValuesForLibrary/-Series outside stores and tests, so a deleted library left orphaned profile_library preferences in every user's store forever. Adds userstore.SettingValuesCleaner, a per-user best-effort sweep in the mutation-sweeper's mold, and wires it into the library delete job. The series-side cleanup is exposed on the same cleaner for the scanner's orphan pruning to adopt; series have no single delete executor today. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): keep off-step playback speeds working until cutover The legacy device endpoint gained step enforcement mid-branch, turning an existing in-range PUT of 0.26 from 204 into 400 — a behavior change on a live /api/v1 endpoint before the coordinated break, which the v1 rules forbid. The legacy validator is back to range-only; the typed mutation endpoint keeps enforcing the manifest's step. The migration planner now snaps stored off-step numbers onto their definition's step grid instead of quarantining them: a stored 0.26 is a real preference, and every client's stepper was going to snap it on the next write anyway. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): serve canonical values to the readers the cutover stranded Four P1 review findings where the web writes canonical rows the server never reads — and the legacy keys those readers use are now unwritable, so the values are frozen and user edits silently do nothing: - access.DisabledLibraryIDs and the policy viewer resolver read the legacy account key while the library screen writes profile-scoped ui.disabled_library_ids. Both now resolve the canonical profile row, falling back to the legacy key only when no canonical row exists. - The sections fetcher and handler read the legacy next_up_mode account key while the playback screen writes ui.next_up_mode. Same ladder, behind one shared sections.NextUpMode helper. - Profile creation committed the profile and then synced settings non-atomically, so a mid-sync failure left a profile the retry could not recreate (name conflict) with preferences that read as contract defaults forever. The create path now compensates by deleting the profile it created. - The mounted legacy PUT /subtitle-prefs/{series_id} wrote only user_subtitle_preferences, but item detail resolves those three keys canonically, so a post-upgrade client's "subtitles off" returned 204 and was ignored. The legacy handler now dual-writes the canonical profile_series rows, and its delete clears them. Plus the migration's disposition for stranded Apple device-scope audio language rows: nothing read them before the contract, so promoting them to real overrides would change track selection at upgrade. They are recorded in the rejects table instead of copied. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web): make canonical settings writes take effect Three P1 review findings on the web half of the cutover: - Every profile-default editor reads the resolved value but writes the profile row, so a device override — left by the migration converting legacy user_device_settings, or written by another client — kept shadowing the save and snapped the control back with no affordance to remove it. useAutoPlayNextSetting already solved this for one key; that logic is now a shared useProfileDefaultWriter used by the playback screen, the quality picker, subtitle behavior, and the four appearance setters. It only clears when the key is device-scopable and the resolved value actually came from a device row. - The appearance cache only ever grew: when the effective response resolved a key to "default" — because another client deleted it — the namespaced entry and local state survived and kept winning the fallback, so a removal never reached this browser. The mirror now runs both ways, clearing only on an explicit default answer (silence is not a deletion) and only within the current identity's namespace. Custom theme vars and CSS do the same, except while a local draft is unsaved. - The quality picker wrote the canonical two-axis keys while playback still derived its cap from currentProfile.quality_preference, a legacy compound column the canonical write deliberately does not mirror — so choosing a quality changed nothing about what played. The watch route and both item-detail pages now read playback.preferred_quality, falling back to the profile column until the settings read resolves so playback never blocks on it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): repair the type error and the bindings gate's job placement Two breaks from the previous commits: - useTheme referenced storage.StorageKey, but storage is a value, not a namespace — the Web job's tsc caught what the local incremental typecheck had already cached past. Imported the type properly. - verify-settings-bindings gained a prettier step, and the Go job that runs it has no pnpm, so the check failed on its own tooling rather than on a stale binding. Split the web half into verify-settings-bindings-web and moved it to the Web job, which has pnpm; verify-settings-bindings-all runs both locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): close the second-round review findings Three from the review of the pushed work: - The live profile sync omitted auto_skip_intro/credits/recap and auto_play_next_preview, which my own change made load-bearing: the player now resolves those keys canonically, so a legacy PUT /profiles moved the columns, returned 200, and changed nothing about playback. All four now mirror on write. The DTO read block keeps its shape — clients pin it — and its columns are what the sync keeps current. - The effective endpoint dropped unknown keys silently, letting a client fill the gap with its own vendored default and present a value this server would refuse to store. Unknown keys now 404 by name. - Two sidebar-pin toggles in flight at once could commit in either order, and the server upsert is last-write-wins, so the first request landing second restored the pre-toggle document. The writes are now chained, and each link reads the document when it runs, so a queued toggle sends the newest state rather than the one it was queued with. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(database): make the settings-contract deploy reversible Rolling back this release meant restoring a backup, for a reason that was not obvious: the DisplayPreferences move deletes the jellycompat rows from user_settings once it has copied them, and the previous binary reads exactly those rows. An older server therefore starts cleanly and silently serves defaults, so every Jellyfin client's saved view preferences look reset. The down functions were already written and correct — nothing could invoke them. The backfill and the DisplayPreferences move are Go migrations registered in-process, so the standalone goose CLI in the Makefile cannot see them, and the server exposed only --migrate-only and --migrate-status. Adds MigrateDownTo, the --migrate-down-to flag, and a make target, plus a rehearsal test that seeds a legacy row the way the old binary wrote it, applies the move, rolls back, and asserts the row returns byte-for-byte. Documents the ordering in the spec's cutover section, including the two caveats an operator needs beforehand: take a backup, and the per-user SQLite backend cannot be rolled back at all — its migrations have no down path and an older binary refuses to open a newer database, so those installs restore from backup rather than degrade. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): skip legacy rows whose profile was deleted The dev-server migration aborted on real data: writing playback.subtitle_appearance at profile_device for user 1: violates foreign key constraint user_setting_values_profile_fkey user_device_settings carries an ON DELETE CASCADE on (user_id, profile_id) today, but rows written before that constraint outlived the profiles they belonged to — that install had 46 such rows across 14 deleted profiles. The planner copied them faithfully and the canonical table, which declares the same foreign key, refused them; because the backfill runs in one transaction, the whole migration failed and the server could not start. An override belonging to a profile nobody can select is not a preference anyone can be shown or reset, so Plan now drops those rows rather than repairing them, recording each in user_setting_migration_rejects so an operator can see what was left behind. Account-scope rows carry no profile and pass through untouched. Verified by replaying that install's 514 device rows through the planner: 9 rows would have hit the constraint before, 0 after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): address canonical cutover review findings * fix(settings): address latest review findings --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import { loadStoredImpersonationAdminSession } from "@/lib/impersonationSession"
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { RealtimeEventsProvider } from "@/components/RealtimeEventsProvider";
|
||||
import { useEventChannel } from "@/components/realtimeEventsContext";
|
||||
import { useSettingValuesRealtime } from "@/hooks/queries/settingValues";
|
||||
import Layout from "@/components/Layout";
|
||||
import AdminLayout from "@/components/AdminLayout";
|
||||
import Home from "@/pages/Home";
|
||||
@@ -610,6 +611,9 @@ function RealtimeEventChannels() {
|
||||
|
||||
useEventChannel("catalog");
|
||||
useEventChannel("user_state");
|
||||
// Subscribes user_settings and invalidates the canonical value queries, so a
|
||||
// setting changed on another device (or by an admin) reaches this tab.
|
||||
useSettingValuesRealtime();
|
||||
// Profile-scoped; the server rejects the subscription until the connection
|
||||
// is bound to a profile via the websocket ticket, which is harmless.
|
||||
useEventChannel("notifications");
|
||||
|
||||
+4
-10
@@ -2667,6 +2667,10 @@ export type EventChannel =
|
||||
| "scans"
|
||||
| "history_import"
|
||||
| "user_state"
|
||||
// Per-account settings changed somewhere (another device, or an admin
|
||||
// editing this account). Identity only, never a value — see
|
||||
// useSettingValuesRealtime.
|
||||
| "user_settings"
|
||||
| "settings"
|
||||
| "notifications";
|
||||
|
||||
@@ -3739,16 +3743,6 @@ export interface UserLibrary {
|
||||
poster_url?: string;
|
||||
}
|
||||
|
||||
export interface LibraryPlaybackPreference {
|
||||
profile_id: string;
|
||||
library_id: number;
|
||||
audio_language?: string;
|
||||
subtitle_language?: string;
|
||||
subtitle_mode?: string;
|
||||
show_forced_subtitles?: boolean;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// Progress entry from GET /progress
|
||||
export interface ProgressEntry {
|
||||
media_item_id: string;
|
||||
|
||||
@@ -14,9 +14,13 @@ import { Button } from "@/components/ui/button";
|
||||
import { RegistrySettingControl } from "@/components/settings/RegistrySettingControl";
|
||||
import {
|
||||
ALL_DEVICE_SETTING_KEYS,
|
||||
controlKindFor,
|
||||
defaultValueToString,
|
||||
formatSettingValue,
|
||||
getSettingDefinition,
|
||||
} from "@/lib/settingsManifest";
|
||||
isStructuredSetting,
|
||||
} from "@/lib/settingsDisplay";
|
||||
import { SETTING_KEYS } from "@/lib/settingsContract";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AdminDeviceSetting } from "@/hooks/queries/admin/users";
|
||||
import { formatDate } from "@/lib/datetime";
|
||||
@@ -250,14 +254,15 @@ export function getSettingAnomaly(
|
||||
// the [min,max] range so each slider's threshold scales with its own
|
||||
// sensitivity rather than picking arbitrary unit thresholds per key.
|
||||
if (
|
||||
definition?.control === "slider" &&
|
||||
definition.min !== undefined &&
|
||||
definition.max !== undefined
|
||||
definition &&
|
||||
controlKindFor(definition) === "slider" &&
|
||||
definition.minimum !== undefined &&
|
||||
definition.maximum !== undefined
|
||||
) {
|
||||
const v = Number(setting.value);
|
||||
const def = Number(definition.defaultValue ?? 0);
|
||||
if (Number.isFinite(v) && Number.isFinite(def)) {
|
||||
const range = Math.abs(definition.max - definition.min);
|
||||
const range = Math.abs(definition.maximum - definition.minimum);
|
||||
const distance = Math.abs(v - def);
|
||||
if (range > 0 && distance / range >= 0.4) {
|
||||
const unit = definition.unit ? ` ${definition.unit}` : "";
|
||||
@@ -315,7 +320,9 @@ export function DeviceOverrideRow({
|
||||
onReset,
|
||||
}: DeviceOverrideRowProps) {
|
||||
const definition = getSettingDefinition(setting.key);
|
||||
const isJsonOnly = !definition || definition.control === "json";
|
||||
// Object-valued settings have no inline widget: they open a bespoke panel
|
||||
// (subtitle appearance) or the raw JSON editor.
|
||||
const isJsonOnly = isStructuredSetting(definition);
|
||||
|
||||
const anomalyBadgeLabel: Record<SettingAnomalyKind, string> = {
|
||||
extreme: "extreme",
|
||||
@@ -374,10 +381,10 @@ export function DeviceOverrideRow({
|
||||
<span className="text-foreground/80">
|
||||
{formatSettingValue(setting.key, setting.value)}
|
||||
</span>
|
||||
{!isJsonOnly && definition?.defaultValue !== undefined && (
|
||||
{!isJsonOnly && definition && (
|
||||
<span className="text-muted-foreground/60">
|
||||
{" "}
|
||||
· default {formatSettingValue(setting.key, definition.defaultValue)}
|
||||
· default {formatSettingValue(setting.key, defaultValueToString(definition))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -390,9 +397,9 @@ export function DeviceOverrideRow({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-stretch gap-2 md:min-w-[220px] md:items-end">
|
||||
{isJsonOnly ? (
|
||||
{isJsonOnly || !definition ? (
|
||||
<Button variant="outline" size="sm" onClick={() => onEditJson(setting, isOverride)}>
|
||||
{setting.key === "subtitle_appearance" ? "Customize" : "Edit JSON"}
|
||||
{setting.key === SETTING_KEYS.PLAYBACK_SUBTITLE_APPEARANCE ? "Customize" : "Edit JSON"}
|
||||
</Button>
|
||||
) : (
|
||||
<RegistrySettingControl
|
||||
@@ -500,8 +507,8 @@ function buildRenderedRows(
|
||||
device_id: device.deviceId,
|
||||
device_name: device.deviceName,
|
||||
device_platform: device.devicePlatform,
|
||||
key,
|
||||
value: definition?.defaultValue ?? "",
|
||||
key: key as string,
|
||||
value: definition ? defaultValueToString(definition) : "",
|
||||
updated_at: "",
|
||||
},
|
||||
};
|
||||
@@ -511,7 +518,7 @@ function buildRenderedRows(
|
||||
// Render in canonical manifest order so the layout is stable as overrides
|
||||
// are added or removed (rather than "overrides bubble to top, defaults
|
||||
// sink"). Stable order > recency for an admin browsing fields.
|
||||
const order = new Map(ALL_DEVICE_SETTING_KEYS.map((key, idx) => [key, idx]));
|
||||
const order = new Map<string, number>(ALL_DEVICE_SETTING_KEYS.map((key, idx) => [key, idx]));
|
||||
const all = [...overridden, ...synthetics];
|
||||
all.sort(
|
||||
(a, b) =>
|
||||
@@ -675,7 +682,9 @@ export function DeviceProfileTabs({
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResetProfile(active.settings[0]?.profile_id ?? "", active.profileName)}
|
||||
// The tab's own id, not the first row's: a profile registered on the
|
||||
// device with no override yet has no row to read it from.
|
||||
onClick={() => onResetProfile(active.profileId, active.profileName)}
|
||||
disabled={resetPending || overrideCount === 0}
|
||||
className="text-muted-foreground hover:text-foreground disabled:hover:text-muted-foreground inline-flex items-center gap-1 text-[11.5px] transition-colors disabled:opacity-40"
|
||||
>
|
||||
|
||||
@@ -21,7 +21,9 @@ import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useOnboardingProgress } from "@/hooks/queries/onboarding";
|
||||
import { useUpdateProfile } from "@/hooks/queries/profiles";
|
||||
import { useSetSetting } from "@/hooks/queries/settings";
|
||||
import { settingValueFromString } from "@/hooks/queries/admin/users";
|
||||
import { useSetSettingValue, type SettingIdentity } from "@/hooks/queries/settingValues";
|
||||
import type { SettingKey } from "@/lib/settingsContract";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -265,7 +267,7 @@ function SettingControl({ step }: { step: OnboardingStep }) {
|
||||
const spec = step.setting!;
|
||||
const { profile, selectProfile } = useAuth();
|
||||
const updateProfile = useUpdateProfile();
|
||||
const setSetting = useSetSetting();
|
||||
const setSettingValue = useSetSettingValue();
|
||||
|
||||
const currentValue =
|
||||
spec.target === "profile_field" && profile
|
||||
@@ -285,9 +287,15 @@ function SettingControl({ step }: { step: OnboardingStep }) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// "setting" and "device_setting" share the user-settings mutation shape;
|
||||
// device scope only differs in endpoint, handled by the settings hook.
|
||||
setSetting.mutate({ key: spec.key, value: next });
|
||||
// "setting" and "device_setting" write the same canonical mutation; they
|
||||
// differ only in the scope the value is stored at.
|
||||
const identity: SettingIdentity =
|
||||
spec.target === "device_setting" ? { scope: "profile_device" } : { scope: "profile" };
|
||||
setSettingValue.mutate({
|
||||
key: spec.key as SettingKey,
|
||||
value: settingValueFromString(spec.key, next),
|
||||
identity,
|
||||
});
|
||||
}
|
||||
|
||||
if (spec.control === "segmented") {
|
||||
|
||||
@@ -7,24 +7,43 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { SettingDefinition } from "@/lib/settingsManifest";
|
||||
import { controlKindFor, optionsFor, type SettingDisplay } from "@/lib/settingsDisplay";
|
||||
|
||||
const EMPTY_SELECT_VALUE = "__empty__";
|
||||
|
||||
interface RegistrySettingControlProps {
|
||||
definition: SettingDefinition;
|
||||
/**
|
||||
* The generated definition this control edits, as
|
||||
* {@link SettingDisplay} resolves it from SETTING_DEFINITIONS.
|
||||
*/
|
||||
definition: SettingDisplay;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one admin-facing setting control from the generated contract.
|
||||
*
|
||||
* The control shape, bounds, and option list all come from the manifest, so a
|
||||
* definition the server knows and this build does not is impossible to edit
|
||||
* with the wrong widget — the hand-written registry this used to read could
|
||||
* (and did) disagree with the server about a setting's type and range.
|
||||
*
|
||||
* Values are strings here on purpose: the admin surface edits the string form
|
||||
* and re-types on save through the contract (see
|
||||
* hooks/queries/admin/users.ts), which keeps this component free of the typed
|
||||
* JSON round trip.
|
||||
*/
|
||||
export function RegistrySettingControl({
|
||||
definition,
|
||||
value,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: RegistrySettingControlProps) {
|
||||
if (definition.control === "switch") {
|
||||
const control = controlKindFor(definition);
|
||||
|
||||
if (control === "switch") {
|
||||
return (
|
||||
<Switch
|
||||
checked={value === "true"}
|
||||
@@ -34,14 +53,16 @@ export function RegistrySettingControl({
|
||||
);
|
||||
}
|
||||
|
||||
if (definition.control === "slider") {
|
||||
const numericValue = Number(value || definition.defaultValue || 0);
|
||||
if (control === "slider" || control === "stepper") {
|
||||
const fallback = Number(definition.defaultValue ?? 0);
|
||||
const parsed = Number(value);
|
||||
const numericValue = Number.isFinite(parsed) && value !== "" ? parsed : fallback;
|
||||
return (
|
||||
<div className="flex w-full max-w-[260px] items-center gap-3">
|
||||
<Slider
|
||||
value={[numericValue]}
|
||||
min={definition.min}
|
||||
max={definition.max}
|
||||
min={definition.minimum}
|
||||
max={definition.maximum}
|
||||
step={definition.step}
|
||||
disabled={disabled}
|
||||
onValueCommit={(values) => onChange(String(values[0] ?? numericValue))}
|
||||
@@ -64,7 +85,7 @@ export function RegistrySettingControl({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(definition.options ?? []).map((option) => (
|
||||
{optionsFor(definition).map((option) => (
|
||||
<SelectItem
|
||||
key={option.value || EMPTY_SELECT_VALUE}
|
||||
value={option.value === "" ? EMPTY_SELECT_VALUE : option.value}
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { appearanceCache, storage } from "@/utils/storage";
|
||||
import { DEFAULT_THEME } from "@/lib/themes";
|
||||
import { SETTING_KEYS } from "@/lib/settingsContract";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useOptionalAuth: vi.fn(),
|
||||
useEffectiveSettings: vi.fn(),
|
||||
useBranding: vi.fn(),
|
||||
mutate: vi.fn(),
|
||||
clearMutate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useAuth", () => ({
|
||||
useOptionalAuth: () => mocks.useOptionalAuth(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/queries/settingValues", () => ({
|
||||
useEffectiveSettings: (options?: { keys?: readonly string[]; enabled?: boolean }) =>
|
||||
mocks.useEffectiveSettings(options),
|
||||
useSetSettingValue: () => ({ mutate: mocks.mutate, mutateAsync: mocks.mutate, isPending: false }),
|
||||
useClearSettingValue: () => ({
|
||||
mutate: mocks.clearMutate,
|
||||
mutateAsync: mocks.clearMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useBranding", () => ({
|
||||
useBranding: () => mocks.useBranding(),
|
||||
}));
|
||||
|
||||
import { ThemeProvider, useTheme } from "./useTheme";
|
||||
import { useCustomTheme } from "./useCustomTheme";
|
||||
|
||||
const KEYS = storage.KEYS;
|
||||
|
||||
interface Captured {
|
||||
theme: ReturnType<typeof useTheme>;
|
||||
custom: ReturnType<typeof useCustomTheme>;
|
||||
}
|
||||
|
||||
function Probe({ onRender }: { onRender: (captured: Captured) => void }) {
|
||||
const theme = useTheme();
|
||||
const custom = useCustomTheme();
|
||||
onRender({ theme, custom });
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the appearance providers and keeps returning the latest captured
|
||||
* values, so a test can change the signed-in identity and re-render the same
|
||||
* tree — the account or profile switch a running SPA actually performs.
|
||||
*/
|
||||
function renderAppearance() {
|
||||
const latest: { current: Captured | null } = { current: null };
|
||||
// A fresh element each time: re-rendering the identical element reference
|
||||
// lets React bail out, which would silently skip the identity change.
|
||||
const tree = () => (
|
||||
<ThemeProvider>
|
||||
<Probe
|
||||
onRender={(next) => {
|
||||
latest.current = next;
|
||||
}}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
const { rerender } = render(tree());
|
||||
if (!latest.current) throw new Error("probe never rendered");
|
||||
return {
|
||||
get captured(): Captured {
|
||||
if (!latest.current) throw new Error("probe never rendered");
|
||||
return latest.current;
|
||||
},
|
||||
rerender: () => rerender(tree()),
|
||||
};
|
||||
}
|
||||
|
||||
/** Everything the given identity (`user:profile`) left behind on this browser. */
|
||||
function seedAppearance(owner: string): void {
|
||||
appearanceCache.set(KEYS.THEME, "cobalt-studio", owner);
|
||||
appearanceCache.set(KEYS.UI_TEXT_SCALE, "large", owner);
|
||||
appearanceCache.set(KEYS.UI_TEXT_WEIGHT, "strong", owner);
|
||||
appearanceCache.set(KEYS.UI_HIGH_CONTRAST, "true", owner);
|
||||
appearanceCache.set(KEYS.UI_CUSTOM_THEME_VARS, JSON.stringify({ "color-bg": "#ff0000" }), owner);
|
||||
appearanceCache.set(KEYS.UI_CUSTOM_CSS, "body { filter: invert(1); }", owner);
|
||||
}
|
||||
|
||||
/** Everything account 1's first profile left behind on this browser. */
|
||||
function seedAccountOneAppearance(): void {
|
||||
seedAppearance("1:p1");
|
||||
}
|
||||
|
||||
function signedInAs(id: number, profileId = "p1"): void {
|
||||
mocks.useOptionalAuth.mockReturnValue({
|
||||
loading: false,
|
||||
user: { id },
|
||||
profile: { id: profileId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a canonical effective-settings answer: each entry carries the scope it
|
||||
* resolved from, and `source: "default"` marks a value nobody stored.
|
||||
*/
|
||||
function effectiveAnswer(values: Record<string, { value: unknown; source?: string }>): {
|
||||
data: Record<string, { key: string; value: unknown; source: string }>;
|
||||
} {
|
||||
const data: Record<string, { key: string; value: unknown; source: string }> = {};
|
||||
for (const [key, entry] of Object.entries(values)) {
|
||||
data[key] = { key, value: entry.value, source: entry.source ?? "profile" };
|
||||
}
|
||||
return { data };
|
||||
}
|
||||
|
||||
describe("appearance cache ownership", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mocks.useEffectiveSettings.mockReturnValue({ data: {} });
|
||||
mocks.useBranding.mockReturnValue({ defaultTheme: null });
|
||||
});
|
||||
|
||||
it("does not apply another account's cached appearance", () => {
|
||||
seedAccountOneAppearance();
|
||||
signedInAs(2);
|
||||
|
||||
const { captured } = renderAppearance();
|
||||
|
||||
expect(captured.theme.theme).toBe(DEFAULT_THEME);
|
||||
expect(captured.theme.textScale).toBe("default");
|
||||
expect(captured.theme.textWeight).toBe("default");
|
||||
expect(captured.theme.highContrast).toBe(false);
|
||||
expect(captured.custom.vars).toEqual({});
|
||||
expect(captured.custom.customCss).toBe("");
|
||||
});
|
||||
|
||||
it("does not apply a sibling profile's cached appearance", () => {
|
||||
seedAccountOneAppearance();
|
||||
signedInAs(1, "p2");
|
||||
|
||||
const { captured } = renderAppearance();
|
||||
|
||||
expect(captured.theme.theme).toBe(DEFAULT_THEME);
|
||||
expect(captured.theme.textScale).toBe("default");
|
||||
expect(captured.theme.textWeight).toBe("default");
|
||||
expect(captured.theme.highContrast).toBe(false);
|
||||
expect(captured.custom.vars).toEqual({});
|
||||
expect(captured.custom.customCss).toBe("");
|
||||
});
|
||||
|
||||
it("leaves the other identity's values intact instead of deleting them", () => {
|
||||
seedAccountOneAppearance();
|
||||
signedInAs(2);
|
||||
|
||||
renderAppearance();
|
||||
|
||||
// Profile 1:p1 signing back in must still get their warm start; the
|
||||
// previous design cleared these keys, which cost them a default-theme
|
||||
// flash on every cold start from then on.
|
||||
expect(appearanceCache.get(KEYS.THEME, "1:p1")).toBe("cobalt-studio");
|
||||
expect(appearanceCache.get(KEYS.UI_TEXT_SCALE, "1:p1")).toBe("large");
|
||||
expect(appearanceCache.get(KEYS.UI_CUSTOM_CSS, "1:p1")).toBe("body { filter: invert(1); }");
|
||||
});
|
||||
|
||||
it("still applies the admin default theme to an identity with no cached appearance", () => {
|
||||
seedAccountOneAppearance();
|
||||
signedInAs(2);
|
||||
mocks.useBranding.mockReturnValue({ defaultTheme: "evergreen-studio" });
|
||||
|
||||
const { captured } = renderAppearance();
|
||||
|
||||
expect(captured.theme.theme).toBe("evergreen-studio");
|
||||
});
|
||||
|
||||
it("keeps the warm start for the profile that stored it", () => {
|
||||
seedAccountOneAppearance();
|
||||
signedInAs(1);
|
||||
|
||||
const { captured } = renderAppearance();
|
||||
|
||||
expect(captured.theme.theme).toBe("cobalt-studio");
|
||||
expect(captured.theme.textScale).toBe("large");
|
||||
expect(captured.theme.textWeight).toBe("strong");
|
||||
expect(captured.theme.highContrast).toBe(true);
|
||||
expect(captured.custom.vars).toEqual({ "color-bg": "#ff0000" });
|
||||
expect(captured.custom.customCss).toBe("body { filter: invert(1); }");
|
||||
});
|
||||
|
||||
it("keeps the warm start while auth is still bootstrapping", () => {
|
||||
seedAccountOneAppearance();
|
||||
mocks.useOptionalAuth.mockReturnValue({ loading: true, user: null, profile: null });
|
||||
|
||||
const { captured } = renderAppearance();
|
||||
|
||||
expect(captured.theme.theme).toBe("cobalt-studio");
|
||||
expect(captured.theme.textScale).toBe("large");
|
||||
expect(captured.custom.customCss).toBe("body { filter: invert(1); }");
|
||||
});
|
||||
|
||||
it("keeps the warm start on the profile picker, before a profile is chosen", () => {
|
||||
seedAccountOneAppearance();
|
||||
mocks.useOptionalAuth.mockReturnValue({ loading: false, user: { id: 1 }, profile: null });
|
||||
|
||||
const { captured } = renderAppearance();
|
||||
|
||||
expect(captured.theme.theme).toBe("cobalt-studio");
|
||||
expect(captured.theme.textScale).toBe("large");
|
||||
expect(captured.custom.customCss).toBe("body { filter: invert(1); }");
|
||||
});
|
||||
|
||||
it("ignores a legacy cache written before namespacing existed", () => {
|
||||
storage.set(KEYS.THEME, "cobalt-studio");
|
||||
storage.set(KEYS.UI_TEXT_SCALE, "large");
|
||||
storage.set(KEYS.UI_CUSTOM_CSS, "body { filter: invert(1); }");
|
||||
signedInAs(2);
|
||||
|
||||
const { captured } = renderAppearance();
|
||||
|
||||
expect(captured.theme.theme).toBe(DEFAULT_THEME);
|
||||
expect(captured.theme.textScale).toBe("default");
|
||||
expect(captured.custom.customCss).toBe("");
|
||||
});
|
||||
|
||||
it("lets the signed-in profile's own server values win over an empty local cache", () => {
|
||||
seedAccountOneAppearance();
|
||||
signedInAs(2);
|
||||
mocks.useEffectiveSettings.mockReturnValue(
|
||||
effectiveAnswer({
|
||||
[SETTING_KEYS.UI_THEME]: { value: "oxblood-noir" },
|
||||
[SETTING_KEYS.UI_TEXT_SCALE]: { value: "x-large" },
|
||||
[SETTING_KEYS.UI_CUSTOM_CSS]: { value: "body { color: blue; }" },
|
||||
}),
|
||||
);
|
||||
|
||||
const { captured } = renderAppearance();
|
||||
|
||||
expect(captured.theme.theme).toBe("oxblood-noir");
|
||||
expect(captured.theme.textScale).toBe("x-large");
|
||||
expect(captured.custom.customCss).toBe("body { color: blue; }");
|
||||
});
|
||||
|
||||
it("keeps applying the server's theme once the mirror has written it back", () => {
|
||||
signedInAs(2);
|
||||
mocks.useEffectiveSettings.mockReturnValue(
|
||||
effectiveAnswer({
|
||||
[SETTING_KEYS.UI_THEME]: { value: "oxblood-noir" },
|
||||
[SETTING_KEYS.UI_TEXT_SCALE]: { value: "x-large" },
|
||||
}),
|
||||
);
|
||||
|
||||
const view = renderAppearance();
|
||||
expect(view.captured.theme.theme).toBe("oxblood-noir");
|
||||
|
||||
// The mirror effect writes the server's theme into the same namespace the
|
||||
// resolver reads. A resolver that compared the two would see them agree
|
||||
// here and fall back to the default from the second render on, so this
|
||||
// re-renders rather than trusting the first paint.
|
||||
act(() => {
|
||||
view.rerender();
|
||||
});
|
||||
act(() => {
|
||||
view.rerender();
|
||||
});
|
||||
|
||||
expect(view.captured.theme.theme).toBe("oxblood-noir");
|
||||
expect(view.captured.theme.textScale).toBe("x-large");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("oxblood-noir");
|
||||
});
|
||||
|
||||
it("mirrors the server's appearance so the next cold start paints it", () => {
|
||||
signedInAs(2);
|
||||
mocks.useEffectiveSettings.mockReturnValue(
|
||||
effectiveAnswer({
|
||||
[SETTING_KEYS.UI_THEME]: { value: "oxblood-noir" },
|
||||
[SETTING_KEYS.UI_TEXT_SCALE]: { value: "x-large" },
|
||||
[SETTING_KEYS.UI_TEXT_WEIGHT]: { value: "strong" },
|
||||
[SETTING_KEYS.UI_HIGH_CONTRAST]: { value: true },
|
||||
}),
|
||||
);
|
||||
|
||||
renderAppearance();
|
||||
|
||||
// Without this the cache only ever held choices made on this device, so a
|
||||
// user who picked their theme elsewhere flashed the default on every load.
|
||||
expect(appearanceCache.get(KEYS.THEME, "2:p1")).toBe("oxblood-noir");
|
||||
expect(appearanceCache.get(KEYS.UI_TEXT_SCALE, "2:p1")).toBe("x-large");
|
||||
expect(appearanceCache.get(KEYS.UI_TEXT_WEIGHT, "2:p1")).toBe("strong");
|
||||
expect(appearanceCache.get(KEYS.UI_HIGH_CONTRAST, "2:p1")).toBe("true");
|
||||
});
|
||||
|
||||
it("does not mirror a theme the user never chose, so the admin default still moves", () => {
|
||||
signedInAs(2);
|
||||
mocks.useBranding.mockReturnValue({ defaultTheme: "evergreen-studio" });
|
||||
|
||||
renderAppearance();
|
||||
|
||||
expect(appearanceCache.get(KEYS.THEME, "2:p1")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not treat a resolved contract default as the profile's own choice", () => {
|
||||
signedInAs(2);
|
||||
mocks.useBranding.mockReturnValue({ defaultTheme: "evergreen-studio" });
|
||||
// The canonical effective endpoint always answers, resolving unset keys to
|
||||
// the contract default. That answer must not shadow the admin default nor
|
||||
// be mirrored as if the profile had chosen it.
|
||||
mocks.useEffectiveSettings.mockReturnValue(
|
||||
effectiveAnswer({
|
||||
[SETTING_KEYS.UI_THEME]: { value: "midnight-cinema", source: "default" },
|
||||
[SETTING_KEYS.UI_TEXT_SCALE]: { value: "default", source: "default" },
|
||||
[SETTING_KEYS.UI_TEXT_WEIGHT]: { value: "default", source: "default" },
|
||||
[SETTING_KEYS.UI_HIGH_CONTRAST]: { value: false, source: "default" },
|
||||
}),
|
||||
);
|
||||
|
||||
const { captured } = renderAppearance();
|
||||
|
||||
expect(captured.theme.theme).toBe("evergreen-studio");
|
||||
expect(appearanceCache.get(KEYS.THEME, "2:p1")).toBeNull();
|
||||
expect(appearanceCache.get(KEYS.UI_TEXT_SCALE, "2:p1")).toBeNull();
|
||||
});
|
||||
|
||||
it("stops painting the previous account when the signed-in account changes", () => {
|
||||
seedAccountOneAppearance();
|
||||
signedInAs(1);
|
||||
|
||||
const view = renderAppearance();
|
||||
expect(view.captured.theme.theme).toBe("cobalt-studio");
|
||||
|
||||
act(() => {
|
||||
signedInAs(2);
|
||||
view.rerender();
|
||||
});
|
||||
|
||||
expect(view.captured.theme.theme).toBe(DEFAULT_THEME);
|
||||
expect(view.captured.theme.textScale).toBe("default");
|
||||
expect(view.captured.theme.highContrast).toBe(false);
|
||||
expect(view.captured.custom.vars).toEqual({});
|
||||
expect(view.captured.custom.customCss).toBe("");
|
||||
});
|
||||
|
||||
it("stops painting the previous profile when switching profiles on one account", () => {
|
||||
seedAccountOneAppearance();
|
||||
signedInAs(1, "p1");
|
||||
|
||||
const view = renderAppearance();
|
||||
expect(view.captured.theme.theme).toBe("cobalt-studio");
|
||||
|
||||
act(() => {
|
||||
signedInAs(1, "p2");
|
||||
view.rerender();
|
||||
});
|
||||
|
||||
expect(view.captured.theme.theme).toBe(DEFAULT_THEME);
|
||||
expect(view.captured.theme.textScale).toBe("default");
|
||||
expect(view.captured.theme.textWeight).toBe("default");
|
||||
expect(view.captured.theme.highContrast).toBe(false);
|
||||
expect(view.captured.custom.vars).toEqual({});
|
||||
expect(view.captured.custom.customCss).toBe("");
|
||||
// The sibling's warm start is untouched, and nothing leaked into p2's.
|
||||
expect(appearanceCache.get(KEYS.THEME, "1:p1")).toBe("cobalt-studio");
|
||||
expect(appearanceCache.get(KEYS.THEME, "1:p2")).toBeNull();
|
||||
expect(appearanceCache.get(KEYS.UI_CUSTOM_CSS, "1:p2")).toBeNull();
|
||||
});
|
||||
|
||||
it("restores the first account's look when they sign back in", () => {
|
||||
seedAccountOneAppearance();
|
||||
signedInAs(2);
|
||||
|
||||
const view = renderAppearance();
|
||||
expect(view.captured.theme.theme).toBe(DEFAULT_THEME);
|
||||
|
||||
act(() => {
|
||||
signedInAs(1);
|
||||
view.rerender();
|
||||
});
|
||||
|
||||
expect(view.captured.theme.theme).toBe("cobalt-studio");
|
||||
expect(view.captured.theme.textScale).toBe("large");
|
||||
expect(view.captured.custom.customCss).toBe("body { filter: invert(1); }");
|
||||
});
|
||||
|
||||
it("restores a profile's look when switching back to it", () => {
|
||||
seedAccountOneAppearance();
|
||||
signedInAs(1, "p2");
|
||||
|
||||
const view = renderAppearance();
|
||||
expect(view.captured.theme.theme).toBe(DEFAULT_THEME);
|
||||
|
||||
act(() => {
|
||||
signedInAs(1, "p1");
|
||||
view.rerender();
|
||||
});
|
||||
|
||||
expect(view.captured.theme.theme).toBe("cobalt-studio");
|
||||
expect(view.captured.theme.textScale).toBe("large");
|
||||
expect(view.captured.theme.highContrast).toBe(true);
|
||||
expect(view.captured.custom.vars).toEqual({ "color-bg": "#ff0000" });
|
||||
expect(view.captured.custom.customCss).toBe("body { filter: invert(1); }");
|
||||
});
|
||||
|
||||
it("clears the warm start when the server says the setting is unset", () => {
|
||||
// Another client deleted this profile's appearance choices. The effective
|
||||
// response still answers for every key, now resolving to the contract
|
||||
// default — the removal has to reach this browser, or the cached value
|
||||
// keeps painting a preference the server no longer holds.
|
||||
seedAccountOneAppearance();
|
||||
signedInAs(1, "p1");
|
||||
mocks.useEffectiveSettings.mockReturnValue(
|
||||
effectiveAnswer({
|
||||
[SETTING_KEYS.UI_THEME]: { value: DEFAULT_THEME, source: "default" },
|
||||
[SETTING_KEYS.UI_TEXT_SCALE]: { value: "default", source: "default" },
|
||||
[SETTING_KEYS.UI_TEXT_WEIGHT]: { value: "default", source: "default" },
|
||||
[SETTING_KEYS.UI_HIGH_CONTRAST]: { value: false, source: "default" },
|
||||
}),
|
||||
);
|
||||
|
||||
const view = renderAppearance();
|
||||
|
||||
expect(view.captured.theme.theme).toBe(DEFAULT_THEME);
|
||||
expect(view.captured.theme.textScale).toBe("default");
|
||||
expect(view.captured.theme.textWeight).toBe("default");
|
||||
expect(view.captured.theme.highContrast).toBe(false);
|
||||
for (const key of [
|
||||
KEYS.THEME,
|
||||
KEYS.UI_TEXT_SCALE,
|
||||
KEYS.UI_TEXT_WEIGHT,
|
||||
KEYS.UI_HIGH_CONTRAST,
|
||||
]) {
|
||||
expect(appearanceCache.get(key, "1:p1")).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("clears only the current identity's warm start on an unset answer", () => {
|
||||
// The sibling's namespace is not ours to clear: the server answered about
|
||||
// this profile, and the other identity's warm start must survive for when
|
||||
// they sign back in.
|
||||
seedAppearance("1:p1");
|
||||
seedAppearance("2:p9");
|
||||
signedInAs(1, "p1");
|
||||
mocks.useEffectiveSettings.mockReturnValue(
|
||||
effectiveAnswer({
|
||||
[SETTING_KEYS.UI_THEME]: { value: DEFAULT_THEME, source: "default" },
|
||||
}),
|
||||
);
|
||||
|
||||
renderAppearance();
|
||||
|
||||
expect(appearanceCache.get(KEYS.THEME, "1:p1")).toBeNull();
|
||||
expect(appearanceCache.get(KEYS.THEME, "2:p9")).toBe("cobalt-studio");
|
||||
});
|
||||
|
||||
it("does not leak one profile's mirrored server values to a sibling profile", () => {
|
||||
signedInAs(1, "p1");
|
||||
mocks.useEffectiveSettings.mockReturnValue(
|
||||
effectiveAnswer({ [SETTING_KEYS.UI_THEME]: { value: "oxblood-noir" } }),
|
||||
);
|
||||
|
||||
const view = renderAppearance();
|
||||
expect(view.captured.theme.theme).toBe("oxblood-noir");
|
||||
|
||||
// p2 has no stored settings of their own; the server resolves defaults.
|
||||
act(() => {
|
||||
signedInAs(1, "p2");
|
||||
mocks.useEffectiveSettings.mockReturnValue({ data: {} });
|
||||
view.rerender();
|
||||
});
|
||||
|
||||
expect(view.captured.theme.theme).toBe(DEFAULT_THEME);
|
||||
expect(appearanceCache.get(KEYS.THEME, "1:p1")).toBe("oxblood-noir");
|
||||
expect(appearanceCache.get(KEYS.THEME, "1:p2")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom theme debounced writes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mocks.useEffectiveSettings.mockReturnValue({ data: {} });
|
||||
mocks.useBranding.mockReturnValue({ defaultTheme: null });
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("drops a pending write when the account changes mid-debounce", () => {
|
||||
signedInAs(1);
|
||||
const view = renderAppearance();
|
||||
|
||||
act(() => {
|
||||
view.captured.custom.setCustomCss("body { filter: invert(1); }");
|
||||
});
|
||||
|
||||
// Account 1 signs out and account 2 signs in inside the 1s debounce.
|
||||
act(() => {
|
||||
signedInAs(2);
|
||||
view.rerender();
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
// The timer captured account 1's owner and account 2's live session. It
|
||||
// must not store account 1's CSS against account 2.
|
||||
expect(mocks.mutate).not.toHaveBeenCalled();
|
||||
expect(appearanceCache.get(KEYS.UI_CUSTOM_CSS, "2:p1")).toBeNull();
|
||||
expect(view.captured.custom.customCss).toBe("");
|
||||
});
|
||||
|
||||
it("drops a pending write when the profile changes mid-debounce", () => {
|
||||
signedInAs(1, "p1");
|
||||
const view = renderAppearance();
|
||||
|
||||
act(() => {
|
||||
view.captured.custom.setCustomCss("body { filter: invert(1); }");
|
||||
});
|
||||
|
||||
// The household switches from p1 to p2 inside the 1s debounce. The timer
|
||||
// captured p1's draft; firing now would store p1's CSS as p2's preference
|
||||
// at profile scope.
|
||||
act(() => {
|
||||
signedInAs(1, "p2");
|
||||
view.rerender();
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(mocks.mutate).not.toHaveBeenCalled();
|
||||
expect(appearanceCache.get(KEYS.UI_CUSTOM_CSS, "1:p2")).toBeNull();
|
||||
expect(view.captured.custom.customCss).toBe("");
|
||||
});
|
||||
|
||||
it("still persists a write that is not interrupted", () => {
|
||||
signedInAs(1);
|
||||
const view = renderAppearance();
|
||||
|
||||
act(() => {
|
||||
view.captured.custom.setCustomCss("body { color: red; }");
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(mocks.mutate).toHaveBeenCalledWith({
|
||||
key: SETTING_KEYS.UI_CUSTOM_CSS,
|
||||
value: "body { color: red; }",
|
||||
identity: { scope: "profile" },
|
||||
});
|
||||
expect(appearanceCache.get(KEYS.UI_CUSTOM_CSS, "1:p1")).toBe("body { color: red; }");
|
||||
});
|
||||
});
|
||||
@@ -45,8 +45,14 @@ describe("downloadDiagnosticReport", () => {
|
||||
const click = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, "click")
|
||||
.mockImplementation(() => undefined);
|
||||
// A string body, not a Blob: `new Response(blob)` reads the body via
|
||||
// `blob.stream()`, which jsdom's Blob does not implement on Node 22 — the
|
||||
// version the Dockerfile builds with — so the Blob form passes locally on
|
||||
// Node 24 and throws "object.stream is not a function" in CI. Nothing here
|
||||
// asserts on the body's contents, only that the object URL and filename
|
||||
// reach the anchor, so the string is equivalent and version-independent.
|
||||
mocks.apiResponse.mockResolvedValue(
|
||||
new Response(new Blob(["bundle"]), { headers: { "Content-Type": "application/gzip" } }),
|
||||
new Response("bundle", { headers: { "Content-Type": "application/gzip" } }),
|
||||
);
|
||||
|
||||
await downloadDiagnosticReport(report);
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { createElement } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { installPolicyStorageMocks, jsonResponse } from "@/pages/admin-policy/policyTestUtils";
|
||||
|
||||
import {
|
||||
useAdminDeviceOverrides,
|
||||
useAdminUserDeviceSettings,
|
||||
useAdminUserSettings,
|
||||
useDeleteAdminUserSetting,
|
||||
useDeleteAllAdminUserDeviceSettingsForDevice,
|
||||
useUpdateAdminUserDeviceSetting,
|
||||
useUpdateAdminUserSetting,
|
||||
} from "./users";
|
||||
|
||||
// These hooks moved off the removed string-registry routes
|
||||
// (/admin/users/{id}/settings, /device-settings, /profiles/{pid}/device-settings/…)
|
||||
// onto the canonical values API. Every assertion here pins the new URL shape
|
||||
// and the typed JSON bodies — against the old hooks each of these requests
|
||||
// would 404 in production, which is exactly the breakage this file guards.
|
||||
|
||||
function createWrapper() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return createElement(QueryClientProvider, { client }, children);
|
||||
};
|
||||
}
|
||||
|
||||
const valuesResponse = {
|
||||
revision: 1,
|
||||
values: [
|
||||
{
|
||||
key: "playback.subtitle_mode",
|
||||
scope: "profile",
|
||||
profile_id: "p1",
|
||||
value: "always",
|
||||
revision: 1,
|
||||
updated_at: "2026-07-28T10:00:00Z",
|
||||
},
|
||||
{
|
||||
key: "playback.auto_skip_intro",
|
||||
scope: "profile",
|
||||
profile_id: "p1",
|
||||
value: true,
|
||||
revision: 1,
|
||||
},
|
||||
{
|
||||
key: "player.audio_sync_ms",
|
||||
scope: "profile_device",
|
||||
profile_id: "p1",
|
||||
device_id: "tv-1",
|
||||
value: 250,
|
||||
revision: 1,
|
||||
updated_at: "2026-07-28T11:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("admin canonical settings hooks", () => {
|
||||
beforeEach(() => {
|
||||
installPolicyStorageMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("lists non-device values from /settings/values with stringified values", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn<typeof fetch>(async (input) => {
|
||||
expect(String(input)).toBe("/api/v1/admin/users/7/settings/values");
|
||||
return jsonResponse(valuesResponse);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAdminUserSettings(7), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(result.current.data).toEqual([
|
||||
{
|
||||
key: "playback.subtitle_mode",
|
||||
scope: "profile",
|
||||
profile_id: "p1",
|
||||
library_id: undefined,
|
||||
series_id: undefined,
|
||||
value: "always",
|
||||
updated_at: "2026-07-28T10:00:00Z",
|
||||
},
|
||||
{
|
||||
key: "playback.auto_skip_intro",
|
||||
scope: "profile",
|
||||
profile_id: "p1",
|
||||
library_id: undefined,
|
||||
series_id: undefined,
|
||||
value: "true",
|
||||
updated_at: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("derives device overrides from the same list, enriched with names", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn<typeof fetch>(async (input) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/v1/admin/users/7/settings/values") {
|
||||
return jsonResponse(valuesResponse);
|
||||
}
|
||||
if (url === "/api/v1/admin/devices") {
|
||||
return jsonResponse({
|
||||
devices: [
|
||||
{
|
||||
user_id: 7,
|
||||
device_id: "tv-1",
|
||||
device_name: "Living Room TV",
|
||||
device_platform: "tvos",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url === "/api/v1/admin/users/7/profiles") {
|
||||
return jsonResponse([{ id: "p1", name: "Laura" }]);
|
||||
}
|
||||
throw new Error(`unexpected request: ${url}`);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAdminUserDeviceSettings(7), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() => expect(result.current.data.length).toBe(1));
|
||||
|
||||
expect(result.current.data).toEqual([
|
||||
{
|
||||
user_id: 7,
|
||||
profile_id: "p1",
|
||||
profile_name: "Laura",
|
||||
device_id: "tv-1",
|
||||
device_name: "Living Room TV",
|
||||
device_platform: "tvos",
|
||||
key: "player.audio_sync_ms",
|
||||
value: "250",
|
||||
updated_at: "2026-07-28T11:00:00Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("scopes the device panel's overrides to one device, from the canonical list", async () => {
|
||||
// The device detail endpoint reports the legacy user_device_settings
|
||||
// table, which nothing canonical writes to; the panel has to read the
|
||||
// values API or an override written since the cutover never appears.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn<typeof fetch>(async (input) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/v1/admin/users/7/settings/values") {
|
||||
return jsonResponse({
|
||||
revision: 1,
|
||||
values: [
|
||||
...valuesResponse.values,
|
||||
{
|
||||
key: "player.hdr_enabled",
|
||||
scope: "profile_device",
|
||||
profile_id: "p1",
|
||||
device_id: "phone-9",
|
||||
value: false,
|
||||
revision: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url === "/api/v1/admin/devices") {
|
||||
return jsonResponse({ devices: [] });
|
||||
}
|
||||
if (url === "/api/v1/admin/users/7/profiles") {
|
||||
return jsonResponse([{ id: "p1", name: "Laura" }]);
|
||||
}
|
||||
throw new Error(`unexpected request: ${url}`);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAdminDeviceOverrides(7, "tv-1"), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() => expect(result.current.data.length).toBe(1));
|
||||
|
||||
// Only tv-1's row, and never a non-device-scoped row from the same list.
|
||||
expect(result.current.data.map((setting) => setting.key)).toEqual(["player.audio_sync_ms"]);
|
||||
expect(result.current.data[0]?.device_id).toBe("tv-1");
|
||||
});
|
||||
|
||||
it("writes a user setting to the canonical route with a typed value", async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
|
||||
const url = String(input);
|
||||
if (init?.method === "PUT") {
|
||||
expect(url).toBe(
|
||||
"/api/v1/admin/users/7/settings/values/playback.auto_skip_intro?scope=profile&profile_id=p1",
|
||||
);
|
||||
// Booleans travel typed, not as the "true" string the old registry stored.
|
||||
expect(JSON.parse(String(init.body))).toEqual({ value: true });
|
||||
return jsonResponse({ key: "playback.auto_skip_intro", scope: "profile", value: true });
|
||||
}
|
||||
return jsonResponse(valuesResponse);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { result } = renderHook(() => useUpdateAdminUserSetting(), { wrapper: createWrapper() });
|
||||
result.current.mutate({
|
||||
userId: 7,
|
||||
key: "playback.auto_skip_intro",
|
||||
identity: { scope: "profile", profileId: "p1" },
|
||||
value: "true",
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
});
|
||||
|
||||
it("deletes a user setting at its exact scope", async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
|
||||
expect(init?.method).toBe("DELETE");
|
||||
expect(String(input)).toBe(
|
||||
"/api/v1/admin/users/7/settings/values/playback.subtitle_mode?scope=profile&profile_id=p1",
|
||||
);
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { result } = renderHook(() => useDeleteAdminUserSetting(), { wrapper: createWrapper() });
|
||||
result.current.mutate({
|
||||
userId: 7,
|
||||
key: "playback.subtitle_mode",
|
||||
identity: { scope: "profile", profileId: "p1" },
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
});
|
||||
|
||||
it("writes a device override at profile_device scope with a typed number", async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
|
||||
if (init?.method === "PUT") {
|
||||
expect(String(input)).toBe(
|
||||
"/api/v1/admin/users/7/settings/values/player.audio_sync_ms?scope=profile_device&profile_id=p1&device_id=tv-1",
|
||||
);
|
||||
expect(JSON.parse(String(init.body))).toEqual({ value: 250 });
|
||||
return jsonResponse({ key: "player.audio_sync_ms", scope: "profile_device", value: 250 });
|
||||
}
|
||||
return jsonResponse(valuesResponse);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { result } = renderHook(() => useUpdateAdminUserDeviceSetting(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
result.current.mutate({
|
||||
userId: 7,
|
||||
profileId: "p1",
|
||||
deviceId: "tv-1",
|
||||
key: "player.audio_sync_ms",
|
||||
value: "250",
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
});
|
||||
|
||||
it("resets a whole device profile via per-key deletes, tolerating 404s", async () => {
|
||||
const deleted: string[] = [];
|
||||
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
|
||||
if (init?.method === "DELETE") {
|
||||
const url = String(input);
|
||||
deleted.push(url);
|
||||
// The first key's row is already gone: that is the goal state, not a
|
||||
// failure, so the loop must carry on to the second key.
|
||||
if (url.includes("player.hdr_enabled")) {
|
||||
return jsonResponse(
|
||||
{ error: "not_found", message: "No value is set at this scope" },
|
||||
404,
|
||||
);
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
return jsonResponse(valuesResponse);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { result } = renderHook(() => useDeleteAllAdminUserDeviceSettingsForDevice(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
result.current.mutate({
|
||||
userId: 7,
|
||||
profileId: "p1",
|
||||
deviceId: "tv-1",
|
||||
keys: ["player.hdr_enabled", "player.audio_sync_ms"],
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(deleted).toEqual([
|
||||
"/api/v1/admin/users/7/settings/values/player.hdr_enabled?scope=profile_device&profile_id=p1&device_id=tv-1",
|
||||
"/api/v1/admin/users/7/settings/values/player.audio_sync_ms?scope=profile_device&profile_id=p1&device_id=tv-1",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,86 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/api/client";
|
||||
import { useMemo } from "react";
|
||||
import { api, ApiClientError } from "@/api/client";
|
||||
import type {
|
||||
AdminDeviceDetail,
|
||||
AdminDeviceSummary,
|
||||
AdminSettingEntry,
|
||||
AdminUser,
|
||||
CreateUserRequest,
|
||||
LoginResponse,
|
||||
UpdateUserRequest,
|
||||
} from "@/api/types";
|
||||
import { SETTING_DEFINITIONS, type SettingKey } from "@/lib/settingsContract";
|
||||
import { adminKeys } from "../keys";
|
||||
import { useAdminUserProfiles } from "./history";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const ADMIN_STALE_TIME = 30_000;
|
||||
|
||||
interface AdminSettingsResponse {
|
||||
settings: AdminSettingEntry[];
|
||||
}
|
||||
|
||||
interface AdminDeviceSettingsResponse {
|
||||
settings: AdminDeviceSetting[];
|
||||
}
|
||||
|
||||
interface AdminDevicesResponse {
|
||||
devices: AdminDeviceSummary[];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Canonical admin settings surface.
|
||||
//
|
||||
// The string-registry routes (/admin/users/{id}/settings*,
|
||||
// /device-settings*) were removed server-side in the settings-contract
|
||||
// cutover; the replacement is the canonical values API:
|
||||
// GET /admin/users/{id}/settings/values
|
||||
// PUT /admin/users/{id}/settings/values/{key}?scope=…
|
||||
// DELETE /admin/users/{id}/settings/values/{key}?scope=…
|
||||
// Values there are typed JSON at an explicit scope. The admin controls still
|
||||
// edit strings (the registry-era shape the components speak), so these hooks
|
||||
// are the seam: display stringifies, save re-types through the generated
|
||||
// contract so requests carry what the server's validation expects.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The scopes the canonical settings API stores values at. */
|
||||
export type AdminSettingScope =
|
||||
| "account"
|
||||
| "profile"
|
||||
| "profile_device"
|
||||
| "profile_library"
|
||||
| "profile_series";
|
||||
|
||||
/** One stored row as GET /admin/users/{id}/settings/values reports it. */
|
||||
interface AdminSettingValueRow {
|
||||
key: string;
|
||||
scope: AdminSettingScope;
|
||||
profile_id?: string;
|
||||
device_id?: string;
|
||||
library_id?: number;
|
||||
series_id?: string;
|
||||
value: unknown;
|
||||
revision: number;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface AdminSettingValuesResponse {
|
||||
values: AdminSettingValueRow[] | null;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
/** Locates the exact row a mutation addresses. */
|
||||
export interface AdminSettingIdentity {
|
||||
scope: AdminSettingScope;
|
||||
profileId?: string;
|
||||
deviceId?: string;
|
||||
libraryId?: number;
|
||||
seriesId?: string;
|
||||
}
|
||||
|
||||
/** One non-device setting row, string-valued for the admin controls. */
|
||||
export interface AdminUserSettingEntry {
|
||||
key: string;
|
||||
scope: AdminSettingScope;
|
||||
profile_id?: string;
|
||||
library_id?: number;
|
||||
series_id?: string;
|
||||
value: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface AdminDeviceSetting {
|
||||
user_id: number;
|
||||
profile_id: string;
|
||||
@@ -38,11 +93,80 @@ export interface AdminDeviceSetting {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function identityQuery(identity: AdminSettingIdentity): string {
|
||||
const params = new URLSearchParams({ scope: identity.scope });
|
||||
if (identity.profileId) params.set("profile_id", identity.profileId);
|
||||
if (identity.deviceId) params.set("device_id", identity.deviceId);
|
||||
if (identity.libraryId !== undefined) params.set("library_id", String(identity.libraryId));
|
||||
if (identity.seriesId !== undefined) params.set("series_id", identity.seriesId);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
/** Renders a typed JSON value in the string form the admin controls edit. */
|
||||
export function settingValueToString(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-types an edited string through the generated contract. Throwing (inside
|
||||
* a mutationFn) surfaces as the mutation's error toast, which beats sending a
|
||||
* value the server will refuse anyway.
|
||||
*/
|
||||
export function settingValueFromString(key: string, raw: string): unknown {
|
||||
const definition = SETTING_DEFINITIONS[key as SettingKey];
|
||||
const trimmed = raw.trim();
|
||||
if (definition?.nullable && trimmed === "") return null;
|
||||
switch (definition?.type) {
|
||||
case "boolean":
|
||||
return trimmed === "true";
|
||||
case "integer":
|
||||
case "number": {
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new Error(`${key}: "${raw}" is not a number`);
|
||||
}
|
||||
return definition.type === "integer" ? Math.trunc(parsed) : parsed;
|
||||
}
|
||||
case "object":
|
||||
try {
|
||||
return JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
throw new Error(`${key}: the value must be valid JSON`);
|
||||
}
|
||||
default:
|
||||
// Strings, enums and language tags travel as themselves; so does a key
|
||||
// this build's contract does not know — the server answers with its own
|
||||
// authoritative error.
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function adminSettingValuePath(userId: number, key: string, identity: AdminSettingIdentity) {
|
||||
return `/admin/users/${userId}/settings/values/${encodeURIComponent(key)}?${identityQuery(identity)}`;
|
||||
}
|
||||
|
||||
/** Every explicit canonical value the target user has stored, all scopes. */
|
||||
function useAdminUserSettingValues(userId: number) {
|
||||
return useQuery({
|
||||
queryKey: adminKeys.userSettings(userId),
|
||||
queryFn: () =>
|
||||
api<AdminSettingValuesResponse>(`/admin/users/${userId}/settings/values`).then(
|
||||
(response) => response?.values ?? [],
|
||||
),
|
||||
staleTime: ADMIN_STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
function invalidateAdminDeviceCaches(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
userId: number,
|
||||
) {
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.userDeviceSettings(userId) });
|
||||
// Device overrides derive from the canonical values list, which lives under
|
||||
// the userSettings key.
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.userSettings(userId) });
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.devices() });
|
||||
}
|
||||
|
||||
@@ -116,22 +240,48 @@ export function useDeleteUser() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The target user's non-device settings: everything the settings tab shows.
|
||||
* Device overrides live in useAdminUserDeviceSettings, matching the old
|
||||
* two-endpoint split the UI is built around.
|
||||
*/
|
||||
export function useAdminUserSettings(userId: number) {
|
||||
return useQuery({
|
||||
queryKey: adminKeys.userSettings(userId),
|
||||
queryFn: () =>
|
||||
api<AdminSettingsResponse>(`/admin/users/${userId}/settings`).then((d) => d?.settings ?? []),
|
||||
staleTime: ADMIN_STALE_TIME,
|
||||
});
|
||||
const values = useAdminUserSettingValues(userId);
|
||||
const data = useMemo<AdminUserSettingEntry[]>(
|
||||
() =>
|
||||
(values.data ?? [])
|
||||
.filter((row) => row.scope !== "profile_device")
|
||||
.map((row) => ({
|
||||
key: row.key,
|
||||
scope: row.scope,
|
||||
profile_id: row.profile_id,
|
||||
library_id: row.library_id,
|
||||
series_id: row.series_id,
|
||||
value: settingValueToString(row.value),
|
||||
updated_at: row.updated_at,
|
||||
})),
|
||||
[values.data],
|
||||
);
|
||||
return { data, isLoading: values.isLoading, isError: values.isError };
|
||||
}
|
||||
|
||||
export function useUpdateAdminUserSetting() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, key, value }: { userId: number; key: string; value: string }) =>
|
||||
api(`/admin/users/${userId}/settings/${encodeURIComponent(key)}`, {
|
||||
mutationFn: async ({
|
||||
userId,
|
||||
key,
|
||||
identity,
|
||||
value,
|
||||
}: {
|
||||
userId: number;
|
||||
key: string;
|
||||
identity: AdminSettingIdentity;
|
||||
value: string;
|
||||
}) =>
|
||||
api(adminSettingValuePath(userId, key, identity), {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ value }),
|
||||
body: JSON.stringify({ value: settingValueFromString(key, value) }),
|
||||
}),
|
||||
onSuccess: (_data, variables) => {
|
||||
toast.success("User setting updated");
|
||||
@@ -146,10 +296,15 @@ export function useUpdateAdminUserSetting() {
|
||||
export function useDeleteAdminUserSetting() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, key }: { userId: number; key: string }) =>
|
||||
api(`/admin/users/${userId}/settings/${encodeURIComponent(key)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
mutationFn: ({
|
||||
userId,
|
||||
key,
|
||||
identity,
|
||||
}: {
|
||||
userId: number;
|
||||
key: string;
|
||||
identity: AdminSettingIdentity;
|
||||
}) => api(adminSettingValuePath(userId, key, identity), { method: "DELETE" }),
|
||||
onSuccess: (_data, variables) => {
|
||||
toast.success("User setting reset");
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.userSettings(variables.userId) });
|
||||
@@ -160,21 +315,74 @@ export function useDeleteAdminUserSetting() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The target user's device overrides, enriched with device and profile names
|
||||
* the way the removed /device-settings endpoint reported them: the canonical
|
||||
* rows carry only ids, so names come from the devices list and the profile
|
||||
* list and degrade to the raw id when a registration is gone.
|
||||
*/
|
||||
export function useAdminUserDeviceSettings(userId: number) {
|
||||
return useQuery({
|
||||
queryKey: adminKeys.userDeviceSettings(userId),
|
||||
queryFn: () =>
|
||||
api<AdminDeviceSettingsResponse>(`/admin/users/${userId}/device-settings`).then(
|
||||
(d) => d?.settings ?? [],
|
||||
),
|
||||
staleTime: ADMIN_STALE_TIME,
|
||||
});
|
||||
const values = useAdminUserSettingValues(userId);
|
||||
const devices = useAdminDevices();
|
||||
const profiles = useAdminUserProfiles(userId);
|
||||
|
||||
const data = useMemo<AdminDeviceSetting[]>(() => {
|
||||
const deviceMeta = new Map<string, { name: string; platform: string }>();
|
||||
for (const device of devices.data ?? []) {
|
||||
if (device.user_id === userId) {
|
||||
deviceMeta.set(device.device_id, {
|
||||
name: device.device_name,
|
||||
platform: device.device_platform,
|
||||
});
|
||||
}
|
||||
}
|
||||
const profileNames = new Map(
|
||||
(profiles.data ?? []).map((profile) => [profile.id, profile.name]),
|
||||
);
|
||||
return (values.data ?? [])
|
||||
.filter((row) => row.scope === "profile_device")
|
||||
.map((row) => {
|
||||
const meta = row.device_id ? deviceMeta.get(row.device_id) : undefined;
|
||||
return {
|
||||
user_id: userId,
|
||||
profile_id: row.profile_id ?? "",
|
||||
profile_name: row.profile_id ? profileNames.get(row.profile_id) : undefined,
|
||||
device_id: row.device_id ?? "",
|
||||
device_name: meta?.name ?? "",
|
||||
device_platform: meta?.platform ?? "",
|
||||
key: row.key,
|
||||
value: settingValueToString(row.value),
|
||||
updated_at: row.updated_at ?? "",
|
||||
};
|
||||
});
|
||||
}, [values.data, devices.data, profiles.data, userId]);
|
||||
|
||||
return { data, isLoading: values.isLoading, isError: values.isError };
|
||||
}
|
||||
|
||||
/**
|
||||
* One device's overrides, canonically.
|
||||
*
|
||||
* The device *detail* endpoint (GET /admin/devices/{user}/{device}) still
|
||||
* reports rows straight out of the legacy user_device_settings table, which
|
||||
* the settings-contract migration folded into user_setting_values and which
|
||||
* nothing canonical writes to any more. Reading the values API instead means
|
||||
* the panel shows both storage generations — the migrated legacy rows and
|
||||
* everything written since — rather than a table that only ever shrinks.
|
||||
*/
|
||||
export function useAdminDeviceOverrides(userId: number, deviceId: string) {
|
||||
const settings = useAdminUserDeviceSettings(userId);
|
||||
const data = useMemo(
|
||||
() => settings.data.filter((setting) => setting.device_id === deviceId),
|
||||
[settings.data, deviceId],
|
||||
);
|
||||
return { data, isLoading: settings.isLoading, isError: settings.isError };
|
||||
}
|
||||
|
||||
export function useUpdateAdminUserDeviceSetting() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
mutationFn: async ({
|
||||
userId,
|
||||
profileId,
|
||||
deviceId,
|
||||
@@ -187,13 +395,10 @@ export function useUpdateAdminUserDeviceSetting() {
|
||||
key: string;
|
||||
value: string;
|
||||
}) =>
|
||||
api(
|
||||
`/admin/users/${userId}/profiles/${encodeURIComponent(profileId)}/device-settings/${encodeURIComponent(key)}/${encodeURIComponent(deviceId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ value }),
|
||||
},
|
||||
),
|
||||
api(adminSettingValuePath(userId, key, { scope: "profile_device", profileId, deviceId }), {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ value: settingValueFromString(key, value) }),
|
||||
}),
|
||||
onSuccess: (_data, variables) => {
|
||||
toast.success("Device override updated");
|
||||
invalidateAdminDeviceCaches(queryClient, variables.userId);
|
||||
@@ -221,10 +426,9 @@ export function useDeleteAdminUserDeviceSetting() {
|
||||
deviceId: string;
|
||||
key: string;
|
||||
}) =>
|
||||
api(
|
||||
`/admin/users/${userId}/profiles/${encodeURIComponent(profileId)}/device-settings/${encodeURIComponent(key)}/${encodeURIComponent(deviceId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
api(adminSettingValuePath(userId, key, { scope: "profile_device", profileId, deviceId }), {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: (_data, variables) => {
|
||||
toast.success("Device override reset");
|
||||
invalidateAdminDeviceCaches(queryClient, variables.userId);
|
||||
@@ -238,24 +442,38 @@ export function useDeleteAdminUserDeviceSetting() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets every override for one (profile, device) pair. The bulk server route
|
||||
* went away with the registry API, so this issues one canonical delete per
|
||||
* key; a 404 means the row is already gone, which is the goal state, not a
|
||||
* failure.
|
||||
*/
|
||||
export function useDeleteAllAdminUserDeviceSettingsForDevice() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
mutationFn: async ({
|
||||
userId,
|
||||
profileId,
|
||||
deviceId,
|
||||
keys,
|
||||
}: {
|
||||
userId: number;
|
||||
profileId: string;
|
||||
deviceId: string;
|
||||
}) =>
|
||||
api(
|
||||
`/admin/users/${userId}/profiles/${encodeURIComponent(profileId)}/devices/${encodeURIComponent(deviceId)}/settings`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
),
|
||||
keys: readonly string[];
|
||||
}) => {
|
||||
for (const key of keys) {
|
||||
try {
|
||||
await api(
|
||||
adminSettingValuePath(userId, key, { scope: "profile_device", profileId, deviceId }),
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiClientError && err.status === 404) continue;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
toast.success("All device overrides reset");
|
||||
invalidateAdminDeviceCaches(queryClient, variables.userId);
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
import { ApiClientError } from "@/api/client";
|
||||
import { SETTING_KEYS } from "@/lib/settingsContract";
|
||||
import { resolveSettingValues, type StoredSettingRow } from "@/lib/settingsResolve";
|
||||
import { useAutoPlayNextSetting } from "./autoPlayNext";
|
||||
|
||||
const apiMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/api/client", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/api/client")>("@/api/client");
|
||||
return { ...actual, api: apiMock };
|
||||
});
|
||||
|
||||
const KEY = SETTING_KEYS.PLAYBACK_AUTO_PLAY_NEXT;
|
||||
|
||||
function createHarness() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
return { queryClient, wrapper };
|
||||
}
|
||||
|
||||
/**
|
||||
* A server stand-in that resolves through the shared client resolver, which
|
||||
* mirrors internal/settingsresolve. Using it rather than a canned answer is
|
||||
* what makes the scope-precedence assertions below mean something: a
|
||||
* profile_device row really does outrank a profile row here.
|
||||
*/
|
||||
function fakeSettingsServer(initial: StoredSettingRow[] = []) {
|
||||
const rows = [...initial];
|
||||
apiMock.mockImplementation((path: string, options?: RequestInit) => {
|
||||
if (path.startsWith("/settings/values/effective")) {
|
||||
const resolved = resolveSettingValues([KEY], rows, {
|
||||
profileId: "profile-1",
|
||||
deviceId: "device-1",
|
||||
})[0]!;
|
||||
return Promise.resolve({
|
||||
revision: 1,
|
||||
settings: [{ key: KEY, value: resolved.value, source: resolved.source }],
|
||||
});
|
||||
}
|
||||
const match = /^\/settings\/values\/([^?]+)\?scope=([a-z_]+)/.exec(path);
|
||||
if (!match) return Promise.resolve(undefined);
|
||||
const key = match[1]!;
|
||||
const scope = match[2]!;
|
||||
const index = rows.findIndex((row) => row.key === key && row.scope === scope);
|
||||
if (options?.method === "DELETE") {
|
||||
if (index < 0) {
|
||||
return Promise.reject(
|
||||
new ApiClientError(404, "not_found", "No value is set at this scope"),
|
||||
);
|
||||
}
|
||||
rows.splice(index, 1);
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
const value = (JSON.parse(options?.body as string) as { value: unknown }).value;
|
||||
const row: StoredSettingRow = {
|
||||
key,
|
||||
scope: scope as StoredSettingRow["scope"],
|
||||
profileId: "profile-1",
|
||||
deviceId: scope === "profile_device" ? "device-1" : undefined,
|
||||
value,
|
||||
};
|
||||
if (index < 0) rows.push(row);
|
||||
else rows[index] = row;
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
describe("useAutoPlayNextSetting", () => {
|
||||
beforeEach(() => {
|
||||
apiMock.mockReset();
|
||||
});
|
||||
|
||||
it("resolves an unset key to the contract default", async () => {
|
||||
fakeSettingsServer();
|
||||
const { wrapper } = createHarness();
|
||||
const { result } = renderHook(() => useAutoPlayNextSetting(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.enabled).toBe(true));
|
||||
expect(result.current.hasDeviceOverride).toBe(false);
|
||||
});
|
||||
|
||||
it("saves at profile scope so both surfaces read the same row", async () => {
|
||||
const rows = fakeSettingsServer();
|
||||
const { wrapper } = createHarness();
|
||||
const { result } = renderHook(() => useAutoPlayNextSetting(), { wrapper });
|
||||
await waitFor(() => expect(result.current.enabled).toBe(true));
|
||||
|
||||
await result.current.setEnabled(false);
|
||||
|
||||
expect(rows).toEqual([expect.objectContaining({ key: KEY, scope: "profile", value: false })]);
|
||||
});
|
||||
|
||||
it("clears a shadowing device row so the saved value actually takes effect", async () => {
|
||||
// The regression: the post-roll toggle used to write profile_device while
|
||||
// the settings screen wrote profile. profile_device wins resolution, so
|
||||
// the settings switch saved a value that never applied and snapped back.
|
||||
const rows = fakeSettingsServer([
|
||||
{
|
||||
key: KEY,
|
||||
scope: "profile_device",
|
||||
profileId: "profile-1",
|
||||
deviceId: "device-1",
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
const { wrapper } = createHarness();
|
||||
const { result } = renderHook(() => useAutoPlayNextSetting(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.enabled).toBe(false));
|
||||
expect(result.current.hasDeviceOverride).toBe(true);
|
||||
|
||||
await result.current.setEnabled(true);
|
||||
|
||||
expect(rows.some((row) => row.scope === "profile_device")).toBe(false);
|
||||
expect(rows).toEqual([expect.objectContaining({ scope: "profile", value: true })]);
|
||||
// The resolved answer now matches what was asked for, rather than the
|
||||
// device row's stale false.
|
||||
await waitFor(() => expect(result.current.enabled).toBe(true));
|
||||
});
|
||||
|
||||
it("does not attempt a delete when nothing is shadowing the profile row", async () => {
|
||||
fakeSettingsServer([{ key: KEY, scope: "profile", profileId: "profile-1", value: false }]);
|
||||
const { wrapper } = createHarness();
|
||||
const { result } = renderHook(() => useAutoPlayNextSetting(), { wrapper });
|
||||
await waitFor(() => expect(result.current.enabled).toBe(false));
|
||||
|
||||
await result.current.setEnabled(true);
|
||||
|
||||
const deletes = apiMock.mock.calls.filter(
|
||||
([, options]) => (options as RequestInit | undefined)?.method === "DELETE",
|
||||
);
|
||||
expect(deletes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useCallback } from "react";
|
||||
import { ApiClientError } from "@/api/client";
|
||||
import {
|
||||
useClearSettingValue,
|
||||
useEffectiveSettings,
|
||||
useSetSettingValue,
|
||||
type SettingIdentity,
|
||||
} from "@/hooks/queries/settingValues";
|
||||
import { SETTING_DEFINITIONS, SETTING_KEYS } from "@/lib/settingsContract";
|
||||
|
||||
/**
|
||||
* The one place playback.auto_play_next is read and written.
|
||||
*
|
||||
* Two surfaces edit this setting — the Playback settings screen and the
|
||||
* post-roll "Auto-play is on/off" toggle — and both render the *resolved*
|
||||
* value. The contract resolves profile_device above profile, so a surface that
|
||||
* writes the device row while the other writes the profile row makes the
|
||||
* second control inert: it saves a profile value the device row keeps
|
||||
* shadowing, and the switch snaps back on the next read. Sharing one hook is
|
||||
* what stops the two disagreeing about which scope a save lands at.
|
||||
*
|
||||
* The chosen scope is the profile, matching the rest of the Playback screen:
|
||||
* "auto-play the next episode" is a viewing preference, not a property of the
|
||||
* browser it was expressed in. A device row can still exist — the settings
|
||||
* migration converts legacy user_device_settings rows into profile_device
|
||||
* ones, and the contract lets other clients write it — so a save clears this
|
||||
* device's row as well. Without that, a migrated override would silently
|
||||
* shadow every later choice with no web affordance to remove it.
|
||||
*/
|
||||
|
||||
const KEY = SETTING_KEYS.PLAYBACK_AUTO_PLAY_NEXT;
|
||||
|
||||
/** Both surfaces edit the profile layer. */
|
||||
const PROFILE_SCOPE: SettingIdentity = { scope: "profile" };
|
||||
|
||||
/** The scope a stale override can hide in, cleared on every save. */
|
||||
const DEVICE_SCOPE: SettingIdentity = { scope: "profile_device" };
|
||||
|
||||
export function useAutoPlayNextSetting() {
|
||||
const query = useEffectiveSettings({ keys: [KEY] });
|
||||
const setValue = useSetSettingValue();
|
||||
const clearValue = useClearSettingValue();
|
||||
|
||||
const entry = query.data?.[KEY];
|
||||
// The effective endpoint resolves an unset key to the contract default, so
|
||||
// an absent answer (first paint) reads the same as a stored default rather
|
||||
// than as a local literal that could disagree with the server.
|
||||
const enabled = (entry?.value ?? SETTING_DEFINITIONS[KEY].defaultValue) as boolean;
|
||||
|
||||
/** Whether this device holds an override that outranks the profile row. */
|
||||
const hasDeviceOverride = entry?.source === "profile_device";
|
||||
|
||||
const setEnabled = useCallback(
|
||||
async (next: boolean) => {
|
||||
await setValue.mutateAsync({ key: KEY, value: next, identity: PROFILE_SCOPE });
|
||||
// The profile value is the durable expression, so it is written first: a
|
||||
// failed clear leaves the choice stored rather than losing it. Nothing to
|
||||
// clear is the state this asks for, so a 404 is success.
|
||||
if (!hasDeviceOverride) return;
|
||||
try {
|
||||
await clearValue.mutateAsync({ key: KEY, identity: DEVICE_SCOPE });
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError && error.status === 404) return;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[clearValue, hasDeviceOverride, setValue],
|
||||
);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
hasDeviceOverride,
|
||||
setEnabled,
|
||||
isSaving: setValue.isPending || clearValue.isPending,
|
||||
};
|
||||
}
|
||||
@@ -203,27 +203,16 @@ export const libraryKeys = {
|
||||
user: (profileId?: string | null) => ["libraries", "user", profileId ?? "none"] as const,
|
||||
};
|
||||
|
||||
export const libraryPlaybackPreferenceKeys = {
|
||||
all: ["libraryPlaybackPreferences"] as const,
|
||||
list: (profileId?: string | null) =>
|
||||
["libraryPlaybackPreferences", "list", profileId ?? "none"] as const,
|
||||
library: (profileId: string | null | undefined, libraryId: number) =>
|
||||
["libraryPlaybackPreferences", "library", profileId ?? "none", libraryId] as const,
|
||||
};
|
||||
|
||||
export const progressKeys = {
|
||||
all: ["progress"] as const,
|
||||
list: (status?: string, libraryId?: number) => ["progress", "list", status, libraryId] as const,
|
||||
};
|
||||
|
||||
export const settingsKeys = {
|
||||
// The canonical value queries live under ["settings", "values", …] and build
|
||||
// their own key (effectiveSettingsQueryKey), so one invalidation of that
|
||||
// prefix covers every scope and batch.
|
||||
all: ["settings"] as const,
|
||||
list: () => ["settings", "list"] as const,
|
||||
detail: (key: string) => ["settings", key] as const,
|
||||
deviceDetail: (profileId: string | null | undefined, key: string) =>
|
||||
["settings", "device", profileId ?? "none", key] as const,
|
||||
effective: (profileId: string | null | undefined, keys: string[]) =>
|
||||
["settings", "effective", profileId ?? "none", [...keys].sort().join(",")] as const,
|
||||
plugins: () => ["settings", "plugins"] as const,
|
||||
pluginDetail: (installationId: number) => ["settings", "plugins", installationId] as const,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UserLibrary } from "@/api/types";
|
||||
import {
|
||||
filterVisibleLibraries,
|
||||
parseDisabledLibraryIDs,
|
||||
serializeDisabledLibraryIDs,
|
||||
} from "./libraries";
|
||||
import { filterVisibleLibraries, normalizeLibraryIDs, parseLibraryIDList } from "./libraries";
|
||||
|
||||
const libraries: UserLibrary[] = [
|
||||
{ id: 1, name: "Movies", type: "movies", sort_order: 0 },
|
||||
@@ -12,22 +8,29 @@ const libraries: UserLibrary[] = [
|
||||
{ id: 3, name: "Anime", type: "series", sort_order: 2 },
|
||||
];
|
||||
|
||||
describe("parseDisabledLibraryIDs", () => {
|
||||
describe("parseLibraryIDList", () => {
|
||||
it("returns an empty list when the setting is missing or invalid", () => {
|
||||
expect(parseDisabledLibraryIDs(null)).toEqual([]);
|
||||
expect(parseDisabledLibraryIDs("")).toEqual([]);
|
||||
expect(parseDisabledLibraryIDs("nope")).toEqual([]);
|
||||
expect(parseDisabledLibraryIDs('{"ids":[1,2]}')).toEqual([]);
|
||||
expect(parseLibraryIDList(null)).toEqual([]);
|
||||
expect(parseLibraryIDList(undefined)).toEqual([]);
|
||||
expect(parseLibraryIDList("")).toEqual([]);
|
||||
expect(parseLibraryIDList("nope")).toEqual([]);
|
||||
expect(parseLibraryIDList('{"ids":[1,2]}')).toEqual([]);
|
||||
expect(parseLibraryIDList({ ids: [1, 2] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts the canonical array value directly", () => {
|
||||
expect(parseLibraryIDList([3, 1])).toEqual([3, 1]);
|
||||
});
|
||||
|
||||
it("keeps only positive integer library ids", () => {
|
||||
expect(parseDisabledLibraryIDs('[1,2,2,0,-1,3.5,"4"]')).toEqual([1, 2]);
|
||||
expect(parseLibraryIDList([1, 2, 2, 0, -1, 3.5, "4"])).toEqual([1, 2]);
|
||||
expect(parseLibraryIDList('[1,2,2,0,-1,3.5,"4"]')).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeDisabledLibraryIDs", () => {
|
||||
it("stores a normalized id list", () => {
|
||||
expect(serializeDisabledLibraryIDs([3, 1, 3, -1, 0])).toBe("[3,1]");
|
||||
describe("normalizeLibraryIDs", () => {
|
||||
it("produces a normalized id list", () => {
|
||||
expect(normalizeLibraryIDs([3, 1, 3, -1, 0])).toEqual([3, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,51 +1,46 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/api/client";
|
||||
import type { UserLibrary } from "@/api/types";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { SETTING_KEYS } from "@/lib/settingsContract";
|
||||
import { libraryKeys } from "./keys";
|
||||
import { useSettings } from "./settings";
|
||||
import { useEffectiveSettings } from "./settingValues";
|
||||
|
||||
export const DISABLED_LIBRARY_IDS_SETTING_KEY = "disabled_library_ids";
|
||||
export const LIBRARY_ORDER_SETTING_KEY = "library_order";
|
||||
const LIBRARY_PREF_KEYS = [
|
||||
SETTING_KEYS.UI_DISABLED_LIBRARY_IDS,
|
||||
SETTING_KEYS.UI_LIBRARY_ORDER,
|
||||
] as const;
|
||||
|
||||
function normalizeLibraryIDs(ids: number[]) {
|
||||
/**
|
||||
* Drops non-integers, duplicates, and anything below 1 — the same
|
||||
* normalization library-id-list.json enforces server-side, applied before a
|
||||
* write so a value the client sends always validates.
|
||||
*/
|
||||
export function normalizeLibraryIDs(ids: number[]) {
|
||||
return [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))];
|
||||
}
|
||||
|
||||
export function parseDisabledLibraryIDs(value: string | null | undefined) {
|
||||
if (!value) return [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return normalizeLibraryIDs(
|
||||
parsed.map((entry) => (typeof entry === "number" ? entry : Number.NaN)),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
/**
|
||||
* Accepts the canonical array value, the legacy JSON-string encoding, or
|
||||
* null/undefined (the contract default), and always lands on a normalized id
|
||||
* list. Backs both ui.disabled_library_ids and ui.library_order.
|
||||
*/
|
||||
export function parseLibraryIDList(value: unknown): number[] {
|
||||
if (value == null) return [];
|
||||
let parsed: unknown = value;
|
||||
if (typeof value === "string") {
|
||||
if (!value) return [];
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeDisabledLibraryIDs(ids: number[]) {
|
||||
return JSON.stringify(normalizeLibraryIDs(ids));
|
||||
}
|
||||
|
||||
export function parseLibraryOrder(value: string | null | undefined): number[] {
|
||||
if (!value) return [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return normalizeLibraryIDs(
|
||||
parsed.map((entry) => (typeof entry === "number" ? entry : Number.NaN)),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeLibraryOrder(ids: number[]) {
|
||||
return JSON.stringify(normalizeLibraryIDs(ids));
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return normalizeLibraryIDs(
|
||||
parsed.map((entry) => (typeof entry === "number" ? entry : Number.NaN)),
|
||||
);
|
||||
}
|
||||
|
||||
export function applyLibraryOrder(libraries: UserLibrary[], orderIDs: number[]): UserLibrary[] {
|
||||
@@ -80,29 +75,44 @@ export function useAvailableUserLibraries() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile's own hide/order preferences, resolved through the canonical
|
||||
* settings API in one batched read. Exported for the settings screen that
|
||||
* edits them; most callers want useUserLibraries, which applies them.
|
||||
*/
|
||||
export function useLibraryDisplayPreferences() {
|
||||
const { profile } = useAuth();
|
||||
const query = useEffectiveSettings({ keys: LIBRARY_PREF_KEYS, enabled: Boolean(profile) });
|
||||
const disabledValue = query.data?.[SETTING_KEYS.UI_DISABLED_LIBRARY_IDS]?.value;
|
||||
const orderValue = query.data?.[SETTING_KEYS.UI_LIBRARY_ORDER]?.value;
|
||||
// Memoized so effects keyed on these lists fire on saved-value changes, not
|
||||
// on every render.
|
||||
const disabledLibraryIDs = useMemo(() => parseLibraryIDList(disabledValue), [disabledValue]);
|
||||
const libraryOrder = useMemo(() => parseLibraryIDList(orderValue), [orderValue]);
|
||||
return {
|
||||
...query,
|
||||
disabledLibraryIDs,
|
||||
libraryOrder,
|
||||
// A profile-less session has no preferences to wait for.
|
||||
isLoading: Boolean(profile) && query.isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function useUserLibraries() {
|
||||
const librariesQuery = useAvailableUserLibraries();
|
||||
const settingsQuery = useSettings();
|
||||
const disabledLibraryIDs = settingsQuery.isLoading
|
||||
? null
|
||||
: parseDisabledLibraryIDs(settingsQuery.data?.[DISABLED_LIBRARY_IDS_SETTING_KEY]);
|
||||
const libraryOrder = settingsQuery.isLoading
|
||||
? null
|
||||
: parseLibraryOrder(settingsQuery.data?.[LIBRARY_ORDER_SETTING_KEY]);
|
||||
const prefs = useLibraryDisplayPreferences();
|
||||
|
||||
let data = librariesQuery.data;
|
||||
if (data != null && disabledLibraryIDs != null) {
|
||||
data = filterVisibleLibraries(data, disabledLibraryIDs);
|
||||
}
|
||||
if (data != null && libraryOrder != null) {
|
||||
data = applyLibraryOrder(data, libraryOrder);
|
||||
if (data != null && !prefs.isLoading) {
|
||||
data = filterVisibleLibraries(data, prefs.disabledLibraryIDs);
|
||||
data = applyLibraryOrder(data, prefs.libraryOrder);
|
||||
}
|
||||
|
||||
return {
|
||||
...librariesQuery,
|
||||
data,
|
||||
isLoading: librariesQuery.isLoading || settingsQuery.isLoading,
|
||||
isFetching: librariesQuery.isFetching || settingsQuery.isFetching,
|
||||
error: librariesQuery.error ?? settingsQuery.error,
|
||||
isLoading: librariesQuery.isLoading || prefs.isLoading,
|
||||
isFetching: librariesQuery.isFetching || prefs.isFetching,
|
||||
error: librariesQuery.error ?? prefs.error,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,12 +4,27 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
parseLibraryPageStatePreference,
|
||||
serializeLibraryPageStatePreference,
|
||||
updateLibraryPageStatePreference,
|
||||
} from "./libraryPageState";
|
||||
|
||||
describe("library page state preference helpers", () => {
|
||||
it("parses valid preferences", () => {
|
||||
it("parses valid preferences from the canonical object value", () => {
|
||||
expect(
|
||||
parseLibraryPageStatePreference({
|
||||
version: 1,
|
||||
libraries: {
|
||||
"7": { search: "tab=library&sort=year" },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
version: 1,
|
||||
libraries: {
|
||||
"7": { search: "tab=library&sort=year" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("still parses the legacy JSON-string encoding", () => {
|
||||
expect(
|
||||
parseLibraryPageStatePreference(
|
||||
JSON.stringify({
|
||||
@@ -27,23 +42,26 @@ describe("library page state preference helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("treats null and undefined as an empty preference", () => {
|
||||
expect(parseLibraryPageStatePreference(null)).toEqual({ version: 1, libraries: {} });
|
||||
expect(parseLibraryPageStatePreference(undefined)).toEqual({ version: 1, libraries: {} });
|
||||
});
|
||||
|
||||
it("ignores malformed, wrong-version, and non-string entries", () => {
|
||||
expect(parseLibraryPageStatePreference("not json")).toEqual({ version: 1, libraries: {} });
|
||||
expect(parseLibraryPageStatePreference(JSON.stringify({ version: 2, libraries: {} }))).toEqual({
|
||||
expect(parseLibraryPageStatePreference({ version: 2, libraries: {} })).toEqual({
|
||||
version: 1,
|
||||
libraries: {},
|
||||
});
|
||||
expect(
|
||||
parseLibraryPageStatePreference(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
libraries: {
|
||||
"7": { search: 42 },
|
||||
nope: { search: "tab=library" },
|
||||
"9": { search: "tab=collections" },
|
||||
},
|
||||
}),
|
||||
),
|
||||
parseLibraryPageStatePreference({
|
||||
version: 1,
|
||||
libraries: {
|
||||
"7": { search: 42 },
|
||||
nope: { search: "tab=library" },
|
||||
"9": { search: "tab=collections" },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
version: 1,
|
||||
libraries: {
|
||||
@@ -52,7 +70,7 @@ describe("library page state preference helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("updates and serializes per-library entries", () => {
|
||||
it("updates per-library entries", () => {
|
||||
const next = updateLibraryPageStatePreference(
|
||||
{
|
||||
version: 1,
|
||||
@@ -64,7 +82,7 @@ describe("library page state preference helpers", () => {
|
||||
"tab=library&sort=year",
|
||||
);
|
||||
|
||||
expect(JSON.parse(serializeLibraryPageStatePreference(next))).toEqual({
|
||||
expect(next).toEqual({
|
||||
version: 1,
|
||||
libraries: {
|
||||
"1": { search: "tab=collections" },
|
||||
|
||||
@@ -1,63 +1,69 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
useDeviceSetting,
|
||||
useEffectiveSettings,
|
||||
useSetDeviceSetting,
|
||||
} from "@/hooks/queries/settings";
|
||||
import { useEffectiveSettings, useSetSettingValue } from "@/hooks/queries/settingValues";
|
||||
import type { SettingIdentity } from "@/hooks/queries/settingValues";
|
||||
import { SETTING_KEYS } from "@/lib/settingsContract";
|
||||
import { storage } from "@/utils/storage";
|
||||
|
||||
export const LIBRARY_PAGE_STATE_SETTING_KEY = "ui.library_page_state";
|
||||
export const REMEMBER_LIBRARY_PAGE_STATE_SETTING_KEY = "ui.remember_library_page_state";
|
||||
/** Both keys are profile+device scoped in the contract. */
|
||||
const DEVICE_SCOPE: SettingIdentity = { scope: "profile_device" };
|
||||
|
||||
const PAGE_STATE_KEYS = [
|
||||
SETTING_KEYS.UI_LIBRARY_PAGE_STATE,
|
||||
SETTING_KEYS.UI_REMEMBER_LIBRARY_PAGE_STATE,
|
||||
] as const;
|
||||
|
||||
export interface LibraryPageStatePreference {
|
||||
version: 1;
|
||||
libraries: Record<string, { search: string }>;
|
||||
}
|
||||
|
||||
export function parseLibraryPageStatePreference(
|
||||
raw: string | null | undefined,
|
||||
): LibraryPageStatePreference {
|
||||
if (!raw) {
|
||||
/**
|
||||
* Accepts the canonical object value, the legacy JSON-string encoding, or
|
||||
* null/undefined (the contract default), and always lands on a usable
|
||||
* preference.
|
||||
*/
|
||||
export function parseLibraryPageStatePreference(raw: unknown): LibraryPageStatePreference {
|
||||
if (raw == null) {
|
||||
return createEmptyLibraryPageStatePreference();
|
||||
}
|
||||
try {
|
||||
const value = JSON.parse(raw) as unknown;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
let value: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
if (!raw) {
|
||||
return createEmptyLibraryPageStatePreference();
|
||||
}
|
||||
const maybePreference = value as {
|
||||
version?: unknown;
|
||||
libraries?: unknown;
|
||||
};
|
||||
if (maybePreference.version !== 1 || !maybePreference.libraries) {
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
} catch {
|
||||
return createEmptyLibraryPageStatePreference();
|
||||
}
|
||||
if (typeof maybePreference.libraries !== "object" || Array.isArray(maybePreference.libraries)) {
|
||||
return createEmptyLibraryPageStatePreference();
|
||||
}
|
||||
|
||||
const libraries: LibraryPageStatePreference["libraries"] = {};
|
||||
Object.entries(maybePreference.libraries).forEach(([libraryId, entry]) => {
|
||||
if (!/^\d+$/.test(libraryId) || !entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return;
|
||||
}
|
||||
const search = (entry as { search?: unknown }).search;
|
||||
if (typeof search !== "string") {
|
||||
return;
|
||||
}
|
||||
libraries[libraryId] = { search };
|
||||
});
|
||||
|
||||
return { version: 1, libraries };
|
||||
} catch {
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return createEmptyLibraryPageStatePreference();
|
||||
}
|
||||
const maybePreference = value as {
|
||||
version?: unknown;
|
||||
libraries?: unknown;
|
||||
};
|
||||
if (maybePreference.version !== 1 || !maybePreference.libraries) {
|
||||
return createEmptyLibraryPageStatePreference();
|
||||
}
|
||||
if (typeof maybePreference.libraries !== "object" || Array.isArray(maybePreference.libraries)) {
|
||||
return createEmptyLibraryPageStatePreference();
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeLibraryPageStatePreference(
|
||||
preference: LibraryPageStatePreference,
|
||||
): string {
|
||||
return JSON.stringify(preference);
|
||||
const libraries: LibraryPageStatePreference["libraries"] = {};
|
||||
Object.entries(maybePreference.libraries).forEach(([libraryId, entry]) => {
|
||||
if (!/^\d+$/.test(libraryId) || !entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return;
|
||||
}
|
||||
const search = (entry as { search?: unknown }).search;
|
||||
if (typeof search !== "string") {
|
||||
return;
|
||||
}
|
||||
libraries[libraryId] = { search };
|
||||
});
|
||||
|
||||
return { version: 1, libraries };
|
||||
}
|
||||
|
||||
export function updateLibraryPageStatePreference(
|
||||
@@ -79,29 +85,32 @@ function createEmptyLibraryPageStatePreference(): LibraryPageStatePreference {
|
||||
}
|
||||
|
||||
export function useLibraryPageStatePreference() {
|
||||
const profileId = storage.get(storage.KEYS.PROFILE_ID);
|
||||
const setting = useDeviceSetting(profileId, LIBRARY_PAGE_STATE_SETTING_KEY);
|
||||
const rememberSetting = useEffectiveSettings(profileId, [
|
||||
REMEMBER_LIBRARY_PAGE_STATE_SETTING_KEY,
|
||||
]);
|
||||
const mutation = useSetDeviceSetting();
|
||||
// The effective endpoint requires a profile header; before one is chosen
|
||||
// there is no saved state to restore and nowhere to save it.
|
||||
const enabled = Boolean(storage.get(storage.KEYS.PROFILE_ID));
|
||||
const { data, isLoading } = useEffectiveSettings({ keys: PAGE_STATE_KEYS, enabled });
|
||||
const mutation = useSetSettingValue();
|
||||
const { mutate } = mutation;
|
||||
const preference = useMemo(() => parseLibraryPageStatePreference(setting.data), [setting.data]);
|
||||
const rememberEnabled =
|
||||
rememberSetting.data?.[REMEMBER_LIBRARY_PAGE_STATE_SETTING_KEY]?.effective_value !== "false";
|
||||
|
||||
const stateValue = data?.[SETTING_KEYS.UI_LIBRARY_PAGE_STATE]?.value;
|
||||
const preference = useMemo(() => parseLibraryPageStatePreference(stateValue), [stateValue]);
|
||||
// The contract default is true; anything but an explicit false keeps the
|
||||
// feature on, matching the legacy `!== "false"` reading.
|
||||
const rememberEnabled = data?.[SETTING_KEYS.UI_REMEMBER_LIBRARY_PAGE_STATE]?.value !== false;
|
||||
const saveLibrarySearch = useCallback(
|
||||
(libraryId: number, search: string) => {
|
||||
const nextPreference = updateLibraryPageStatePreference(preference, libraryId, search);
|
||||
mutate({
|
||||
key: LIBRARY_PAGE_STATE_SETTING_KEY,
|
||||
value: serializeLibraryPageStatePreference(nextPreference),
|
||||
key: SETTING_KEYS.UI_LIBRARY_PAGE_STATE,
|
||||
value: nextPreference,
|
||||
identity: DEVICE_SCOPE,
|
||||
});
|
||||
},
|
||||
[mutate, preference],
|
||||
);
|
||||
|
||||
return {
|
||||
isLoading: setting.isLoading || rememberSetting.isLoading,
|
||||
isLoading: enabled && isLoading,
|
||||
preference,
|
||||
rememberEnabled,
|
||||
saveLibrarySearch,
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
api: vi.fn(),
|
||||
storageGet: vi.fn(),
|
||||
useMutation: vi.fn(),
|
||||
useQuery: vi.fn(),
|
||||
useQueryClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query");
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useMutation: (...args: unknown[]) => mocks.useMutation(...args),
|
||||
useQuery: (...args: unknown[]) => mocks.useQuery(...args),
|
||||
useQueryClient: () => mocks.useQueryClient(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/api/client", () => ({
|
||||
api: (...args: unknown[]) => mocks.api(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/storage", () => ({
|
||||
storage: {
|
||||
KEYS: {
|
||||
PROFILE_ID: "profile_id",
|
||||
},
|
||||
get: (...args: unknown[]) => mocks.storageGet(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import type { LibraryPlaybackPreference } from "@/api/types";
|
||||
import { libraryPlaybackPreferenceKeys } from "./keys";
|
||||
import {
|
||||
type UpdateLibraryPlaybackPreferenceVariables,
|
||||
useDeleteLibraryPlaybackPreference,
|
||||
useLibraryPlaybackPreferences,
|
||||
useSetLibraryPlaybackPreference,
|
||||
} from "./libraryPlaybackPreferences";
|
||||
|
||||
type QueryOptions = {
|
||||
queryKey: ReturnType<typeof libraryPlaybackPreferenceKeys.list>;
|
||||
queryFn: () => Promise<LibraryPlaybackPreference[]>;
|
||||
};
|
||||
|
||||
type SetMutationOptions = {
|
||||
mutationFn: (variables: UpdateLibraryPlaybackPreferenceVariables) => Promise<unknown>;
|
||||
onSuccess?: (
|
||||
data: unknown,
|
||||
variables: UpdateLibraryPlaybackPreferenceVariables,
|
||||
) => Promise<unknown> | unknown;
|
||||
};
|
||||
|
||||
type DeleteMutationOptions = {
|
||||
mutationFn: (libraryId: number) => Promise<unknown>;
|
||||
onSuccess?: (data: unknown, libraryId: number) => Promise<unknown> | unknown;
|
||||
};
|
||||
|
||||
function latestQueryOptions(): QueryOptions {
|
||||
return mocks.useQuery.mock.calls[mocks.useQuery.mock.calls.length - 1]?.[0] as QueryOptions;
|
||||
}
|
||||
|
||||
function latestSetMutationOptions(): SetMutationOptions {
|
||||
return mocks.useMutation.mock.calls[
|
||||
mocks.useMutation.mock.calls.length - 1
|
||||
]?.[0] as SetMutationOptions;
|
||||
}
|
||||
|
||||
function latestDeleteMutationOptions(): DeleteMutationOptions {
|
||||
return mocks.useMutation.mock.calls[
|
||||
mocks.useMutation.mock.calls.length - 1
|
||||
]?.[0] as DeleteMutationOptions;
|
||||
}
|
||||
|
||||
describe("library playback preference query hooks", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mocks.api.mockReset();
|
||||
mocks.storageGet.mockReset();
|
||||
mocks.useMutation.mockReset();
|
||||
mocks.useQuery.mockReset();
|
||||
mocks.useQueryClient.mockReset();
|
||||
|
||||
mocks.storageGet.mockReturnValue("profile-1");
|
||||
mocks.useQuery.mockImplementation((options: unknown) => options);
|
||||
mocks.useMutation.mockImplementation((options: unknown) => options);
|
||||
mocks.useQueryClient.mockReturnValue(queryClient);
|
||||
});
|
||||
|
||||
it("loads the active profile playback preference list", async () => {
|
||||
const row: LibraryPlaybackPreference = {
|
||||
profile_id: "profile-1",
|
||||
library_id: 7,
|
||||
audio_language: "ja",
|
||||
subtitle_language: "en",
|
||||
subtitle_mode: "always",
|
||||
show_forced_subtitles: true,
|
||||
updated_at: "2026-03-23T00:00:00.000Z",
|
||||
};
|
||||
mocks.api.mockResolvedValueOnce({ preferences: [row] });
|
||||
|
||||
useLibraryPlaybackPreferences();
|
||||
const query = latestQueryOptions();
|
||||
|
||||
expect(query.queryKey).toEqual(libraryPlaybackPreferenceKeys.list("profile-1"));
|
||||
await expect(query.queryFn()).resolves.toEqual([row]);
|
||||
expect(mocks.api).toHaveBeenCalledWith("/library-playback-prefs");
|
||||
});
|
||||
|
||||
it("updates an existing preference row in cache after PUT", async () => {
|
||||
const initial: LibraryPlaybackPreference = {
|
||||
profile_id: "profile-1",
|
||||
library_id: 7,
|
||||
audio_language: "en",
|
||||
subtitle_language: "en",
|
||||
subtitle_mode: "auto",
|
||||
show_forced_subtitles: true,
|
||||
updated_at: "2026-03-23T00:00:00.000Z",
|
||||
};
|
||||
const key = libraryPlaybackPreferenceKeys.list("profile-1");
|
||||
queryClient.setQueryData(key, [initial]);
|
||||
|
||||
useSetLibraryPlaybackPreference();
|
||||
const mutation = latestSetMutationOptions();
|
||||
const body = { audio_language: "ja", subtitle_mode: "always", show_forced_subtitles: false };
|
||||
|
||||
await mutation.mutationFn({ libraryId: 7, body });
|
||||
expect(mocks.api).toHaveBeenCalledWith("/library-playback-prefs/7", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
await mutation.onSuccess?.(undefined, { libraryId: 7, body });
|
||||
|
||||
expect(queryClient.getQueryData<LibraryPlaybackPreference[]>(key)).toEqual([
|
||||
{
|
||||
...initial,
|
||||
audio_language: "ja",
|
||||
subtitle_language: "en",
|
||||
subtitle_mode: "always",
|
||||
show_forced_subtitles: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes an existing preference row from cache when PUT clears all fields", async () => {
|
||||
const initial: LibraryPlaybackPreference = {
|
||||
profile_id: "profile-1",
|
||||
library_id: 7,
|
||||
audio_language: "ja",
|
||||
subtitle_language: "en",
|
||||
subtitle_mode: "always",
|
||||
show_forced_subtitles: true,
|
||||
updated_at: "2026-03-23T00:00:00.000Z",
|
||||
};
|
||||
const key = libraryPlaybackPreferenceKeys.list("profile-1");
|
||||
queryClient.setQueryData(key, [initial]);
|
||||
|
||||
useSetLibraryPlaybackPreference();
|
||||
const mutation = latestSetMutationOptions();
|
||||
const body = {};
|
||||
|
||||
await mutation.mutationFn({ libraryId: 7, body });
|
||||
await mutation.onSuccess?.(undefined, { libraryId: 7, body });
|
||||
|
||||
expect(queryClient.getQueryData<LibraryPlaybackPreference[]>(key)).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves an explicit empty subtitle language override in cache", async () => {
|
||||
const initial: LibraryPlaybackPreference = {
|
||||
profile_id: "profile-1",
|
||||
library_id: 7,
|
||||
audio_language: "ja",
|
||||
subtitle_language: "en",
|
||||
subtitle_mode: "auto",
|
||||
show_forced_subtitles: true,
|
||||
updated_at: "2026-03-23T00:00:00.000Z",
|
||||
};
|
||||
const key = libraryPlaybackPreferenceKeys.list("profile-1");
|
||||
queryClient.setQueryData(key, [initial]);
|
||||
|
||||
useSetLibraryPlaybackPreference();
|
||||
const mutation = latestSetMutationOptions();
|
||||
const body = { subtitle_language: "" };
|
||||
|
||||
await mutation.onSuccess?.(undefined, { libraryId: 7, body });
|
||||
|
||||
expect(queryClient.getQueryData<LibraryPlaybackPreference[]>(key)).toEqual([
|
||||
{
|
||||
...initial,
|
||||
subtitle_language: "",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes an existing preference row from cache after DELETE", async () => {
|
||||
const initial: LibraryPlaybackPreference = {
|
||||
profile_id: "profile-1",
|
||||
library_id: 7,
|
||||
audio_language: "ja",
|
||||
subtitle_language: "en",
|
||||
subtitle_mode: "always",
|
||||
show_forced_subtitles: true,
|
||||
updated_at: "2026-03-23T00:00:00.000Z",
|
||||
};
|
||||
const key = libraryPlaybackPreferenceKeys.list("profile-1");
|
||||
queryClient.setQueryData(key, [initial]);
|
||||
|
||||
useDeleteLibraryPlaybackPreference();
|
||||
const mutation = latestDeleteMutationOptions();
|
||||
|
||||
await mutation.mutationFn(7);
|
||||
expect(mocks.api).toHaveBeenCalledWith("/library-playback-prefs/7", {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
await mutation.onSuccess?.(undefined, 7);
|
||||
|
||||
expect(queryClient.getQueryData<LibraryPlaybackPreference[]>(key)).toEqual([]);
|
||||
});
|
||||
|
||||
it("scopes the query key to the active profile id", () => {
|
||||
mocks.storageGet.mockReturnValue("profile-2");
|
||||
|
||||
useLibraryPlaybackPreferences();
|
||||
const query = latestQueryOptions();
|
||||
|
||||
expect(query.queryKey).toEqual(libraryPlaybackPreferenceKeys.list("profile-2"));
|
||||
});
|
||||
|
||||
it("invalidates the active profile list when PUT succeeds without cached data", async () => {
|
||||
const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries");
|
||||
useSetLibraryPlaybackPreference();
|
||||
const mutation = latestSetMutationOptions();
|
||||
const body = { audio_language: "ja" };
|
||||
|
||||
await mutation.onSuccess?.(undefined, { libraryId: 7, body });
|
||||
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: libraryPlaybackPreferenceKeys.list("profile-1"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/api/client";
|
||||
import type { LibraryPlaybackPreference } from "@/api/types";
|
||||
import { storage } from "@/utils/storage";
|
||||
import { libraryPlaybackPreferenceKeys } from "./keys";
|
||||
|
||||
interface LibraryPlaybackPreferencesResponse {
|
||||
preferences: LibraryPlaybackPreference[];
|
||||
}
|
||||
|
||||
export interface UpdateLibraryPlaybackPreferenceRequest {
|
||||
audio_language?: string;
|
||||
subtitle_language?: string;
|
||||
subtitle_mode?: string;
|
||||
show_forced_subtitles?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateLibraryPlaybackPreferenceVariables {
|
||||
libraryId: number;
|
||||
body: UpdateLibraryPlaybackPreferenceRequest;
|
||||
}
|
||||
|
||||
function hasOwnField<T extends object>(value: T, field: keyof T) {
|
||||
return Object.prototype.hasOwnProperty.call(value, field);
|
||||
}
|
||||
|
||||
function isEmptyPreference(body: UpdateLibraryPlaybackPreferenceRequest) {
|
||||
return (
|
||||
!hasOwnField(body, "audio_language") &&
|
||||
!hasOwnField(body, "subtitle_language") &&
|
||||
!hasOwnField(body, "subtitle_mode") &&
|
||||
!hasOwnField(body, "show_forced_subtitles")
|
||||
);
|
||||
}
|
||||
|
||||
function updateCachedPreference(
|
||||
preferences: LibraryPlaybackPreference[],
|
||||
libraryId: number,
|
||||
body: UpdateLibraryPlaybackPreferenceRequest,
|
||||
) {
|
||||
const index = preferences.findIndex((entry) => entry.library_id === libraryId);
|
||||
if (index < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = preferences[index]!;
|
||||
const next: LibraryPlaybackPreference = {
|
||||
...current,
|
||||
};
|
||||
if (hasOwnField(body, "audio_language")) {
|
||||
next.audio_language = body.audio_language;
|
||||
}
|
||||
if (hasOwnField(body, "subtitle_language")) {
|
||||
next.subtitle_language = body.subtitle_language;
|
||||
}
|
||||
if (hasOwnField(body, "subtitle_mode")) {
|
||||
next.subtitle_mode = body.subtitle_mode;
|
||||
}
|
||||
if (hasOwnField(body, "show_forced_subtitles")) {
|
||||
next.show_forced_subtitles = body.show_forced_subtitles;
|
||||
}
|
||||
|
||||
const nextPreferences = [...preferences];
|
||||
nextPreferences[index] = next;
|
||||
return nextPreferences;
|
||||
}
|
||||
|
||||
function removeCachedPreference(preferences: LibraryPlaybackPreference[], libraryId: number) {
|
||||
return preferences.filter((entry) => entry.library_id !== libraryId);
|
||||
}
|
||||
|
||||
export function useLibraryPlaybackPreferences(options?: { enabled?: boolean }) {
|
||||
const profileId = storage.get(storage.KEYS.PROFILE_ID);
|
||||
|
||||
return useQuery({
|
||||
queryKey: libraryPlaybackPreferenceKeys.list(profileId),
|
||||
queryFn: async () => {
|
||||
const result = await api<LibraryPlaybackPreferencesResponse>("/library-playback-prefs");
|
||||
return result.preferences ?? [];
|
||||
},
|
||||
enabled: (options?.enabled ?? true) && profileId != null,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetLibraryPlaybackPreference() {
|
||||
const queryClient = useQueryClient();
|
||||
const profileId = storage.get(storage.KEYS.PROFILE_ID);
|
||||
const key = libraryPlaybackPreferenceKeys.list(profileId);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ libraryId, body }: UpdateLibraryPlaybackPreferenceVariables) =>
|
||||
api(`/library-playback-prefs/${libraryId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
onSuccess: (_data, { libraryId, body }) => {
|
||||
const current = queryClient.getQueryData<LibraryPlaybackPreference[]>(key);
|
||||
|
||||
if (!current) {
|
||||
return queryClient.invalidateQueries({ queryKey: key });
|
||||
}
|
||||
|
||||
if (isEmptyPreference(body)) {
|
||||
queryClient.setQueryData(key, removeCachedPreference(current, libraryId));
|
||||
return;
|
||||
}
|
||||
|
||||
const next = updateCachedPreference(current, libraryId, body);
|
||||
if (!next) {
|
||||
return queryClient.invalidateQueries({ queryKey: key });
|
||||
}
|
||||
|
||||
queryClient.setQueryData(key, next);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteLibraryPlaybackPreference() {
|
||||
const queryClient = useQueryClient();
|
||||
const profileId = storage.get(storage.KEYS.PROFILE_ID);
|
||||
const key = libraryPlaybackPreferenceKeys.list(profileId);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (libraryId: number) =>
|
||||
api(`/library-playback-prefs/${libraryId}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: (_data, libraryId) => {
|
||||
const current = queryClient.getQueryData<LibraryPlaybackPreference[]>(key);
|
||||
|
||||
if (!current) {
|
||||
return queryClient.invalidateQueries({ queryKey: key });
|
||||
}
|
||||
|
||||
queryClient.setQueryData(key, removeCachedPreference(current, libraryId));
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useCallback } from "react";
|
||||
import { ApiClientError } from "@/api/client";
|
||||
import {
|
||||
useClearSettingValue,
|
||||
useSetSettingValue,
|
||||
type EffectiveSettingsMap,
|
||||
type SettingIdentity,
|
||||
} from "@/hooks/queries/settingValues";
|
||||
import { SETTING_DEFINITIONS, type SettingKey } from "@/lib/settingsContract";
|
||||
|
||||
/**
|
||||
* Writing a profile default when a device override may be shadowing it.
|
||||
*
|
||||
* Every "Defaults" screen reads the *resolved* value and writes the profile
|
||||
* row. The contract resolves profile_device above profile, so if a device row
|
||||
* exists — the settings migration converts legacy user_device_settings rows
|
||||
* into profile_device ones, and other clients write them directly — the
|
||||
* profile write lands but the displayed value never moves: the override keeps
|
||||
* winning and the control snaps back. Clearing this device's row alongside the
|
||||
* profile write is what makes the control mean what it says, and is the only
|
||||
* affordance the web has for removing a migrated override.
|
||||
*
|
||||
* useAutoPlayNextSetting arrived at this shape first, for the same reason;
|
||||
* this is that logic with the key as a parameter.
|
||||
*/
|
||||
|
||||
const PROFILE_SCOPE: SettingIdentity = { scope: "profile" };
|
||||
const DEVICE_SCOPE: SettingIdentity = { scope: "profile_device" };
|
||||
|
||||
/** Whether the contract lets this key be overridden per device. */
|
||||
export function isDeviceOverridable(key: SettingKey): boolean {
|
||||
return SETTING_DEFINITIONS[key].scopes.includes("profile_device");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a writer that saves at profile scope and clears any override on this
|
||||
* device. Pass the effective map the screen already reads so the clear is
|
||||
* skipped when the resolved value did not come from a device row — the common
|
||||
* case, and a DELETE for a row that does not exist is a wasted round trip.
|
||||
*/
|
||||
export function useProfileDefaultWriter(effective: EffectiveSettingsMap | undefined) {
|
||||
const setValue = useSetSettingValue();
|
||||
const clearValue = useClearSettingValue();
|
||||
|
||||
const save = useCallback(
|
||||
async (key: SettingKey, value: unknown) => {
|
||||
// The profile value is the durable expression, so it is written first: a
|
||||
// failed clear leaves the choice stored rather than losing it.
|
||||
await setValue.mutateAsync({ key, value, identity: PROFILE_SCOPE });
|
||||
|
||||
if (!isDeviceOverridable(key)) return;
|
||||
if (effective?.[key]?.source !== "profile_device") return;
|
||||
try {
|
||||
await clearValue.mutateAsync({ key, identity: DEVICE_SCOPE });
|
||||
} catch (error) {
|
||||
// Nothing to clear is the state this asks for.
|
||||
if (error instanceof ApiClientError && error.status === 404) return;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[clearValue, effective, setValue],
|
||||
);
|
||||
|
||||
/** Clear the profile row, and any device row shadowing it, so the key inherits again. */
|
||||
const reset = useCallback(
|
||||
async (key: SettingKey) => {
|
||||
const clearScope = async (identity: SettingIdentity) => {
|
||||
try {
|
||||
await clearValue.mutateAsync({ key, identity });
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError && error.status === 404) return;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
await clearScope(PROFILE_SCOPE);
|
||||
if (isDeviceOverridable(key)) await clearScope(DEVICE_SCOPE);
|
||||
},
|
||||
[clearValue],
|
||||
);
|
||||
|
||||
return {
|
||||
save,
|
||||
reset,
|
||||
isSaving: setValue.isPending || clearValue.isPending,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffectiveSettings } from "@/hooks/queries/settingValues";
|
||||
import { SETTING_KEYS } from "@/lib/settingsContract";
|
||||
|
||||
/**
|
||||
* The resolution cap playback should apply, read from the contract.
|
||||
*
|
||||
* The settings screen writes `playback.preferred_quality` (and its bitrate
|
||||
* companion) as canonical rows, while playback historically took the cap from
|
||||
* `currentProfile.quality_preference` — a legacy compound column the canonical
|
||||
* write deliberately does not mirror, because one string cannot losslessly
|
||||
* carry two axes. Left alone, choosing a quality would appear to save and then
|
||||
* change nothing about what plays.
|
||||
*
|
||||
* The canonical vocabulary is already what these consumers parse — "auto",
|
||||
* "480p", "720p", "1080p", "2160p" — so the value passes straight through.
|
||||
* "original" means "do not cap", which is what "auto" already expresses to
|
||||
* every consumer, so it maps there rather than reaching a resolution table
|
||||
* that has no entry for it.
|
||||
*
|
||||
* `fallback` is the profile column, used until the settings read resolves (and
|
||||
* if it fails). Playback must never block on a preferences fetch, and the
|
||||
* column still holds the pre-cutover choice for anyone who has not re-picked.
|
||||
*/
|
||||
export function useQualityPreference(fallback?: string | null): string | null {
|
||||
const { data } = useEffectiveSettings({ keys: [SETTING_KEYS.PLAYBACK_PREFERRED_QUALITY] });
|
||||
const resolved = data?.[SETTING_KEYS.PLAYBACK_PREFERRED_QUALITY]?.value;
|
||||
|
||||
if (typeof resolved !== "string") return fallback ?? null;
|
||||
if (resolved === "original") return "auto";
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/api/client";
|
||||
import { storage } from "@/utils/storage";
|
||||
import { SETTING_DEFINITIONS, SETTINGS_REVISION, type SettingKey } from "@/lib/settingsContract";
|
||||
import { useEventChannel } from "@/components/realtimeEventsContext";
|
||||
import { settingsKeys } from "./keys";
|
||||
|
||||
/**
|
||||
* Typed access to the canonical settings API.
|
||||
*
|
||||
* The hooks in ./settings.ts speak the legacy string-only endpoints: every
|
||||
* value is a string, scope is implied by which function you call, and an
|
||||
* unknown key is silently accepted. These speak the contract instead — values
|
||||
* are typed JSON, scope is explicit, and a key that is not in the manifest
|
||||
* cannot be expressed because SettingKey is generated from it.
|
||||
*/
|
||||
|
||||
/** The five scopes a value can live at. */
|
||||
export type SettingScope =
|
||||
| "account"
|
||||
| "profile"
|
||||
| "profile_device"
|
||||
| "profile_library"
|
||||
| "profile_series";
|
||||
|
||||
/** Where a resolved value came from, or "default" when nothing was stored. */
|
||||
export type SettingSource = SettingScope | "default";
|
||||
|
||||
export interface SettingIdentity {
|
||||
scope: SettingScope;
|
||||
/** Required for profile_library. */
|
||||
libraryId?: number;
|
||||
/** Required for profile_series. */
|
||||
seriesId?: string;
|
||||
}
|
||||
|
||||
export interface EffectiveSetting<T = unknown> {
|
||||
key: SettingKey;
|
||||
value: T;
|
||||
source: SettingSource;
|
||||
/** Present only when policy narrowed the answer; this is what the user chose. */
|
||||
stored_value?: T;
|
||||
constrained?: boolean;
|
||||
constraint_kind?: "ceiling" | "floor" | "allowlist" | "locked";
|
||||
/** The scope holding the value, so a reset can target it exactly. */
|
||||
scope?: SettingScope;
|
||||
library_id?: number;
|
||||
series_id?: string;
|
||||
}
|
||||
|
||||
interface EffectiveResponse {
|
||||
settings: EffectiveSetting[];
|
||||
revision: number;
|
||||
}
|
||||
|
||||
/** The cache shape one effective-settings query resolves to. */
|
||||
export type EffectiveSettingsMap = Partial<Record<SettingKey, EffectiveSetting>>;
|
||||
|
||||
function identityQuery(identity: SettingIdentity): string {
|
||||
const params = new URLSearchParams({ scope: identity.scope });
|
||||
if (identity.libraryId !== undefined) params.set("library_id", String(identity.libraryId));
|
||||
if (identity.seriesId !== undefined) params.set("series_id", identity.seriesId);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function activeProfileId() {
|
||||
return storage.get(storage.KEYS.PROFILE_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* The cache key one useEffectiveSettings call resolves under. Exported so a
|
||||
* store that layers optimistic updates on top of an effective read (sidebar
|
||||
* pins) can target the exact entry that read populated.
|
||||
*/
|
||||
export function effectiveSettingsQueryKey(options?: {
|
||||
keys?: readonly SettingKey[];
|
||||
libraryIds?: readonly number[];
|
||||
seriesIds?: readonly string[];
|
||||
}) {
|
||||
const { keys, libraryIds, seriesIds } = options ?? {};
|
||||
return [
|
||||
...settingsKeys.all,
|
||||
"values",
|
||||
"effective",
|
||||
activeProfileId(),
|
||||
keys ? [...keys].sort().join(",") : "*",
|
||||
libraryIds ? [...libraryIds].sort().join(",") : "",
|
||||
seriesIds ? [...seriesIds].sort().join(",") : "",
|
||||
] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve settings the way the server does, including the scope each answer
|
||||
* came from.
|
||||
*
|
||||
* Batched on purpose: a settings screen wants every key at once and a series
|
||||
* view wants several keys for one series, and the server answers either in one
|
||||
* read. Passing no keys returns every remote setting.
|
||||
*/
|
||||
export function useEffectiveSettings(options?: {
|
||||
keys?: readonly SettingKey[];
|
||||
libraryIds?: readonly number[];
|
||||
seriesIds?: readonly string[];
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const keys = options?.keys;
|
||||
const libraryIds = options?.libraryIds;
|
||||
const seriesIds = options?.seriesIds;
|
||||
|
||||
return useQuery({
|
||||
queryKey: effectiveSettingsQueryKey({ keys, libraryIds, seriesIds }),
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (keys?.length) params.set("keys", keys.join(","));
|
||||
if (libraryIds?.length) params.set("library_ids", libraryIds.join(","));
|
||||
if (seriesIds?.length) params.set("series_ids", seriesIds.join(","));
|
||||
const query = params.toString();
|
||||
const result = await api<EffectiveResponse>(
|
||||
`/settings/values/effective${query ? `?${query}` : ""}`,
|
||||
);
|
||||
const byKey: EffectiveSettingsMap = {};
|
||||
for (const setting of result.settings) {
|
||||
byKey[setting.key] = setting;
|
||||
}
|
||||
return byKey;
|
||||
},
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective value for one key, already unwrapped, falling back to the
|
||||
* contract default while the request is in flight.
|
||||
*
|
||||
* The default comes from the generated table rather than a literal at the call
|
||||
* site, which is what stops a client and the server disagreeing about what
|
||||
* "unset" means — the bug that made every default-on toggle flip off against a
|
||||
* server that did not know the key.
|
||||
*/
|
||||
export function useSettingValue<T = unknown>(
|
||||
key: SettingKey,
|
||||
options?: { libraryIds?: readonly number[]; seriesIds?: readonly string[]; enabled?: boolean },
|
||||
) {
|
||||
const query = useEffectiveSettings({ keys: [key], ...options });
|
||||
const setting = query.data?.[key];
|
||||
return {
|
||||
...query,
|
||||
value: (setting?.value ?? SETTING_DEFINITIONS[key].defaultValue) as T,
|
||||
source: setting?.source ?? "default",
|
||||
constrained: setting?.constrained ?? false,
|
||||
setting,
|
||||
};
|
||||
}
|
||||
|
||||
/** Write one value at one scope. */
|
||||
export function useSetSettingValue() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
key,
|
||||
value,
|
||||
identity,
|
||||
mutationId,
|
||||
}: {
|
||||
key: SettingKey;
|
||||
value: unknown;
|
||||
identity: SettingIdentity;
|
||||
/** Optional idempotency key; a retry with the same id replays rather than re-applying. */
|
||||
mutationId?: string;
|
||||
}) =>
|
||||
api(`/settings/values/${key}?${identityQuery(identity)}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(mutationId ? { "X-Silo-Mutation-Id": mutationId } : {}),
|
||||
},
|
||||
body: JSON.stringify({ value }),
|
||||
}),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: [...settingsKeys.all, "values"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear the value at one scope, so the setting inherits again. */
|
||||
export function useClearSettingValue() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ key, identity }: { key: SettingKey; identity: SettingIdentity }) =>
|
||||
api(`/settings/values/${key}?${identityQuery(identity)}`, { method: "DELETE" }),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: [...settingsKeys.all, "values"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's contract revision, for the server-upgrade-required case: a
|
||||
* client built against a newer manifest hides definitions the connected server
|
||||
* does not know rather than offering a choice it will refuse.
|
||||
*/
|
||||
export function useSettingsCapabilities() {
|
||||
return useQuery({
|
||||
queryKey: [...settingsKeys.all, "capabilities"] as const,
|
||||
queryFn: () =>
|
||||
api<{ api_version: number; revision: number; contract_etag: string }>(
|
||||
"/settings/contract/capabilities",
|
||||
),
|
||||
staleTime: 30 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether this build's contract is newer than the connected server's. */
|
||||
export function useContractIsAheadOfServer() {
|
||||
const { data } = useSettingsCapabilities();
|
||||
if (!data) return false;
|
||||
return SETTINGS_REVISION > data.revision;
|
||||
}
|
||||
|
||||
/** The user_settings.changed payload. Identity only — never a value. */
|
||||
interface UserSettingsChangedPayload {
|
||||
key?: string;
|
||||
scope?: SettingScope;
|
||||
profile_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps this client's resolved settings honest while another device edits them.
|
||||
*
|
||||
* The server publishes user_settings.changed on every canonical write and
|
||||
* delete, carrying only what changed and never the value — admins receive
|
||||
* other accounts' events, so a value in the payload would leak private
|
||||
* settings. The event is therefore a pure invalidation signal: mark the value
|
||||
* queries stale and let react-query refetch the ones a mounted screen is
|
||||
* actually reading. Nothing is written into the cache from the socket.
|
||||
*
|
||||
* A burst (a settings screen saving several keys, or a profile sync writing a
|
||||
* batch) costs one refetch, not one per event: invalidateQueries only marks
|
||||
* the entries stale and react-query coalesces the resulting fetches per key.
|
||||
*/
|
||||
export function useSettingValuesRealtime() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const handlers = useMemo(
|
||||
() => ({
|
||||
onEvent: (message: unknown) => {
|
||||
const event = message as { event?: string; data?: UserSettingsChangedPayload };
|
||||
if (event?.event !== "user_settings.changed") return;
|
||||
|
||||
// One account can have several profiles, and admins additionally
|
||||
// receive other accounts' user-scoped events (the frame carries no
|
||||
// user id to filter on, so that case falls through to a harmless
|
||||
// refetch). A profile-addressed change to a profile that is not the
|
||||
// signed-in one cannot alter what we resolve, so it is dropped. An
|
||||
// account-scoped change carries no profile and does affect us.
|
||||
const changedProfile = event.data?.profile_id;
|
||||
const activeProfile = activeProfileId();
|
||||
if (changedProfile && activeProfile && changedProfile !== activeProfile) return;
|
||||
|
||||
qc.invalidateQueries({ queryKey: [...settingsKeys.all, "values"] });
|
||||
},
|
||||
}),
|
||||
[qc],
|
||||
);
|
||||
|
||||
useEventChannel("user_settings", handlers);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
|
||||
import { SETTING_KEYS } from "@/lib/settingsContract";
|
||||
import { storage } from "@/utils/storage";
|
||||
import {
|
||||
effectiveSettingsQueryKey,
|
||||
useSettingValue,
|
||||
useSettingValuesRealtime,
|
||||
type EffectiveSettingsMap,
|
||||
} from "./settingValues";
|
||||
import type { EventChannelHandlers } from "@/components/realtimeEventsContext";
|
||||
|
||||
const apiMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/api/client", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/api/client")>("@/api/client");
|
||||
return { ...actual, api: apiMock };
|
||||
});
|
||||
|
||||
// The provider owns the websocket; the hook under test only cares that it
|
||||
// subscribes the right channel and what it does with a frame. Capturing the
|
||||
// handlers lets the test push a frame the way the socket would.
|
||||
const subscriptions = vi.hoisted(
|
||||
() => [] as { channel: string; handlers?: EventChannelHandlers }[],
|
||||
);
|
||||
vi.mock("@/components/realtimeEventsContext", async () => {
|
||||
const { useEffect } = await vi.importActual<typeof import("react")>("react");
|
||||
return {
|
||||
useEventChannel: (channel: string, handlers?: EventChannelHandlers) => {
|
||||
useEffect(() => {
|
||||
const entry = { channel, handlers };
|
||||
subscriptions.push(entry);
|
||||
return () => {
|
||||
const index = subscriptions.indexOf(entry);
|
||||
if (index >= 0) subscriptions.splice(index, 1);
|
||||
};
|
||||
}, [channel, handlers]);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function Subscriber() {
|
||||
useSettingValuesRealtime();
|
||||
return null;
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
return { queryClient, wrapper };
|
||||
}
|
||||
|
||||
function changedFrame(data: { key: string; scope: string; profile_id?: string }) {
|
||||
return {
|
||||
type: "event",
|
||||
channel: "user_settings",
|
||||
event: "user_settings.changed",
|
||||
event_id: "e1",
|
||||
timestamp: new Date().toISOString(),
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
/** Seeds a resolved value the way a mounted screen's read would have. */
|
||||
function seedEffective(queryClient: QueryClient, value: string) {
|
||||
const key = effectiveSettingsQueryKey({ keys: [SETTING_KEYS.UI_THEME] });
|
||||
queryClient.setQueryData<EffectiveSettingsMap>(key, {
|
||||
[SETTING_KEYS.UI_THEME]: { key: SETTING_KEYS.UI_THEME, value, source: "profile" },
|
||||
});
|
||||
return key;
|
||||
}
|
||||
|
||||
describe("useSettingValuesRealtime", () => {
|
||||
beforeEach(() => {
|
||||
apiMock.mockReset();
|
||||
subscriptions.length = 0;
|
||||
storage.set(storage.KEYS.PROFILE_ID, "profile-1");
|
||||
});
|
||||
|
||||
it("subscribes the user_settings channel", () => {
|
||||
const { wrapper } = createHarness();
|
||||
render(<Subscriber />, { wrapper });
|
||||
expect(subscriptions.map((entry) => entry.channel)).toContain("user_settings");
|
||||
});
|
||||
|
||||
it("refetches a mounted reader when another device changes this profile", async () => {
|
||||
const { wrapper } = createHarness();
|
||||
let served = "dark";
|
||||
apiMock.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
revision: 1,
|
||||
settings: [{ key: SETTING_KEYS.UI_THEME, value: served, source: "profile" }],
|
||||
}),
|
||||
);
|
||||
|
||||
function Reader() {
|
||||
useSettingValuesRealtime();
|
||||
const { value } = useSettingValue<string>(SETTING_KEYS.UI_THEME);
|
||||
return <span data-testid="theme">{value}</span>;
|
||||
}
|
||||
|
||||
const view = render(<Reader />, { wrapper });
|
||||
await waitFor(() => expect(view.getByTestId("theme").textContent).toBe("dark"));
|
||||
|
||||
// The other device's write lands on the server; this tab only learns of it
|
||||
// through the channel.
|
||||
served = "light";
|
||||
subscriptions
|
||||
.find((entry) => entry.channel === "user_settings")
|
||||
?.handlers?.onEvent?.(
|
||||
changedFrame({ key: SETTING_KEYS.UI_THEME, scope: "profile", profile_id: "profile-1" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(view.getByTestId("theme").textContent).toBe("light"));
|
||||
});
|
||||
|
||||
it("ignores a change addressed to another profile on the same account", () => {
|
||||
const { queryClient, wrapper } = createHarness();
|
||||
const key = seedEffective(queryClient, "dark");
|
||||
render(<Subscriber />, { wrapper });
|
||||
|
||||
subscriptions
|
||||
.find((entry) => entry.channel === "user_settings")
|
||||
?.handlers?.onEvent?.(
|
||||
changedFrame({ key: SETTING_KEYS.UI_THEME, scope: "profile", profile_id: "profile-2" }),
|
||||
);
|
||||
|
||||
expect(queryClient.getQueryState(key)?.isInvalidated ?? false).toBe(false);
|
||||
});
|
||||
|
||||
it("invalidates for an account-scoped change, which carries no profile", () => {
|
||||
const { queryClient, wrapper } = createHarness();
|
||||
const key = seedEffective(queryClient, "dark");
|
||||
render(<Subscriber />, { wrapper });
|
||||
|
||||
subscriptions
|
||||
.find((entry) => entry.channel === "user_settings")
|
||||
?.handlers?.onEvent?.(changedFrame({ key: SETTING_KEYS.UI_THEME, scope: "account" }));
|
||||
|
||||
expect(queryClient.getQueryState(key)?.isInvalidated ?? false).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores frames from other events on the channel", () => {
|
||||
const { queryClient, wrapper } = createHarness();
|
||||
const key = seedEffective(queryClient, "dark");
|
||||
render(<Subscriber />, { wrapper });
|
||||
|
||||
subscriptions
|
||||
.find((entry) => entry.channel === "user_settings")
|
||||
?.handlers?.onEvent?.({
|
||||
type: "event",
|
||||
channel: "user_settings",
|
||||
event: "something.else",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryState(key)?.isInvalidated ?? false).toBe(false);
|
||||
});
|
||||
|
||||
it("costs one invalidation pass per event without a manual refetch", () => {
|
||||
const { queryClient, wrapper } = createHarness();
|
||||
seedEffective(queryClient, "dark");
|
||||
render(<Subscriber />, { wrapper });
|
||||
const handlers = subscriptions.find((entry) => entry.channel === "user_settings")?.handlers;
|
||||
|
||||
// A burst of writes (a settings screen saving several keys at once) must
|
||||
// not fan out into a fetch per event: nothing is observing these queries,
|
||||
// so invalidation marks them stale and issues no request at all.
|
||||
for (const key of [SETTING_KEYS.UI_THEME, SETTING_KEYS.UI_DATE_FORMAT]) {
|
||||
handlers?.onEvent?.(changedFrame({ key, scope: "profile", profile_id: "profile-1" }));
|
||||
}
|
||||
|
||||
expect(apiMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
import { useSetSetting, type SettingsMap } from "./settings";
|
||||
import { settingsKeys } from "./keys";
|
||||
|
||||
const apiMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/api/client", () => ({ api: apiMock }));
|
||||
|
||||
function createHarness() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
return { queryClient, wrapper };
|
||||
}
|
||||
|
||||
describe("useSetSetting overlapping mutations", () => {
|
||||
it("does not roll back a newer successful save of the same key when an older save fails", async () => {
|
||||
const { queryClient, wrapper } = createHarness();
|
||||
queryClient.setQueryData<SettingsMap>(settingsKeys.list(), { "ui.date_format": "auto" });
|
||||
|
||||
let failFirst: (err: Error) => void = () => {};
|
||||
let resolveSecond: (value: unknown) => void = () => {};
|
||||
apiMock
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
failFirst = reject;
|
||||
}),
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSecond = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useSetSetting(), { wrapper });
|
||||
|
||||
result.current.mutate({ key: "ui.date_format", value: "DD/MM/YYYY" });
|
||||
result.current.mutate({ key: "ui.date_format", value: "YYYY-MM-DD" });
|
||||
await waitFor(() => expect(apiMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
resolveSecond(undefined);
|
||||
failFirst(new Error("network"));
|
||||
|
||||
await waitFor(() => {
|
||||
const list = queryClient.getQueryData<SettingsMap>(settingsKeys.list());
|
||||
// The failed older save must not resurrect "auto" over the accepted value.
|
||||
expect(list?.["ui.date_format"]).toBe("YYYY-MM-DD");
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back the optimistic value when a lone save fails", async () => {
|
||||
const { queryClient, wrapper } = createHarness();
|
||||
queryClient.setQueryData<SettingsMap>(settingsKeys.list(), { "ui.time_format": "12h" });
|
||||
apiMock.mockImplementationOnce(() => Promise.reject(new Error("network")));
|
||||
|
||||
const { result } = renderHook(() => useSetSetting(), { wrapper });
|
||||
result.current.mutate({ key: "ui.time_format", value: "24h" });
|
||||
|
||||
await waitFor(() => {
|
||||
const list = queryClient.getQueryData<SettingsMap>(settingsKeys.list());
|
||||
expect(list?.["ui.time_format"]).toBe("12h");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,248 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/api/client";
|
||||
import { storage } from "@/utils/storage";
|
||||
import { settingsKeys } from "./keys";
|
||||
|
||||
export interface SettingEntry {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface EffectiveSettingEntry {
|
||||
key: string;
|
||||
profile_id?: string;
|
||||
user_value?: string;
|
||||
device_value?: string;
|
||||
effective_value: string;
|
||||
source: "unset" | "default" | "user" | "device";
|
||||
has_device_override: boolean;
|
||||
device_id?: string;
|
||||
device_name?: string;
|
||||
device_platform?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface SettingsListResponse {
|
||||
settings: SettingEntry[];
|
||||
}
|
||||
|
||||
interface EffectiveSettingsListResponse {
|
||||
settings: EffectiveSettingEntry[];
|
||||
}
|
||||
|
||||
export type SettingsMap = Record<string, string>;
|
||||
export type EffectiveSettingsMap = Record<string, EffectiveSettingEntry>;
|
||||
|
||||
function normalizeSettings(entries: SettingEntry[] | undefined): SettingsMap {
|
||||
if (!entries || entries.length === 0) {
|
||||
return {};
|
||||
}
|
||||
return entries.reduce<SettingsMap>((acc, entry) => {
|
||||
acc[entry.key] = entry.value;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function normalizeEffectiveSettings(
|
||||
entries: EffectiveSettingEntry[] | undefined,
|
||||
): EffectiveSettingsMap {
|
||||
if (!entries || entries.length === 0) {
|
||||
return {};
|
||||
}
|
||||
return entries.reduce<EffectiveSettingsMap>((acc, entry) => {
|
||||
acc[entry.key] = entry;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function buildEffectiveQuery(keys: string[]) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("keys", keys.join(","));
|
||||
return `/settings/effective?${params.toString()}`;
|
||||
}
|
||||
|
||||
function getActiveProfileIdForSettings() {
|
||||
return storage.get(storage.KEYS.PROFILE_ID);
|
||||
}
|
||||
|
||||
export function useSettings(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: settingsKeys.list(),
|
||||
queryFn: async () => {
|
||||
const result = await api<SettingsListResponse>("/settings");
|
||||
return normalizeSettings(result.settings);
|
||||
},
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetting(key: string, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: settingsKeys.detail(key),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const result = await api<SettingEntry>(`/settings/${key}`);
|
||||
return result.value;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSetting() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
||||
api(`/settings/${key}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ value }),
|
||||
}),
|
||||
onMutate: async ({ key, value }) => {
|
||||
await Promise.all([
|
||||
qc.cancelQueries({ queryKey: settingsKeys.list() }),
|
||||
qc.cancelQueries({ queryKey: settingsKeys.detail(key) }),
|
||||
]);
|
||||
const previousList = qc.getQueryData<SettingsMap>(settingsKeys.list());
|
||||
const previousDetail = qc.getQueryData<string | null>(settingsKeys.detail(key));
|
||||
qc.setQueryData(settingsKeys.detail(key), value);
|
||||
qc.setQueryData<SettingsMap | undefined>(settingsKeys.list(), (current) => ({
|
||||
...(current ?? {}),
|
||||
[key]: value,
|
||||
}));
|
||||
return { previousList, previousDetail, key, attemptedValue: value };
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
if (!context) return;
|
||||
// Roll back only this key, and only while our optimistic value is still
|
||||
// current: a concurrent mutation (other key, or a newer save of the same
|
||||
// key) may have already overwritten it and must not be resurrected.
|
||||
qc.setQueryData<SettingsMap | undefined>(settingsKeys.list(), (current) => {
|
||||
if (!current) return context.previousList;
|
||||
if (current[context.key] !== context.attemptedValue) return current;
|
||||
const next = { ...current };
|
||||
const previousValue = context.previousList?.[context.key];
|
||||
if (previousValue === undefined) {
|
||||
delete next[context.key];
|
||||
} else {
|
||||
next[context.key] = previousValue;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
qc.setQueryData<string | null | undefined>(settingsKeys.detail(context.key), (current) =>
|
||||
current === context.attemptedValue ? context.previousDetail : current,
|
||||
);
|
||||
},
|
||||
onSettled: (_data, _err, variables) => {
|
||||
const profileId = getActiveProfileIdForSettings();
|
||||
qc.invalidateQueries({ queryKey: settingsKeys.list() });
|
||||
qc.invalidateQueries({ queryKey: settingsKeys.detail(variables.key) });
|
||||
qc.invalidateQueries({ queryKey: settingsKeys.effective(profileId, [variables.key]) });
|
||||
qc.invalidateQueries({ queryKey: ["settings", "effective"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSetting() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ key }: { key: string }) =>
|
||||
api(`/settings/${key}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: (_data, variables) => {
|
||||
qc.removeQueries({ queryKey: settingsKeys.detail(variables.key) });
|
||||
qc.setQueryData<SettingsMap | undefined>(settingsKeys.list(), (current) => {
|
||||
if (!current) return current;
|
||||
const next = { ...current };
|
||||
delete next[variables.key];
|
||||
return next;
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: settingsKeys.list() });
|
||||
const profileId = getActiveProfileIdForSettings();
|
||||
qc.invalidateQueries({ queryKey: settingsKeys.effective(profileId, [variables.key]) });
|
||||
qc.invalidateQueries({ queryKey: ["settings", "effective"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeviceSetting(
|
||||
profileId: string | null | undefined,
|
||||
key: string,
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: settingsKeys.deviceDetail(profileId, key),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const result = await api<SettingEntry>(`/settings/device/${key}`);
|
||||
return result.value;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: (options?.enabled ?? true) && Boolean(profileId),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetDeviceSetting() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
||||
api(`/settings/device/${key}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ value }),
|
||||
}),
|
||||
onSuccess: (_data, variables) => {
|
||||
const profileId = getActiveProfileIdForSettings();
|
||||
qc.setQueryData(settingsKeys.deviceDetail(profileId, variables.key), variables.value);
|
||||
qc.invalidateQueries({ queryKey: settingsKeys.deviceDetail(profileId, variables.key) });
|
||||
qc.invalidateQueries({ queryKey: settingsKeys.effective(profileId, [variables.key]) });
|
||||
qc.invalidateQueries({ queryKey: ["settings", "effective"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteDeviceSetting() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ key }: { key: string }) =>
|
||||
api(`/settings/device/${key}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: (_data, variables) => {
|
||||
const profileId = getActiveProfileIdForSettings();
|
||||
qc.setQueryData(settingsKeys.deviceDetail(profileId, variables.key), null);
|
||||
qc.invalidateQueries({ queryKey: settingsKeys.deviceDetail(profileId, variables.key) });
|
||||
qc.invalidateQueries({ queryKey: settingsKeys.effective(profileId, [variables.key]) });
|
||||
qc.invalidateQueries({ queryKey: ["settings", "effective"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useEffectiveSettings(
|
||||
profileId: string | null | undefined,
|
||||
keys: string[],
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
const uniqueKeys = [...new Set(keys.filter(Boolean))];
|
||||
return useQuery({
|
||||
queryKey: settingsKeys.effective(profileId, uniqueKeys),
|
||||
queryFn: async () => {
|
||||
const result = await api<EffectiveSettingsListResponse>(buildEffectiveQuery(uniqueKeys));
|
||||
return normalizeEffectiveSettings(result.settings);
|
||||
},
|
||||
enabled: (options?.enabled ?? true) && uniqueKeys.length > 0 && Boolean(profileId),
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
@@ -9,8 +9,26 @@ import {
|
||||
describe("sidebar pin helpers", () => {
|
||||
it("parses invalid values as an empty pin map", () => {
|
||||
expect(parseSidebarPins(null)).toEqual({});
|
||||
expect(parseSidebarPins(undefined)).toEqual({});
|
||||
expect(parseSidebarPins("not-json")).toEqual({});
|
||||
expect(parseSidebarPins("[]")).toEqual({});
|
||||
expect(parseSidebarPins([])).toEqual({});
|
||||
expect(parseSidebarPins(42)).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts the canonical object value directly", () => {
|
||||
const pins = {
|
||||
"42": [{ type: "collection", id: "col-1", label: "Pinned Horror" }],
|
||||
};
|
||||
expect(parseSidebarPins(pins)).toEqual(pins);
|
||||
});
|
||||
|
||||
it("still accepts the legacy JSON-string encoding", () => {
|
||||
expect(
|
||||
parseSidebarPins('{"42":[{"type":"collection","id":"col-1","label":"Pinned Horror"}]}'),
|
||||
).toEqual({
|
||||
"42": [{ type: "collection", id: "col-1", label: "Pinned Horror" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("adds a new pin to the target library", () => {
|
||||
@@ -40,9 +58,26 @@ describe("sidebar pin helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("builds the optimistic value as a typed object, not a JSON string", () => {
|
||||
const mutation = createSidebarPinsOptimisticMutation({
|
||||
currentValue: { "42": [{ type: "section", id: "sec-1", label: "Recently Added" }] },
|
||||
currentRevision: null,
|
||||
libraryId: 42,
|
||||
pin: { type: "collection", id: "col-1", label: "Pinned Horror" },
|
||||
revision: 1,
|
||||
});
|
||||
|
||||
expect(mutation.optimisticValue).toEqual({
|
||||
"42": [
|
||||
{ type: "section", id: "sec-1", label: "Recently Added" },
|
||||
{ type: "collection", id: "col-1", label: "Pinned Horror" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back the latest optimistic mutation when its revision is still current", () => {
|
||||
const mutation = createSidebarPinsOptimisticMutation({
|
||||
currentRaw: null,
|
||||
currentValue: null,
|
||||
currentRevision: null,
|
||||
libraryId: 42,
|
||||
pin: { type: "collection", id: "col-1", label: "Pinned Horror" },
|
||||
@@ -55,21 +90,21 @@ describe("sidebar pin helpers", () => {
|
||||
mutation,
|
||||
}),
|
||||
).toEqual({
|
||||
raw: null,
|
||||
value: null,
|
||||
revision: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not roll back over a newer optimistic mutation revision", () => {
|
||||
const firstMutation = createSidebarPinsOptimisticMutation({
|
||||
currentRaw: null,
|
||||
currentValue: null,
|
||||
currentRevision: null,
|
||||
libraryId: 42,
|
||||
pin: { type: "collection", id: "col-1", label: "Pinned Horror" },
|
||||
revision: 1,
|
||||
});
|
||||
createSidebarPinsOptimisticMutation({
|
||||
currentRaw: firstMutation.optimisticRaw,
|
||||
currentValue: firstMutation.optimisticValue,
|
||||
currentRevision: 1,
|
||||
libraryId: 42,
|
||||
pin: { type: "section", id: "sec-1", label: "Recently Added" },
|
||||
|
||||
@@ -1,30 +1,40 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useMemo, useCallback, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { SidebarPin, SidebarPins } from "@/api/types";
|
||||
import { useSettings, useSetSetting, type SettingsMap } from "./settings";
|
||||
import { settingsKeys } from "./keys";
|
||||
import { storage } from "@/utils/storage";
|
||||
import { SETTING_KEYS } from "@/lib/settingsContract";
|
||||
import {
|
||||
effectiveSettingsQueryKey,
|
||||
useEffectiveSettings,
|
||||
useSetSettingValue,
|
||||
type EffectiveSettingsMap,
|
||||
type SettingIdentity,
|
||||
} from "./settingValues";
|
||||
|
||||
const SIDEBAR_PINS_KEY = "sidebar_pins";
|
||||
const SIDEBAR_PINS_REVISION_KEY = [
|
||||
...settingsKeys.detail(SIDEBAR_PINS_KEY),
|
||||
"optimistic-revision",
|
||||
] as const;
|
||||
/** `ui.sidebar_pins` is profile-wide in the contract (no device scope). */
|
||||
const PROFILE_SCOPE: SettingIdentity = { scope: "profile" };
|
||||
|
||||
const PINS_KEYS = [SETTING_KEYS.UI_SIDEBAR_PINS] as const;
|
||||
|
||||
let nextSidebarPinsRevision = 0;
|
||||
|
||||
export function parseSidebarPins(value: string | null | undefined): SidebarPins {
|
||||
if (!value) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
|
||||
return parsed as SidebarPins;
|
||||
} catch {
|
||||
return {};
|
||||
/**
|
||||
* Accepts the canonical object value, the legacy JSON-string encoding, or
|
||||
* null/undefined (the contract default), and always lands on a usable map.
|
||||
*/
|
||||
export function parseSidebarPins(value: unknown): SidebarPins {
|
||||
if (value == null) return {};
|
||||
let parsed: unknown = value;
|
||||
if (typeof value === "string") {
|
||||
if (!value) return {};
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function serializeSidebarPins(pins: SidebarPins): string {
|
||||
return JSON.stringify(pins);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
|
||||
return parsed as SidebarPins;
|
||||
}
|
||||
|
||||
export function toggleSidebarPins(
|
||||
@@ -52,32 +62,32 @@ export function toggleSidebarPins(
|
||||
}
|
||||
|
||||
interface SidebarPinsOptimisticMutation {
|
||||
previousRaw: string | null;
|
||||
previousValue: unknown;
|
||||
previousRevision: number | null;
|
||||
optimisticRaw: string;
|
||||
optimisticValue: SidebarPins;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export function createSidebarPinsOptimisticMutation({
|
||||
currentRaw,
|
||||
currentValue,
|
||||
currentRevision,
|
||||
libraryId,
|
||||
pin,
|
||||
revision,
|
||||
}: {
|
||||
currentRaw: string | null | undefined;
|
||||
currentValue: unknown;
|
||||
currentRevision: number | null | undefined;
|
||||
libraryId: number;
|
||||
pin: SidebarPin;
|
||||
revision: number;
|
||||
}): SidebarPinsOptimisticMutation {
|
||||
const currentPins = parseSidebarPins(currentRaw);
|
||||
const currentPins = parseSidebarPins(currentValue);
|
||||
const nextPins = toggleSidebarPins(currentPins, libraryId, pin);
|
||||
|
||||
return {
|
||||
previousRaw: currentRaw ?? null,
|
||||
previousValue: currentValue ?? null,
|
||||
previousRevision: currentRevision ?? null,
|
||||
optimisticRaw: serializeSidebarPins(nextPins),
|
||||
optimisticValue: nextPins,
|
||||
revision,
|
||||
};
|
||||
}
|
||||
@@ -88,104 +98,136 @@ export function rollbackSidebarPinsOptimisticMutation({
|
||||
}: {
|
||||
currentRevision: number | null | undefined;
|
||||
mutation: SidebarPinsOptimisticMutation;
|
||||
}): { raw: string | null; revision: number | null } | null {
|
||||
}): { value: unknown; revision: number | null } | null {
|
||||
if ((currentRevision ?? null) !== mutation.revision) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
raw: mutation.previousRaw,
|
||||
value: mutation.previousValue,
|
||||
revision: mutation.previousRevision,
|
||||
};
|
||||
}
|
||||
|
||||
function useHasActiveProfile() {
|
||||
// The effective endpoint requires a profile header; before a profile is
|
||||
// chosen there is nothing to resolve and nothing to pin.
|
||||
return Boolean(storage.get(storage.KEYS.PROFILE_ID));
|
||||
}
|
||||
|
||||
export function useSidebarPins() {
|
||||
const { data: settings, isLoading } = useSettings();
|
||||
const raw = settings?.[SIDEBAR_PINS_KEY] ?? null;
|
||||
const pins = useMemo(() => parseSidebarPins(raw), [raw]);
|
||||
const enabled = useHasActiveProfile();
|
||||
const { data, isLoading } = useEffectiveSettings({ keys: PINS_KEYS, enabled });
|
||||
const value = data?.[SETTING_KEYS.UI_SIDEBAR_PINS]?.value;
|
||||
const pins = useMemo(() => parseSidebarPins(value), [value]);
|
||||
return { pins, isLoading };
|
||||
}
|
||||
|
||||
export function useToggleSidebarPin() {
|
||||
const { data: settings } = useSettings();
|
||||
const raw = settings?.[SIDEBAR_PINS_KEY] ?? null;
|
||||
const enabled = useHasActiveProfile();
|
||||
const { data } = useEffectiveSettings({ keys: PINS_KEYS, enabled });
|
||||
const renderValue = data?.[SETTING_KEYS.UI_SIDEBAR_PINS]?.value;
|
||||
const queryClient = useQueryClient();
|
||||
const setSetting = useSetSetting();
|
||||
const setValue = useSetSettingValue();
|
||||
|
||||
/**
|
||||
* Writes are chained rather than fired concurrently.
|
||||
*
|
||||
* This setting is one whole document, and the server upsert is last-write-
|
||||
* wins. Two toggles in flight at once can commit in either order, so the
|
||||
* first request landing second restores the document from before the second
|
||||
* toggle and silently drops it. The optimistic revision only orders the
|
||||
* client-side rollback; it cannot order the server. Chaining keeps at most
|
||||
* one request in flight, and because each link reads the document at the
|
||||
* moment it runs, a queued toggle sends the latest state rather than the one
|
||||
* it was queued with.
|
||||
*/
|
||||
const writeChain = useRef<Promise<unknown>>(Promise.resolve());
|
||||
|
||||
const readCachedValue = useCallback(() => {
|
||||
const cached = queryClient.getQueryData<EffectiveSettingsMap>(
|
||||
effectiveSettingsQueryKey({ keys: PINS_KEYS }),
|
||||
)?.[SETTING_KEYS.UI_SIDEBAR_PINS];
|
||||
return cached !== undefined ? cached.value : renderValue;
|
||||
}, [queryClient, renderValue]);
|
||||
|
||||
const isPinned = useCallback(
|
||||
(libraryId: number, pinType: SidebarPin["type"], targetId: string): boolean => {
|
||||
const cachedRaw =
|
||||
queryClient.getQueryData<string | null>(settingsKeys.detail(SIDEBAR_PINS_KEY)) ??
|
||||
queryClient.getQueryData<SettingsMap | undefined>(settingsKeys.list())?.[
|
||||
SIDEBAR_PINS_KEY
|
||||
] ??
|
||||
raw;
|
||||
const pins = parseSidebarPins(cachedRaw);
|
||||
const pins = parseSidebarPins(readCachedValue());
|
||||
const key = String(libraryId);
|
||||
return (pins[key] ?? []).some((p) => p.type === pinType && p.id === targetId);
|
||||
},
|
||||
[queryClient, raw],
|
||||
[readCachedValue],
|
||||
);
|
||||
|
||||
const togglePin = useCallback(
|
||||
(libraryId: number, pin: SidebarPin) => {
|
||||
const cachedRaw =
|
||||
queryClient.getQueryData<string | null>(settingsKeys.detail(SIDEBAR_PINS_KEY)) ??
|
||||
queryClient.getQueryData<SettingsMap | undefined>(settingsKeys.list())?.[
|
||||
SIDEBAR_PINS_KEY
|
||||
] ??
|
||||
raw;
|
||||
const cachedRevision =
|
||||
queryClient.getQueryData<number | null>(SIDEBAR_PINS_REVISION_KEY) ?? null;
|
||||
const pinsQueryKey = effectiveSettingsQueryKey({ keys: PINS_KEYS });
|
||||
const revisionKey = [...pinsQueryKey, "optimistic-revision"] as const;
|
||||
const previousEntry =
|
||||
queryClient.getQueryData<EffectiveSettingsMap>(pinsQueryKey)?.[
|
||||
SETTING_KEYS.UI_SIDEBAR_PINS
|
||||
];
|
||||
const cachedRevision = queryClient.getQueryData<number | null>(revisionKey) ?? null;
|
||||
nextSidebarPinsRevision += 1;
|
||||
const mutation = createSidebarPinsOptimisticMutation({
|
||||
currentRaw: cachedRaw,
|
||||
currentValue: previousEntry !== undefined ? previousEntry.value : renderValue,
|
||||
currentRevision: cachedRevision,
|
||||
libraryId,
|
||||
pin,
|
||||
revision: nextSidebarPinsRevision,
|
||||
});
|
||||
|
||||
queryClient.setQueryData(settingsKeys.detail(SIDEBAR_PINS_KEY), mutation.optimisticRaw);
|
||||
queryClient.setQueryData<SettingsMap | undefined>(settingsKeys.list(), (current) => ({
|
||||
queryClient.setQueryData<EffectiveSettingsMap>(pinsQueryKey, (current) => ({
|
||||
...(current ?? {}),
|
||||
[SIDEBAR_PINS_KEY]: mutation.optimisticRaw,
|
||||
}));
|
||||
queryClient.setQueryData(SIDEBAR_PINS_REVISION_KEY, mutation.revision);
|
||||
setSetting.mutate(
|
||||
{ key: SIDEBAR_PINS_KEY, value: mutation.optimisticRaw },
|
||||
{
|
||||
onError: () => {
|
||||
const rollback = rollbackSidebarPinsOptimisticMutation({
|
||||
currentRevision: queryClient.getQueryData<number | null>(SIDEBAR_PINS_REVISION_KEY),
|
||||
mutation,
|
||||
});
|
||||
|
||||
if (!rollback) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData(settingsKeys.detail(SIDEBAR_PINS_KEY), rollback.raw);
|
||||
queryClient.setQueryData<SettingsMap | undefined>(settingsKeys.list(), (current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
if (rollback.raw == null) {
|
||||
const next = { ...current };
|
||||
delete next[SIDEBAR_PINS_KEY];
|
||||
return next;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
[SIDEBAR_PINS_KEY]: rollback.raw,
|
||||
};
|
||||
});
|
||||
queryClient.setQueryData(SIDEBAR_PINS_REVISION_KEY, rollback.revision);
|
||||
},
|
||||
[SETTING_KEYS.UI_SIDEBAR_PINS]: {
|
||||
key: SETTING_KEYS.UI_SIDEBAR_PINS,
|
||||
...previousEntry,
|
||||
value: mutation.optimisticValue,
|
||||
source: "profile",
|
||||
},
|
||||
);
|
||||
}));
|
||||
queryClient.setQueryData(revisionKey, mutation.revision);
|
||||
writeChain.current = writeChain.current
|
||||
.catch(() => undefined)
|
||||
.then(() =>
|
||||
// The cache already holds every optimistic toggle applied so far, so
|
||||
// reading it here — rather than closing over this toggle's own value
|
||||
// — means a queued write sends the newest document.
|
||||
setValue.mutateAsync({
|
||||
key: SETTING_KEYS.UI_SIDEBAR_PINS,
|
||||
value: parseSidebarPins(readCachedValue()),
|
||||
identity: PROFILE_SCOPE,
|
||||
}),
|
||||
)
|
||||
.catch(() => {
|
||||
const rollback = rollbackSidebarPinsOptimisticMutation({
|
||||
currentRevision: queryClient.getQueryData<number | null>(revisionKey),
|
||||
mutation,
|
||||
});
|
||||
|
||||
if (!rollback) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData<EffectiveSettingsMap>(pinsQueryKey, (current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
if (previousEntry === undefined) {
|
||||
const next = { ...current };
|
||||
delete next[SETTING_KEYS.UI_SIDEBAR_PINS];
|
||||
return next;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
[SETTING_KEYS.UI_SIDEBAR_PINS]: previousEntry,
|
||||
};
|
||||
});
|
||||
queryClient.setQueryData(revisionKey, rollback.revision);
|
||||
});
|
||||
},
|
||||
[queryClient, raw, setSetting],
|
||||
[queryClient, readCachedValue, renderValue, setValue],
|
||||
);
|
||||
|
||||
return { togglePin, isPinned };
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
import { ApiClientError } from "@/api/client";
|
||||
import { DEFAULT_SUBTITLE_APPEARANCE } from "@/lib/subtitleAppearance";
|
||||
import { SETTING_KEYS } from "@/lib/settingsContract";
|
||||
import { useSubtitleAppearanceSetting } from "./subtitleAppearance";
|
||||
|
||||
const apiMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/api/client", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/api/client")>("@/api/client");
|
||||
return { ...actual, api: apiMock };
|
||||
});
|
||||
|
||||
function createHarness() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
return { queryClient, wrapper };
|
||||
}
|
||||
|
||||
/** The effective response the server sends for one resolved key. */
|
||||
function effectiveResponse(value: unknown, source: string) {
|
||||
return {
|
||||
revision: 1,
|
||||
settings: [{ key: SETTING_KEYS.PLAYBACK_SUBTITLE_APPEARANCE, value, source }],
|
||||
};
|
||||
}
|
||||
|
||||
describe("useSubtitleAppearanceSetting", () => {
|
||||
beforeEach(() => {
|
||||
apiMock.mockReset();
|
||||
});
|
||||
|
||||
it("saves the device override at profile_device scope", async () => {
|
||||
apiMock.mockImplementation((path: string) => {
|
||||
if (path.startsWith("/settings/values/effective")) {
|
||||
return Promise.resolve(effectiveResponse(DEFAULT_SUBTITLE_APPEARANCE, "default"));
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
const { wrapper } = createHarness();
|
||||
const { result } = renderHook(() => useSubtitleAppearanceSetting(), { wrapper });
|
||||
await waitFor(() => expect(result.current.appearance.fontSize).toBe("large"));
|
||||
|
||||
await result.current.save({ ...DEFAULT_SUBTITLE_APPEARANCE, fontSize: "xlarge" });
|
||||
|
||||
const write = apiMock.mock.calls.find(([path]) => (path as string).includes("?scope="));
|
||||
expect(write?.[0]).toBe(
|
||||
`/settings/values/${SETTING_KEYS.PLAYBACK_SUBTITLE_APPEARANCE}?scope=profile_device`,
|
||||
);
|
||||
expect(write?.[1]).toMatchObject({ method: "PUT" });
|
||||
// The contract types this value as an object, so the body carries the
|
||||
// object itself rather than the JSON-encoded string the legacy route took.
|
||||
expect(JSON.parse((write?.[1] as RequestInit).body as string)).toEqual({
|
||||
value: { ...DEFAULT_SUBTITLE_APPEARANCE, fontSize: "xlarge" },
|
||||
});
|
||||
});
|
||||
|
||||
it("resets by clearing the device row so the profile value applies again", async () => {
|
||||
apiMock.mockImplementation((path: string) => {
|
||||
if (path.startsWith("/settings/values/effective")) {
|
||||
return Promise.resolve(effectiveResponse(DEFAULT_SUBTITLE_APPEARANCE, "profile_device"));
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
const { wrapper } = createHarness();
|
||||
const { result } = renderHook(() => useSubtitleAppearanceSetting(), { wrapper });
|
||||
await waitFor(() => expect(result.current.hasDeviceOverride).toBe(true));
|
||||
|
||||
await result.current.reset();
|
||||
|
||||
const write = apiMock.mock.calls.find(([path]) => (path as string).includes("?scope="));
|
||||
expect(write?.[0]).toBe(
|
||||
`/settings/values/${SETTING_KEYS.PLAYBACK_SUBTITLE_APPEARANCE}?scope=profile_device`,
|
||||
);
|
||||
expect(write?.[1]).toMatchObject({ method: "DELETE" });
|
||||
});
|
||||
|
||||
it("reports no device override when the value resolved from a wider scope", async () => {
|
||||
apiMock.mockResolvedValue(effectiveResponse(DEFAULT_SUBTITLE_APPEARANCE, "profile"));
|
||||
|
||||
const { wrapper } = createHarness();
|
||||
const { result } = renderHook(() => useSubtitleAppearanceSetting(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.appearance.fontSize).toBe("large"));
|
||||
// A profile-wide appearance is not this device's override, so offering
|
||||
// "reset this device" would be a no-op the user cannot see the effect of.
|
||||
expect(result.current.hasDeviceOverride).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a reset with nothing stored as already done", async () => {
|
||||
apiMock.mockImplementation((path: string, options?: RequestInit) => {
|
||||
if (path.startsWith("/settings/values/effective")) {
|
||||
return Promise.resolve(effectiveResponse(DEFAULT_SUBTITLE_APPEARANCE, "profile_device"));
|
||||
}
|
||||
if (options?.method === "DELETE") {
|
||||
return Promise.reject(
|
||||
new ApiClientError(404, "not_found", "No value is set at this scope"),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
const { wrapper } = createHarness();
|
||||
const { result } = renderHook(() => useSubtitleAppearanceSetting(), { wrapper });
|
||||
await waitFor(() => expect(result.current.hasDeviceOverride).toBe(true));
|
||||
|
||||
// The canonical DELETE answers 404 for "nothing stored here", which for a
|
||||
// reset is the requested state rather than a failure.
|
||||
await expect(result.current.reset()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { ApiClientError } from "@/api/client";
|
||||
import {
|
||||
useClearSettingValue,
|
||||
useEffectiveSettings,
|
||||
useSetSettingValue,
|
||||
type SettingIdentity,
|
||||
} from "@/hooks/queries/settingValues";
|
||||
import { SETTING_KEYS } from "@/lib/settingsContract";
|
||||
import { parseSubtitleAppearance, type SubtitleAppearance } from "@/lib/subtitleAppearance";
|
||||
|
||||
/**
|
||||
* The one place subtitle appearance is read and written.
|
||||
*
|
||||
* The value used to live behind three bespoke endpoints
|
||||
* (/settings/subtitle_appearance/effective and the PUT/DELETE pair on
|
||||
* /settings/device/subtitle_appearance) that existed only because the legacy
|
||||
* string API had no way to express "this device's copy of an object-valued
|
||||
* setting". The contract does: playback.subtitle_appearance is an object typed
|
||||
* by the manifest, resolving profile_device → profile → default, so a device
|
||||
* override is an ordinary write at profile_device and "use the fallback" is an
|
||||
* ordinary delete at that scope.
|
||||
*
|
||||
* Three surfaces render this value — the settings screen, the in-player panel,
|
||||
* and the cue renderer — and before the cutover each parsed the effective
|
||||
* response itself. Sharing one hook is what keeps them from disagreeing about
|
||||
* which scope a save lands at.
|
||||
*/
|
||||
|
||||
/** Both the settings screen and the in-player panel override per device. */
|
||||
const DEVICE_SCOPE: SettingIdentity = { scope: "profile_device" };
|
||||
|
||||
export function useSubtitleAppearanceSetting() {
|
||||
const query = useEffectiveSettings({ keys: [SETTING_KEYS.PLAYBACK_SUBTITLE_APPEARANCE] });
|
||||
const setValue = useSetSettingValue();
|
||||
const clearValue = useClearSettingValue();
|
||||
|
||||
const entry = query.data?.[SETTING_KEYS.PLAYBACK_SUBTITLE_APPEARANCE];
|
||||
// The manifest default is the same object DEFAULT_SUBTITLE_APPEARANCE spells,
|
||||
// and the effective endpoint always answers, so an unset value arrives as the
|
||||
// default rather than as nothing to parse.
|
||||
const appearance = useMemo(() => parseSubtitleAppearance(entry?.value), [entry?.value]);
|
||||
|
||||
/**
|
||||
* Whether this device holds its own override, which is what gates the
|
||||
* "reset" affordance. The resolved scope names the row the value came from,
|
||||
* so this needs no separate read of the stored value.
|
||||
*/
|
||||
const hasDeviceOverride = entry?.source === "profile_device";
|
||||
|
||||
const save = useCallback(
|
||||
(next: SubtitleAppearance) =>
|
||||
setValue.mutateAsync({
|
||||
key: SETTING_KEYS.PLAYBACK_SUBTITLE_APPEARANCE,
|
||||
value: next,
|
||||
identity: DEVICE_SCOPE,
|
||||
}),
|
||||
[setValue],
|
||||
);
|
||||
|
||||
const reset = useCallback(async () => {
|
||||
try {
|
||||
await clearValue.mutateAsync({
|
||||
key: SETTING_KEYS.PLAYBACK_SUBTITLE_APPEARANCE,
|
||||
identity: DEVICE_SCOPE,
|
||||
});
|
||||
} catch (error) {
|
||||
// Nothing stored at this scope is the state a reset asks for, so the
|
||||
// canonical DELETE's 404 is success, matching how the legacy per-device
|
||||
// delete behaved.
|
||||
if (error instanceof ApiClientError && error.status === 404) return;
|
||||
throw error;
|
||||
}
|
||||
}, [clearValue]);
|
||||
|
||||
return {
|
||||
appearance,
|
||||
hasDeviceOverride,
|
||||
save,
|
||||
reset,
|
||||
isSaving: setValue.isPending,
|
||||
isResetting: clearValue.isPending,
|
||||
isLoading: query.isLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
import { ApiClientError } from "@/api/client";
|
||||
import { SETTING_KEYS } from "@/lib/settingsContract";
|
||||
import { resolveSettingValues, type StoredSettingRow } from "@/lib/settingsResolve";
|
||||
import { buildSubtitleChoiceRequests } from "@/player/utils/subtitleChoicePersistence";
|
||||
import type { PlayerSubtitleInfo } from "@/player/types";
|
||||
import { useDeleteSubtitlePreference } from "./subtitles";
|
||||
|
||||
const apiMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/api/client", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/api/client")>("@/api/client");
|
||||
return { ...actual, api: apiMock };
|
||||
});
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
||||
|
||||
function createHarness() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
return { queryClient, wrapper };
|
||||
}
|
||||
|
||||
const TRACKS: PlayerSubtitleInfo[] = [
|
||||
{
|
||||
index: 0,
|
||||
language: "ja",
|
||||
codec: "subrip",
|
||||
label: "Japanese",
|
||||
source: "embedded",
|
||||
forced: false,
|
||||
hearing_impaired: false,
|
||||
url: "",
|
||||
},
|
||||
];
|
||||
|
||||
/** The profile_series rows an in-player pick leaves behind. */
|
||||
function rowsFromInPlayerPick(seriesId: string): StoredSettingRow[] {
|
||||
return buildSubtitleChoiceRequests({ seriesId, index: 0, tracks: TRACKS })
|
||||
.filter((request) => request.path.startsWith("/settings/values/"))
|
||||
.map((request) => ({
|
||||
key: decodeURIComponent(request.path.slice("/settings/values/".length).split("?")[0]!),
|
||||
scope: "profile_series" as const,
|
||||
profileId: "profile-1",
|
||||
seriesId,
|
||||
value: (request.body as { value: unknown }).value,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("useDeleteSubtitlePreference", () => {
|
||||
beforeEach(() => {
|
||||
apiMock.mockReset();
|
||||
});
|
||||
|
||||
it("clears the canonical profile_series rows alongside the legacy row", async () => {
|
||||
// Before this, "Auto" deleted only /subtitle-prefs/{id}. profile_series is
|
||||
// the first scope in the resolution order for these keys, so the rows an
|
||||
// in-player pick left behind kept resolving the abandoned language for
|
||||
// every episode of the series, permanently and unreachably.
|
||||
const store = rowsFromInPlayerPick("series-1");
|
||||
apiMock.mockImplementation((path: string, options?: RequestInit) => {
|
||||
if (options?.method !== "DELETE") return Promise.resolve(undefined);
|
||||
if (path.startsWith("/subtitle-prefs/")) return Promise.resolve(undefined);
|
||||
const key = decodeURIComponent(path.slice("/settings/values/".length).split("?")[0]!);
|
||||
expect(path).toContain("scope=profile_series&series_id=series-1");
|
||||
const index = store.findIndex((row) => row.key === key);
|
||||
if (index < 0) {
|
||||
return Promise.reject(
|
||||
new ApiClientError(404, "not_found", "No value is set at this scope"),
|
||||
);
|
||||
}
|
||||
store.splice(index, 1);
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
const { wrapper } = createHarness();
|
||||
const { result } = renderHook(() => useDeleteSubtitlePreference(), { wrapper });
|
||||
|
||||
await result.current.mutateAsync("series-1");
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(store).toEqual([]);
|
||||
expect(apiMock.mock.calls.map(([path]) => path as string)).toContain(
|
||||
"/subtitle-prefs/series-1",
|
||||
);
|
||||
// Resolution falls all the way back to the contract default again.
|
||||
const [language] = resolveSettingValues([SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE], store, {
|
||||
profileId: "profile-1",
|
||||
seriesIds: ["series-1"],
|
||||
});
|
||||
expect(language?.source).toBe("default");
|
||||
});
|
||||
|
||||
it("treats an already-absent canonical row as success", async () => {
|
||||
apiMock.mockImplementation((path: string) => {
|
||||
if (path.startsWith("/subtitle-prefs/")) return Promise.resolve(undefined);
|
||||
return Promise.reject(new ApiClientError(404, "not_found", "No value is set at this scope"));
|
||||
});
|
||||
|
||||
const { wrapper } = createHarness();
|
||||
const { result } = renderHook(() => useDeleteSubtitlePreference(), { wrapper });
|
||||
|
||||
await expect(result.current.mutateAsync("series-1")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces a real failure rather than reporting a reset that did not happen", async () => {
|
||||
apiMock.mockImplementation((path: string) => {
|
||||
if (path.startsWith("/subtitle-prefs/")) return Promise.resolve(undefined);
|
||||
return Promise.reject(new ApiClientError(500, "internal_error", "boom"));
|
||||
});
|
||||
|
||||
const { wrapper } = createHarness();
|
||||
const { result } = renderHook(() => useDeleteSubtitlePreference(), { wrapper });
|
||||
|
||||
await expect(result.current.mutateAsync("series-1")).rejects.toThrow("boom");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { api } from "@/api/client";
|
||||
import { api, ApiClientError } from "@/api/client";
|
||||
import {
|
||||
SERIES_SUBTITLE_SETTING_KEYS,
|
||||
seriesSubtitleSettingPath,
|
||||
} from "@/lib/seriesSubtitleSettings";
|
||||
import type { PrePlaySubtitleSelection } from "@/player/types";
|
||||
import { derivePersistedSubtitleMode } from "@/player/utils/subtitleMode";
|
||||
import type {
|
||||
@@ -13,7 +17,7 @@ import type {
|
||||
SubtitleUploadRequest,
|
||||
} from "@/api/types";
|
||||
|
||||
import { itemKeys, subtitleKeys } from "./keys";
|
||||
import { itemKeys, settingsKeys, subtitleKeys } from "./keys";
|
||||
|
||||
interface DownloadSubtitleResponse {
|
||||
subtitle: DownloadedSubtitle;
|
||||
@@ -124,13 +128,37 @@ function invalidateItemDetails(queryClient: ReturnType<typeof useQueryClient>):
|
||||
* Clears the persisted subtitle override (saved when a track is manually
|
||||
* selected during playback) so profile-level auto selection applies again.
|
||||
* Keyed by the movie's content ID or the episode's series ID.
|
||||
*
|
||||
* A manual selection writes two stores: the specialized per-series row holding
|
||||
* the concrete track, and the canonical language/mode settings at
|
||||
* profile_series. Resetting has to clear both. profile_series is the first
|
||||
* scope in the manifest's resolution order for those keys, so leaving the
|
||||
* canonical rows behind would keep resolving the abandoned language for every
|
||||
* episode of the series with nothing in the UI able to remove it.
|
||||
*/
|
||||
export function useDeleteSubtitlePreference() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (prefId: string) => api<void>(`/subtitle-prefs/${prefId}`, { method: "DELETE" }),
|
||||
onSuccess: () => invalidateItemDetails(queryClient),
|
||||
mutationFn: async (prefId: string) => {
|
||||
await api<void>(`/subtitle-prefs/${prefId}`, { method: "DELETE" });
|
||||
await Promise.all(
|
||||
SERIES_SUBTITLE_SETTING_KEYS.map((key) =>
|
||||
api<void>(seriesSubtitleSettingPath(key, prefId), { method: "DELETE" }).catch((error) => {
|
||||
// Nothing stored at this scope is the state a reset asks for.
|
||||
if (error instanceof ApiClientError && error.status === 404) return;
|
||||
throw error;
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
onSuccess: () =>
|
||||
Promise.all([
|
||||
invalidateItemDetails(queryClient),
|
||||
// The player and the settings screens read these keys through the
|
||||
// effective endpoint, so the cleared rows have to leave that cache too.
|
||||
queryClient.invalidateQueries({ queryKey: [...settingsKeys.all, "values"] }),
|
||||
]),
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to reset subtitle preference");
|
||||
},
|
||||
|
||||
@@ -1,18 +1,53 @@
|
||||
import { storage } from "@/utils/storage";
|
||||
import { appearanceCache, storage } from "@/utils/storage";
|
||||
import { useOptionalAuth } from "@/hooks/useAuth";
|
||||
import type { ThemeId } from "@/lib/themes";
|
||||
import { DEFAULT_THEME, THEME_IDS } from "@/lib/themes";
|
||||
|
||||
export type TextScale = "default" | "large" | "x-large";
|
||||
export type TextWeight = "default" | "strong";
|
||||
|
||||
export function shouldLoadApiTheme({
|
||||
loading,
|
||||
user,
|
||||
}: {
|
||||
export interface AppearanceAuth {
|
||||
loading: boolean;
|
||||
user: { id: number } | null;
|
||||
}): boolean {
|
||||
return !loading && !!user;
|
||||
profile: { id: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The namespace that owns the device-local appearance caches, or null while
|
||||
* auth is bootstrapping, nobody is signed in, or no profile has been selected
|
||||
* yet.
|
||||
*
|
||||
* Appearance settings are profile-scoped in the settings contract (`ui.theme`
|
||||
* lives at `profile`, with an optional `profile_device` override), and several
|
||||
* profiles on one account share a user id — so the owner token is the user id
|
||||
* plus the active profile id. Every cache read and write in the app resolves
|
||||
* its namespace through this function, so no call site can be left behind.
|
||||
*
|
||||
* On the profile picker (user signed in, no profile chosen) the owner is null,
|
||||
* which falls back to the last profile that wrote here — the same "keep the
|
||||
* last look" behavior as the login screen — and gates off the settings request,
|
||||
* which cannot resolve profile scope without an active profile anyway.
|
||||
*/
|
||||
export function appearanceCacheOwner({ loading, user, profile }: AppearanceAuth): string | null {
|
||||
return !loading && user && profile ? `${user.id}:${profile.id}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `appearanceCacheOwner` for the currently authenticated session.
|
||||
*
|
||||
* The three appearance providers each need the same owner token, and each was
|
||||
* adapting the auth context to `AppearanceAuth` itself with identical code.
|
||||
* That put the shape of auth back in three places, which is the duplication the
|
||||
* doc comment above argues against: widening ownership has to be a change to
|
||||
* this file alone.
|
||||
*/
|
||||
export function useAppearanceCacheOwner(): string | null {
|
||||
const auth = useOptionalAuth();
|
||||
return appearanceCacheOwner({
|
||||
loading: auth?.loading ?? false,
|
||||
user: auth?.user ? { id: auth.user.id } : null,
|
||||
profile: auth?.profile ? { id: auth.profile.id } : null,
|
||||
});
|
||||
}
|
||||
|
||||
export function isValidTheme(value: string | null | undefined): value is ThemeId {
|
||||
@@ -31,7 +66,7 @@ export function parseHighContrast(value: string | null | undefined): boolean {
|
||||
return value === "true";
|
||||
}
|
||||
|
||||
export function getInitialTheme(): ThemeId {
|
||||
const stored = storage.get(storage.KEYS.THEME);
|
||||
export function getInitialTheme(owner: string | null): ThemeId {
|
||||
const stored = appearanceCache.get(storage.KEYS.THEME, owner);
|
||||
return isValidTheme(stored) ? stored : DEFAULT_THEME;
|
||||
}
|
||||
|
||||
+105
-33
@@ -1,14 +1,30 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useSettings, useSetSetting } from "@/hooks/queries/settings";
|
||||
import { useOptionalAuth } from "@/hooks/useAuth";
|
||||
import { shouldLoadApiTheme } from "@/hooks/themePreferences";
|
||||
import { storage } from "@/utils/storage";
|
||||
import { useEffectiveSettings, useSetSettingValue } from "@/hooks/queries/settingValues";
|
||||
import type { SettingIdentity } from "@/hooks/queries/settingValues";
|
||||
import { SETTING_KEYS } from "@/lib/settingsContract";
|
||||
import { useAppearanceCacheOwner } from "@/hooks/themePreferences";
|
||||
import { appearanceCache, storage } from "@/utils/storage";
|
||||
import { parseVarsJson } from "@/lib/themeExport";
|
||||
import { sanitizeCss } from "@/lib/cssSanitizer";
|
||||
import type { ThemeToken } from "@/lib/themeTokens";
|
||||
|
||||
export type ThemeVarOverrides = Partial<Record<ThemeToken, string>>;
|
||||
|
||||
/** Both custom-theme keys are profile-wide in the contract (no device scope). */
|
||||
const PROFILE_SCOPE: SettingIdentity = { scope: "profile" };
|
||||
|
||||
const CUSTOM_THEME_KEYS = [SETTING_KEYS.UI_CUSTOM_THEME_VARS, SETTING_KEYS.UI_CUSTOM_CSS] as const;
|
||||
|
||||
/**
|
||||
* The canonical API stores `ui.custom_theme_vars` as a JSON object; the local
|
||||
* cache still mirrors it as a JSON string. Accept either shape so a value that
|
||||
* round-tripped through the legacy string endpoints keeps parsing.
|
||||
*/
|
||||
function parseVarsValue(value: unknown): ThemeVarOverrides {
|
||||
if (typeof value === "string") return parseVarsJson(value);
|
||||
return typeof value === "object" && value !== null ? (value as ThemeVarOverrides) : {};
|
||||
}
|
||||
|
||||
interface UseCustomThemeResult {
|
||||
vars: ThemeVarOverrides;
|
||||
customCss: string;
|
||||
@@ -29,24 +45,29 @@ interface UseCustomThemeResult {
|
||||
}
|
||||
|
||||
export function useCustomTheme(): UseCustomThemeResult {
|
||||
const auth = useOptionalAuth();
|
||||
const loadApi = shouldLoadApiTheme({
|
||||
loading: auth?.loading ?? false,
|
||||
user: auth?.user ? { id: auth.user.id } : null,
|
||||
});
|
||||
// Owner of the localStorage warm start; null while auth bootstraps, when
|
||||
// nobody is signed in, or before a profile is selected, which keeps the last
|
||||
// look on the login and profile screens.
|
||||
const cacheOwner = useAppearanceCacheOwner();
|
||||
const loadApi = cacheOwner !== null;
|
||||
|
||||
// API values
|
||||
const { data: apiSettings } = useSettings({ enabled: loadApi });
|
||||
const apiVars = apiSettings?.ui_custom_theme_vars;
|
||||
const apiCss = apiSettings?.ui_custom_css;
|
||||
const settingMutation = useSetSetting();
|
||||
// API values, one batched effective read for both keys. A source of
|
||||
// "default" means this profile has stored nothing, which must leave the
|
||||
// local draft alone rather than clearing it.
|
||||
const { data: effectiveSettings } = useEffectiveSettings({
|
||||
keys: CUSTOM_THEME_KEYS,
|
||||
enabled: loadApi,
|
||||
});
|
||||
const varsSetting = effectiveSettings?.[SETTING_KEYS.UI_CUSTOM_THEME_VARS];
|
||||
const cssSetting = effectiveSettings?.[SETTING_KEYS.UI_CUSTOM_CSS];
|
||||
const settingMutation = useSetSettingValue();
|
||||
|
||||
// Local draft state (for instant updates without waiting for API)
|
||||
const [localVars, setLocalVars] = useState<ThemeVarOverrides>(() =>
|
||||
parseVarsJson(storage.get(storage.KEYS.UI_CUSTOM_THEME_VARS)),
|
||||
parseVarsJson(appearanceCache.get(storage.KEYS.UI_CUSTOM_THEME_VARS, cacheOwner)),
|
||||
);
|
||||
const [localCss, setLocalCss] = useState<string>(
|
||||
() => storage.get(storage.KEYS.UI_CUSTOM_CSS) ?? "",
|
||||
() => appearanceCache.get(storage.KEYS.UI_CUSTOM_CSS, cacheOwner) ?? "",
|
||||
);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
@@ -54,38 +75,89 @@ export function useCustomTheme(): UseCustomThemeResult {
|
||||
const varsTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const cssTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
// Sync API values into local state when they arrive
|
||||
// A debounced persist closes over the identity it was scheduled for. Drop
|
||||
// any pending write when the owner changes — a different account, or a
|
||||
// sibling profile on the same account — or the editor unmounts: otherwise a
|
||||
// timer armed by the previous identity fires against the new one's session
|
||||
// and stores their predecessor's CSS under their name.
|
||||
useEffect(() => {
|
||||
if (loadApi && apiVars !== undefined) {
|
||||
const parsed = parseVarsJson(apiVars);
|
||||
setLocalVars(parsed);
|
||||
storage.set(storage.KEYS.UI_CUSTOM_THEME_VARS, JSON.stringify(parsed));
|
||||
return () => {
|
||||
clearTimeout(varsTimerRef.current);
|
||||
clearTimeout(cssTimerRef.current);
|
||||
};
|
||||
}, [cacheOwner]);
|
||||
|
||||
// This state was seeded for whoever was signed in when the hook mounted.
|
||||
// Re-seed from the new owner's namespace when the identity changes, so an
|
||||
// account or profile switch without a reload stops rendering the previous
|
||||
// identity's tokens even before their own settings arrive. Adjusted during
|
||||
// render, so there is no frame in which the new identity sees the old one's
|
||||
// theme.
|
||||
const [seededOwner, setSeededOwner] = useState(cacheOwner);
|
||||
if (seededOwner !== cacheOwner) {
|
||||
setSeededOwner(cacheOwner);
|
||||
setLocalVars(parseVarsJson(appearanceCache.get(storage.KEYS.UI_CUSTOM_THEME_VARS, cacheOwner)));
|
||||
setLocalCss(appearanceCache.get(storage.KEYS.UI_CUSTOM_CSS, cacheOwner) ?? "");
|
||||
setIsDirty(false);
|
||||
}
|
||||
|
||||
// Sync stored API values into local state when they arrive.
|
||||
//
|
||||
// A source of "default" is an answer, not silence: the profile has no stored
|
||||
// custom theme, because it never had one or because another client just
|
||||
// deleted it. The cached copy has to go with it, or a theme removed
|
||||
// elsewhere would keep painting on this browser forever. An unsaved local
|
||||
// draft is left alone — the user is mid-edit, and their work is not the
|
||||
// server's to discard.
|
||||
useEffect(() => {
|
||||
if (!loadApi || varsSetting === undefined) return;
|
||||
if (varsSetting.source === "default") {
|
||||
if (isDirty) return;
|
||||
setLocalVars({});
|
||||
appearanceCache.remove(storage.KEYS.UI_CUSTOM_THEME_VARS, cacheOwner);
|
||||
return;
|
||||
}
|
||||
}, [loadApi, apiVars]);
|
||||
const parsed = parseVarsValue(varsSetting.value);
|
||||
setLocalVars(parsed);
|
||||
appearanceCache.set(storage.KEYS.UI_CUSTOM_THEME_VARS, JSON.stringify(parsed), cacheOwner);
|
||||
}, [loadApi, varsSetting, cacheOwner, isDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadApi && apiCss !== undefined && apiCss !== null) {
|
||||
setLocalCss(apiCss);
|
||||
storage.set(storage.KEYS.UI_CUSTOM_CSS, apiCss);
|
||||
if (!loadApi || cssSetting === undefined) return;
|
||||
if (cssSetting.source === "default") {
|
||||
if (isDirty) return;
|
||||
setLocalCss("");
|
||||
appearanceCache.remove(storage.KEYS.UI_CUSTOM_CSS, cacheOwner);
|
||||
return;
|
||||
}
|
||||
}, [loadApi, apiCss]);
|
||||
if (typeof cssSetting.value !== "string") return;
|
||||
setLocalCss(cssSetting.value);
|
||||
appearanceCache.set(storage.KEYS.UI_CUSTOM_CSS, cssSetting.value, cacheOwner);
|
||||
}, [loadApi, cssSetting, cacheOwner, isDirty]);
|
||||
|
||||
const persistVars = useCallback(
|
||||
(vars: ThemeVarOverrides) => {
|
||||
const json = JSON.stringify(vars);
|
||||
storage.set(storage.KEYS.UI_CUSTOM_THEME_VARS, json);
|
||||
settingMutation.mutate({ key: "ui_custom_theme_vars", value: json });
|
||||
appearanceCache.set(storage.KEYS.UI_CUSTOM_THEME_VARS, JSON.stringify(vars), cacheOwner);
|
||||
settingMutation.mutate({
|
||||
key: SETTING_KEYS.UI_CUSTOM_THEME_VARS,
|
||||
value: vars,
|
||||
identity: PROFILE_SCOPE,
|
||||
});
|
||||
},
|
||||
[settingMutation],
|
||||
[settingMutation, cacheOwner],
|
||||
);
|
||||
|
||||
const persistCss = useCallback(
|
||||
(css: string) => {
|
||||
const safe = sanitizeCss(css);
|
||||
storage.set(storage.KEYS.UI_CUSTOM_CSS, safe);
|
||||
settingMutation.mutate({ key: "ui_custom_css", value: safe });
|
||||
appearanceCache.set(storage.KEYS.UI_CUSTOM_CSS, safe, cacheOwner);
|
||||
settingMutation.mutate({
|
||||
key: SETTING_KEYS.UI_CUSTOM_CSS,
|
||||
value: safe,
|
||||
identity: PROFILE_SCOPE,
|
||||
});
|
||||
},
|
||||
[settingMutation],
|
||||
[settingMutation, cacheOwner],
|
||||
);
|
||||
|
||||
const setVar = useCallback(
|
||||
|
||||
@@ -13,19 +13,28 @@ import type {
|
||||
DateTimeFormatPreferences,
|
||||
TimeFormatPreference,
|
||||
} from "@/lib/datetime";
|
||||
import { useSettings, useSetSetting } from "@/hooks/queries/settings";
|
||||
import { useOptionalAuth } from "@/hooks/useAuth";
|
||||
import { storage } from "@/utils/storage";
|
||||
import { useEffectiveSettings, useSetSettingValue } from "@/hooks/queries/settingValues";
|
||||
import type { SettingIdentity } from "@/hooks/queries/settingValues";
|
||||
import { SETTING_KEYS } from "@/lib/settingsContract";
|
||||
import { useAppearanceCacheOwner } from "@/hooks/themePreferences";
|
||||
import { appearanceCache, storage } from "@/utils/storage";
|
||||
|
||||
export const DATE_FORMAT_SETTING_KEY = "ui.date_format";
|
||||
export const TIME_FORMAT_SETTING_KEY = "ui.time_format";
|
||||
/** Both format keys are profile-wide in the contract (no device scope). */
|
||||
const PROFILE_SCOPE: SettingIdentity = { scope: "profile" };
|
||||
|
||||
const FORMAT_KEYS = [SETTING_KEYS.UI_DATE_FORMAT, SETTING_KEYS.UI_TIME_FORMAT] as const;
|
||||
|
||||
// Seed the shared formatter state from localStorage at module load, before the
|
||||
// first render, so an app booting straight into a date-heavy page doesn't
|
||||
// paint in the wrong format while the settings request is in flight.
|
||||
//
|
||||
// Auth has not resolved this early, so this reads through the same namespace
|
||||
// resolution as every other access — the identity that last wrote here —
|
||||
// rather than the bare key. Reading the bare key would seed the formatter with
|
||||
// whichever account happened to write before namespacing existed.
|
||||
setDateTimeFormatPreferences({
|
||||
dateFormat: parseDateFormatPreference(storage.get(storage.KEYS.UI_DATE_FORMAT)),
|
||||
timeFormat: parseTimeFormatPreference(storage.get(storage.KEYS.UI_TIME_FORMAT)),
|
||||
dateFormat: parseDateFormatPreference(appearanceCache.get(storage.KEYS.UI_DATE_FORMAT, null)),
|
||||
timeFormat: parseTimeFormatPreference(appearanceCache.get(storage.KEYS.UI_TIME_FORMAT, null)),
|
||||
});
|
||||
|
||||
interface DateTimeFormatContextValue {
|
||||
@@ -43,69 +52,73 @@ const DateTimeFormatContext = createContext<DateTimeFormatContextValue | null>(n
|
||||
* lib/datetime, and exposes setters for the settings UI.
|
||||
*/
|
||||
export function DateTimeFormatProvider({ children }: { children: ReactNode }) {
|
||||
const auth = useOptionalAuth();
|
||||
const authUserId = auth && !auth.loading && auth.user ? String(auth.user.id) : null;
|
||||
const loadApiSettings = authUserId !== null;
|
||||
const { data: apiSettings } = useSettings({ enabled: loadApiSettings });
|
||||
const settingMutation = useSetSetting();
|
||||
// Same owner token as the theme caches, from the same hook, so widening
|
||||
// ownership can never move one provider and leave this one behind.
|
||||
const cacheOwner = useAppearanceCacheOwner();
|
||||
const loadApiSettings = cacheOwner !== null;
|
||||
const { data: effectiveSettings } = useEffectiveSettings({
|
||||
keys: FORMAT_KEYS,
|
||||
enabled: loadApiSettings,
|
||||
});
|
||||
const settingMutation = useSetSettingValue();
|
||||
|
||||
const local = useDateTimeFormat();
|
||||
// Once the authenticated settings have loaded they are authoritative: a
|
||||
// missing key means the user has no preference (auto), not "fall back to
|
||||
// whatever this device saw last" — and rollback of a failed save flows back
|
||||
// through the query cache. Until then (including when the settings request
|
||||
// fails), the localStorage warm start is only trusted if it was mirrored for
|
||||
// this same user; another account's device-local preference must not leak in.
|
||||
const apiLoaded = loadApiSettings && apiSettings !== undefined;
|
||||
const localTrusted =
|
||||
authUserId === null || storage.get(storage.KEYS.UI_DATETIME_FORMAT_OWNER) === authUserId;
|
||||
// Once the effective settings have loaded they are authoritative: the server
|
||||
// resolves an unset key to the contract default ("auto"), not to "fall back
|
||||
// to whatever this device saw last" — and rollback of a failed save flows
|
||||
// back through the query cache. Until then (including when the settings
|
||||
// request fails), fall back to this identity's own mirrored values rather
|
||||
// than the module-level seed, which ran before auth resolved and on a shared
|
||||
// browser may hold the identity that used this device last.
|
||||
const apiLoaded = loadApiSettings && effectiveSettings !== undefined;
|
||||
const apiDateFormat = effectiveSettings?.[SETTING_KEYS.UI_DATE_FORMAT]?.value;
|
||||
const apiTimeFormat = effectiveSettings?.[SETTING_KEYS.UI_TIME_FORMAT]?.value;
|
||||
const dateFormat = apiLoaded
|
||||
? parseDateFormatPreference(apiSettings[DATE_FORMAT_SETTING_KEY])
|
||||
: localTrusted
|
||||
? local.dateFormat
|
||||
: "auto";
|
||||
? parseDateFormatPreference(typeof apiDateFormat === "string" ? apiDateFormat : undefined)
|
||||
: loadApiSettings
|
||||
? parseDateFormatPreference(appearanceCache.get(storage.KEYS.UI_DATE_FORMAT, cacheOwner))
|
||||
: local.dateFormat;
|
||||
const timeFormat = apiLoaded
|
||||
? parseTimeFormatPreference(apiSettings[TIME_FORMAT_SETTING_KEY])
|
||||
: localTrusted
|
||||
? local.timeFormat
|
||||
: "auto";
|
||||
? parseTimeFormatPreference(typeof apiTimeFormat === "string" ? apiTimeFormat : undefined)
|
||||
: loadApiSettings
|
||||
? parseTimeFormatPreference(appearanceCache.get(storage.KEYS.UI_TIME_FORMAT, cacheOwner))
|
||||
: local.timeFormat;
|
||||
|
||||
useEffect(() => {
|
||||
setDateTimeFormatPreferences({ dateFormat, timeFormat });
|
||||
// Mirror the resolved values (tagged with their owner) so the next load on
|
||||
// this device paints in the right format before the settings request
|
||||
// resolves.
|
||||
// Mirror the resolved values into this identity's namespace so the next
|
||||
// load on this device paints in the right format before the settings
|
||||
// request resolves.
|
||||
if (apiLoaded) {
|
||||
storage.set(storage.KEYS.UI_DATE_FORMAT, dateFormat);
|
||||
storage.set(storage.KEYS.UI_TIME_FORMAT, timeFormat);
|
||||
if (authUserId !== null) {
|
||||
storage.set(storage.KEYS.UI_DATETIME_FORMAT_OWNER, authUserId);
|
||||
}
|
||||
appearanceCache.set(storage.KEYS.UI_DATE_FORMAT, dateFormat, cacheOwner);
|
||||
appearanceCache.set(storage.KEYS.UI_TIME_FORMAT, timeFormat, cacheOwner);
|
||||
}
|
||||
}, [dateFormat, timeFormat, apiLoaded, authUserId]);
|
||||
}, [dateFormat, timeFormat, apiLoaded, cacheOwner]);
|
||||
|
||||
const setDateFormat = useCallback(
|
||||
(value: DateFormatPreference) => {
|
||||
setDateTimeFormatPreferences({ ...getDateTimeFormatPreferences(), dateFormat: value });
|
||||
storage.set(storage.KEYS.UI_DATE_FORMAT, value);
|
||||
if (authUserId !== null) {
|
||||
storage.set(storage.KEYS.UI_DATETIME_FORMAT_OWNER, authUserId);
|
||||
}
|
||||
settingMutation.mutate({ key: DATE_FORMAT_SETTING_KEY, value });
|
||||
appearanceCache.set(storage.KEYS.UI_DATE_FORMAT, value, cacheOwner);
|
||||
settingMutation.mutate({
|
||||
key: SETTING_KEYS.UI_DATE_FORMAT,
|
||||
value,
|
||||
identity: PROFILE_SCOPE,
|
||||
});
|
||||
},
|
||||
[settingMutation, authUserId],
|
||||
[settingMutation, cacheOwner],
|
||||
);
|
||||
|
||||
const setTimeFormat = useCallback(
|
||||
(value: TimeFormatPreference) => {
|
||||
setDateTimeFormatPreferences({ ...getDateTimeFormatPreferences(), timeFormat: value });
|
||||
storage.set(storage.KEYS.UI_TIME_FORMAT, value);
|
||||
if (authUserId !== null) {
|
||||
storage.set(storage.KEYS.UI_DATETIME_FORMAT_OWNER, authUserId);
|
||||
}
|
||||
settingMutation.mutate({ key: TIME_FORMAT_SETTING_KEY, value });
|
||||
appearanceCache.set(storage.KEYS.UI_TIME_FORMAT, value, cacheOwner);
|
||||
settingMutation.mutate({
|
||||
key: SETTING_KEYS.UI_TIME_FORMAT,
|
||||
value,
|
||||
identity: PROFILE_SCOPE,
|
||||
});
|
||||
},
|
||||
[settingMutation, authUserId],
|
||||
[settingMutation, cacheOwner],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/api/client";
|
||||
import { useSetting, useSetSetting } from "@/hooks/queries/settings";
|
||||
import { useEffectiveSettings, useSetSettingValue } from "@/hooks/queries/settingValues";
|
||||
import type { SettingIdentity } from "@/hooks/queries/settingValues";
|
||||
import { SETTING_KEYS } from "@/lib/settingsContract";
|
||||
import { settingsKeys } from "@/hooks/queries/keys";
|
||||
import { storage } from "@/utils/storage";
|
||||
import { parseOverlayPrefs, serializeOverlayPrefs, type CardOverlayPrefs } from "@/lib/overlays";
|
||||
|
||||
const SETTING_KEY = "card_overlays";
|
||||
/** `ui.card_overlays` is profile-wide in the contract (no device scope). */
|
||||
const PROFILE_SCOPE: SettingIdentity = { scope: "profile" };
|
||||
|
||||
const OVERLAY_KEYS = [SETTING_KEYS.UI_CARD_OVERLAYS] as const;
|
||||
|
||||
interface OverlayConfig {
|
||||
enabled: boolean;
|
||||
defaults?: string;
|
||||
}
|
||||
|
||||
// The admin kill switch and server-wide defaults live in server_settings, not
|
||||
// the user-settings contract, so this endpoint stays alongside the canonical
|
||||
// values API.
|
||||
function useOverlayConfig() {
|
||||
return useQuery({
|
||||
queryKey: [...settingsKeys.all, "overlay-config"] as const,
|
||||
@@ -21,34 +30,49 @@ function useOverlayConfig() {
|
||||
}
|
||||
|
||||
export function useOverlayPrefs() {
|
||||
const { data: raw, isLoading: userLoading } = useSetting(SETTING_KEY);
|
||||
// The effective endpoint requires a profile header; without one the user
|
||||
// has no stored preference and the admin defaults apply on their own.
|
||||
const hasProfile = Boolean(storage.get(storage.KEYS.PROFILE_ID));
|
||||
const { data: effective, isLoading: userLoading } = useEffectiveSettings({
|
||||
keys: OVERLAY_KEYS,
|
||||
enabled: hasProfile,
|
||||
});
|
||||
const { data: config, isLoading: configLoading } = useOverlayConfig();
|
||||
const setSetting = useSetSetting();
|
||||
const setValue = useSetSettingValue();
|
||||
|
||||
// The contract default is null — "no preference expressed" — which is what
|
||||
// lets the server-wide admin default apply; a stored value wins outright.
|
||||
const userValue = effective?.[SETTING_KEYS.UI_CARD_OVERLAYS]?.value ?? null;
|
||||
|
||||
const prefs = useMemo(() => {
|
||||
// User setting takes priority; fall back to admin defaults
|
||||
const source = raw ?? config?.defaults ?? null;
|
||||
const source = userValue ?? config?.defaults ?? null;
|
||||
return parseOverlayPrefs(source);
|
||||
}, [raw, config?.defaults]);
|
||||
}, [userValue, config?.defaults]);
|
||||
|
||||
// Admin kill switch: if disabled server-wide, return null prefs
|
||||
const enabled = config?.enabled !== false;
|
||||
|
||||
const setPrefs = useCallback(
|
||||
(next: CardOverlayPrefs) => {
|
||||
const serialized = serializeOverlayPrefs(next);
|
||||
// Avoid a network round-trip and downstream re-render cascade when
|
||||
// the user toggles a control to its current value.
|
||||
if (raw === serialized) return;
|
||||
setSetting.mutate({ key: SETTING_KEY, value: serialized });
|
||||
// the user toggles a control to its current value. Comparison goes
|
||||
// through the parser so key ordering in the stored JSON is irrelevant.
|
||||
if (
|
||||
userValue != null &&
|
||||
serializeOverlayPrefs(parseOverlayPrefs(userValue)) === serializeOverlayPrefs(next)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setValue.mutate({ key: SETTING_KEYS.UI_CARD_OVERLAYS, value: next, identity: PROFILE_SCOPE });
|
||||
},
|
||||
[raw, setSetting],
|
||||
[userValue, setValue],
|
||||
);
|
||||
|
||||
// While either query is in flight, report null prefs instead of built-in
|
||||
// defaults: rendering defaults first would flash badges that vanish (or
|
||||
// change) the moment the user's own config or the admin kill switch loads.
|
||||
const isLoading = userLoading || configLoading;
|
||||
const isLoading = (hasProfile && userLoading) || configLoading;
|
||||
|
||||
return {
|
||||
prefs: enabled && !isLoading ? prefs : null,
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useSetSetting, useSetting } from "@/hooks/queries/settings";
|
||||
|
||||
export const SEARCH_MEDIA_SCOPE_SETTING_KEY = "search.media_scope";
|
||||
import { useSettingValue, useSetSettingValue } from "@/hooks/queries/settingValues";
|
||||
import type { SettingIdentity } from "@/hooks/queries/settingValues";
|
||||
import { SETTING_KEYS } from "@/lib/settingsContract";
|
||||
|
||||
/** Coarse search scope: "video" = movies & series, "audiobook" = books. */
|
||||
export type SearchMediaScope = "all" | "video" | "audiobook";
|
||||
|
||||
export const DEFAULT_SEARCH_MEDIA_SCOPE: SearchMediaScope = "video";
|
||||
|
||||
/** `search.media_scope` is profile-wide in the contract (no device scope). */
|
||||
const PROFILE_SCOPE: SettingIdentity = { scope: "profile" };
|
||||
|
||||
export function parseSearchMediaScope(value: string | null | undefined): SearchMediaScope | null {
|
||||
return value === "all" || value === "video" || value === "audiobook" ? value : null;
|
||||
}
|
||||
@@ -20,20 +23,24 @@ export function parseSearchMediaScope(value: string | null | undefined): SearchM
|
||||
* to it so the choice sticks across sessions and devices.
|
||||
*/
|
||||
export function useSearchMediaScope() {
|
||||
const settingQuery = useSetting(SEARCH_MEDIA_SCOPE_SETTING_KEY);
|
||||
const setSetting = useSetSetting();
|
||||
const { value } = useSettingValue<string>(SETTING_KEYS.SEARCH_MEDIA_SCOPE);
|
||||
const setSetting = useSetSettingValue();
|
||||
|
||||
const scope = parseSearchMediaScope(settingQuery.data) ?? DEFAULT_SEARCH_MEDIA_SCOPE;
|
||||
const scope = parseSearchMediaScope(value) ?? DEFAULT_SEARCH_MEDIA_SCOPE;
|
||||
|
||||
const setScope = useCallback(
|
||||
(next: SearchMediaScope) => {
|
||||
setSetting.mutate({ key: SEARCH_MEDIA_SCOPE_SETTING_KEY, value: next });
|
||||
setSetting.mutate({
|
||||
key: SETTING_KEYS.SEARCH_MEDIA_SCOPE,
|
||||
value: next,
|
||||
identity: PROFILE_SCOPE,
|
||||
});
|
||||
},
|
||||
[setSetting],
|
||||
);
|
||||
|
||||
// While the setting loads, scope falls back to the default ("video") so
|
||||
// consumers can fetch immediately; a differing stored preference simply
|
||||
// refetches once it arrives.
|
||||
// While the setting loads, scope falls back to the contract default
|
||||
// ("video") so consumers can fetch immediately; a differing stored
|
||||
// preference simply refetches once it arrives.
|
||||
return { scope, setScope };
|
||||
}
|
||||
|
||||
@@ -1,14 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldLoadApiTheme } from "./themePreferences";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { appearanceCache, storage } from "@/utils/storage";
|
||||
import { DEFAULT_THEME } from "@/lib/themes";
|
||||
import { appearanceCacheOwner, getInitialTheme } from "./themePreferences";
|
||||
|
||||
describe("shouldLoadApiTheme", () => {
|
||||
it("waits until auth bootstrap finishes before loading the API theme", () => {
|
||||
expect(shouldLoadApiTheme({ loading: true, user: null })).toBe(false);
|
||||
expect(shouldLoadApiTheme({ loading: true, user: { id: 1 } })).toBe(false);
|
||||
describe("appearanceCacheOwner", () => {
|
||||
// No owner also means no API settings request: the hooks gate the query on
|
||||
// having resolved an owner.
|
||||
it("has no owner until auth bootstrap finishes", () => {
|
||||
expect(appearanceCacheOwner({ loading: true, user: null, profile: null })).toBeNull();
|
||||
expect(
|
||||
appearanceCacheOwner({ loading: true, user: { id: 1 }, profile: { id: "p1" } }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("only loads the API theme for authenticated users after bootstrap", () => {
|
||||
expect(shouldLoadApiTheme({ loading: false, user: null })).toBe(false);
|
||||
expect(shouldLoadApiTheme({ loading: false, user: { id: 1 } })).toBe(true);
|
||||
it("has no owner when nobody is signed in", () => {
|
||||
expect(appearanceCacheOwner({ loading: false, user: null, profile: null })).toBeNull();
|
||||
});
|
||||
|
||||
// Appearance is profile-scoped: on the profile picker there is no identity
|
||||
// to resolve settings for yet, so the cache keeps the device's last look.
|
||||
it("has no owner before a profile is selected", () => {
|
||||
expect(appearanceCacheOwner({ loading: false, user: { id: 7 }, profile: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("identifies the cache owner by user id plus active profile id", () => {
|
||||
expect(appearanceCacheOwner({ loading: false, user: { id: 7 }, profile: { id: "p2" } })).toBe(
|
||||
"7:p2",
|
||||
);
|
||||
});
|
||||
|
||||
it("gives sibling profiles on one account distinct owners", () => {
|
||||
const user = { id: 7 };
|
||||
expect(appearanceCacheOwner({ loading: false, user, profile: { id: "p1" } })).not.toBe(
|
||||
appearanceCacheOwner({ loading: false, user, profile: { id: "p2" } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInitialTheme", () => {
|
||||
beforeEach(() => {
|
||||
// Not storage.remove over storage.KEYS: appearanceCache writes namespaced
|
||||
// keys ("silo-theme:1") and an owner pointer, none of which appear in
|
||||
// storage.KEYS, so that cleanup left both behind and made these cases
|
||||
// order-dependent. storage.test.ts already clears the whole store.
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("warms up from the cache while the owner is unknown", () => {
|
||||
appearanceCache.set(storage.KEYS.THEME, "cobalt-studio", "1");
|
||||
|
||||
expect(getInitialTheme(null)).toBe("cobalt-studio");
|
||||
});
|
||||
|
||||
it("warms up from the cache for the account that stored it", () => {
|
||||
appearanceCache.set(storage.KEYS.THEME, "cobalt-studio", "1");
|
||||
|
||||
expect(getInitialTheme("1")).toBe("cobalt-studio");
|
||||
});
|
||||
|
||||
it("ignores another account's cached theme", () => {
|
||||
appearanceCache.set(storage.KEYS.THEME, "cobalt-studio", "1");
|
||||
|
||||
expect(getInitialTheme("2")).toBe(DEFAULT_THEME);
|
||||
});
|
||||
});
|
||||
|
||||
+190
-51
@@ -1,17 +1,19 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { ThemeId } from "@/lib/themes";
|
||||
import { useSettings, useSetSetting } from "@/hooks/queries/settings";
|
||||
import { useOptionalAuth } from "@/hooks/useAuth";
|
||||
import { useEffectiveSettings } from "@/hooks/queries/settingValues";
|
||||
import { useProfileDefaultWriter } from "@/hooks/queries/profileDefaults";
|
||||
import { SETTING_KEYS } from "@/lib/settingsContract";
|
||||
import { useBranding } from "@/hooks/useBranding";
|
||||
import { storage } from "@/utils/storage";
|
||||
import { appearanceCache, storage } from "@/utils/storage";
|
||||
import type { StorageKey } from "@/utils/storage";
|
||||
import {
|
||||
getInitialTheme,
|
||||
isValidTheme,
|
||||
parseHighContrast,
|
||||
parseTextScale,
|
||||
parseTextWeight,
|
||||
shouldLoadApiTheme,
|
||||
useAppearanceCacheOwner,
|
||||
} from "@/hooks/themePreferences";
|
||||
import type { TextScale, TextWeight } from "@/hooks/themePreferences";
|
||||
|
||||
@@ -30,6 +32,29 @@ interface ThemeContextValue {
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* The four appearance keys this provider needs, fetched in one batched
|
||||
* effective read rather than a query per key.
|
||||
*/
|
||||
const APPEARANCE_KEYS = [
|
||||
SETTING_KEYS.UI_THEME,
|
||||
SETTING_KEYS.UI_TEXT_SCALE,
|
||||
SETTING_KEYS.UI_TEXT_WEIGHT,
|
||||
SETTING_KEYS.UI_HIGH_CONTRAST,
|
||||
] as const;
|
||||
|
||||
/*
|
||||
* These keys are profile-scoped with an optional per-device override; the
|
||||
* effective read already resolves profile_device over profile, and these
|
||||
* setters write the profile-wide value. There is no device-override UI for
|
||||
* appearance on the web, but a device row can still exist — the settings
|
||||
* migration converts legacy user_device_settings rows into profile_device
|
||||
* ones, other clients write them, and the admin surface can too — so the
|
||||
* shared writer clears one alongside the profile write. Without that a
|
||||
* migrated override would shadow every later choice with no affordance to
|
||||
* remove it, and the control would snap straight back.
|
||||
*/
|
||||
|
||||
function applyThemeToDOM(theme: ThemeId): void {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}
|
||||
@@ -47,50 +72,169 @@ function applyHighContrastToDOM(value: boolean): void {
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [themePreference, setThemePreference] = useState<ThemeId>(getInitialTheme);
|
||||
// The identity that owns the localStorage warm start. Null while auth is
|
||||
// bootstrapping, nobody is signed in, or no profile is selected yet, which
|
||||
// still trusts the cache so the app paints in the last look this device used.
|
||||
const cacheOwner = useAppearanceCacheOwner();
|
||||
const loadApiTheme = cacheOwner !== null;
|
||||
|
||||
const [themePreference, setThemePreference] = useState<ThemeId>(() =>
|
||||
getInitialTheme(cacheOwner),
|
||||
);
|
||||
const [previewThemeState, setPreviewThemeState] = useState<ThemeId | null>(null);
|
||||
const [textScalePreference, setTextScalePreference] = useState<TextScale>(() =>
|
||||
parseTextScale(storage.get(storage.KEYS.UI_TEXT_SCALE)),
|
||||
parseTextScale(appearanceCache.get(storage.KEYS.UI_TEXT_SCALE, cacheOwner)),
|
||||
);
|
||||
const [textWeightPreference, setTextWeightPreference] = useState<TextWeight>(() =>
|
||||
parseTextWeight(storage.get(storage.KEYS.UI_TEXT_WEIGHT)),
|
||||
parseTextWeight(appearanceCache.get(storage.KEYS.UI_TEXT_WEIGHT, cacheOwner)),
|
||||
);
|
||||
const [highContrastPreference, setHighContrastPreference] = useState<boolean>(() =>
|
||||
parseHighContrast(storage.get(storage.KEYS.UI_HIGH_CONTRAST)),
|
||||
parseHighContrast(appearanceCache.get(storage.KEYS.UI_HIGH_CONTRAST, cacheOwner)),
|
||||
);
|
||||
const auth = useOptionalAuth();
|
||||
const loadApiTheme = shouldLoadApiTheme({
|
||||
loading: auth?.loading ?? false,
|
||||
user: auth?.user ? { id: auth.user.id } : null,
|
||||
});
|
||||
|
||||
// Load persisted setting from API (profile-scoped)
|
||||
const { data: apiSettings } = useSettings({ enabled: loadApiTheme });
|
||||
const apiTheme = apiSettings?.ui_theme;
|
||||
const apiTextScale = apiSettings?.ui_text_scale;
|
||||
const apiTextWeight = apiSettings?.ui_text_weight;
|
||||
const apiHighContrast = apiSettings?.ui_high_contrast;
|
||||
const settingMutation = useSetSetting();
|
||||
// This state was seeded for whoever was signed in when the provider mounted.
|
||||
// Re-seed from the new owner's namespace when the identity changes — a
|
||||
// different account, or a sibling profile on the same account — so switching
|
||||
// without a reload stops painting the previous identity's look. Values are
|
||||
// namespaced, so this reads the new identity's own warm start rather than
|
||||
// falling back to defaults.
|
||||
//
|
||||
// Adjusted during render rather than in an effect: React re-runs this pass
|
||||
// before committing, so the new identity never gets a frame painted with the
|
||||
// previous one's appearance.
|
||||
const [seededOwner, setSeededOwner] = useState(cacheOwner);
|
||||
if (seededOwner !== cacheOwner) {
|
||||
setSeededOwner(cacheOwner);
|
||||
setThemePreference(getInitialTheme(cacheOwner));
|
||||
setTextScalePreference(
|
||||
parseTextScale(appearanceCache.get(storage.KEYS.UI_TEXT_SCALE, cacheOwner)),
|
||||
);
|
||||
setTextWeightPreference(
|
||||
parseTextWeight(appearanceCache.get(storage.KEYS.UI_TEXT_WEIGHT, cacheOwner)),
|
||||
);
|
||||
setHighContrastPreference(
|
||||
parseHighContrast(appearanceCache.get(storage.KEYS.UI_HIGH_CONTRAST, cacheOwner)),
|
||||
);
|
||||
}
|
||||
|
||||
// Load persisted settings from the canonical effective endpoint, one batched
|
||||
// read for all four appearance keys. The server resolves the profile_device
|
||||
// override over the profile value, so this needs no per-scope reads.
|
||||
//
|
||||
// A source of "default" means the profile has stored no choice of its own;
|
||||
// that must stay distinguishable from an explicit choice so the admin default
|
||||
// and the local warm start keep their layering, so those values are dropped
|
||||
// here rather than treated as the profile's preference.
|
||||
const { data: effectiveSettings } = useEffectiveSettings({
|
||||
keys: APPEARANCE_KEYS,
|
||||
enabled: loadApiTheme,
|
||||
});
|
||||
const storedValue = (key: (typeof APPEARANCE_KEYS)[number]): unknown => {
|
||||
const setting = effectiveSettings?.[key];
|
||||
return setting !== undefined && setting.source !== "default" ? setting.value : undefined;
|
||||
};
|
||||
const rawApiTheme = storedValue(SETTING_KEYS.UI_THEME);
|
||||
const apiTheme = typeof rawApiTheme === "string" ? rawApiTheme : undefined;
|
||||
const rawApiTextScale = storedValue(SETTING_KEYS.UI_TEXT_SCALE);
|
||||
const apiTextScale = typeof rawApiTextScale === "string" ? rawApiTextScale : undefined;
|
||||
const rawApiTextWeight = storedValue(SETTING_KEYS.UI_TEXT_WEIGHT);
|
||||
const apiTextWeight = typeof rawApiTextWeight === "string" ? rawApiTextWeight : undefined;
|
||||
const rawApiHighContrast = storedValue(SETTING_KEYS.UI_HIGH_CONTRAST);
|
||||
const apiHighContrast = typeof rawApiHighContrast === "boolean" ? rawApiHighContrast : undefined;
|
||||
const { save: saveProfileDefault } = useProfileDefaultWriter(effectiveSettings);
|
||||
|
||||
// Admin-set server default theme applies only when the user has expressed no
|
||||
// preference of their own (no stored local choice and no profile ui_theme).
|
||||
// A user's explicit choice always wins, preserving the per-user layering.
|
||||
// preference of their own (no stored local choice and no profile ui.theme).
|
||||
// A profile's explicit choice always wins, preserving the per-profile
|
||||
// layering.
|
||||
const { defaultTheme: adminDefaultTheme } = useBranding();
|
||||
const hasStoredThemeChoice = storage.get(storage.KEYS.THEME) != null;
|
||||
const localTheme = themePreference;
|
||||
const localTextScale = textScalePreference;
|
||||
const localTextWeight = textWeightPreference;
|
||||
const localHighContrast = highContrastPreference;
|
||||
const hasStoredThemeChoice = appearanceCache.get(storage.KEYS.THEME, cacheOwner) != null;
|
||||
const fallbackTheme: ThemeId =
|
||||
!hasStoredThemeChoice && isValidTheme(adminDefaultTheme) ? adminDefaultTheme : themePreference;
|
||||
!hasStoredThemeChoice && isValidTheme(adminDefaultTheme) ? adminDefaultTheme : localTheme;
|
||||
|
||||
const theme =
|
||||
loadApiTheme && apiTheme ? getInitialThemeFromApi(apiTheme, fallbackTheme) : fallbackTheme;
|
||||
const textScale = loadApiTheme
|
||||
? parseTextScale(apiTextScale ?? textScalePreference)
|
||||
: textScalePreference;
|
||||
// The server's value is this profile's own stored choice, so it wins outright
|
||||
// whenever it is present and valid. It is deliberately not compared against
|
||||
// the local cache: the effect below mirrors the server's value into that very
|
||||
// cache, so any such comparison stops holding after the first render and the
|
||||
// theme silently reverts to the default on the second.
|
||||
const theme = loadApiTheme && isValidTheme(apiTheme) ? apiTheme : fallbackTheme;
|
||||
const textScale = loadApiTheme ? parseTextScale(apiTextScale ?? localTextScale) : localTextScale;
|
||||
const textWeight = loadApiTheme
|
||||
? parseTextWeight(apiTextWeight ?? textWeightPreference)
|
||||
: textWeightPreference;
|
||||
const highContrast = loadApiTheme
|
||||
? parseHighContrast(apiHighContrast ?? String(highContrastPreference))
|
||||
: highContrastPreference;
|
||||
? parseTextWeight(apiTextWeight ?? localTextWeight)
|
||||
: localTextWeight;
|
||||
const highContrast = loadApiTheme ? (apiHighContrast ?? localHighContrast) : localHighContrast;
|
||||
|
||||
// Mirror the server's values into this identity's namespace so the next cold
|
||||
// start paints them before the settings request resolves. Without this the
|
||||
// cache would only ever hold choices made on this device, and a user who
|
||||
// picked their theme elsewhere would flash the default on every load.
|
||||
//
|
||||
// Only keys the profile actually has a stored preference for are mirrored:
|
||||
// the absence of a cached theme is what lets the admin default apply, so
|
||||
// writing a resolved-but-unchosen value here would silently pin them to
|
||||
// whatever the default happened to be the first time they loaded the app.
|
||||
// The mirror runs both ways. A key the server answered for but has no stored
|
||||
// value at — source "default" — is a key this profile has no preference for,
|
||||
// whether it never chose one or another client just deleted it. Its cached
|
||||
// value has to go, or a removal made elsewhere would never take effect here
|
||||
// and the stale entry would keep winning the fallback below.
|
||||
//
|
||||
// Only an explicit "default" clears. A key simply absent from the response
|
||||
// is not an answer about it, and treating silence as a deletion would drop
|
||||
// a good warm start on any partial read. Only this owner's namespace is
|
||||
// touched; another identity's warm start is not ours to clear.
|
||||
useEffect(() => {
|
||||
if (!loadApiTheme || effectiveSettings === undefined) return;
|
||||
const mirror = (
|
||||
key: (typeof APPEARANCE_KEYS)[number],
|
||||
cacheKey: StorageKey,
|
||||
value: string | undefined,
|
||||
) => {
|
||||
if (value !== undefined) {
|
||||
appearanceCache.set(cacheKey, value, cacheOwner);
|
||||
return false;
|
||||
}
|
||||
if (effectiveSettings[key]?.source !== "default") return false;
|
||||
appearanceCache.remove(cacheKey, cacheOwner);
|
||||
return true;
|
||||
};
|
||||
const cleared = {
|
||||
theme: mirror(
|
||||
SETTING_KEYS.UI_THEME,
|
||||
storage.KEYS.THEME,
|
||||
isValidTheme(apiTheme) ? apiTheme : undefined,
|
||||
),
|
||||
textScale: mirror(SETTING_KEYS.UI_TEXT_SCALE, storage.KEYS.UI_TEXT_SCALE, apiTextScale),
|
||||
textWeight: mirror(SETTING_KEYS.UI_TEXT_WEIGHT, storage.KEYS.UI_TEXT_WEIGHT, apiTextWeight),
|
||||
highContrast: mirror(
|
||||
SETTING_KEYS.UI_HIGH_CONTRAST,
|
||||
storage.KEYS.UI_HIGH_CONTRAST,
|
||||
apiHighContrast === undefined ? undefined : String(apiHighContrast),
|
||||
),
|
||||
};
|
||||
|
||||
// The local state was seeded from those same cached values, and the render
|
||||
// below falls back to it whenever the server has none — so clearing the
|
||||
// cache alone would leave the removed value on screen until a reload.
|
||||
// Parsing undefined yields each preference's own default, and React bails
|
||||
// out when the value is already that, so this cannot loop.
|
||||
if (cleared.textScale) setTextScalePreference(parseTextScale(undefined));
|
||||
if (cleared.textWeight) setTextWeightPreference(parseTextWeight(undefined));
|
||||
if (cleared.highContrast) setHighContrastPreference(parseHighContrast(undefined));
|
||||
if (cleared.theme) setThemePreference(getInitialTheme(cacheOwner));
|
||||
}, [
|
||||
loadApiTheme,
|
||||
effectiveSettings,
|
||||
apiTheme,
|
||||
apiTextScale,
|
||||
apiTextWeight,
|
||||
apiHighContrast,
|
||||
cacheOwner,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
applyThemeToDOM(previewThemeState ?? theme);
|
||||
@@ -113,10 +257,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
setPreviewThemeState(null);
|
||||
setThemePreference(newTheme);
|
||||
applyThemeToDOM(newTheme);
|
||||
storage.set(storage.KEYS.THEME, newTheme);
|
||||
settingMutation.mutate({ key: "ui_theme", value: newTheme });
|
||||
appearanceCache.set(storage.KEYS.THEME, newTheme, cacheOwner);
|
||||
void saveProfileDefault(SETTING_KEYS.UI_THEME, newTheme);
|
||||
},
|
||||
[settingMutation],
|
||||
[saveProfileDefault, cacheOwner],
|
||||
);
|
||||
|
||||
const previewTheme = useCallback((newTheme: ThemeId) => {
|
||||
@@ -131,30 +275,30 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
(value: TextScale) => {
|
||||
setTextScalePreference(value);
|
||||
applyTextScaleToDOM(value);
|
||||
storage.set(storage.KEYS.UI_TEXT_SCALE, value);
|
||||
settingMutation.mutate({ key: "ui_text_scale", value });
|
||||
appearanceCache.set(storage.KEYS.UI_TEXT_SCALE, value, cacheOwner);
|
||||
void saveProfileDefault(SETTING_KEYS.UI_TEXT_SCALE, value);
|
||||
},
|
||||
[settingMutation],
|
||||
[saveProfileDefault, cacheOwner],
|
||||
);
|
||||
|
||||
const setTextWeight = useCallback(
|
||||
(value: TextWeight) => {
|
||||
setTextWeightPreference(value);
|
||||
applyTextWeightToDOM(value);
|
||||
storage.set(storage.KEYS.UI_TEXT_WEIGHT, value);
|
||||
settingMutation.mutate({ key: "ui_text_weight", value });
|
||||
appearanceCache.set(storage.KEYS.UI_TEXT_WEIGHT, value, cacheOwner);
|
||||
void saveProfileDefault(SETTING_KEYS.UI_TEXT_WEIGHT, value);
|
||||
},
|
||||
[settingMutation],
|
||||
[saveProfileDefault, cacheOwner],
|
||||
);
|
||||
|
||||
const setHighContrast = useCallback(
|
||||
(value: boolean) => {
|
||||
setHighContrastPreference(value);
|
||||
applyHighContrastToDOM(value);
|
||||
storage.set(storage.KEYS.UI_HIGH_CONTRAST, String(value));
|
||||
settingMutation.mutate({ key: "ui_high_contrast", value: String(value) });
|
||||
appearanceCache.set(storage.KEYS.UI_HIGH_CONTRAST, String(value), cacheOwner);
|
||||
void saveProfileDefault(SETTING_KEYS.UI_HIGH_CONTRAST, value);
|
||||
},
|
||||
[settingMutation],
|
||||
[saveProfileDefault, cacheOwner],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -182,8 +326,3 @@ export function useTheme(): ThemeContextValue {
|
||||
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function getInitialThemeFromApi(apiTheme: string | null, fallback: ThemeId): ThemeId {
|
||||
if (!apiTheme || !isValidTheme(apiTheme)) return fallback;
|
||||
return storage.get(storage.KEYS.THEME) !== apiTheme ? apiTheme : fallback;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { LANGUAGES } from "@/player/utils/languageNames";
|
||||
import { LANGUAGE_OPTIONS, NAMED_LANGUAGE_OPTIONS } from "./languageOptions";
|
||||
|
||||
describe("languageOptions", () => {
|
||||
it("covers every language the shared list knows", () => {
|
||||
// The hand-written settings registry this replaced enumerated its own
|
||||
// subset, so a language the player could name was not always selectable in
|
||||
// settings. Deriving the list means the two cannot drift.
|
||||
expect(NAMED_LANGUAGE_OPTIONS.map((option) => option.value)).toEqual(
|
||||
LANGUAGES.map((language) => language.code),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefixes the unset entry only on the nullable list", () => {
|
||||
expect(LANGUAGE_OPTIONS[0]).toEqual({ value: "", label: "No preference" });
|
||||
expect(NAMED_LANGUAGE_OPTIONS.some((option) => option.value === "")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { LANGUAGES } from "@/player/utils/languageNames";
|
||||
|
||||
/** One choice in a settings dropdown. */
|
||||
export interface SettingOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every language a setting can name, in display order.
|
||||
*
|
||||
* Derived from the shared language list rather than enumerated here, because
|
||||
* the contract types language settings as BCP 47 rather than as an enum — the
|
||||
* manifest has no member list to render, so this is the one place a language
|
||||
* dropdown's contents come from.
|
||||
*/
|
||||
export const NAMED_LANGUAGE_OPTIONS: SettingOption[] = LANGUAGES.map(({ code, label }) => ({
|
||||
value: code,
|
||||
label,
|
||||
}));
|
||||
|
||||
/**
|
||||
* The same list with a leading "no preference" entry, which is how a nullable
|
||||
* language setting spells its unset state (the contract stores null).
|
||||
*/
|
||||
export const LANGUAGE_OPTIONS: SettingOption[] = [
|
||||
{ value: "", label: "No preference" },
|
||||
...NAMED_LANGUAGE_OPTIONS,
|
||||
];
|
||||
@@ -84,15 +84,21 @@ function parseV2(parsed: Record<string, unknown>): CardOverlayPrefs {
|
||||
};
|
||||
}
|
||||
|
||||
export function parseOverlayPrefs(raw: string | null): CardOverlayPrefs {
|
||||
if (!raw) return buildDefaultPrefs();
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return buildDefaultPrefs();
|
||||
// Accepts the canonical settings-contract value (an object or null), the
|
||||
// legacy JSON-string encoding, and anything malformed, always landing on a
|
||||
// complete prefs document.
|
||||
export function parseOverlayPrefs(raw: unknown): CardOverlayPrefs {
|
||||
if (raw == null) return buildDefaultPrefs();
|
||||
let parsed: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
if (!raw) return buildDefaultPrefs();
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return buildDefaultPrefs();
|
||||
}
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") return buildDefaultPrefs();
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return buildDefaultPrefs();
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (looksLikeV2(obj)) return parseV2(obj);
|
||||
return migrateFromV1(obj);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SETTING_DEFINITIONS } from "./settingsContract";
|
||||
import { describeQuality, presetById, presetIdFor, QUALITY_PRESETS } from "./qualityPresets";
|
||||
|
||||
describe("quality presets", () => {
|
||||
it("only composes values the contract accepts", () => {
|
||||
// The presets are a client-side convenience over two server settings. If one
|
||||
// named a resolution the enum does not have, or a bitrate outside the
|
||||
// declared bounds, picking it would 400 at the moment of saving.
|
||||
const resolutions = new Set(
|
||||
(SETTING_DEFINITIONS["playback.preferred_quality"].values ?? []).map(
|
||||
(member) => member.value,
|
||||
),
|
||||
);
|
||||
const bitrate = SETTING_DEFINITIONS["playback.max_bitrate_kbps"];
|
||||
|
||||
for (const preset of QUALITY_PRESETS) {
|
||||
expect(resolutions, `${preset.id} resolution`).toContain(preset.resolution);
|
||||
if (preset.bitrateKbps !== null) {
|
||||
expect(preset.bitrateKbps, `${preset.id} bitrate`).toBeGreaterThanOrEqual(
|
||||
bitrate.minimum ?? 0,
|
||||
);
|
||||
expect(preset.bitrateKbps, `${preset.id} bitrate`).toBeLessThanOrEqual(
|
||||
bitrate.maximum ?? Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("has no duplicate ids or duplicate axis pairs", () => {
|
||||
const ids = new Set(QUALITY_PRESETS.map((preset) => preset.id));
|
||||
expect(ids.size).toBe(QUALITY_PRESETS.length);
|
||||
|
||||
// Two presets resolving to the same pair would make the picker ambiguous:
|
||||
// whichever matched first would win on read and the other could never
|
||||
// display as selected.
|
||||
const pairs = new Set(
|
||||
QUALITY_PRESETS.map((preset) => `${preset.resolution}|${preset.bitrateKbps}`),
|
||||
);
|
||||
expect(pairs.size).toBe(QUALITY_PRESETS.length);
|
||||
});
|
||||
|
||||
it("round-trips every preset through the stored pair", () => {
|
||||
for (const preset of QUALITY_PRESETS) {
|
||||
expect(presetIdFor(preset.resolution, preset.bitrateKbps)).toBe(preset.id);
|
||||
expect(presetById(preset.id)).toEqual(preset);
|
||||
}
|
||||
});
|
||||
|
||||
it("treats a missing bitrate as uncapped rather than unmatched", () => {
|
||||
// The server omits a null value, so undefined and null both arrive here.
|
||||
expect(presetIdFor("auto", null)).toBe("auto");
|
||||
expect(presetIdFor("auto", undefined)).toBe("auto");
|
||||
expect(presetIdFor("2160p", undefined)).toBe("2160p");
|
||||
});
|
||||
|
||||
it("describes combinations no preset covers", () => {
|
||||
// Reachable two ways: someone set the axes independently through the API, or
|
||||
// the migration decomposed a legacy value whose bitrate is not on this
|
||||
// ladder. Either way the picker has to say something true.
|
||||
expect(presetIdFor("1080p", 4500)).toBeNull();
|
||||
expect(describeQuality("1080p", 4500)).toBe("1080p at 4.5 Mbps");
|
||||
expect(describeQuality("720p", 3000)).toBe("720p at 3 Mbps");
|
||||
expect(describeQuality("2160p", 25000)).toBe("4K at 25 Mbps");
|
||||
});
|
||||
|
||||
it("describes the sentinels without inventing a bitrate", () => {
|
||||
expect(describeQuality("auto", null)).toBe("Auto");
|
||||
expect(describeQuality("original", null)).toBe("Original");
|
||||
expect(describeQuality(null, null)).toBe("Auto");
|
||||
});
|
||||
|
||||
it("covers the legacy ladder the migration decomposes", () => {
|
||||
// Every compound value the server used to accept now maps to a pair. These
|
||||
// are the pairs internal/settingsmigrate writes, so a user who had one of
|
||||
// them should open the picker and see a named preset, not "custom".
|
||||
for (const [resolution, bitrate, label] of [
|
||||
["1080p", 10000, "1080p High"],
|
||||
["1080p", 6000, "1080p"],
|
||||
["720p", 4000, "720p High"],
|
||||
["720p", 2000, "720p"],
|
||||
["480p", 1500, "480p"],
|
||||
] as const) {
|
||||
expect(describeQuality(resolution, bitrate)).toBe(label);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* The Quality picker's presets.
|
||||
*
|
||||
* The server stores two orthogonal values — `playback.preferred_quality` (a
|
||||
* resolution cap) and `playback.max_bitrate_kbps` (a bandwidth cap, null for
|
||||
* uncapped). This file composes them into the single list a user picks from.
|
||||
*
|
||||
* Presets live here rather than in the contract on purpose. Baking "high" into
|
||||
* an enum member would freeze what it means: retuning 1080p High from 10 to 12
|
||||
* Mbps would be a contract change every client has to agree to, and every
|
||||
* client would still have to decompose the compound value before sending it.
|
||||
* As a client-side table it is a one-line edit, and older servers keep working
|
||||
* because they only ever see the two axes they already understand.
|
||||
*
|
||||
* The bitrates match the ladder the in-player switcher already used
|
||||
* (web/src/player/hooks/useTranscodeQuality.ts), so a preset chosen here lines
|
||||
* up with what the player offers mid-playback.
|
||||
*/
|
||||
|
||||
export interface QualityPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
/** null means "let the server decide", matching the contract's auto member. */
|
||||
resolution: "auto" | "original" | "2160p" | "1080p" | "720p" | "480p";
|
||||
/** null is uncapped. */
|
||||
bitrateKbps: number | null;
|
||||
}
|
||||
|
||||
export const QUALITY_PRESETS: readonly QualityPreset[] = [
|
||||
{
|
||||
id: "auto",
|
||||
label: "Auto",
|
||||
description: "Silo picks based on your connection.",
|
||||
resolution: "auto",
|
||||
bitrateKbps: null,
|
||||
},
|
||||
{
|
||||
id: "original",
|
||||
label: "Original",
|
||||
description: "Never transcode. Needs bandwidth to match the file.",
|
||||
resolution: "original",
|
||||
bitrateKbps: null,
|
||||
},
|
||||
{
|
||||
id: "2160p",
|
||||
label: "4K",
|
||||
description: "Up to 2160p.",
|
||||
resolution: "2160p",
|
||||
bitrateKbps: null,
|
||||
},
|
||||
{
|
||||
id: "1080p-high",
|
||||
label: "1080p High",
|
||||
description: "1080p at up to 10 Mbps.",
|
||||
resolution: "1080p",
|
||||
bitrateKbps: 10000,
|
||||
},
|
||||
{
|
||||
id: "1080p",
|
||||
label: "1080p",
|
||||
description: "1080p at up to 6 Mbps.",
|
||||
resolution: "1080p",
|
||||
bitrateKbps: 6000,
|
||||
},
|
||||
{
|
||||
id: "1080p-low",
|
||||
label: "1080p Low",
|
||||
description: "1080p at up to 3 Mbps, for a slower link.",
|
||||
resolution: "1080p",
|
||||
bitrateKbps: 3000,
|
||||
},
|
||||
{
|
||||
id: "720p-high",
|
||||
label: "720p High",
|
||||
description: "720p at up to 4 Mbps.",
|
||||
resolution: "720p",
|
||||
bitrateKbps: 4000,
|
||||
},
|
||||
{
|
||||
id: "720p",
|
||||
label: "720p",
|
||||
description: "720p at up to 2 Mbps.",
|
||||
resolution: "720p",
|
||||
bitrateKbps: 2000,
|
||||
},
|
||||
{
|
||||
id: "480p",
|
||||
label: "480p",
|
||||
description: "480p at up to 1.5 Mbps, for the tightest connections.",
|
||||
resolution: "480p",
|
||||
bitrateKbps: 1500,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** The preset id for a stored (resolution, bitrate) pair, or null for a custom combination. */
|
||||
export function presetIdFor(
|
||||
resolution: string | null | undefined,
|
||||
bitrateKbps: number | null | undefined,
|
||||
): string | null {
|
||||
const normalizedBitrate = bitrateKbps ?? null;
|
||||
const match = QUALITY_PRESETS.find(
|
||||
(preset) => preset.resolution === resolution && preset.bitrateKbps === normalizedBitrate,
|
||||
);
|
||||
return match?.id ?? null;
|
||||
}
|
||||
|
||||
export function presetById(id: string): QualityPreset | undefined {
|
||||
return QUALITY_PRESETS.find((preset) => preset.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* A label for any stored pair, including combinations no preset covers —
|
||||
* someone who set the two axes independently through the API, or whose values
|
||||
* came from a legacy compound value the migration decomposed.
|
||||
*/
|
||||
export function describeQuality(
|
||||
resolution: string | null | undefined,
|
||||
bitrateKbps: number | null | undefined,
|
||||
): string {
|
||||
const preset = presetIdFor(resolution, bitrateKbps);
|
||||
if (preset) return presetById(preset)!.label;
|
||||
|
||||
const resolutionLabel =
|
||||
resolution === "auto" || !resolution
|
||||
? "Auto"
|
||||
: resolution === "original"
|
||||
? "Original"
|
||||
: resolution === "2160p"
|
||||
? "4K"
|
||||
: resolution;
|
||||
if (bitrateKbps == null) return resolutionLabel;
|
||||
const mbps = bitrateKbps / 1000;
|
||||
const rounded = Number.isInteger(mbps) ? String(mbps) : mbps.toFixed(1);
|
||||
return `${resolutionLabel} at ${rounded} Mbps`;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { SETTING_KEYS, type SettingKey } from "./settingsContract";
|
||||
|
||||
/**
|
||||
* The canonical settings an in-player subtitle pick stores for a series.
|
||||
*
|
||||
* Two surfaces have to agree on this list. The player writes these rows when
|
||||
* the viewer picks a track (WatchPage), and "Auto" on the item page clears
|
||||
* them (useDeleteSubtitlePreference) — profile_series is the first scope in
|
||||
* the manifest's resolution order for both keys, so a row this list forgets is
|
||||
* a row that outranks every later choice with nothing in the UI able to remove
|
||||
* it. Adding a key here wires up both halves at once.
|
||||
*
|
||||
* playback.show_forced_subtitles is deliberately absent. The player has no
|
||||
* forced-subtitle control: the value it holds is the *resolved* one, which for
|
||||
* a viewer who never expressed a preference is the contract default. Writing
|
||||
* it back would pin that default at the top of the ladder and permanently
|
||||
* shadow the profile-scope toggle on the Subtitles settings screen. It stays
|
||||
* on the legacy composite row, which is not part of the canonical ladder.
|
||||
*/
|
||||
export const SERIES_SUBTITLE_SETTING_KEYS = [
|
||||
SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE,
|
||||
SETTING_KEYS.PLAYBACK_SUBTITLE_MODE,
|
||||
] as const satisfies readonly SettingKey[];
|
||||
|
||||
export type SeriesSubtitleSettingKey = (typeof SERIES_SUBTITLE_SETTING_KEYS)[number];
|
||||
|
||||
/**
|
||||
* The values one subtitle pick stores, keyed the way the writer sends them.
|
||||
*
|
||||
* Returning a full record (rather than letting the caller assemble one) is
|
||||
* what keeps the written set and {@link SERIES_SUBTITLE_SETTING_KEYS} from
|
||||
* drifting: a key added to the list without a value here fails to type-check.
|
||||
*/
|
||||
export function seriesSubtitleSettingValues(selection: {
|
||||
/** The chosen track's language, or null when subtitles were turned off. */
|
||||
language: string | null;
|
||||
mode: string;
|
||||
}): Record<SeriesSubtitleSettingKey, unknown> {
|
||||
return {
|
||||
// The contract spells "no preference" as null, where the legacy route
|
||||
// spelled it as the empty string.
|
||||
[SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE]: selection.language || null,
|
||||
[SETTING_KEYS.PLAYBACK_SUBTITLE_MODE]: selection.mode,
|
||||
};
|
||||
}
|
||||
|
||||
/** The canonical values path addressing one key at one series. */
|
||||
export function seriesSubtitleSettingPath(key: SettingKey, seriesId: string): string {
|
||||
return `/settings/values/${key}?scope=profile_series&series_id=${encodeURIComponent(seriesId)}`;
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
{
|
||||
"fixture_version": 1,
|
||||
"manifest_revision": 1,
|
||||
"description": "Cross-platform conformance cases for settings resolution. Every case runs against the shipped manifest in this directory: definitions are referenced by key, never restated, so an expectation can only be satisfied by resolving the real contract. Each platform's resolver (Go in internal/settingsresolve, TypeScript in web/src/lib/settingsResolve.ts, Kotlin and Swift in the client repos) runs every case through a hand-written runner; a runner must fail on any fixture field it does not know, because schema drift in the fixture itself is drift. A case's constraint_bindings attach a constraint to a copy of a real definition so constraint semantics stay testable even while no shipped definition carries that constraint kind. In expected entries, constrained:true requires stored_value and constraint_kind to be present, and stored_value may be null to mean the authored value was JSON null.",
|
||||
"cases": [
|
||||
{
|
||||
"name": "resolution_order_series_wins",
|
||||
"description": "The full ladder: with values stored at every scope a content key allows, profile_series beats profile_library, profile_device, and profile.",
|
||||
"keys": ["playback.subtitle_language"],
|
||||
"context": {
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"library_ids": [7],
|
||||
"series_ids": ["s-101"]
|
||||
},
|
||||
"stored": [
|
||||
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" },
|
||||
{
|
||||
"key": "playback.subtitle_language",
|
||||
"scope": "profile_device",
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"value": "de"
|
||||
},
|
||||
{
|
||||
"key": "playback.subtitle_language",
|
||||
"scope": "profile_library",
|
||||
"profile_id": "p1",
|
||||
"library_id": 7,
|
||||
"value": "fr"
|
||||
},
|
||||
{
|
||||
"key": "playback.subtitle_language",
|
||||
"scope": "profile_series",
|
||||
"profile_id": "p1",
|
||||
"series_id": "s-101",
|
||||
"value": "ja"
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{ "key": "playback.subtitle_language", "value": "ja", "source": "profile_series" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "resolution_order_library_beats_device",
|
||||
"description": "Without a series row, the library row wins over the device and profile rows.",
|
||||
"keys": ["playback.subtitle_language"],
|
||||
"context": {
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"library_ids": [7],
|
||||
"series_ids": ["s-101"]
|
||||
},
|
||||
"stored": [
|
||||
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" },
|
||||
{
|
||||
"key": "playback.subtitle_language",
|
||||
"scope": "profile_device",
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"value": "de"
|
||||
},
|
||||
{
|
||||
"key": "playback.subtitle_language",
|
||||
"scope": "profile_library",
|
||||
"profile_id": "p1",
|
||||
"library_id": 7,
|
||||
"value": "fr"
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{ "key": "playback.subtitle_language", "value": "fr", "source": "profile_library" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "resolution_order_device_beats_profile",
|
||||
"description": "Without content rows, the device override wins over the profile fallback even though the context names a library and a series.",
|
||||
"keys": ["playback.subtitle_language"],
|
||||
"context": {
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"library_ids": [7],
|
||||
"series_ids": ["s-101"]
|
||||
},
|
||||
"stored": [
|
||||
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" },
|
||||
{
|
||||
"key": "playback.subtitle_language",
|
||||
"scope": "profile_device",
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"value": "de"
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{ "key": "playback.subtitle_language", "value": "de", "source": "profile_device" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "resolution_order_profile_alone",
|
||||
"description": "A profile row alone resolves at profile scope.",
|
||||
"keys": ["playback.subtitle_language"],
|
||||
"context": {
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"library_ids": [7],
|
||||
"series_ids": ["s-101"]
|
||||
},
|
||||
"stored": [
|
||||
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" }
|
||||
],
|
||||
"expected": [{ "key": "playback.subtitle_language", "value": "en", "source": "profile" }]
|
||||
},
|
||||
{
|
||||
"name": "device_override_beats_profile_for_quality",
|
||||
"description": "playback.preferred_quality has no content scopes; its device override wins over the profile value.",
|
||||
"keys": ["playback.preferred_quality"],
|
||||
"context": { "profile_id": "p1", "device_id": "d1" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "playback.preferred_quality",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": "720p"
|
||||
},
|
||||
{
|
||||
"key": "playback.preferred_quality",
|
||||
"scope": "profile_device",
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"value": "1080p"
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{ "key": "playback.preferred_quality", "value": "1080p", "source": "profile_device" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "missing_device_identity_drops_device_scope",
|
||||
"description": "A caller with no device identity must not see a device override; the profile row answers instead. This is the anonymous jellycompat seed: a device row leaking here hands one device's settings to every client.",
|
||||
"keys": ["playback.subtitle_language"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" },
|
||||
{
|
||||
"key": "playback.subtitle_language",
|
||||
"scope": "profile_device",
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"value": "de"
|
||||
}
|
||||
],
|
||||
"expected": [{ "key": "playback.subtitle_language", "value": "en", "source": "profile" }]
|
||||
},
|
||||
{
|
||||
"name": "foreign_identity_rows_never_resolve",
|
||||
"description": "Rows for another profile, another device, or another series must not resolve just because a batched read returned them; the answer falls to the contract default.",
|
||||
"keys": ["playback.subtitle_language"],
|
||||
"context": { "profile_id": "p1", "device_id": "d1", "series_ids": ["s-101"] },
|
||||
"stored": [
|
||||
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p2", "value": "xx" },
|
||||
{
|
||||
"key": "playback.subtitle_language",
|
||||
"scope": "profile_device",
|
||||
"profile_id": "p1",
|
||||
"device_id": "d2",
|
||||
"value": "yy"
|
||||
},
|
||||
{
|
||||
"key": "playback.subtitle_language",
|
||||
"scope": "profile_series",
|
||||
"profile_id": "p1",
|
||||
"series_id": "s-other",
|
||||
"value": "zz"
|
||||
}
|
||||
],
|
||||
"expected": [{ "key": "playback.subtitle_language", "value": null, "source": "default" }]
|
||||
},
|
||||
{
|
||||
"name": "absent_values_resolve_to_contract_defaults",
|
||||
"description": "Nothing stored resolves to each definition's default_value with source \"default\": enum, boolean, integer, and nullable language tag.",
|
||||
"keys": [
|
||||
"playback.subtitle_mode",
|
||||
"playback.show_forced_subtitles",
|
||||
"playback.next_up_prompt_seconds",
|
||||
"playback.audio_language"
|
||||
],
|
||||
"context": { "profile_id": "p1", "device_id": "d1" },
|
||||
"expected": [
|
||||
{ "key": "playback.subtitle_mode", "value": "auto", "source": "default" },
|
||||
{ "key": "playback.show_forced_subtitles", "value": true, "source": "default" },
|
||||
{ "key": "playback.next_up_prompt_seconds", "value": 30, "source": "default" },
|
||||
{ "key": "playback.audio_language", "value": null, "source": "default" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "batch_resolves_each_key_independently",
|
||||
"description": "One batch, three keys, three different sources: a device override, a profile value, and a default.",
|
||||
"keys": [
|
||||
"playback.preferred_quality",
|
||||
"playback.subtitle_mode",
|
||||
"playback.audio_language"
|
||||
],
|
||||
"context": { "profile_id": "p1", "device_id": "d1" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "playback.preferred_quality",
|
||||
"scope": "profile_device",
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"value": "1080p"
|
||||
},
|
||||
{ "key": "playback.subtitle_mode", "scope": "profile", "profile_id": "p1", "value": "always" }
|
||||
],
|
||||
"expected": [
|
||||
{ "key": "playback.preferred_quality", "value": "1080p", "source": "profile_device" },
|
||||
{ "key": "playback.subtitle_mode", "value": "always", "source": "profile" },
|
||||
{ "key": "playback.audio_language", "value": null, "source": "default" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ceiling_caps_stored_quality_and_reports_the_stored_value",
|
||||
"description": "The manifest binds playback.preferred_quality to the max_playback_quality ceiling. A stored 2160p over a 1080p cap resolves to 1080p while the authored value survives, reported as stored_value with constrained:true.",
|
||||
"keys": ["playback.preferred_quality"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "playback.preferred_quality",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": "2160p"
|
||||
}
|
||||
],
|
||||
"constraints": { "max_playback_quality": "1080p" },
|
||||
"expected": [
|
||||
{
|
||||
"key": "playback.preferred_quality",
|
||||
"value": "1080p",
|
||||
"source": "profile",
|
||||
"constrained": true,
|
||||
"stored_value": "2160p",
|
||||
"constraint_kind": "ceiling"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ceiling_leaves_quality_under_the_cap_alone",
|
||||
"description": "A value at or under the cap passes through untouched and is not reported as constrained.",
|
||||
"keys": ["playback.preferred_quality"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "playback.preferred_quality",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": "720p"
|
||||
}
|
||||
],
|
||||
"constraints": { "max_playback_quality": "1080p" },
|
||||
"expected": [{ "key": "playback.preferred_quality", "value": "720p", "source": "profile" }]
|
||||
},
|
||||
{
|
||||
"name": "ceiling_ranks_auto_below_every_cap",
|
||||
"description": "The ordered enum lists \"auto\" first because it never exceeds a cap: even the lowest cap leaves it alone.",
|
||||
"keys": ["playback.preferred_quality"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "playback.preferred_quality",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": "auto"
|
||||
}
|
||||
],
|
||||
"constraints": { "max_playback_quality": "480p" },
|
||||
"expected": [{ "key": "playback.preferred_quality", "value": "auto", "source": "profile" }]
|
||||
},
|
||||
{
|
||||
"name": "ceiling_caps_original_as_the_highest_member",
|
||||
"description": "\"original\" is the uncapped source and ranks above every resolution, so any cap brings it down.",
|
||||
"keys": ["playback.preferred_quality"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "playback.preferred_quality",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": "original"
|
||||
}
|
||||
],
|
||||
"constraints": { "max_playback_quality": "2160p" },
|
||||
"expected": [
|
||||
{
|
||||
"key": "playback.preferred_quality",
|
||||
"value": "2160p",
|
||||
"source": "profile",
|
||||
"constrained": true,
|
||||
"stored_value": "original",
|
||||
"constraint_kind": "ceiling"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "null_bitrate_is_unbounded_and_a_ceiling_caps_it",
|
||||
"description": "null on the nullable integer playback.max_bitrate_kbps means \"no cap of my own\", which is unbounded above. It has no numeric rank, so a resolver that compares it as equal lets the one value that most needs capping slip past; a ceiling must bring it down to the limit.",
|
||||
"keys": ["playback.max_bitrate_kbps"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{ "key": "playback.max_bitrate_kbps", "scope": "profile", "profile_id": "p1", "value": null }
|
||||
],
|
||||
"constraint_bindings": [
|
||||
{
|
||||
"key": "playback.max_bitrate_kbps",
|
||||
"policy_input": "max_bitrate_kbps",
|
||||
"constraint": "ceiling"
|
||||
}
|
||||
],
|
||||
"constraints": { "max_bitrate_kbps": 8000 },
|
||||
"expected": [
|
||||
{
|
||||
"key": "playback.max_bitrate_kbps",
|
||||
"value": 8000,
|
||||
"source": "profile",
|
||||
"constrained": true,
|
||||
"stored_value": null,
|
||||
"constraint_kind": "ceiling"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "default_null_bitrate_is_capped_by_a_ceiling",
|
||||
"description": "The contract default for playback.max_bitrate_kbps is null, so even with nothing stored a ceiling caps the resolved default; source stays \"default\" and the null is reported as stored_value.",
|
||||
"keys": ["playback.max_bitrate_kbps"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"constraint_bindings": [
|
||||
{
|
||||
"key": "playback.max_bitrate_kbps",
|
||||
"policy_input": "max_bitrate_kbps",
|
||||
"constraint": "ceiling"
|
||||
}
|
||||
],
|
||||
"constraints": { "max_bitrate_kbps": 8000 },
|
||||
"expected": [
|
||||
{
|
||||
"key": "playback.max_bitrate_kbps",
|
||||
"value": 8000,
|
||||
"source": "default",
|
||||
"constrained": true,
|
||||
"stored_value": null,
|
||||
"constraint_kind": "ceiling"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "floor_leaves_an_unbounded_bitrate_alone",
|
||||
"description": "The mirror rule: unbounded already satisfies any floor, so a floor must not touch a null numeric.",
|
||||
"keys": ["playback.max_bitrate_kbps"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{ "key": "playback.max_bitrate_kbps", "scope": "profile", "profile_id": "p1", "value": null }
|
||||
],
|
||||
"constraint_bindings": [
|
||||
{
|
||||
"key": "playback.max_bitrate_kbps",
|
||||
"policy_input": "min_bitrate_kbps",
|
||||
"constraint": "floor"
|
||||
}
|
||||
],
|
||||
"constraints": { "min_bitrate_kbps": 8000 },
|
||||
"expected": [{ "key": "playback.max_bitrate_kbps", "value": null, "source": "profile" }]
|
||||
},
|
||||
{
|
||||
"name": "allowlist_falls_back_when_the_default_is_outside_the_list",
|
||||
"description": "With nothing stored, catalog.metadata_language resolves to its default null, which is outside the allowlist. The fallback is the first allowed member — not the definition default, which is exactly the value the policy forbids.",
|
||||
"keys": ["catalog.metadata_language"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"constraint_bindings": [
|
||||
{
|
||||
"key": "catalog.metadata_language",
|
||||
"policy_input": "allowed_metadata_languages",
|
||||
"constraint": "allowlist"
|
||||
}
|
||||
],
|
||||
"constraints": { "allowed_metadata_languages": ["en", "fr"] },
|
||||
"expected": [
|
||||
{
|
||||
"key": "catalog.metadata_language",
|
||||
"value": "en",
|
||||
"source": "default",
|
||||
"constrained": true,
|
||||
"stored_value": null,
|
||||
"constraint_kind": "allowlist"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "allowlist_replaces_a_forbidden_choice",
|
||||
"description": "A stored value outside the allowlist is replaced by the first allowed member, with the authored choice preserved as stored_value.",
|
||||
"keys": ["catalog.metadata_language"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{ "key": "catalog.metadata_language", "scope": "profile", "profile_id": "p1", "value": "ja" }
|
||||
],
|
||||
"constraint_bindings": [
|
||||
{
|
||||
"key": "catalog.metadata_language",
|
||||
"policy_input": "allowed_metadata_languages",
|
||||
"constraint": "allowlist"
|
||||
}
|
||||
],
|
||||
"constraints": { "allowed_metadata_languages": ["en", "fr"] },
|
||||
"expected": [
|
||||
{
|
||||
"key": "catalog.metadata_language",
|
||||
"value": "en",
|
||||
"source": "profile",
|
||||
"constrained": true,
|
||||
"stored_value": "ja",
|
||||
"constraint_kind": "allowlist"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "allowlist_passes_a_permitted_choice",
|
||||
"description": "A stored value inside the allowlist passes through untouched.",
|
||||
"keys": ["catalog.metadata_language"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{ "key": "catalog.metadata_language", "scope": "profile", "profile_id": "p1", "value": "fr" }
|
||||
],
|
||||
"constraint_bindings": [
|
||||
{
|
||||
"key": "catalog.metadata_language",
|
||||
"policy_input": "allowed_metadata_languages",
|
||||
"constraint": "allowlist"
|
||||
}
|
||||
],
|
||||
"constraints": { "allowed_metadata_languages": ["en", "fr"] },
|
||||
"expected": [{ "key": "catalog.metadata_language", "value": "fr", "source": "profile" }]
|
||||
},
|
||||
{
|
||||
"name": "locked_replaces_a_differing_choice",
|
||||
"description": "locked is total: the policy value replaces the user's outright, and the authored choice is preserved as stored_value so it takes effect the day the lock lifts.",
|
||||
"keys": ["playback.subtitle_mode"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{ "key": "playback.subtitle_mode", "scope": "profile", "profile_id": "p1", "value": "off" }
|
||||
],
|
||||
"constraint_bindings": [
|
||||
{
|
||||
"key": "playback.subtitle_mode",
|
||||
"policy_input": "forced_subtitle_mode",
|
||||
"constraint": "locked"
|
||||
}
|
||||
],
|
||||
"constraints": { "forced_subtitle_mode": "always" },
|
||||
"expected": [
|
||||
{
|
||||
"key": "playback.subtitle_mode",
|
||||
"value": "always",
|
||||
"source": "profile",
|
||||
"constrained": true,
|
||||
"stored_value": "off",
|
||||
"constraint_kind": "locked"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "locked_leaves_an_equal_value_unconstrained",
|
||||
"description": "A stored value already equal to the lock is not a narrowing: it passes through with no constrained flag, so clients do not tell the user their own choice was overridden.",
|
||||
"keys": ["playback.subtitle_mode"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "playback.subtitle_mode",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": "always"
|
||||
}
|
||||
],
|
||||
"constraint_bindings": [
|
||||
{
|
||||
"key": "playback.subtitle_mode",
|
||||
"policy_input": "forced_subtitle_mode",
|
||||
"constraint": "locked"
|
||||
}
|
||||
],
|
||||
"constraints": { "forced_subtitle_mode": "always" },
|
||||
"expected": [{ "key": "playback.subtitle_mode", "value": "always", "source": "profile" }]
|
||||
},
|
||||
{
|
||||
"name": "locked_replaces_the_contract_default",
|
||||
"description": "With nothing stored, the lock replaces even the contract default: source stays \"default\" and the default is reported as stored_value, exactly like a capped default.",
|
||||
"keys": ["playback.subtitle_mode"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"constraint_bindings": [
|
||||
{
|
||||
"key": "playback.subtitle_mode",
|
||||
"policy_input": "forced_subtitle_mode",
|
||||
"constraint": "locked"
|
||||
}
|
||||
],
|
||||
"constraints": { "forced_subtitle_mode": "always" },
|
||||
"expected": [
|
||||
{
|
||||
"key": "playback.subtitle_mode",
|
||||
"value": "always",
|
||||
"source": "default",
|
||||
"constrained": true,
|
||||
"stored_value": "auto",
|
||||
"constraint_kind": "locked"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "subtitle_appearance_ignores_content_scopes",
|
||||
"description": "playback.subtitle_appearance resolves profile_device then profile only. With a library and a series in the context, the device row still wins — and the sparse device object replaces the profile object outright rather than merging with it.",
|
||||
"keys": ["playback.subtitle_appearance"],
|
||||
"context": {
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"library_ids": [7],
|
||||
"series_ids": ["s-101"]
|
||||
},
|
||||
"stored": [
|
||||
{
|
||||
"key": "playback.subtitle_appearance",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": { "fontSize": "medium", "fontColor": "#ffcc00" }
|
||||
},
|
||||
{
|
||||
"key": "playback.subtitle_appearance",
|
||||
"scope": "profile_device",
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"value": { "fontSize": "xxlarge", "position": "top" }
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"key": "playback.subtitle_appearance",
|
||||
"value": { "fontSize": "xxlarge", "position": "top" },
|
||||
"source": "profile_device"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "subtitle_appearance_falls_to_profile_without_device",
|
||||
"description": "Without a device identity the profile's appearance object answers, unmerged.",
|
||||
"keys": ["playback.subtitle_appearance"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "playback.subtitle_appearance",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": { "fontSize": "medium", "fontColor": "#ffcc00" }
|
||||
},
|
||||
{
|
||||
"key": "playback.subtitle_appearance",
|
||||
"scope": "profile_device",
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"value": { "fontSize": "xxlarge", "position": "top" }
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"key": "playback.subtitle_appearance",
|
||||
"value": { "fontSize": "medium", "fontColor": "#ffcc00" },
|
||||
"source": "profile"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// ?raw keeps JSON.parse in this file's hands: a plain JSON import would let the
|
||||
// bundler normalize the document before the strict field check ever sees it.
|
||||
import conformanceRaw from "./settingsConformance.json?raw";
|
||||
|
||||
import { SETTINGS_REVISION } from "./settingsContract";
|
||||
import {
|
||||
jsonEquals,
|
||||
resolveSettingValues,
|
||||
type RemoteSettingScope,
|
||||
type SettingConstraintBinding,
|
||||
type SettingConstraintKind,
|
||||
type SettingResolutionContext,
|
||||
type StoredSettingRow,
|
||||
} from "./settingsResolve";
|
||||
|
||||
/**
|
||||
* The web runner for the cross-platform conformance fixture — the spec's named
|
||||
* drift gate. The vendored copy read here is written by `make settings-bindings`
|
||||
* from contracts/settings/v1/conformance.json (and `make
|
||||
* verify-settings-bindings` fails CI when the two disagree). The Go resolver
|
||||
* runs the identical cases in internal/settingsresolve/conformance_test.go;
|
||||
* Kotlin and Swift runners follow in the client repos. The fixture is decoded
|
||||
* strictly: a field this runner does not recognize fails the suite, because
|
||||
* schema drift in the fixture itself is drift.
|
||||
*/
|
||||
|
||||
interface ConformanceFixture {
|
||||
fixture_version: number;
|
||||
manifest_revision: number;
|
||||
description: string;
|
||||
cases: ConformanceCase[];
|
||||
}
|
||||
|
||||
interface ConformanceCase {
|
||||
name: string;
|
||||
description?: string;
|
||||
keys: string[];
|
||||
context?: {
|
||||
profile_id?: string;
|
||||
device_id?: string;
|
||||
library_ids?: number[];
|
||||
series_ids?: string[];
|
||||
};
|
||||
stored?: {
|
||||
key: string;
|
||||
scope: RemoteSettingScope;
|
||||
profile_id?: string;
|
||||
device_id?: string;
|
||||
library_id?: number;
|
||||
series_id?: string;
|
||||
value: unknown;
|
||||
}[];
|
||||
constraints?: Record<string, unknown>;
|
||||
constraint_bindings?: {
|
||||
key: string;
|
||||
policy_input: string;
|
||||
constraint: SettingConstraintKind;
|
||||
}[];
|
||||
expected: {
|
||||
key: string;
|
||||
value: unknown;
|
||||
source: string;
|
||||
constrained?: boolean;
|
||||
stored_value?: unknown;
|
||||
constraint_kind?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
/** Fails on any object key outside `allowed` — the unknown-field gate. */
|
||||
function assertOnlyKnownFields(value: unknown, allowed: readonly string[], path: string): void {
|
||||
expect(value, path).toBeTypeOf("object");
|
||||
expect(value, path).not.toBeNull();
|
||||
expect(Array.isArray(value), `${path} must be an object, not an array`).toBe(false);
|
||||
const unknown = Object.keys(value as Record<string, unknown>).filter(
|
||||
(key) => !allowed.includes(key),
|
||||
);
|
||||
expect(unknown, `unknown fixture field(s) at ${path}`).toEqual([]);
|
||||
}
|
||||
|
||||
function loadFixture(): ConformanceFixture {
|
||||
const fixture = JSON.parse(conformanceRaw) as unknown;
|
||||
|
||||
assertOnlyKnownFields(
|
||||
fixture,
|
||||
["fixture_version", "manifest_revision", "description", "cases"],
|
||||
"fixture",
|
||||
);
|
||||
const typed = fixture as ConformanceFixture;
|
||||
expect(Array.isArray(typed.cases), "fixture.cases must be an array").toBe(true);
|
||||
|
||||
typed.cases.forEach((testCase, caseIndex) => {
|
||||
const casePath = `cases[${caseIndex}]`;
|
||||
assertOnlyKnownFields(
|
||||
testCase,
|
||||
[
|
||||
"name",
|
||||
"description",
|
||||
"keys",
|
||||
"context",
|
||||
"stored",
|
||||
"constraints",
|
||||
"constraint_bindings",
|
||||
"expected",
|
||||
],
|
||||
casePath,
|
||||
);
|
||||
if (testCase.context !== undefined) {
|
||||
assertOnlyKnownFields(
|
||||
testCase.context,
|
||||
["profile_id", "device_id", "library_ids", "series_ids"],
|
||||
`${casePath}.context`,
|
||||
);
|
||||
}
|
||||
testCase.stored?.forEach((row, rowIndex) => {
|
||||
const rowPath = `${casePath}.stored[${rowIndex}]`;
|
||||
assertOnlyKnownFields(
|
||||
row,
|
||||
["key", "scope", "profile_id", "device_id", "library_id", "series_id", "value"],
|
||||
rowPath,
|
||||
);
|
||||
expect("value" in row, `${rowPath} must spell an authored null as null`).toBe(true);
|
||||
});
|
||||
testCase.constraint_bindings?.forEach((binding, bindingIndex) => {
|
||||
assertOnlyKnownFields(
|
||||
binding,
|
||||
["key", "policy_input", "constraint"],
|
||||
`${casePath}.constraint_bindings[${bindingIndex}]`,
|
||||
);
|
||||
});
|
||||
testCase.expected.forEach((expectation, expectationIndex) => {
|
||||
const expectationPath = `${casePath}.expected[${expectationIndex}]`;
|
||||
assertOnlyKnownFields(
|
||||
expectation,
|
||||
["key", "value", "source", "constrained", "stored_value", "constraint_kind"],
|
||||
expectationPath,
|
||||
);
|
||||
expect("value" in expectation, `${expectationPath} must spell an expected null as null`).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
return typed;
|
||||
}
|
||||
|
||||
const fixture = loadFixture();
|
||||
|
||||
describe("settings conformance fixture", () => {
|
||||
it("targets this build's manifest revision", () => {
|
||||
expect(fixture.fixture_version).toBe(1);
|
||||
// A revision bump changes definitions, so the fixture author must
|
||||
// re-confirm every expectation against the new manifest.
|
||||
expect(fixture.manifest_revision).toBe(SETTINGS_REVISION);
|
||||
expect(fixture.cases.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("declares each case once", () => {
|
||||
const names = fixture.cases.map((testCase) => testCase.name);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
describe.each(fixture.cases.map((testCase) => [testCase.name, testCase] as const))(
|
||||
"%s",
|
||||
(_name, testCase) => {
|
||||
it("resolves to the expected effective values", () => {
|
||||
const context: SettingResolutionContext = {
|
||||
profileId: testCase.context?.profile_id,
|
||||
deviceId: testCase.context?.device_id,
|
||||
libraryIds: testCase.context?.library_ids,
|
||||
seriesIds: testCase.context?.series_ids,
|
||||
};
|
||||
const stored: StoredSettingRow[] = (testCase.stored ?? []).map((row) => ({
|
||||
key: row.key,
|
||||
scope: row.scope,
|
||||
profileId: row.profile_id,
|
||||
deviceId: row.device_id,
|
||||
libraryId: row.library_id,
|
||||
seriesId: row.series_id,
|
||||
value: row.value,
|
||||
}));
|
||||
const bindings: Record<string, SettingConstraintBinding> = {};
|
||||
for (const binding of testCase.constraint_bindings ?? []) {
|
||||
expect(bindings[binding.key], `duplicate binding for ${binding.key}`).toBeUndefined();
|
||||
bindings[binding.key] = {
|
||||
policyInput: binding.policy_input,
|
||||
constraint: binding.constraint,
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = resolveSettingValues(
|
||||
testCase.keys,
|
||||
stored,
|
||||
context,
|
||||
testCase.constraints,
|
||||
bindings,
|
||||
);
|
||||
|
||||
expect(resolved).toHaveLength(testCase.expected.length);
|
||||
const byKey = new Map(resolved.map((entry) => [entry.key as string, entry]));
|
||||
for (const expectation of testCase.expected) {
|
||||
const entry = byKey.get(expectation.key);
|
||||
expect(entry, `no resolved value for ${expectation.key}`).toBeDefined();
|
||||
if (!entry) continue;
|
||||
expect(
|
||||
jsonEquals(entry.value, expectation.value),
|
||||
`${expectation.key}: value ${JSON.stringify(entry.value)}, want ${JSON.stringify(expectation.value)}`,
|
||||
).toBe(true);
|
||||
expect(entry.source, `${expectation.key}: source`).toBe(expectation.source);
|
||||
expect(entry.constrained, `${expectation.key}: constrained`).toBe(
|
||||
expectation.constrained ?? false,
|
||||
);
|
||||
expect(entry.constraintKind, `${expectation.key}: constraint_kind`).toBe(
|
||||
expectation.constraint_kind,
|
||||
);
|
||||
if (expectation.constrained) {
|
||||
expect(
|
||||
"stored_value" in expectation,
|
||||
`${expectation.key}: a constrained expectation must declare stored_value`,
|
||||
).toBe(true);
|
||||
expect(
|
||||
"constraint_kind" in expectation,
|
||||
`${expectation.key}: a constrained expectation must declare constraint_kind`,
|
||||
).toBe(true);
|
||||
expect(
|
||||
jsonEquals(entry.storedValue, expectation.stored_value),
|
||||
`${expectation.key}: stored_value ${JSON.stringify(entry.storedValue)}, want ${JSON.stringify(expectation.stored_value)}`,
|
||||
).toBe(true);
|
||||
} else {
|
||||
expect(
|
||||
"stored_value" in expectation,
|
||||
`${expectation.key}: an unconstrained expectation must not declare stored_value`,
|
||||
).toBe(false);
|
||||
expect(entry.storedValue, `${expectation.key}: stored_value`).toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,993 @@
|
||||
/**
|
||||
* Code generated by cmd/settingsgen from contracts/settings/v1/manifest.json. DO NOT EDIT.
|
||||
*
|
||||
* Regenerate with: make settings-bindings
|
||||
*
|
||||
* Every key, type, scope and default here comes from the manifest, so a client
|
||||
* cannot drift from the server's contract by editing a constant. Adding a setting
|
||||
* is a manifest change plus a regeneration, never a hand-written key.
|
||||
*/
|
||||
|
||||
export const SETTINGS_REVISION = 1;
|
||||
|
||||
export const SETTING_KEYS = {
|
||||
/** Metadata language */
|
||||
CATALOG_METADATA_LANGUAGE: "catalog.metadata_language",
|
||||
/** Download quality */
|
||||
DOWNLOADS_DEFAULT_QUALITY: "downloads.default_quality",
|
||||
/** Keep watched downloads */
|
||||
DOWNLOADS_KEEP_WATCHED: "downloads.keep_watched",
|
||||
/** Download over Wi-Fi only */
|
||||
DOWNLOADS_WIFI_ONLY: "downloads.wifi_only",
|
||||
/** Show audiobooks */
|
||||
NAV_SHOW_AUDIOBOOKS: "nav.show_audiobooks",
|
||||
/** Preferred audio language */
|
||||
PLAYBACK_AUDIO_LANGUAGE: "playback.audio_language",
|
||||
/** Auto-play next episode */
|
||||
PLAYBACK_AUTO_PLAY_NEXT: "playback.auto_play_next",
|
||||
/** Preview next episode */
|
||||
PLAYBACK_AUTO_PLAY_NEXT_PREVIEW: "playback.auto_play_next_preview",
|
||||
/** Auto-skip credits */
|
||||
PLAYBACK_AUTO_SKIP_CREDITS: "playback.auto_skip_credits",
|
||||
/** Auto-skip intros */
|
||||
PLAYBACK_AUTO_SKIP_INTRO: "playback.auto_skip_intro",
|
||||
/** Auto-skip recaps */
|
||||
PLAYBACK_AUTO_SKIP_RECAP: "playback.auto_skip_recap",
|
||||
/** Maximum bitrate */
|
||||
PLAYBACK_MAX_BITRATE_KBPS: "playback.max_bitrate_kbps",
|
||||
/** Next up prompt */
|
||||
PLAYBACK_NEXT_UP_PROMPT_SECONDS: "playback.next_up_prompt_seconds",
|
||||
/** Preferred quality */
|
||||
PLAYBACK_PREFERRED_QUALITY: "playback.preferred_quality",
|
||||
/** Show forced subtitles */
|
||||
PLAYBACK_SHOW_FORCED_SUBTITLES: "playback.show_forced_subtitles",
|
||||
/** Subtitle appearance */
|
||||
PLAYBACK_SUBTITLE_APPEARANCE: "playback.subtitle_appearance",
|
||||
/** Preferred subtitle language */
|
||||
PLAYBACK_SUBTITLE_LANGUAGE: "playback.subtitle_language",
|
||||
/** Subtitles */
|
||||
PLAYBACK_SUBTITLE_MODE: "playback.subtitle_mode",
|
||||
/** Audio sync offset */
|
||||
PLAYER_AUDIO_SYNC_MS: "player.audio_sync_ms",
|
||||
/** Dolby Vision */
|
||||
PLAYER_DOLBY_VISION_ENABLED: "player.dolby_vision_enabled",
|
||||
/** Dolby Vision Profile 7 fallback */
|
||||
PLAYER_DV_PROFILE7_HDR10_FALLBACK: "player.dv_profile7_hdr10_fallback",
|
||||
/** HDR */
|
||||
PLAYER_HDR_ENABLED: "player.hdr_enabled",
|
||||
/** Match content frame rate */
|
||||
PLAYER_MATCH_FRAME_RATE: "player.match_frame_rate",
|
||||
/** Screen orientation */
|
||||
PLAYER_ORIENTATION_MODE: "player.orientation_mode",
|
||||
/** Still watching prompt */
|
||||
PLAYER_PASSOUT_THRESHOLD: "player.passout_threshold",
|
||||
/** Picture in picture */
|
||||
PLAYER_PICTURE_IN_PICTURE_ENABLED: "player.picture_in_picture_enabled",
|
||||
/** Playback speed */
|
||||
PLAYER_PLAYBACK_SPEED: "player.playback_speed",
|
||||
/** Rewind on resume */
|
||||
PLAYER_RESUME_REWIND_SECONDS: "player.resume_rewind_seconds",
|
||||
/** Seek cache */
|
||||
PLAYER_SEEK_CACHE_ENABLED: "player.seek_cache_enabled",
|
||||
/** Default sleep timer */
|
||||
PLAYER_SLEEP_TIMER_DEFAULT_MINUTES: "player.sleep_timer_default_minutes",
|
||||
/** Subtitle sync offset */
|
||||
PLAYER_SUBTITLE_SYNC_MS: "player.subtitle_sync_ms",
|
||||
/** Video sizing */
|
||||
PLAYER_VIDEO_GRAVITY: "player.video_gravity",
|
||||
/** Search scope */
|
||||
SEARCH_MEDIA_SCOPE: "search.media_scope",
|
||||
/** Match device caption settings */
|
||||
SUBTITLE_MATCHES_DEVICE: "subtitle.matches_device",
|
||||
/** Poster badges */
|
||||
UI_CARD_OVERLAYS: "ui.card_overlays",
|
||||
/** Custom CSS */
|
||||
UI_CUSTOM_CSS: "ui.custom_css",
|
||||
/** Custom theme variables */
|
||||
UI_CUSTOM_THEME_VARS: "ui.custom_theme_vars",
|
||||
/** Date format */
|
||||
UI_DATE_FORMAT: "ui.date_format",
|
||||
/** Hidden libraries */
|
||||
UI_DISABLED_LIBRARY_IDS: "ui.disabled_library_ids",
|
||||
/** High contrast */
|
||||
UI_HIGH_CONTRAST: "ui.high_contrast",
|
||||
/** Library order */
|
||||
UI_LIBRARY_ORDER: "ui.library_order",
|
||||
/** Remembered library view */
|
||||
UI_LIBRARY_PAGE_STATE: "ui.library_page_state",
|
||||
/** Next up episodes */
|
||||
UI_NEXT_UP_MODE: "ui.next_up_mode",
|
||||
/** Remember library view */
|
||||
UI_REMEMBER_LIBRARY_PAGE_STATE: "ui.remember_library_page_state",
|
||||
/** Pinned sidebar items */
|
||||
UI_SIDEBAR_PINS: "ui.sidebar_pins",
|
||||
/** Text size */
|
||||
UI_TEXT_SCALE: "ui.text_scale",
|
||||
/** Text weight */
|
||||
UI_TEXT_WEIGHT: "ui.text_weight",
|
||||
/** Theme */
|
||||
UI_THEME: "ui.theme",
|
||||
/** Time format */
|
||||
UI_TIME_FORMAT: "ui.time_format",
|
||||
} as const;
|
||||
|
||||
export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];
|
||||
|
||||
export interface SettingDefinition {
|
||||
key: SettingKey;
|
||||
type: string;
|
||||
nullable: boolean;
|
||||
persistence: "remote" | "client_local";
|
||||
/** The manifest revision this definition first appeared in. A client
|
||||
* pinned to a newer contract than the server's advertised revision must
|
||||
* hide definitions, scopes, enum members and widened bounds introduced
|
||||
* after that revision — the server would reject them. */
|
||||
introducedIn: number;
|
||||
scopes: readonly string[];
|
||||
/** Revision each scope became writable at, aligned with scopes. */
|
||||
scopeIntroducedIn: readonly number[];
|
||||
resolutionOrder: readonly string[];
|
||||
defaultValue: unknown;
|
||||
label: string;
|
||||
description: string;
|
||||
category: string;
|
||||
control?: string;
|
||||
unit?: string;
|
||||
values?: readonly { value: unknown; label: string; introducedIn: number }[];
|
||||
/** Present on enums whose members are ranked, so a ceiling or floor has a direction. */
|
||||
ordered?: boolean;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
/** Bound history, oldest first, when a bound was widened after revision 1;
|
||||
* a client filtering to an older server revision applies the newest entry
|
||||
* whose introducedIn does not exceed it. */
|
||||
minimumHistory?: readonly { value: number; introducedIn: number }[];
|
||||
maximumHistory?: readonly { value: number; introducedIn: number }[];
|
||||
step?: number;
|
||||
/** The policy input that narrows this setting, when the manifest binds one. */
|
||||
constrainedBy?: {
|
||||
policyInput: string;
|
||||
constraint: "ceiling" | "floor" | "allowlist" | "locked";
|
||||
};
|
||||
}
|
||||
|
||||
export const SETTING_DEFINITIONS: Record<SettingKey, SettingDefinition> = {
|
||||
"catalog.metadata_language": {
|
||||
key: "catalog.metadata_language",
|
||||
type: "language_tag",
|
||||
nullable: true,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile", "default"],
|
||||
defaultValue: null,
|
||||
label: "Metadata language",
|
||||
description: "Language Silo prefers for titles, descriptions, and artwork.",
|
||||
category: "catalog",
|
||||
control: "select",
|
||||
},
|
||||
"downloads.default_quality": {
|
||||
key: "downloads.default_quality",
|
||||
type: "enum",
|
||||
nullable: false,
|
||||
persistence: "client_local",
|
||||
introducedIn: 1,
|
||||
scopes: ["client_local"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["client_local", "default"],
|
||||
defaultValue: "original",
|
||||
label: "Download quality",
|
||||
description: "Quality preset used for new downloads.",
|
||||
category: "downloads",
|
||||
control: "select",
|
||||
values: [
|
||||
{ value: "1mbps", label: "1 Mbps", introducedIn: 1 },
|
||||
{ value: "2mbps", label: "2 Mbps", introducedIn: 1 },
|
||||
{ value: "5mbps", label: "5 Mbps", introducedIn: 1 },
|
||||
{ value: "10mbps", label: "10 Mbps", introducedIn: 1 },
|
||||
{ value: "20mbps", label: "20 Mbps", introducedIn: 1 },
|
||||
{ value: "original", label: "Original", introducedIn: 1 },
|
||||
],
|
||||
ordered: true,
|
||||
},
|
||||
"downloads.keep_watched": {
|
||||
key: "downloads.keep_watched",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
persistence: "client_local",
|
||||
introducedIn: 1,
|
||||
scopes: ["client_local"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["client_local", "default"],
|
||||
defaultValue: false,
|
||||
label: "Keep watched downloads",
|
||||
description: "Do not suggest reclaiming space from downloads you have finished.",
|
||||
category: "downloads",
|
||||
control: "switch",
|
||||
},
|
||||
"downloads.wifi_only": {
|
||||
key: "downloads.wifi_only",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
persistence: "client_local",
|
||||
introducedIn: 1,
|
||||
scopes: ["client_local"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["client_local", "default"],
|
||||
defaultValue: true,
|
||||
label: "Download over Wi-Fi only",
|
||||
description: "Only download while connected to Wi-Fi.",
|
||||
category: "downloads",
|
||||
control: "switch",
|
||||
},
|
||||
"nav.show_audiobooks": {
|
||||
key: "nav.show_audiobooks",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
persistence: "client_local",
|
||||
introducedIn: 1,
|
||||
scopes: ["client_local"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["client_local", "default"],
|
||||
defaultValue: false,
|
||||
label: "Show audiobooks",
|
||||
description: "Show the Audiobooks section in navigation.",
|
||||
category: "nav",
|
||||
control: "switch",
|
||||
},
|
||||
"playback.audio_language": {
|
||||
key: "playback.audio_language",
|
||||
type: "language_tag",
|
||||
nullable: true,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile", "profile_device", "profile_library", "profile_series"],
|
||||
scopeIntroducedIn: [1, 1, 1, 1],
|
||||
resolutionOrder: ["profile_series", "profile_library", "profile_device", "profile", "default"],
|
||||
defaultValue: null,
|
||||
label: "Preferred audio language",
|
||||
description: "Choose which spoken language Silo should prefer first.",
|
||||
category: "playback",
|
||||
control: "select",
|
||||
},
|
||||
"playback.auto_play_next": {
|
||||
key: "playback.auto_play_next",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile", "profile_device"],
|
||||
scopeIntroducedIn: [1, 1],
|
||||
resolutionOrder: ["profile_device", "profile", "default"],
|
||||
defaultValue: true,
|
||||
label: "Auto-play next episode",
|
||||
description: "Continue to the next episode automatically.",
|
||||
category: "playback",
|
||||
control: "switch",
|
||||
},
|
||||
"playback.auto_play_next_preview": {
|
||||
key: "playback.auto_play_next_preview",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile", "profile_device"],
|
||||
scopeIntroducedIn: [1, 1],
|
||||
resolutionOrder: ["profile_device", "profile", "default"],
|
||||
defaultValue: false,
|
||||
label: "Preview next episode",
|
||||
description: "Show a preview of the next episode while credits play.",
|
||||
category: "playback",
|
||||
control: "switch",
|
||||
},
|
||||
"playback.auto_skip_credits": {
|
||||
key: "playback.auto_skip_credits",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile", "profile_device"],
|
||||
scopeIntroducedIn: [1, 1],
|
||||
resolutionOrder: ["profile_device", "profile", "default"],
|
||||
defaultValue: false,
|
||||
label: "Auto-skip credits",
|
||||
description: "Move through end credits automatically when a skip is available.",
|
||||
category: "playback",
|
||||
control: "switch",
|
||||
},
|
||||
"playback.auto_skip_intro": {
|
||||
key: "playback.auto_skip_intro",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile", "profile_device"],
|
||||
scopeIntroducedIn: [1, 1],
|
||||
resolutionOrder: ["profile_device", "profile", "default"],
|
||||
defaultValue: false,
|
||||
label: "Auto-skip intros",
|
||||
description: "Jump past intros automatically when Silo can detect them.",
|
||||
category: "playback",
|
||||
control: "switch",
|
||||
},
|
||||
"playback.auto_skip_recap": {
|
||||
key: "playback.auto_skip_recap",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile", "profile_device"],
|
||||
scopeIntroducedIn: [1, 1],
|
||||
resolutionOrder: ["profile_device", "profile", "default"],
|
||||
defaultValue: false,
|
||||
label: "Auto-skip recaps",
|
||||
description: 'Skip "previously on" recaps automatically when Silo can detect them.',
|
||||
category: "playback",
|
||||
control: "switch",
|
||||
},
|
||||
"playback.max_bitrate_kbps": {
|
||||
key: "playback.max_bitrate_kbps",
|
||||
type: "integer",
|
||||
nullable: true,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile", "profile_device"],
|
||||
scopeIntroducedIn: [1, 1],
|
||||
resolutionOrder: ["profile_device", "profile", "default"],
|
||||
defaultValue: null,
|
||||
label: "Maximum bitrate",
|
||||
description:
|
||||
"Cap how much bandwidth playback may use. No cap means Silo picks for the chosen resolution.",
|
||||
category: "playback",
|
||||
control: "select",
|
||||
unit: "kbps",
|
||||
minimum: 100,
|
||||
maximum: 200000,
|
||||
},
|
||||
"playback.next_up_prompt_seconds": {
|
||||
key: "playback.next_up_prompt_seconds",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile", "profile_device"],
|
||||
scopeIntroducedIn: [1, 1],
|
||||
resolutionOrder: ["profile_device", "profile", "default"],
|
||||
defaultValue: 30,
|
||||
label: "Next up prompt",
|
||||
description: "How long before the end of an episode the next-up prompt appears.",
|
||||
category: "playback",
|
||||
control: "slider",
|
||||
unit: "seconds",
|
||||
minimum: 0,
|
||||
maximum: 120,
|
||||
},
|
||||
"playback.preferred_quality": {
|
||||
key: "playback.preferred_quality",
|
||||
type: "enum",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile", "profile_device"],
|
||||
scopeIntroducedIn: [1, 1],
|
||||
resolutionOrder: ["profile_device", "profile", "default"],
|
||||
defaultValue: "auto",
|
||||
label: "Preferred quality",
|
||||
description: "Pick the quality Silo should prefer.",
|
||||
category: "playback",
|
||||
control: "select",
|
||||
values: [
|
||||
{ value: "auto", label: "Auto", introducedIn: 1 },
|
||||
{ value: "480p", label: "480p", introducedIn: 1 },
|
||||
{ value: "720p", label: "720p", introducedIn: 1 },
|
||||
{ value: "1080p", label: "1080p", introducedIn: 1 },
|
||||
{ value: "2160p", label: "2160p / 4K", introducedIn: 1 },
|
||||
{ value: "original", label: "Original quality", introducedIn: 1 },
|
||||
],
|
||||
ordered: true,
|
||||
constrainedBy: { policyInput: "max_playback_quality", constraint: "ceiling" },
|
||||
},
|
||||
"playback.show_forced_subtitles": {
|
||||
key: "playback.show_forced_subtitles",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile", "profile_device", "profile_library", "profile_series"],
|
||||
scopeIntroducedIn: [1, 1, 1, 1],
|
||||
resolutionOrder: ["profile_series", "profile_library", "profile_device", "profile", "default"],
|
||||
defaultValue: true,
|
||||
label: "Show forced subtitles",
|
||||
description: "Show subtitles for foreign-language dialogue even when subtitles are off.",
|
||||
category: "playback",
|
||||
control: "switch",
|
||||
},
|
||||
"playback.subtitle_appearance": {
|
||||
key: "playback.subtitle_appearance",
|
||||
type: "object",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile", "profile_device"],
|
||||
scopeIntroducedIn: [1, 1],
|
||||
resolutionOrder: ["profile_device", "profile", "default"],
|
||||
defaultValue: {
|
||||
fontSize: "large",
|
||||
fontFamily: "sans-serif",
|
||||
fontColor: "#ffffff",
|
||||
backgroundColor: "#000000",
|
||||
backgroundStyle: "shadow",
|
||||
backgroundOpacity: 75,
|
||||
textOutline: false,
|
||||
textOutlineColor: "#000000",
|
||||
position: "bottom",
|
||||
},
|
||||
label: "Subtitle appearance",
|
||||
description: "How subtitles are drawn during playback.",
|
||||
category: "playback",
|
||||
control: "panel",
|
||||
},
|
||||
"playback.subtitle_language": {
|
||||
key: "playback.subtitle_language",
|
||||
type: "language_tag",
|
||||
nullable: true,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile", "profile_device", "profile_library", "profile_series"],
|
||||
scopeIntroducedIn: [1, 1, 1, 1],
|
||||
resolutionOrder: ["profile_series", "profile_library", "profile_device", "profile", "default"],
|
||||
defaultValue: null,
|
||||
label: "Preferred subtitle language",
|
||||
description: "Choose which subtitle language Silo should prefer first.",
|
||||
category: "playback",
|
||||
control: "select",
|
||||
},
|
||||
"playback.subtitle_mode": {
|
||||
key: "playback.subtitle_mode",
|
||||
type: "enum",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile", "profile_device", "profile_library", "profile_series"],
|
||||
scopeIntroducedIn: [1, 1, 1, 1],
|
||||
resolutionOrder: ["profile_series", "profile_library", "profile_device", "profile", "default"],
|
||||
defaultValue: "auto",
|
||||
label: "Subtitles",
|
||||
description: "When Silo should turn subtitles on.",
|
||||
category: "playback",
|
||||
control: "select",
|
||||
values: [
|
||||
{ value: "auto", label: "Auto", introducedIn: 1 },
|
||||
{ value: "always", label: "Always on", introducedIn: 1 },
|
||||
{ value: "off", label: "Off", introducedIn: 1 },
|
||||
],
|
||||
},
|
||||
"player.audio_sync_ms": {
|
||||
key: "player.audio_sync_ms",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile_device"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile_device", "default"],
|
||||
defaultValue: 0,
|
||||
label: "Audio sync offset",
|
||||
description: "Shift audio earlier or later to correct lip sync on this device.",
|
||||
category: "player",
|
||||
control: "slider",
|
||||
unit: "milliseconds",
|
||||
minimum: -5000,
|
||||
maximum: 5000,
|
||||
},
|
||||
"player.dolby_vision_enabled": {
|
||||
key: "player.dolby_vision_enabled",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile_device"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile_device", "default"],
|
||||
defaultValue: true,
|
||||
label: "Dolby Vision",
|
||||
description: "Allow Dolby Vision output on this device.",
|
||||
category: "player",
|
||||
control: "switch",
|
||||
},
|
||||
"player.dv_profile7_hdr10_fallback": {
|
||||
key: "player.dv_profile7_hdr10_fallback",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile_device"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile_device", "default"],
|
||||
defaultValue: false,
|
||||
label: "Dolby Vision Profile 7 fallback",
|
||||
description: "Play Profile 7 sources as HDR10 when this device cannot decode them natively.",
|
||||
category: "player",
|
||||
control: "switch",
|
||||
},
|
||||
"player.hdr_enabled": {
|
||||
key: "player.hdr_enabled",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile_device"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile_device", "default"],
|
||||
defaultValue: true,
|
||||
label: "HDR",
|
||||
description: "Allow HDR output on this device.",
|
||||
category: "player",
|
||||
control: "switch",
|
||||
},
|
||||
"player.match_frame_rate": {
|
||||
key: "player.match_frame_rate",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile_device"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile_device", "default"],
|
||||
defaultValue: false,
|
||||
label: "Match content frame rate",
|
||||
description: "Switch the display refresh rate to match what is playing.",
|
||||
category: "player",
|
||||
control: "switch",
|
||||
},
|
||||
"player.orientation_mode": {
|
||||
key: "player.orientation_mode",
|
||||
type: "enum",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile_device"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile_device", "default"],
|
||||
defaultValue: "landscapeLocked",
|
||||
label: "Screen orientation",
|
||||
description: "Whether the player rotates with the device.",
|
||||
category: "player",
|
||||
control: "select",
|
||||
values: [
|
||||
{ value: "landscapeLocked", label: "Landscape", introducedIn: 1 },
|
||||
{ value: "rotateFreely", label: "Rotate freely", introducedIn: 1 },
|
||||
],
|
||||
},
|
||||
"player.passout_threshold": {
|
||||
key: "player.passout_threshold",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
persistence: "client_local",
|
||||
introducedIn: 1,
|
||||
scopes: ["client_local"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["client_local", "default"],
|
||||
defaultValue: 3,
|
||||
label: "Still watching prompt",
|
||||
description:
|
||||
"How many episodes auto-play before Silo asks whether you are still watching. 0 never asks.",
|
||||
category: "player",
|
||||
control: "stepper",
|
||||
unit: "episodes",
|
||||
minimum: 0,
|
||||
maximum: 20,
|
||||
},
|
||||
"player.picture_in_picture_enabled": {
|
||||
key: "player.picture_in_picture_enabled",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
persistence: "client_local",
|
||||
introducedIn: 1,
|
||||
scopes: ["client_local"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["client_local", "default"],
|
||||
defaultValue: true,
|
||||
label: "Picture in picture",
|
||||
description: "Keep playing in a floating window when you leave the player.",
|
||||
category: "player",
|
||||
control: "switch",
|
||||
},
|
||||
"player.playback_speed": {
|
||||
key: "player.playback_speed",
|
||||
type: "number",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile_device"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile_device", "default"],
|
||||
defaultValue: 1.0,
|
||||
label: "Playback speed",
|
||||
description: "Default playback speed on this device.",
|
||||
category: "player",
|
||||
control: "slider",
|
||||
unit: "x",
|
||||
minimum: 0.25,
|
||||
maximum: 3,
|
||||
step: 0.05,
|
||||
},
|
||||
"player.resume_rewind_seconds": {
|
||||
key: "player.resume_rewind_seconds",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
persistence: "client_local",
|
||||
introducedIn: 1,
|
||||
scopes: ["client_local"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["client_local", "default"],
|
||||
defaultValue: 7,
|
||||
label: "Rewind on resume",
|
||||
description:
|
||||
"Skip back this far when resuming a partly watched item, to re-establish context. 0 turns it off.",
|
||||
category: "player",
|
||||
control: "stepper",
|
||||
unit: "seconds",
|
||||
minimum: 0,
|
||||
maximum: 30,
|
||||
},
|
||||
"player.seek_cache_enabled": {
|
||||
key: "player.seek_cache_enabled",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile_device"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile_device", "default"],
|
||||
defaultValue: true,
|
||||
label: "Seek cache",
|
||||
description: "Keep recently played segments buffered for faster seeking.",
|
||||
category: "player",
|
||||
control: "switch",
|
||||
},
|
||||
"player.sleep_timer_default_minutes": {
|
||||
key: "player.sleep_timer_default_minutes",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile_device"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile_device", "default"],
|
||||
defaultValue: 30,
|
||||
label: "Default sleep timer",
|
||||
description: "Duration the sleep timer starts on when you turn it on. 0 leaves it off.",
|
||||
category: "player",
|
||||
control: "stepper",
|
||||
unit: "minutes",
|
||||
minimum: 0,
|
||||
maximum: 240,
|
||||
},
|
||||
"player.subtitle_sync_ms": {
|
||||
key: "player.subtitle_sync_ms",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile_device"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile_device", "default"],
|
||||
defaultValue: 0,
|
||||
label: "Subtitle sync offset",
|
||||
description: "Shift subtitles earlier or later on this device.",
|
||||
category: "player",
|
||||
control: "slider",
|
||||
unit: "milliseconds",
|
||||
minimum: -10000,
|
||||
maximum: 10000,
|
||||
},
|
||||
"player.video_gravity": {
|
||||
key: "player.video_gravity",
|
||||
type: "enum",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile_device"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile_device", "default"],
|
||||
defaultValue: "fit",
|
||||
label: "Video sizing",
|
||||
description: "How video fills the screen on this device.",
|
||||
category: "player",
|
||||
control: "select",
|
||||
values: [
|
||||
{ value: "fit", label: "Fit", introducedIn: 1 },
|
||||
{ value: "fill", label: "Fill", introducedIn: 1 },
|
||||
{ value: "stretch", label: "Stretch", introducedIn: 1 },
|
||||
],
|
||||
},
|
||||
"search.media_scope": {
|
||||
key: "search.media_scope",
|
||||
type: "enum",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile", "default"],
|
||||
defaultValue: "video",
|
||||
label: "Search scope",
|
||||
description: "What search covers by default.",
|
||||
category: "search",
|
||||
control: "select",
|
||||
values: [
|
||||
{ value: "all", label: "Everything", introducedIn: 1 },
|
||||
{ value: "video", label: "Movies and series", introducedIn: 1 },
|
||||
{ value: "audiobook", label: "Audiobooks", introducedIn: 1 },
|
||||
],
|
||||
},
|
||||
"subtitle.matches_device": {
|
||||
key: "subtitle.matches_device",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
persistence: "client_local",
|
||||
introducedIn: 1,
|
||||
scopes: ["client_local"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["client_local", "default"],
|
||||
defaultValue: false,
|
||||
label: "Match device caption settings",
|
||||
description: "Use the operating system's caption style instead of Silo's.",
|
||||
category: "playback",
|
||||
control: "switch",
|
||||
},
|
||||
"ui.card_overlays": {
|
||||
key: "ui.card_overlays",
|
||||
type: "object",
|
||||
nullable: true,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile", "default"],
|
||||
defaultValue: null,
|
||||
label: "Poster badges",
|
||||
description: "Which badges appear on poster cards, and where.",
|
||||
category: "appearance",
|
||||
},
|
||||
"ui.custom_css": {
|
||||
key: "ui.custom_css",
|
||||
type: "string",
|
||||
nullable: true,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile", "default"],
|
||||
defaultValue: null,
|
||||
label: "Custom CSS",
|
||||
description: "Raw CSS applied on top of the selected theme.",
|
||||
category: "appearance",
|
||||
control: "text",
|
||||
},
|
||||
"ui.custom_theme_vars": {
|
||||
key: "ui.custom_theme_vars",
|
||||
type: "object",
|
||||
nullable: true,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile", "default"],
|
||||
defaultValue: null,
|
||||
label: "Custom theme variables",
|
||||
description: "Per-token overrides applied on top of the selected theme.",
|
||||
category: "appearance",
|
||||
control: "panel",
|
||||
},
|
||||
"ui.date_format": {
|
||||
key: "ui.date_format",
|
||||
type: "enum",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile", "default"],
|
||||
defaultValue: "auto",
|
||||
label: "Date format",
|
||||
description: "How dates are written across the interface.",
|
||||
category: "appearance",
|
||||
control: "select",
|
||||
values: [
|
||||
{ value: "auto", label: "Match device", introducedIn: 1 },
|
||||
{ value: "DD/MM/YYYY", label: "", introducedIn: 1 },
|
||||
{ value: "MM/DD/YYYY", label: "", introducedIn: 1 },
|
||||
{ value: "YYYY-MM-DD", label: "", introducedIn: 1 },
|
||||
],
|
||||
},
|
||||
"ui.disabled_library_ids": {
|
||||
key: "ui.disabled_library_ids",
|
||||
type: "object",
|
||||
nullable: true,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile", "default"],
|
||||
defaultValue: null,
|
||||
label: "Hidden libraries",
|
||||
description: "Libraries you have hidden from your own browsing.",
|
||||
category: "navigation",
|
||||
},
|
||||
"ui.high_contrast": {
|
||||
key: "ui.high_contrast",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile", "profile_device"],
|
||||
scopeIntroducedIn: [1, 1],
|
||||
resolutionOrder: ["profile_device", "profile", "default"],
|
||||
defaultValue: false,
|
||||
label: "High contrast",
|
||||
description: "Increase contrast across the interface.",
|
||||
category: "appearance",
|
||||
control: "switch",
|
||||
},
|
||||
"ui.library_order": {
|
||||
key: "ui.library_order",
|
||||
type: "object",
|
||||
nullable: true,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile", "default"],
|
||||
defaultValue: null,
|
||||
label: "Library order",
|
||||
description: "The order your libraries appear in.",
|
||||
category: "navigation",
|
||||
},
|
||||
"ui.library_page_state": {
|
||||
key: "ui.library_page_state",
|
||||
type: "object",
|
||||
nullable: true,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile_device"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile_device", "default"],
|
||||
defaultValue: null,
|
||||
label: "Remembered library view",
|
||||
description: "Saved browse state for each library.",
|
||||
category: "navigation",
|
||||
},
|
||||
"ui.next_up_mode": {
|
||||
key: "ui.next_up_mode",
|
||||
type: "enum",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile", "default"],
|
||||
defaultValue: "combined",
|
||||
label: "Next up episodes",
|
||||
description: "Whether upcoming episodes stay with Continue Watching or get their own row.",
|
||||
category: "navigation",
|
||||
control: "select",
|
||||
values: [
|
||||
{ value: "combined", label: "With Continue Watching", introducedIn: 1 },
|
||||
{ value: "separate", label: "Separate row", introducedIn: 1 },
|
||||
],
|
||||
},
|
||||
"ui.remember_library_page_state": {
|
||||
key: "ui.remember_library_page_state",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile_device"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile_device", "default"],
|
||||
defaultValue: true,
|
||||
label: "Remember library view",
|
||||
description: "Return to where you left off when reopening a library.",
|
||||
category: "navigation",
|
||||
control: "switch",
|
||||
},
|
||||
"ui.sidebar_pins": {
|
||||
key: "ui.sidebar_pins",
|
||||
type: "object",
|
||||
nullable: true,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile", "default"],
|
||||
defaultValue: null,
|
||||
label: "Pinned sidebar items",
|
||||
description: "Sections and collections pinned into the sidebar.",
|
||||
category: "navigation",
|
||||
},
|
||||
"ui.text_scale": {
|
||||
key: "ui.text_scale",
|
||||
type: "enum",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile", "profile_device"],
|
||||
scopeIntroducedIn: [1, 1],
|
||||
resolutionOrder: ["profile_device", "profile", "default"],
|
||||
defaultValue: "default",
|
||||
label: "Text size",
|
||||
description: "Overall interface text size.",
|
||||
category: "appearance",
|
||||
control: "select",
|
||||
values: [
|
||||
{ value: "default", label: "Default", introducedIn: 1 },
|
||||
{ value: "large", label: "Large", introducedIn: 1 },
|
||||
{ value: "x-large", label: "Extra large", introducedIn: 1 },
|
||||
],
|
||||
ordered: true,
|
||||
},
|
||||
"ui.text_weight": {
|
||||
key: "ui.text_weight",
|
||||
type: "enum",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile", "profile_device"],
|
||||
scopeIntroducedIn: [1, 1],
|
||||
resolutionOrder: ["profile_device", "profile", "default"],
|
||||
defaultValue: "default",
|
||||
label: "Text weight",
|
||||
description: "Use heavier interface text for readability.",
|
||||
category: "appearance",
|
||||
control: "select",
|
||||
values: [
|
||||
{ value: "default", label: "Default", introducedIn: 1 },
|
||||
{ value: "strong", label: "Bolder", introducedIn: 1 },
|
||||
],
|
||||
},
|
||||
"ui.theme": {
|
||||
key: "ui.theme",
|
||||
type: "enum",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile", "profile_device"],
|
||||
scopeIntroducedIn: [1, 1],
|
||||
resolutionOrder: ["profile_device", "profile", "default"],
|
||||
defaultValue: "midnight-cinema",
|
||||
label: "Theme",
|
||||
description: "Colour theme for the Silo interface.",
|
||||
category: "appearance",
|
||||
control: "select",
|
||||
values: [
|
||||
{ value: "midnight-cinema", label: "Midnight Cinema", introducedIn: 1 },
|
||||
{ value: "cinema-light", label: "Cinema Light", introducedIn: 1 },
|
||||
{ value: "cobalt-studio", label: "Cobalt Studio", introducedIn: 1 },
|
||||
{ value: "oxblood-noir", label: "Oxblood Noir", introducedIn: 1 },
|
||||
{ value: "evergreen-studio", label: "Evergreen Studio", introducedIn: 1 },
|
||||
],
|
||||
},
|
||||
"ui.time_format": {
|
||||
key: "ui.time_format",
|
||||
type: "enum",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 1,
|
||||
scopes: ["profile"],
|
||||
scopeIntroducedIn: [1],
|
||||
resolutionOrder: ["profile", "default"],
|
||||
defaultValue: "auto",
|
||||
label: "Time format",
|
||||
description: "How clock times are written across the interface.",
|
||||
category: "appearance",
|
||||
control: "select",
|
||||
values: [
|
||||
{ value: "auto", label: "Match device", introducedIn: 1 },
|
||||
{ value: "12h", label: "12-hour", introducedIn: 1 },
|
||||
{ value: "24h", label: "24-hour", introducedIn: 1 },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SETTING_DEFINITIONS, SETTING_KEYS, type SettingKey } from "./settingsContract";
|
||||
import {
|
||||
ALL_DEVICE_SETTING_KEYS,
|
||||
controlKindFor,
|
||||
defaultValueToString,
|
||||
formatSettingValue,
|
||||
getSettingDefinition,
|
||||
isStructuredSetting,
|
||||
optionsFor,
|
||||
} from "./settingsDisplay";
|
||||
|
||||
describe("settingsDisplay", () => {
|
||||
it("derives the device-overridable keys from the contract rather than a parallel list", () => {
|
||||
// The hand-written registry this replaced had to be edited alongside the
|
||||
// manifest, and drifted: it declared several profile-scoped keys as device
|
||||
// overrides. Deriving the list means a key is offered as a device override
|
||||
// exactly when the manifest allows one.
|
||||
for (const key of ALL_DEVICE_SETTING_KEYS) {
|
||||
const definition = SETTING_DEFINITIONS[key];
|
||||
expect(definition.persistence).toBe("remote");
|
||||
expect(definition.scopes).toContain("profile_device");
|
||||
}
|
||||
|
||||
const missed = (Object.keys(SETTING_DEFINITIONS) as SettingKey[]).filter(
|
||||
(key) =>
|
||||
SETTING_DEFINITIONS[key].persistence === "remote" &&
|
||||
SETTING_DEFINITIONS[key].scopes.includes("profile_device") &&
|
||||
!ALL_DEVICE_SETTING_KEYS.includes(key),
|
||||
);
|
||||
expect(missed).toEqual([]);
|
||||
});
|
||||
|
||||
it("offers a device override for the skip preferences the player now resolves", () => {
|
||||
// Recaps and next-episode previews are declared at profile_device and read
|
||||
// by the player, so both must be editable as device overrides.
|
||||
expect(ALL_DEVICE_SETTING_KEYS).toContain(SETTING_KEYS.PLAYBACK_AUTO_SKIP_RECAP);
|
||||
expect(ALL_DEVICE_SETTING_KEYS).toContain(SETTING_KEYS.PLAYBACK_AUTO_PLAY_NEXT_PREVIEW);
|
||||
});
|
||||
|
||||
it("falls back to the value type when a definition names no control", () => {
|
||||
expect(controlKindFor(SETTING_DEFINITIONS[SETTING_KEYS.PLAYBACK_AUTO_SKIP_CREDITS])).toBe(
|
||||
"switch",
|
||||
);
|
||||
// ui.disabled_library_ids declares no recommended control; an object is
|
||||
// never an inline widget.
|
||||
expect(isStructuredSetting(getSettingDefinition(SETTING_KEYS.UI_DISABLED_LIBRARY_IDS))).toBe(
|
||||
true,
|
||||
);
|
||||
// Subtitle appearance opens a bespoke panel rather than a select.
|
||||
expect(
|
||||
isStructuredSetting(getSettingDefinition(SETTING_KEYS.PLAYBACK_SUBTITLE_APPEARANCE)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("treats an unknown key as structured so it opens the raw editor", () => {
|
||||
expect(getSettingDefinition("playback.invented_by_a_client")).toBeNull();
|
||||
expect(isStructuredSetting(null)).toBe(true);
|
||||
});
|
||||
|
||||
it("builds enum options from the manifest, adding an unset entry only when nullable", () => {
|
||||
expect(optionsFor(SETTING_DEFINITIONS[SETTING_KEYS.PLAYBACK_SUBTITLE_MODE])).toEqual([
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "always", label: "Always on" },
|
||||
{ value: "off", label: "Off" },
|
||||
]);
|
||||
// Date formats spell themselves, so the manifest leaves the label empty and
|
||||
// the value has to stand in rather than rendering a blank row.
|
||||
expect(optionsFor(SETTING_DEFINITIONS[SETTING_KEYS.UI_DATE_FORMAT])).toContainEqual({
|
||||
value: "YYYY-MM-DD",
|
||||
label: "YYYY-MM-DD",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a language tag through the shared language list", () => {
|
||||
expect(formatSettingValue(SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE, "ja")).toBe("Japanese");
|
||||
expect(formatSettingValue(SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE, "")).toBe("No preference");
|
||||
});
|
||||
|
||||
it("renders booleans and enum members with their contract labels", () => {
|
||||
expect(formatSettingValue(SETTING_KEYS.PLAYBACK_AUTO_SKIP_INTRO, "true")).toBe("Enabled");
|
||||
expect(formatSettingValue(SETTING_KEYS.PLAYBACK_SUBTITLE_MODE, "always")).toBe("Always on");
|
||||
});
|
||||
|
||||
it("passes an unknown key's value through rather than inventing a label", () => {
|
||||
expect(formatSettingValue("playback.invented_by_a_client", "17")).toBe("17");
|
||||
expect(formatSettingValue("playback.invented_by_a_client", undefined)).toBe("Unset");
|
||||
});
|
||||
|
||||
it("stringifies defaults in the form the admin controls compare against", () => {
|
||||
expect(defaultValueToString(SETTING_DEFINITIONS[SETTING_KEYS.PLAYBACK_AUTO_PLAY_NEXT])).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(defaultValueToString(SETTING_DEFINITIONS[SETTING_KEYS.PLAYER_AUDIO_SYNC_MS])).toBe("0");
|
||||
// A null default is "unset", not the string "null".
|
||||
expect(defaultValueToString(SETTING_DEFINITIONS[SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE])).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
SETTING_DEFINITIONS,
|
||||
type SettingDefinition,
|
||||
type SettingKey,
|
||||
} from "@/lib/settingsContract";
|
||||
import { LANGUAGE_OPTIONS, type SettingOption } from "@/lib/languageOptions";
|
||||
|
||||
/**
|
||||
* Display helpers over the generated settings contract.
|
||||
*
|
||||
* Everything a settings control needs to render — label, description, control
|
||||
* shape, option list, bounds, unit — is already in the manifest, so this module
|
||||
* only adapts those fields to what the UI asks for. It deliberately owns no
|
||||
* per-key knowledge: a hand-written table beside the generated one is exactly
|
||||
* the drift the contract exists to remove, and the one that shipped before this
|
||||
* disagreed with the server about the type and range of several keys.
|
||||
*/
|
||||
|
||||
export type SettingDisplay = SettingDefinition;
|
||||
|
||||
/** Control shapes the admin/settings widgets can render. */
|
||||
export type SettingControlKind = "switch" | "select" | "slider" | "stepper" | "panel" | "text";
|
||||
|
||||
/** The generated definition for a key, or null when this build has no such key. */
|
||||
export function getSettingDefinition(key: string): SettingDisplay | null {
|
||||
return SETTING_DEFINITIONS[key as SettingKey] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The control to render for a definition.
|
||||
*
|
||||
* A definition with no recommended_control falls back to its value type, which
|
||||
* is what keeps a newly added key renderable without a manifest edit here.
|
||||
*/
|
||||
export function controlKindFor(definition: SettingDisplay): SettingControlKind {
|
||||
switch (definition.control) {
|
||||
case "switch":
|
||||
case "select":
|
||||
case "slider":
|
||||
case "stepper":
|
||||
case "panel":
|
||||
case "text":
|
||||
return definition.control;
|
||||
}
|
||||
if (definition.type === "boolean") return "switch";
|
||||
if (definition.type === "enum" || definition.type === "language_tag") return "select";
|
||||
if (definition.type === "integer" || definition.type === "number") return "slider";
|
||||
return "text";
|
||||
}
|
||||
|
||||
/** Whether a definition is edited through a bespoke panel or raw JSON. */
|
||||
export function isStructuredSetting(definition: SettingDisplay | null): boolean {
|
||||
if (!definition) return true;
|
||||
return definition.type === "object" || controlKindFor(definition) === "panel";
|
||||
}
|
||||
|
||||
/**
|
||||
* The option list for a select, in manifest order.
|
||||
*
|
||||
* Enum members come straight from the contract. Language tags get the shared
|
||||
* language list instead, because the manifest declares them by type rather than
|
||||
* by enumerating every tag. A nullable definition gains a leading "unset" entry
|
||||
* so a control can express clearing the value.
|
||||
*/
|
||||
export function optionsFor(definition: SettingDisplay): SettingOption[] {
|
||||
if (definition.type === "language_tag") {
|
||||
return LANGUAGE_OPTIONS;
|
||||
}
|
||||
const members = (definition.values ?? []).map((member) => ({
|
||||
value: String(member.value),
|
||||
// The manifest leaves a label empty when the member is its own label
|
||||
// (the date formats spell themselves).
|
||||
label: member.label || String(member.value),
|
||||
}));
|
||||
if (definition.nullable) {
|
||||
return [{ value: "", label: "Unset" }, ...members];
|
||||
}
|
||||
return members;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored value rendered for display, in the string form the admin surface
|
||||
* edits. Falls back to the raw value for a key this build does not know, which
|
||||
* is the older-client-newer-server case.
|
||||
*/
|
||||
export function formatSettingValue(key: string, value: string | null | undefined): string {
|
||||
const definition = getSettingDefinition(key);
|
||||
if (!definition) {
|
||||
return value ?? "Unset";
|
||||
}
|
||||
if (controlKindFor(definition) === "switch") {
|
||||
return value === "true" ? "Enabled" : "Disabled";
|
||||
}
|
||||
if (definition.type === "language_tag") {
|
||||
const match = LANGUAGE_OPTIONS.find((option) => option.value === (value ?? ""));
|
||||
return match?.label ?? value ?? "No preference";
|
||||
}
|
||||
if (definition.values?.length) {
|
||||
const fallback = value ?? defaultValueToString(definition);
|
||||
const match = definition.values.find((member) => String(member.value) === fallback);
|
||||
return match ? match.label || String(match.value) : fallback || "Unset";
|
||||
}
|
||||
if (definition.unit) {
|
||||
return `${value ?? defaultValueToString(definition) ?? "0"} ${definition.unit}`;
|
||||
}
|
||||
if (definition.type === "object") {
|
||||
return value ? "Custom" : "Using fallback";
|
||||
}
|
||||
return value || defaultValueToString(definition) || "Unset";
|
||||
}
|
||||
|
||||
/** The definition's default in the string form the admin controls compare against. */
|
||||
export function defaultValueToString(definition: SettingDisplay): string {
|
||||
const value = definition.defaultValue;
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every remote setting a device can override, in manifest order. The admin
|
||||
* "show all overrides" view iterates this so an admin can create an override on
|
||||
* any device-scoped setting, not only the ones that already have a row.
|
||||
*/
|
||||
export const ALL_DEVICE_SETTING_KEYS: SettingKey[] = (
|
||||
Object.keys(SETTING_DEFINITIONS) as SettingKey[]
|
||||
).filter((key) => {
|
||||
const definition = SETTING_DEFINITIONS[key];
|
||||
return definition.persistence === "remote" && definition.scopes.includes("profile_device");
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ALL_DEVICE_SETTING_KEYS, getSettingDefinition } from "./settingsManifest";
|
||||
|
||||
describe("settingsManifest", () => {
|
||||
it("keeps the client-consumed credits override editable", () => {
|
||||
expect(ALL_DEVICE_SETTING_KEYS).toContain("playback.auto_skip_credits");
|
||||
expect(getSettingDefinition("playback.auto_skip_credits")).toMatchObject({
|
||||
scope: "device",
|
||||
control: "switch",
|
||||
defaultValue: "false",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose inert profile-only playback fields as device overrides", () => {
|
||||
expect(ALL_DEVICE_SETTING_KEYS).not.toContain("playback.auto_skip_recap");
|
||||
expect(ALL_DEVICE_SETTING_KEYS).not.toContain("playback.auto_play_next_preview");
|
||||
});
|
||||
});
|
||||
@@ -1,282 +0,0 @@
|
||||
import { LANGUAGES } from "@/player/utils/languageNames";
|
||||
|
||||
export type SettingScope = "user" | "device";
|
||||
export type SettingControl = "switch" | "select" | "slider" | "json";
|
||||
|
||||
export interface SettingOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SettingDefinition {
|
||||
key: string;
|
||||
scope: SettingScope;
|
||||
label: string;
|
||||
description: string;
|
||||
control: SettingControl;
|
||||
defaultValue?: string;
|
||||
options?: SettingOption[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
unit?: string;
|
||||
summary?: (value: string | null | undefined) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Language choices for settings dropdowns, derived from the canonical
|
||||
* language list so every supported language stays selectable.
|
||||
*/
|
||||
export const LANGUAGE_OPTIONS: SettingOption[] = [
|
||||
{ value: "", label: "No preference" },
|
||||
...LANGUAGES.map(({ code, label }) => ({ value: code, label })),
|
||||
];
|
||||
|
||||
const definitions: SettingDefinition[] = [
|
||||
{
|
||||
key: "playback.preferred_quality",
|
||||
scope: "device",
|
||||
label: "Preferred quality",
|
||||
description: "Pick the quality Silo should prefer on this device for the current profile.",
|
||||
control: "select",
|
||||
defaultValue: "auto",
|
||||
options: [
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "original", label: "Original quality" },
|
||||
{ value: "2160p", label: "2160p / 4K" },
|
||||
{ value: "1080p", label: "1080p" },
|
||||
{ value: "720p", label: "720p" },
|
||||
{ value: "480p", label: "480p" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "playback.audio_language",
|
||||
scope: "device",
|
||||
label: "Preferred audio language",
|
||||
description: "Choose which spoken language Silo should prefer first on this device.",
|
||||
control: "select",
|
||||
options: LANGUAGE_OPTIONS,
|
||||
summary: (value) =>
|
||||
LANGUAGE_OPTIONS.find((option) => option.value === (value ?? ""))?.label ?? (value || "None"),
|
||||
},
|
||||
{
|
||||
key: "playback.auto_skip_intro",
|
||||
scope: "device",
|
||||
label: "Auto-skip intros",
|
||||
description: "Jump past intros automatically on this device when Silo can detect them.",
|
||||
control: "switch",
|
||||
defaultValue: "false",
|
||||
},
|
||||
{
|
||||
key: "playback.auto_skip_credits",
|
||||
scope: "device",
|
||||
label: "Auto-skip credits",
|
||||
description: "Move through end credits automatically on this device when a skip is available.",
|
||||
control: "switch",
|
||||
defaultValue: "false",
|
||||
},
|
||||
{
|
||||
key: "playback.auto_play_next",
|
||||
scope: "device",
|
||||
label: "Auto-play next episode",
|
||||
description: "Start the next episode automatically on this device after the current one ends.",
|
||||
control: "switch",
|
||||
defaultValue: "true",
|
||||
},
|
||||
{
|
||||
key: "playback.next_up_prompt_seconds",
|
||||
scope: "device",
|
||||
label: "Next Up prompt",
|
||||
description: "Choose when the Next Up screen should appear near the end of playback.",
|
||||
control: "select",
|
||||
defaultValue: "30",
|
||||
options: [
|
||||
{ value: "0", label: "At end" },
|
||||
{ value: "10", label: "10 seconds before end" },
|
||||
{ value: "30", label: "30 seconds before end" },
|
||||
{ value: "60", label: "1 minute before end" },
|
||||
{ value: "120", label: "2 minutes before end" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "subtitle_appearance",
|
||||
scope: "device",
|
||||
label: "Subtitle appearance",
|
||||
description: "Customize subtitles on this device.",
|
||||
control: "json",
|
||||
summary: (value) => (value ? "Custom subtitle appearance" : "Using fallback"),
|
||||
},
|
||||
{
|
||||
key: "ui.remember_library_page_state",
|
||||
scope: "device",
|
||||
label: "Remember library pages",
|
||||
description:
|
||||
"Return each library to the last tab, sort, and filters used on this profile and device.",
|
||||
control: "switch",
|
||||
defaultValue: "true",
|
||||
},
|
||||
{
|
||||
key: "player.hdr_enabled",
|
||||
scope: "device",
|
||||
label: "HDR enabled",
|
||||
description: "Allow HDR playback on this device when the player and display support it.",
|
||||
control: "switch",
|
||||
defaultValue: "true",
|
||||
},
|
||||
{
|
||||
key: "player.dolby_vision_enabled",
|
||||
scope: "device",
|
||||
label: "Dolby Vision enabled",
|
||||
description: "Allow Dolby Vision playback when this device and display support it.",
|
||||
control: "switch",
|
||||
defaultValue: "true",
|
||||
},
|
||||
{
|
||||
key: "player.dv_profile7_hdr10_fallback",
|
||||
scope: "device",
|
||||
label: "Profile 7 HDR10 fallback",
|
||||
description: "Play Dolby Vision Profile 7 as the HDR10 base layer on this device.",
|
||||
control: "switch",
|
||||
defaultValue: "false",
|
||||
},
|
||||
{
|
||||
key: "player.seek_cache_enabled",
|
||||
scope: "device",
|
||||
label: "Seek cache",
|
||||
description: "Cache nearby playback data to make repeated seeks faster on this device.",
|
||||
control: "switch",
|
||||
defaultValue: "true",
|
||||
},
|
||||
{
|
||||
key: "player.playback_speed",
|
||||
scope: "device",
|
||||
label: "Playback speed",
|
||||
description: "Choose the default playback speed for this device.",
|
||||
control: "select",
|
||||
defaultValue: "1",
|
||||
options: [
|
||||
{ value: "0.25", label: "0.25x" },
|
||||
{ value: "0.5", label: "0.5x" },
|
||||
{ value: "0.75", label: "0.75x" },
|
||||
{ value: "1", label: "1x (Normal)" },
|
||||
{ value: "1.25", label: "1.25x" },
|
||||
{ value: "1.5", label: "1.5x" },
|
||||
{ value: "1.75", label: "1.75x" },
|
||||
{ value: "2", label: "2x" },
|
||||
{ value: "2.5", label: "2.5x" },
|
||||
{ value: "3", label: "3x" },
|
||||
],
|
||||
summary: (value) => `${value ?? "1"}x`,
|
||||
},
|
||||
{
|
||||
key: "player.audio_sync_ms",
|
||||
scope: "device",
|
||||
label: "Audio sync",
|
||||
description: "Offset audio timing on this device.",
|
||||
control: "slider",
|
||||
defaultValue: "0",
|
||||
min: -5000,
|
||||
max: 5000,
|
||||
step: 50,
|
||||
unit: "ms",
|
||||
summary: (value) => `${value ?? "0"} ms`,
|
||||
},
|
||||
{
|
||||
key: "player.subtitle_sync_ms",
|
||||
scope: "device",
|
||||
label: "Subtitle sync",
|
||||
description: "Offset subtitle timing on this device.",
|
||||
control: "slider",
|
||||
defaultValue: "0",
|
||||
min: -10000,
|
||||
max: 10000,
|
||||
step: 50,
|
||||
unit: "ms",
|
||||
summary: (value) => `${value ?? "0"} ms`,
|
||||
},
|
||||
{
|
||||
key: "player.video_gravity",
|
||||
scope: "device",
|
||||
label: "Video fit",
|
||||
description: "Control how video should fill the screen on this device.",
|
||||
control: "select",
|
||||
defaultValue: "fit",
|
||||
options: [
|
||||
{ value: "fit", label: "Fit" },
|
||||
{ value: "fill", label: "Fill" },
|
||||
{ value: "stretch", label: "Stretch" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "player.orientation_mode",
|
||||
scope: "device",
|
||||
label: "Orientation mode",
|
||||
description: "Choose whether playback stays locked or rotates freely on this device.",
|
||||
control: "select",
|
||||
defaultValue: "landscapeLocked",
|
||||
options: [
|
||||
{ value: "landscapeLocked", label: "Landscape locked" },
|
||||
{ value: "rotateFreely", label: "Rotate freely" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const settingsManifest = definitions.reduce<Record<string, SettingDefinition>>(
|
||||
(acc, definition) => {
|
||||
acc[definition.key] = definition;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
export const USER_SETTING_KEYS = definitions
|
||||
.filter((definition) => definition.scope === "user")
|
||||
.map((definition) => definition.key);
|
||||
|
||||
export const DEVICE_SETTING_KEYS = definitions
|
||||
.filter((definition) => definition.scope === "device" && definition.key !== "subtitle_appearance")
|
||||
.map((definition) => definition.key);
|
||||
|
||||
/**
|
||||
* Every device-scoped setting in canonical manifest order, including the
|
||||
* subtitle_appearance JSON setting. The admin "show all overrides" view
|
||||
* iterates this list so admins can set an override on any setting,
|
||||
* regardless of whether one already exists.
|
||||
*/
|
||||
export const ALL_DEVICE_SETTING_KEYS: string[] = definitions
|
||||
.filter((definition) => definition.scope === "device")
|
||||
.map((definition) => definition.key);
|
||||
|
||||
export function listDeviceSettingDefinitions(): SettingDefinition[] {
|
||||
return definitions.filter((definition) => definition.scope === "device");
|
||||
}
|
||||
|
||||
export function getSettingDefinition(key: string): SettingDefinition | null {
|
||||
return settingsManifest[key] ?? null;
|
||||
}
|
||||
|
||||
export function formatSettingValue(key: string, value: string | null | undefined): string {
|
||||
const definition = getSettingDefinition(key);
|
||||
if (!definition) {
|
||||
return value ?? "Unset";
|
||||
}
|
||||
if (definition.summary) {
|
||||
return definition.summary(value);
|
||||
}
|
||||
if (definition.control === "switch") {
|
||||
return value === "true" ? "Enabled" : "Disabled";
|
||||
}
|
||||
if (definition.options) {
|
||||
return (
|
||||
definition.options.find((option) => option.value === (value ?? definition.defaultValue))
|
||||
?.label ??
|
||||
value ??
|
||||
definition.defaultValue ??
|
||||
"Unset"
|
||||
);
|
||||
}
|
||||
if (definition.unit) {
|
||||
return `${value ?? definition.defaultValue ?? "0"} ${definition.unit}`;
|
||||
}
|
||||
return value ?? definition.defaultValue ?? "Unset";
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { SETTING_DEFINITIONS, type SettingDefinition, type SettingKey } from "./settingsContract";
|
||||
|
||||
/**
|
||||
* Client-side settings resolution, mirroring the server's
|
||||
* internal/settingsresolve semantics exactly: the definition's declared
|
||||
* resolution order decides which stored value wins, an identity absent from the
|
||||
* context drops the scopes that need it, and policy constraints narrow the
|
||||
* answer without destroying what the user authored.
|
||||
*
|
||||
* The server remains the authority for online resolution (the
|
||||
* /settings/values/effective endpoint); this module exists so the web client
|
||||
* can resolve from rows it already holds — and so the cross-platform
|
||||
* conformance fixture in contracts/settings/v1/conformance.json has a web
|
||||
* implementation to run against. Every behavioral choice here is pinned by
|
||||
* settingsConformance.test.ts; do not change one without the fixture agreeing.
|
||||
*/
|
||||
|
||||
/** The five storage scopes the server resolves. */
|
||||
export type RemoteSettingScope =
|
||||
| "account"
|
||||
| "profile"
|
||||
| "profile_device"
|
||||
| "profile_library"
|
||||
| "profile_series";
|
||||
|
||||
/** Where a resolved value came from, or "default" when nothing was stored. */
|
||||
export type ResolvedSettingSource = RemoteSettingScope | "default";
|
||||
|
||||
export type SettingConstraintKind = "ceiling" | "floor" | "allowlist" | "locked";
|
||||
|
||||
/** One stored row, as the server's values API reports it. */
|
||||
export interface StoredSettingRow {
|
||||
key: string;
|
||||
scope: RemoteSettingScope;
|
||||
profileId?: string;
|
||||
deviceId?: string;
|
||||
libraryId?: number;
|
||||
seriesId?: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
/** The identity a resolution happens against. Absent fields drop their scopes. */
|
||||
export interface SettingResolutionContext {
|
||||
profileId?: string;
|
||||
deviceId?: string;
|
||||
libraryIds?: readonly number[];
|
||||
seriesIds?: readonly string[];
|
||||
}
|
||||
|
||||
/** A constraint attached to one key, overriding what the manifest binds. */
|
||||
export interface SettingConstraintBinding {
|
||||
policyInput: string;
|
||||
constraint: SettingConstraintKind;
|
||||
}
|
||||
|
||||
export interface ResolvedSetting {
|
||||
key: SettingKey;
|
||||
value: unknown;
|
||||
source: ResolvedSettingSource;
|
||||
/** True when a policy constraint narrowed value away from what was stored. */
|
||||
constrained: boolean;
|
||||
/** What the user authored (may be null); present only when constrained. */
|
||||
storedValue?: unknown;
|
||||
constraintKind?: SettingConstraintKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective value for each requested key against stored rows.
|
||||
*
|
||||
* Unknown and client_local keys are omitted rather than erroring, matching the
|
||||
* server: they have no server-resolved answer. `constraintBindings` lets a
|
||||
* caller (in practice, the conformance runner) attach a constraint to a key the
|
||||
* shipped manifest does not bind; a key without an entry uses the manifest's
|
||||
* own binding.
|
||||
*/
|
||||
export function resolveSettingValues(
|
||||
keys: readonly string[],
|
||||
stored: readonly StoredSettingRow[],
|
||||
context: SettingResolutionContext,
|
||||
constraints?: Readonly<Record<string, unknown>>,
|
||||
constraintBindings?: Readonly<Record<string, SettingConstraintBinding>>,
|
||||
): ResolvedSetting[] {
|
||||
const out: ResolvedSetting[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const key of keys) {
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
if (!(key in SETTING_DEFINITIONS)) continue;
|
||||
const def = SETTING_DEFINITIONS[key as SettingKey];
|
||||
if (def.persistence !== "remote") continue;
|
||||
out.push(resolveOne(def, stored, context, constraints, constraintBindings?.[key]));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveOne(
|
||||
def: SettingDefinition,
|
||||
stored: readonly StoredSettingRow[],
|
||||
context: SettingResolutionContext,
|
||||
constraints: Readonly<Record<string, unknown>> | undefined,
|
||||
bindingOverride: SettingConstraintBinding | undefined,
|
||||
): ResolvedSetting {
|
||||
const candidates = stored.filter((row) => row.key === def.key);
|
||||
|
||||
let value: unknown = def.defaultValue;
|
||||
let source: ResolvedSettingSource = "default";
|
||||
for (const scope of def.resolutionOrder) {
|
||||
if (scope === "default") break;
|
||||
const row = pickForScope(scope, candidates, context);
|
||||
if (!row) continue;
|
||||
value = row.value;
|
||||
source = scope as RemoteSettingScope;
|
||||
break;
|
||||
}
|
||||
|
||||
return applyConstraint(
|
||||
def,
|
||||
{ key: def.key, value, source, constrained: false },
|
||||
constraints,
|
||||
bindingOverride,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* pickForScope returns the candidate row for one scope, mirroring the server:
|
||||
* an identity missing from the context matches nothing, and a tie between
|
||||
* several content rows breaks deterministically by (libraryId, seriesId).
|
||||
*/
|
||||
function pickForScope(
|
||||
scope: string,
|
||||
candidates: readonly StoredSettingRow[],
|
||||
context: SettingResolutionContext,
|
||||
): StoredSettingRow | undefined {
|
||||
const profileId = context.profileId ?? "";
|
||||
const deviceId = context.deviceId ?? "";
|
||||
const matches = candidates.filter((row) => {
|
||||
if (row.scope !== scope) return false;
|
||||
switch (scope) {
|
||||
case "account":
|
||||
return true;
|
||||
case "profile":
|
||||
return (row.profileId ?? "") === profileId;
|
||||
case "profile_device":
|
||||
return (
|
||||
(row.profileId ?? "") === profileId &&
|
||||
(row.deviceId ?? "") === deviceId &&
|
||||
deviceId !== ""
|
||||
);
|
||||
case "profile_library":
|
||||
return (
|
||||
(row.profileId ?? "") === profileId &&
|
||||
(context.libraryIds ?? []).includes(row.libraryId ?? 0)
|
||||
);
|
||||
case "profile_series":
|
||||
return (
|
||||
(row.profileId ?? "") === profileId &&
|
||||
(context.seriesIds ?? []).includes(row.seriesId ?? "")
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (matches.length > 1) {
|
||||
matches.sort((a, b) => {
|
||||
const byLibrary = (a.libraryId ?? 0) - (b.libraryId ?? 0);
|
||||
if (byLibrary !== 0) return byLibrary;
|
||||
return (a.seriesId ?? "").localeCompare(b.seriesId ?? "");
|
||||
});
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* applyConstraint narrows an effective value to what policy permits without
|
||||
* destroying the authored value: a preference capped today must take effect
|
||||
* the day the cap lifts.
|
||||
*/
|
||||
function applyConstraint(
|
||||
def: SettingDefinition,
|
||||
resolved: ResolvedSetting,
|
||||
constraints: Readonly<Record<string, unknown>> | undefined,
|
||||
bindingOverride: SettingConstraintBinding | undefined,
|
||||
): ResolvedSetting {
|
||||
const binding = bindingOverride ?? def.constrainedBy;
|
||||
if (!binding || !constraints || !(binding.policyInput in constraints)) {
|
||||
return resolved;
|
||||
}
|
||||
const limit = constraints[binding.policyInput];
|
||||
|
||||
const narrowed = narrowValue(def, binding.constraint, resolved.value, limit);
|
||||
if (!narrowed.changed) return resolved;
|
||||
return {
|
||||
...resolved,
|
||||
value: narrowed.value,
|
||||
storedValue: resolved.value,
|
||||
constrained: true,
|
||||
constraintKind: binding.constraint,
|
||||
};
|
||||
}
|
||||
|
||||
function narrowValue(
|
||||
def: SettingDefinition,
|
||||
kind: SettingConstraintKind,
|
||||
value: unknown,
|
||||
limit: unknown,
|
||||
): { value: unknown; changed: boolean } {
|
||||
switch (kind) {
|
||||
case "locked":
|
||||
// The policy value replaces the user's outright.
|
||||
if (jsonEquals(value, limit)) return { value, changed: false };
|
||||
return { value: limit, changed: true };
|
||||
|
||||
case "ceiling":
|
||||
// null on a nullable numeric means "no cap of my own" — unbounded
|
||||
// above, which is exactly what a ceiling exists to bring down. It has
|
||||
// no rank, so a plain comparison would let it slip past the cap.
|
||||
if (value === null && isNumeric(def)) return { value: limit, changed: true };
|
||||
if (compareValues(def, value, limit) <= 0) return { value, changed: false };
|
||||
return { value: limit, changed: true };
|
||||
|
||||
case "floor":
|
||||
// The mirror: unbounded above already satisfies any floor.
|
||||
if (value === null && isNumeric(def)) return { value, changed: false };
|
||||
if (compareValues(def, value, limit) >= 0) return { value, changed: false };
|
||||
return { value: limit, changed: true };
|
||||
|
||||
case "allowlist": {
|
||||
if (!Array.isArray(limit) || limit.length === 0) return { value, changed: false };
|
||||
if (limit.some((entry) => jsonEquals(entry, value))) return { value, changed: false };
|
||||
// Falling back to the first allowed member rather than the default:
|
||||
// the default may itself be outside the allowlist, and an effective
|
||||
// value the policy forbids is the one thing this must never return.
|
||||
return { value: limit[0], changed: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* compareValues ranks two values through the definition's own schema: numbers
|
||||
* numerically, ordered enums by member position. Anything unrankable compares
|
||||
* equal, matching the server.
|
||||
*/
|
||||
function compareValues(def: SettingDefinition, a: unknown, b: unknown): number {
|
||||
if (isNumeric(def)) {
|
||||
if (typeof a !== "number" || typeof b !== "number") return 0;
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
}
|
||||
if (def.type === "enum" && def.ordered && def.values) {
|
||||
const left = def.values.findIndex((member) => jsonEquals(member.value, a));
|
||||
const right = def.values.findIndex((member) => jsonEquals(member.value, b));
|
||||
if (left < 0 || right < 0) return 0;
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isNumeric(def: SettingDefinition): boolean {
|
||||
return def.type === "integer" || def.type === "number";
|
||||
}
|
||||
|
||||
/** Structural equality over JSON values, ignoring object key order. */
|
||||
export function jsonEquals(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (a === null || b === null) return false;
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
||||
return a.every((entry, index) => jsonEquals(entry, b[index]));
|
||||
}
|
||||
if (typeof a === "object" && typeof b === "object") {
|
||||
const left = a as Record<string, unknown>;
|
||||
const right = b as Record<string, unknown>;
|
||||
const keys = Object.keys(left);
|
||||
if (keys.length !== Object.keys(right).length) return false;
|
||||
return keys.every((key) => key in right && jsonEquals(left[key], right[key]));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -90,10 +90,25 @@ const VALID_FONT_FAMILIES: Set<string> = new Set(FONT_FAMILY_OPTIONS.map((o) =>
|
||||
const VALID_BG_STYLES: Set<string> = new Set(BACKGROUND_STYLE_OPTIONS.map((o) => o.value));
|
||||
const VALID_POSITIONS: Set<string> = new Set(POSITION_OPTIONS.map((o) => o.value));
|
||||
|
||||
export function parseSubtitleAppearance(json: string | null): SubtitleAppearance {
|
||||
if (!json) return { ...DEFAULT_SUBTITLE_APPEARANCE };
|
||||
/**
|
||||
* Coerces a stored subtitle appearance into a complete, valid one.
|
||||
*
|
||||
* Accepts both shapes the value has had: the canonical settings API stores it
|
||||
* as a typed JSON object, while the legacy string-only endpoint stored the same
|
||||
* object JSON-encoded into a string. Taking `unknown` means a caller never has
|
||||
* to know which surface it read from, and every field still falls back to the
|
||||
* default individually, so a partial or corrupt value degrades one field at a
|
||||
* time rather than resetting the whole appearance.
|
||||
*/
|
||||
export function parseSubtitleAppearance(value: unknown): SubtitleAppearance {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return { ...DEFAULT_SUBTITLE_APPEARANCE };
|
||||
}
|
||||
try {
|
||||
const p = JSON.parse(json) as Record<string, unknown>;
|
||||
const p = (typeof value === "string" ? JSON.parse(value) : value) as Record<string, unknown>;
|
||||
if (typeof p !== "object" || p === null || Array.isArray(p)) {
|
||||
return { ...DEFAULT_SUBTITLE_APPEARANCE };
|
||||
}
|
||||
return {
|
||||
fontSize: VALID_FONT_SIZES.has(p.fontSize as string)
|
||||
? (p.fontSize as SubtitleAppearance["fontSize"])
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import {
|
||||
type AdminDeviceSetting,
|
||||
useAdminDeviceDetail,
|
||||
useAdminDeviceOverrides,
|
||||
useAdminDevices,
|
||||
useDeleteAdminUserDeviceSetting,
|
||||
useDeleteAllAdminUserDeviceSettingsForDevice,
|
||||
@@ -36,7 +37,8 @@ import {
|
||||
type DeviceProfileTabEntry,
|
||||
type PlatformKind,
|
||||
} from "@/components/admin/deviceOverrides";
|
||||
import { ALL_DEVICE_SETTING_KEYS } from "@/lib/settingsManifest";
|
||||
import { ALL_DEVICE_SETTING_KEYS } from "@/lib/settingsDisplay";
|
||||
import { SETTING_KEYS } from "@/lib/settingsContract";
|
||||
import { AdminSubtitleAppearanceDialog } from "@/components/admin/AdminSubtitleAppearanceDialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -1386,7 +1388,16 @@ function DeviceDetailPanel({
|
||||
defaultShowAllSettings: boolean;
|
||||
}) {
|
||||
const isAnomaly = anomaly !== null;
|
||||
// The detail endpoint supplies registration metadata — device name, owner,
|
||||
// which profiles have ever used it — which is not a setting and has no
|
||||
// canonical equivalent. The overrides themselves come from the canonical
|
||||
// values API, because the detail endpoint's `settings` array still reports
|
||||
// the legacy device-settings table that nothing writes to any more.
|
||||
const { data, isLoading } = useAdminDeviceDetail(userId, deviceId);
|
||||
const { data: overrides, isLoading: overridesLoading } = useAdminDeviceOverrides(
|
||||
userId,
|
||||
deviceId,
|
||||
);
|
||||
const updateSetting = useUpdateAdminUserDeviceSetting();
|
||||
const deleteSetting = useDeleteAdminUserDeviceSetting();
|
||||
const deleteProfileOverrides = useDeleteAllAdminUserDeviceSettingsForDevice();
|
||||
@@ -1419,6 +1430,8 @@ function DeviceDetailPanel({
|
||||
const profileTabs = useMemo<DeviceProfileTabEntry[]>(() => {
|
||||
if (!data) return [];
|
||||
const grouped = new Map<string, DeviceProfileTabEntry>();
|
||||
// Registered profiles come first so a profile that has used the device but
|
||||
// overridden nothing still gets a (possibly empty) tab.
|
||||
for (const profile of data.profiles ?? []) {
|
||||
const profileId = profile.profile_id || UNKNOWN_PROFILE_ID;
|
||||
grouped.set(profileId, {
|
||||
@@ -1427,7 +1440,7 @@ function DeviceDetailPanel({
|
||||
settings: [],
|
||||
});
|
||||
}
|
||||
for (const setting of data.settings) {
|
||||
for (const setting of overrides) {
|
||||
const profileId = setting.profile_id || UNKNOWN_PROFILE_ID;
|
||||
const existing = grouped.get(profileId);
|
||||
if (existing) {
|
||||
@@ -1441,9 +1454,9 @@ function DeviceDetailPanel({
|
||||
}
|
||||
}
|
||||
return Array.from(grouped.values());
|
||||
}, [data]);
|
||||
}, [data, overrides]);
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoading || overridesLoading) {
|
||||
return (
|
||||
<div className="space-y-4 p-5">
|
||||
<Skeleton className="h-16 w-full rounded-md" />
|
||||
@@ -1465,11 +1478,14 @@ function DeviceDetailPanel({
|
||||
}
|
||||
|
||||
const kind = classifyPlatform(data.device_platform);
|
||||
const totalOverrides = data.override_count ?? data.settings.length;
|
||||
// Counted from the canonical rows rather than the endpoint's override_count,
|
||||
// which is computed over the legacy table and would disagree with the rows
|
||||
// rendered below.
|
||||
const totalOverrides = overrides.length;
|
||||
const forceAllSettings = totalOverrides === 0;
|
||||
const effectiveShowAllSettings = forceAllSettings || showAllSettings;
|
||||
const lastUpdate = data.settings
|
||||
.map((s) => s.updated_at)
|
||||
const lastUpdate = overrides
|
||||
.map((setting) => setting.updated_at)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.pop();
|
||||
@@ -1513,6 +1529,13 @@ function DeviceDetailPanel({
|
||||
userId: data.user_id,
|
||||
profileId: profileToReset.id,
|
||||
deviceId: data.device_id,
|
||||
// There is no bulk canonical reset route; the mutation issues one
|
||||
// delete per key, so it needs the keys that actually exist.
|
||||
keys: overrides
|
||||
.filter(
|
||||
(setting) => (setting.profile_id || UNKNOWN_PROFILE_ID) === profileToReset.id,
|
||||
)
|
||||
.map((setting) => setting.key),
|
||||
});
|
||||
}
|
||||
setProfileToReset(null);
|
||||
@@ -1527,7 +1550,9 @@ function DeviceDetailPanel({
|
||||
*/}
|
||||
<AdminSubtitleAppearanceDialog
|
||||
setting={
|
||||
jsonEditor && jsonEditor.setting.key === "subtitle_appearance" ? jsonEditor.setting : null
|
||||
jsonEditor && jsonEditor.setting.key === SETTING_KEYS.PLAYBACK_SUBTITLE_APPEARANCE
|
||||
? jsonEditor.setting
|
||||
: null
|
||||
}
|
||||
isOverride={jsonEditor?.isOverride ?? false}
|
||||
onClose={closeJsonEditor}
|
||||
@@ -1552,7 +1577,10 @@ function DeviceDetailPanel({
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={jsonEditor !== null && jsonEditor.setting.key !== "subtitle_appearance"}
|
||||
open={
|
||||
jsonEditor !== null &&
|
||||
jsonEditor.setting.key !== SETTING_KEYS.PLAYBACK_SUBTITLE_APPEARANCE
|
||||
}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeJsonEditor();
|
||||
}}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AdminUser, UpdateUserRequest } from "@/api/types";
|
||||
import { SETTING_KEYS } from "@/lib/settingsContract";
|
||||
|
||||
import AdminUserDetail from "./AdminUserDetail";
|
||||
|
||||
@@ -17,6 +18,9 @@ interface UpdateUserMutationArg {
|
||||
const mocks = vi.hoisted(() => ({
|
||||
updateUserMutate: vi.fn(),
|
||||
beginImpersonation: vi.fn(),
|
||||
updateSettingMutate: vi.fn(),
|
||||
/** Rows the canonical admin settings list answers with, per test. */
|
||||
userSettings: [] as unknown[],
|
||||
}));
|
||||
|
||||
const adminUser: AdminUser = {
|
||||
@@ -73,12 +77,12 @@ vi.mock("@/hooks/queries/admin/users", () => ({
|
||||
useDeleteUser: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useImpersonateUser: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useAdminUserDeviceSettings: () => ({ data: [], isLoading: false }),
|
||||
useAdminUserSettings: () => ({ data: [], isLoading: false }),
|
||||
useAdminUserSettings: () => ({ data: mocks.userSettings, isLoading: false }),
|
||||
useDeleteAdminUserDeviceSetting: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useDeleteAdminUserSetting: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useDeleteAllAdminUserDeviceSettingsForDevice: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useUpdateAdminUserDeviceSetting: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useUpdateAdminUserSetting: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useUpdateAdminUserSetting: () => ({ mutate: mocks.updateSettingMutate, isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/queries/admin/accessGroups", () => ({
|
||||
@@ -152,6 +156,8 @@ beforeEach(() => {
|
||||
installPointerCaptureMocks();
|
||||
mocks.updateUserMutate.mockReset();
|
||||
mocks.beginImpersonation.mockReset();
|
||||
mocks.updateSettingMutate.mockReset();
|
||||
mocks.userSettings = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -183,6 +189,77 @@ describe("AdminUserDetail access group picker", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AdminUserDetail user settings tab", () => {
|
||||
const pins = JSON.stringify({ "1": [{ type: "collection", id: "42", label: "Pinned Horror" }] });
|
||||
|
||||
it("edits an object-valued setting through the JSON editor, not a select", async () => {
|
||||
// Every non-device canonical row lands in this tab, including the
|
||||
// object-valued profile settings. controlKindFor has no `object` branch, so
|
||||
// an unguarded definition falls through to RegistrySettingControl's select —
|
||||
// which for a nullable object with no enum members renders a single "Unset"
|
||||
// item whose only effect is to null the value and destroy the user's pins.
|
||||
const user = userEvent.setup();
|
||||
mocks.userSettings = [
|
||||
{
|
||||
key: SETTING_KEYS.UI_SIDEBAR_PINS,
|
||||
scope: "profile",
|
||||
profile_id: "profile-1",
|
||||
value: pins,
|
||||
},
|
||||
];
|
||||
renderUserDetail();
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
|
||||
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Edit JSON" }));
|
||||
|
||||
const editor = screen.getByRole("textbox", { name: "Raw value" });
|
||||
expect(editor).toHaveValue(pins);
|
||||
|
||||
const edited = JSON.stringify({ "1": [{ type: "collection", id: "43" }] });
|
||||
await user.clear(editor);
|
||||
await user.type(editor, edited.replace(/[{[]/g, "$&$&"));
|
||||
await user.click(screen.getByRole("button", { name: "Save value" }));
|
||||
|
||||
await waitFor(() => expect(mocks.updateSettingMutate).toHaveBeenCalled());
|
||||
const call = mocks.updateSettingMutate.mock.calls[0]?.[0] as {
|
||||
key: string;
|
||||
value: string;
|
||||
identity: { scope: string; profileId?: string };
|
||||
};
|
||||
expect(call.key).toBe(SETTING_KEYS.UI_SIDEBAR_PINS);
|
||||
expect(call.identity).toMatchObject({ scope: "profile", profileId: "profile-1" });
|
||||
expect(JSON.parse(call.value)).toEqual(JSON.parse(edited));
|
||||
});
|
||||
|
||||
it("still renders an inline control for a scalar setting", async () => {
|
||||
const user = userEvent.setup();
|
||||
mocks.userSettings = [
|
||||
{
|
||||
key: SETTING_KEYS.PLAYBACK_AUTO_SKIP_INTRO,
|
||||
scope: "profile",
|
||||
profile_id: "profile-1",
|
||||
value: "false",
|
||||
},
|
||||
];
|
||||
renderUserDetail();
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Edit JSON" })).not.toBeInTheDocument();
|
||||
const toggle = screen.getByRole("switch");
|
||||
expect(toggle).not.toBeChecked();
|
||||
await user.click(toggle);
|
||||
|
||||
await waitFor(() => expect(mocks.updateSettingMutate).toHaveBeenCalled());
|
||||
expect(mocks.updateSettingMutate.mock.calls[0]?.[0]).toMatchObject({
|
||||
key: SETTING_KEYS.PLAYBACK_AUTO_SKIP_INTRO,
|
||||
value: "true",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AdminUserDetail transcode limits", () => {
|
||||
it("disables transcoding and includes the flag in the save payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { FormEvent } from "react";
|
||||
import { useParams, Link } from "react-router";
|
||||
import {
|
||||
type AdminDeviceSetting,
|
||||
type AdminSettingIdentity,
|
||||
type AdminUserSettingEntry,
|
||||
useAdminUser,
|
||||
useUpdateUser,
|
||||
useDeleteUser,
|
||||
@@ -20,13 +22,7 @@ import { useAdminUserProfiles } from "@/hooks/queries/admin/history";
|
||||
import { useAdminPlaybackHistory } from "@/hooks/queries/admin/history";
|
||||
import { useAdminLibraries } from "@/hooks/queries/admin/libraries";
|
||||
import { useUserIPs } from "@/hooks/queries/admin/ips";
|
||||
import type {
|
||||
AdminSettingEntry,
|
||||
AdminUser,
|
||||
AdminUserProfile,
|
||||
UpdateUserRequest,
|
||||
UserIPEntry,
|
||||
} from "@/api/types";
|
||||
import type { AdminUser, AdminUserProfile, UpdateUserRequest, UserIPEntry } from "@/api/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LibraryAccessSelector } from "@/components/LibraryAccessSelector";
|
||||
@@ -75,7 +71,11 @@ import {
|
||||
setAssignedPermission,
|
||||
} from "@/lib/permissions";
|
||||
import { RegistrySettingControl } from "@/components/settings/RegistrySettingControl";
|
||||
import { formatSettingValue, getSettingDefinition } from "@/lib/settingsManifest";
|
||||
import {
|
||||
formatSettingValue,
|
||||
getSettingDefinition,
|
||||
isStructuredSetting,
|
||||
} from "@/lib/settingsDisplay";
|
||||
import {
|
||||
DeviceProfileTabs,
|
||||
PlatformTile,
|
||||
@@ -531,6 +531,18 @@ function UserSettingsTab({ userId }: { userId: number }) {
|
||||
const { data: settings = [], isLoading } = useAdminUserSettings(userId);
|
||||
const updateSetting = useUpdateAdminUserSetting();
|
||||
const deleteSetting = useDeleteAdminUserSetting();
|
||||
// Object-valued settings (pinned sidebar items, per-library overlays, the
|
||||
// custom theme) have no inline widget, so they open the raw JSON editor —
|
||||
// the same treatment the device tab gives them.
|
||||
const [jsonEditor, setJsonEditor] = useState<{
|
||||
entry: AdminUserSettingEntry;
|
||||
identity: AdminSettingIdentity;
|
||||
} | null>(null);
|
||||
const [jsonValue, setJsonValue] = useState("");
|
||||
const closeJsonEditor = () => {
|
||||
setJsonEditor(null);
|
||||
setJsonValue("");
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -538,30 +550,60 @@ function UserSettingsTab({ userId }: { userId: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
const renderSettingRow = (entry: AdminSettingEntry) => {
|
||||
const renderSettingRow = (entry: AdminUserSettingEntry) => {
|
||||
const definition = getSettingDefinition(entry.key);
|
||||
const label = definition?.label ?? entry.key;
|
||||
const description = definition?.description ?? "Stored account preference.";
|
||||
const description = definition?.description ?? "Stored preference.";
|
||||
// A definition this build does not know, an object-valued one, or one the
|
||||
// manifest marks as panel-edited has no inline control that could render
|
||||
// its value truthfully. Without this the select branch would render a
|
||||
// structured value as a one-entry "Unset" dropdown whose only option
|
||||
// destroys it.
|
||||
const isJsonOnly = isStructuredSetting(definition);
|
||||
// A key can be stored at several scopes (or for several profiles,
|
||||
// libraries or series) at once, so a row is identified by its full
|
||||
// identity, and every mutation addresses exactly that row.
|
||||
const identity = {
|
||||
scope: entry.scope,
|
||||
profileId: entry.profile_id,
|
||||
libraryId: entry.library_id,
|
||||
seriesId: entry.series_id,
|
||||
};
|
||||
const rowKey = [
|
||||
entry.key,
|
||||
entry.scope,
|
||||
entry.profile_id ?? "",
|
||||
entry.library_id ?? "",
|
||||
entry.series_id ?? "",
|
||||
].join(":");
|
||||
const scopeDetail = [
|
||||
entry.profile_id ? `profile ${entry.profile_id}` : null,
|
||||
entry.library_id ? `library ${entry.library_id}` : null,
|
||||
entry.series_id ? `series ${entry.series_id}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.key}
|
||||
key={rowKey}
|
||||
className="border-border/50 grid gap-3 border-t py-4 first:border-t-0 first:pt-0 md:grid-cols-[minmax(0,1fr)_auto]"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[11px] font-medium text-slate-200">
|
||||
Explicit
|
||||
{entry.scope}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[13px] leading-relaxed">{description}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Current: {formatSettingValue(entry.key, entry.value)}
|
||||
{scopeDetail ? ` · ${scopeDetail}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-stretch gap-2 md:items-end">
|
||||
{definition ? (
|
||||
{definition && !isJsonOnly ? (
|
||||
<RegistrySettingControl
|
||||
definition={definition}
|
||||
value={entry.value}
|
||||
@@ -570,31 +612,29 @@ function UserSettingsTab({ userId }: { userId: number }) {
|
||||
updateSetting.mutate({
|
||||
userId,
|
||||
key: entry.key,
|
||||
identity,
|
||||
value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
key={`${entry.key}:${entry.value}`}
|
||||
defaultValue={entry.value}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={updateSetting.isPending || deleteSetting.isPending}
|
||||
className="w-full min-w-[180px] sm:w-[220px]"
|
||||
onBlur={(event) => {
|
||||
if (event.currentTarget.value === entry.value) return;
|
||||
updateSetting.mutate({
|
||||
userId,
|
||||
key: entry.key,
|
||||
value: event.currentTarget.value,
|
||||
});
|
||||
onClick={() => {
|
||||
setJsonEditor({ entry, identity });
|
||||
setJsonValue(entry.value);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
Edit JSON
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 rounded-full px-2 text-xs"
|
||||
onClick={() => deleteSetting.mutate({ userId, key: entry.key })}
|
||||
onClick={() => deleteSetting.mutate({ userId, key: entry.key, identity })}
|
||||
disabled={updateSetting.isPending || deleteSetting.isPending}
|
||||
>
|
||||
<RotateCcw className="mr-1 h-3 w-3" />
|
||||
@@ -607,15 +647,65 @@ function UserSettingsTab({ userId }: { userId: number }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Dialog
|
||||
open={jsonEditor !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeJsonEditor();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-mono text-sm">
|
||||
{jsonEditor?.entry.key ?? "JSON"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground text-[12.5px]">
|
||||
Edit the raw value. This setting has no inline control, so saving replaces the stored
|
||||
value wholesale — clearing it entirely is what the Reset button does.
|
||||
</p>
|
||||
<textarea
|
||||
spellCheck={false}
|
||||
aria-label="Raw value"
|
||||
className="border-border bg-background focus:border-foreground/40 min-h-[260px] w-full rounded-md border px-3 py-2 font-mono text-[13px] leading-relaxed transition-colors outline-none"
|
||||
value={jsonValue}
|
||||
onChange={(event) => setJsonValue(event.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={closeJsonEditor}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!jsonEditor) return;
|
||||
updateSetting.mutate(
|
||||
{
|
||||
userId,
|
||||
key: jsonEditor.entry.key,
|
||||
identity: jsonEditor.identity,
|
||||
value: jsonValue,
|
||||
},
|
||||
{ onSuccess: () => closeJsonEditor() },
|
||||
);
|
||||
}}
|
||||
>
|
||||
Save value
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="surface-panel overflow-hidden rounded-[1.8rem] border-0">
|
||||
<div className="border-border/60 flex items-center gap-3 border-b px-5 py-4">
|
||||
<div className="rounded-full border border-emerald-500/25 bg-emerald-500/10 p-2">
|
||||
<Settings2 className="h-4 w-4 text-emerald-100" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">User Playback Settings</h3>
|
||||
<h3 className="text-sm font-semibold">User Settings</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Account-wide playback preferences that follow this user across devices.
|
||||
Explicit values this user has stored, across account, profile, library and series
|
||||
scopes. Device overrides live in the next tab.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -642,6 +732,7 @@ function DeviceOverridesTab({ userId }: { userId: number }) {
|
||||
deviceId: string;
|
||||
profileId: string;
|
||||
profileName?: string;
|
||||
keys: string[];
|
||||
} | null>(null);
|
||||
const [settingToReset, setSettingToReset] = useState<AdminDeviceSetting | null>(null);
|
||||
const [jsonEditor, setJsonEditor] = useState<AdminDeviceSetting | null>(null);
|
||||
@@ -701,6 +792,7 @@ function DeviceOverridesTab({ userId }: { userId: number }) {
|
||||
userId,
|
||||
profileId: deviceToReset.profileId,
|
||||
deviceId: deviceToReset.deviceId,
|
||||
keys: deviceToReset.keys,
|
||||
});
|
||||
}
|
||||
setDeviceToReset(null);
|
||||
@@ -890,7 +982,15 @@ function DeviceOverridesTab({ userId }: { userId: number }) {
|
||||
}),
|
||||
)}
|
||||
onResetProfile={(profileId, profileName) =>
|
||||
setDeviceToReset({ deviceId, profileId, profileName })
|
||||
setDeviceToReset({
|
||||
deviceId,
|
||||
profileId,
|
||||
profileName,
|
||||
keys:
|
||||
profiles
|
||||
.find((profile) => profile.profileId === profileId)
|
||||
?.entries.map((entry) => entry.key) ?? [],
|
||||
})
|
||||
}
|
||||
onEditJson={(setting) => {
|
||||
setJsonEditor(setting);
|
||||
|
||||
@@ -72,6 +72,12 @@ vi.mock("@/hooks/useAuth", () => ({
|
||||
useOptionalAuth: mocks.useAuth,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/queries/qualityPreference", () => ({
|
||||
// The resolution cap comes from the settings contract; these tests render
|
||||
// without a QueryClient, so the hook stands in for the resolved answer.
|
||||
useQualityPreference: (fallback?: string | null) => fallback ?? null,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useCurrentProfile", () => ({
|
||||
useCurrentProfile: mocks.useCurrentProfile,
|
||||
}));
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
canEditMarkers as canEditMarkersForUser,
|
||||
} from "@/lib/permissions";
|
||||
import { formatRuntimeMinutes } from "@/lib/mediaFormat";
|
||||
import { useQualityPreference } from "@/hooks/queries/qualityPreference";
|
||||
|
||||
export default function EpisodeContent({ item }: { item: ItemDetail & { type: "episode" } }) {
|
||||
const { translating: overviewTranslating, onTranslate: onTranslateOverview } =
|
||||
@@ -56,6 +57,10 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e
|
||||
const { user } = useAuth();
|
||||
const isAdmin = useIsActingAdmin();
|
||||
const { profile: currentProfile } = useCurrentProfile();
|
||||
// The resolution cap comes from the settings contract, where the quality
|
||||
// picker writes; the profile column it falls back to is only the pre-cutover
|
||||
// choice, since that picker no longer mirrors into it.
|
||||
const qualityPreference = useQualityPreference(currentProfile?.quality_preference);
|
||||
const canCurateMetadata = canCurateMetadataForUser(user, currentProfile);
|
||||
const canEditMarkers = canEditMarkersForUser(user, currentProfile);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -78,11 +83,11 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e
|
||||
sortedVersions,
|
||||
item.playback_variants,
|
||||
userData,
|
||||
currentProfile?.quality_preference,
|
||||
qualityPreference,
|
||||
item.effective_version_edition_key,
|
||||
),
|
||||
[
|
||||
currentProfile?.quality_preference,
|
||||
qualityPreference,
|
||||
item.effective_version_edition_key,
|
||||
item.playback_variants,
|
||||
sortedVersions,
|
||||
@@ -381,7 +386,7 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e
|
||||
onSearchSubtitles={
|
||||
(item.versions?.length ?? 0) > 0 ? () => setSubtitleSearchOpen(true) : undefined
|
||||
}
|
||||
qualityPreference={currentProfile?.quality_preference}
|
||||
qualityPreference={qualityPreference}
|
||||
audioSelectionMode={audioSelectionMode}
|
||||
explicitAudioTrackIndex={explicitAudioTrackIndex}
|
||||
onSelectAudioTrack={handleSelectAudioTrack}
|
||||
|
||||
@@ -91,6 +91,12 @@ vi.mock("@/hooks/useAuth", () => ({
|
||||
useOptionalAuth: mocks.useAuth,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/queries/qualityPreference", () => ({
|
||||
// The resolution cap comes from the settings contract; these tests render
|
||||
// without a QueryClient, so the hook stands in for the resolved answer.
|
||||
useQualityPreference: (fallback?: string | null) => fallback ?? null,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useCurrentProfile", () => ({
|
||||
useCurrentProfile: mocks.useCurrentProfile,
|
||||
}));
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
canEditMarkers as canEditMarkersForUser,
|
||||
} from "@/lib/permissions";
|
||||
import { formatRuntimeMinutes } from "@/lib/mediaFormat";
|
||||
import { useQualityPreference } from "@/hooks/queries/qualityPreference";
|
||||
|
||||
export default function MovieContent({ item }: { item: ItemDetail & { type: "movie" } }) {
|
||||
const { translating: overviewTranslating, onTranslate: onTranslateOverview } =
|
||||
@@ -52,6 +53,10 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
|
||||
const { user } = useAuth();
|
||||
const isAdmin = useIsActingAdmin();
|
||||
const { profile: currentProfile } = useCurrentProfile();
|
||||
// The resolution cap comes from the settings contract, where the quality
|
||||
// picker writes; the profile column it falls back to is only the pre-cutover
|
||||
// choice, since that picker no longer mirrors into it.
|
||||
const qualityPreference = useQualityPreference(currentProfile?.quality_preference);
|
||||
const canCurateMetadata = canCurateMetadataForUser(user, currentProfile);
|
||||
const canEditMarkers = canEditMarkersForUser(user, currentProfile);
|
||||
|
||||
@@ -83,11 +88,11 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
|
||||
sortedVersions,
|
||||
item.playback_variants,
|
||||
userData,
|
||||
currentProfile?.quality_preference,
|
||||
qualityPreference,
|
||||
item.effective_version_edition_key,
|
||||
),
|
||||
[
|
||||
currentProfile?.quality_preference,
|
||||
qualityPreference,
|
||||
item.effective_version_edition_key,
|
||||
item.playback_variants,
|
||||
sortedVersions,
|
||||
@@ -319,7 +324,7 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
|
||||
}
|
||||
rating={item.user_rating ?? null}
|
||||
onRatingChange={handleRatingChange}
|
||||
qualityPreference={currentProfile?.quality_preference}
|
||||
qualityPreference={qualityPreference}
|
||||
audioSelectionMode={audioSelectionMode}
|
||||
explicitAudioTrackIndex={explicitAudioTrackIndex}
|
||||
onSelectAudioTrack={handleSelectAudioTrack}
|
||||
|
||||
@@ -3,22 +3,22 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { LibraryPlaybackPreference, Profile, UserLibrary } from "@/api/types";
|
||||
import type { Profile, UserLibrary } from "@/api/types";
|
||||
import type { EffectiveSetting, EffectiveSettingsMap } from "@/hooks/queries/settingValues";
|
||||
import { SETTING_KEYS, type SettingKey } from "@/lib/settingsContract";
|
||||
import {
|
||||
buildLibraryPlaybackSummary,
|
||||
buildLibraryPlaybackRequest,
|
||||
buildLibraryPlaybackMutations,
|
||||
buildLibraryPlaybackSummaryFromState,
|
||||
createLibraryPlaybackEditorState,
|
||||
} from "./libraryPlaybackPreferences";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useAvailableUserLibraries: vi.fn(),
|
||||
useLibraryDisplayPreferences: vi.fn(),
|
||||
useCurrentProfile: vi.fn(),
|
||||
useLibraryPlaybackPreferences: vi.fn(),
|
||||
useDeleteDeviceSetting: vi.fn(),
|
||||
useEffectiveSettings: vi.fn(),
|
||||
useSetting: vi.fn(),
|
||||
useSetDeviceSetting: vi.fn(),
|
||||
useSetSetting: vi.fn(),
|
||||
useSetSettingValue: vi.fn(),
|
||||
useClearSettingValue: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/queries/libraries", async () => {
|
||||
@@ -29,37 +29,24 @@ vi.mock("@/hooks/queries/libraries", async () => {
|
||||
return {
|
||||
...actual,
|
||||
useAvailableUserLibraries: (...args: unknown[]) => mocks.useAvailableUserLibraries(...args),
|
||||
useLibraryDisplayPreferences: (...args: unknown[]) =>
|
||||
mocks.useLibraryDisplayPreferences(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/queries/settings", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/hooks/queries/settings")>(
|
||||
"@/hooks/queries/settings",
|
||||
vi.mock("@/hooks/queries/settingValues", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/hooks/queries/settingValues")>(
|
||||
"@/hooks/queries/settingValues",
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useDeleteDeviceSetting: (...args: unknown[]) => mocks.useDeleteDeviceSetting(...args),
|
||||
useEffectiveSettings: (...args: unknown[]) => mocks.useEffectiveSettings(...args),
|
||||
useSetting: (...args: unknown[]) => mocks.useSetting(...args),
|
||||
useSetDeviceSetting: (...args: unknown[]) => mocks.useSetDeviceSetting(...args),
|
||||
useSetSetting: (...args: unknown[]) => mocks.useSetSetting(...args),
|
||||
useSetSettingValue: (...args: unknown[]) => mocks.useSetSettingValue(...args),
|
||||
useClearSettingValue: (...args: unknown[]) => mocks.useClearSettingValue(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/queries/libraryPlaybackPreferences", () => ({
|
||||
useLibraryPlaybackPreferences: (...args: unknown[]) =>
|
||||
mocks.useLibraryPlaybackPreferences(...args),
|
||||
useSetLibraryPlaybackPreference: () => ({
|
||||
isPending: false,
|
||||
mutate: vi.fn(),
|
||||
}),
|
||||
useDeleteLibraryPlaybackPreference: () => ({
|
||||
isPending: false,
|
||||
mutate: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useCurrentProfile", () => ({
|
||||
useCurrentProfile: (...args: unknown[]) => mocks.useCurrentProfile(...args),
|
||||
}));
|
||||
@@ -93,47 +80,24 @@ const libraries: UserLibrary[] = [
|
||||
{ id: 9, name: "Movies", type: "movies", sort_order: 1 },
|
||||
];
|
||||
|
||||
function makePreference(
|
||||
overrides: Partial<LibraryPlaybackPreference> = {},
|
||||
): LibraryPlaybackPreference {
|
||||
return {
|
||||
profile_id: "profile-1",
|
||||
library_id: overrides.library_id ?? 7,
|
||||
updated_at: overrides.updated_at ?? "2026-03-23T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
/** One entry of a resolved effective-settings map. */
|
||||
function resolved(
|
||||
key: SettingKey,
|
||||
value: unknown,
|
||||
source: EffectiveSetting["source"],
|
||||
extra: Partial<EffectiveSetting> = {},
|
||||
): EffectiveSettingsMap {
|
||||
return { [key]: { key, value, source, ...extra } };
|
||||
}
|
||||
|
||||
describe("LibrarySettings helpers", () => {
|
||||
it("summarizes inherited rows as using profile defaults", () => {
|
||||
expect(buildLibraryPlaybackSummary(null)).toBe("Uses profile defaults");
|
||||
});
|
||||
|
||||
it("summarizes only the overridden playback fields", () => {
|
||||
describe("library playback editor state", () => {
|
||||
it("reads inherit for every key resolved above the library", () => {
|
||||
expect(
|
||||
buildLibraryPlaybackSummary(
|
||||
makePreference({
|
||||
audio_language: "ja",
|
||||
subtitle_language: "en",
|
||||
subtitle_mode: "always",
|
||||
show_forced_subtitles: false,
|
||||
}),
|
||||
),
|
||||
).toBe("Audio: Japanese • Subtitles: English • Behavior: Always • Forced subtitles: Off");
|
||||
});
|
||||
|
||||
it("summarizes original audio language overrides with the original language label", () => {
|
||||
expect(
|
||||
buildLibraryPlaybackSummary(
|
||||
makePreference({
|
||||
audio_language: "original",
|
||||
}),
|
||||
),
|
||||
).toBe("Audio: Original Language");
|
||||
});
|
||||
|
||||
it("initializes editor state with inherit values when a library has no override", () => {
|
||||
expect(createLibraryPlaybackEditorState(null)).toEqual({
|
||||
createLibraryPlaybackEditorState({
|
||||
...resolved(SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE, "en", "profile"),
|
||||
...resolved(SETTING_KEYS.PLAYBACK_SUBTITLE_MODE, "auto", "default"),
|
||||
}),
|
||||
).toEqual({
|
||||
audioLanguage: "inherit",
|
||||
subtitleLanguage: "inherit",
|
||||
subtitleMode: "inherit",
|
||||
@@ -141,93 +105,130 @@ describe("LibrarySettings helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("maps stored empty subtitle overrides back to the none option", () => {
|
||||
it("reads an override only from a value the library itself holds", () => {
|
||||
expect(
|
||||
createLibraryPlaybackEditorState(
|
||||
makePreference({
|
||||
subtitle_language: "",
|
||||
createLibraryPlaybackEditorState({
|
||||
// Same value as the profile default, but stored on the library: still
|
||||
// an override, which is why source rather than value decides.
|
||||
...resolved(SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE, "ja", "profile_library", {
|
||||
library_id: 7,
|
||||
}),
|
||||
),
|
||||
...resolved(SETTING_KEYS.PLAYBACK_SUBTITLE_MODE, "always", "profile_library", {
|
||||
library_id: 7,
|
||||
}),
|
||||
...resolved(SETTING_KEYS.PLAYBACK_SHOW_FORCED_SUBTITLES, false, "profile_library", {
|
||||
library_id: 7,
|
||||
}),
|
||||
}),
|
||||
).toEqual({
|
||||
audioLanguage: "inherit",
|
||||
subtitleLanguage: "none",
|
||||
subtitleMode: "inherit",
|
||||
showForcedSubtitles: "inherit",
|
||||
audioLanguage: "ja",
|
||||
subtitleLanguage: "inherit",
|
||||
subtitleMode: "always",
|
||||
showForcedSubtitles: "off",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps inherited fields out of the request payload and serializes none as an empty string", () => {
|
||||
it("distinguishes a stored null from an absent subtitle language", () => {
|
||||
// A library row holding null is an explicit "no subtitles here", which must
|
||||
// not read as the inherit the absence of a row means.
|
||||
expect(
|
||||
buildLibraryPlaybackRequest({
|
||||
createLibraryPlaybackEditorState(
|
||||
resolved(SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE, null, "profile_library", {
|
||||
library_id: 7,
|
||||
}),
|
||||
).subtitleLanguage,
|
||||
).toBe("none");
|
||||
expect(createLibraryPlaybackEditorState({}).subtitleLanguage).toBe("inherit");
|
||||
});
|
||||
|
||||
it("summarizes an untouched library as using profile defaults", () => {
|
||||
expect(buildLibraryPlaybackSummaryFromState(createLibraryPlaybackEditorState({}))).toBe(
|
||||
"Uses profile defaults",
|
||||
);
|
||||
});
|
||||
|
||||
it("summarizes only the overridden playback fields", () => {
|
||||
expect(
|
||||
buildLibraryPlaybackSummaryFromState({
|
||||
audioLanguage: "ja",
|
||||
subtitleLanguage: "en",
|
||||
subtitleMode: "always",
|
||||
showForcedSubtitles: "off",
|
||||
}),
|
||||
).toBe("Audio: Japanese • Subtitles: English • Behavior: Always on • Forced subtitles: Off");
|
||||
});
|
||||
});
|
||||
|
||||
describe("library playback mutations", () => {
|
||||
it("plans a clear for every inherited field and a typed value for the rest", () => {
|
||||
expect(
|
||||
buildLibraryPlaybackMutations({
|
||||
audioLanguage: "inherit",
|
||||
subtitleLanguage: "none",
|
||||
subtitleMode: "inherit",
|
||||
showForcedSubtitles: "off",
|
||||
}),
|
||||
).toEqual({
|
||||
subtitle_language: "",
|
||||
show_forced_subtitles: false,
|
||||
});
|
||||
).toEqual([
|
||||
// No value means "delete the row at this scope so it inherits again".
|
||||
{ key: SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE },
|
||||
{ key: SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE, value: null },
|
||||
{ key: SETTING_KEYS.PLAYBACK_SUBTITLE_MODE },
|
||||
{ key: SETTING_KEYS.PLAYBACK_SHOW_FORCED_SUBTITLES, value: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats an omitted subtitle field as inherited instead of none", () => {
|
||||
it("sends a language tag rather than the legacy empty string for a chosen language", () => {
|
||||
expect(
|
||||
createLibraryPlaybackEditorState(
|
||||
makePreference({
|
||||
subtitle_language: undefined,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
audioLanguage: "inherit",
|
||||
subtitleLanguage: "inherit",
|
||||
subtitleMode: "inherit",
|
||||
showForcedSubtitles: "inherit",
|
||||
});
|
||||
buildLibraryPlaybackMutations({
|
||||
audioLanguage: "ja",
|
||||
subtitleLanguage: "inherit",
|
||||
subtitleMode: "always",
|
||||
showForcedSubtitles: "inherit",
|
||||
}),
|
||||
).toEqual([
|
||||
{ key: SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE, value: "ja" },
|
||||
{ key: SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE },
|
||||
{ key: SETTING_KEYS.PLAYBACK_SUBTITLE_MODE, value: "always" },
|
||||
{ key: SETTING_KEYS.PLAYBACK_SHOW_FORCED_SUBTITLES },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LibrarySettings", () => {
|
||||
beforeEach(() => {
|
||||
mocks.useAvailableUserLibraries.mockReset();
|
||||
mocks.useLibraryDisplayPreferences.mockReset();
|
||||
mocks.useCurrentProfile.mockReset();
|
||||
mocks.useLibraryPlaybackPreferences.mockReset();
|
||||
mocks.useDeleteDeviceSetting.mockReset();
|
||||
mocks.useEffectiveSettings.mockReset();
|
||||
mocks.useSetting.mockReset();
|
||||
mocks.useSetDeviceSetting.mockReset();
|
||||
mocks.useSetSetting.mockReset();
|
||||
mocks.useSetSettingValue.mockReset();
|
||||
mocks.useClearSettingValue.mockReset();
|
||||
|
||||
mocks.useAvailableUserLibraries.mockReturnValue({
|
||||
data: libraries,
|
||||
isLoading: false,
|
||||
});
|
||||
mocks.useLibraryDisplayPreferences.mockReturnValue({
|
||||
disabledLibraryIDs: [],
|
||||
libraryOrder: [],
|
||||
isLoading: false,
|
||||
});
|
||||
mocks.useCurrentProfile.mockReturnValue({
|
||||
profile,
|
||||
isLoading: false,
|
||||
});
|
||||
mocks.useLibraryPlaybackPreferences.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
});
|
||||
mocks.useDeleteDeviceSetting.mockReturnValue({
|
||||
isPending: false,
|
||||
mutateAsync: vi.fn(),
|
||||
});
|
||||
mocks.useEffectiveSettings.mockReturnValue({
|
||||
data: {},
|
||||
isLoading: false,
|
||||
});
|
||||
mocks.useSetting.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
});
|
||||
mocks.useSetDeviceSetting.mockReturnValue({
|
||||
isPending: false,
|
||||
mutateAsync: vi.fn(),
|
||||
});
|
||||
mocks.useSetSetting.mockReturnValue({
|
||||
mocks.useSetSettingValue.mockReturnValue({
|
||||
isPending: false,
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
});
|
||||
mocks.useClearSettingValue.mockReturnValue({
|
||||
isPending: false,
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,24 +240,48 @@ describe("LibrarySettings", () => {
|
||||
expect(markup).toContain("Edit playback overrides");
|
||||
});
|
||||
|
||||
it("renders the playback override summary for libraries with saved defaults", () => {
|
||||
mocks.useLibraryPlaybackPreferences.mockReturnValue({
|
||||
data: [
|
||||
makePreference({
|
||||
library_id: 7,
|
||||
audio_language: "ja",
|
||||
subtitle_language: "en",
|
||||
subtitle_mode: "always",
|
||||
show_forced_subtitles: false,
|
||||
}),
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
it("renders the playback override summary from the values resolved for that library", () => {
|
||||
mocks.useEffectiveSettings.mockImplementation(
|
||||
(options?: { libraryIds?: readonly number[] }) => {
|
||||
if (!options?.libraryIds?.length) {
|
||||
return { data: {}, isLoading: false };
|
||||
}
|
||||
return {
|
||||
isLoading: false,
|
||||
data: {
|
||||
...resolved(SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE, "ja", "profile_library", {
|
||||
library_id: options.libraryIds[0],
|
||||
}),
|
||||
...resolved(SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE, "en", "profile_library", {
|
||||
library_id: options.libraryIds[0],
|
||||
}),
|
||||
...resolved(SETTING_KEYS.PLAYBACK_SUBTITLE_MODE, "always", "profile_library", {
|
||||
library_id: options.libraryIds[0],
|
||||
}),
|
||||
...resolved(SETTING_KEYS.PLAYBACK_SHOW_FORCED_SUBTITLES, false, "profile_library", {
|
||||
library_id: options.libraryIds[0],
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const markup = renderToStaticMarkup(<LibrarySettings />);
|
||||
|
||||
expect(markup).toContain(
|
||||
"Audio: Japanese • Subtitles: English • Behavior: Always • Forced subtitles: Off",
|
||||
"Audio: Japanese • Subtitles: English • Behavior: Always on • Forced subtitles: Off",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves each library's overrides with that library in context", () => {
|
||||
renderToStaticMarkup(<LibrarySettings />);
|
||||
|
||||
// Every card must resolve for its own library, or one library's override
|
||||
// would be reported for another.
|
||||
for (const library of libraries) {
|
||||
expect(mocks.useEffectiveSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ libraryIds: [library.id] }),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useId, useState, type ReactNode } from "react";
|
||||
import type { LibraryPlaybackPreference, Profile, UserLibrary } from "@/api/types";
|
||||
import type { UserLibrary } from "@/api/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingRow } from "@/components/settings/SettingRow";
|
||||
import { SettingsGroup } from "@/components/settings/SettingsGroup";
|
||||
@@ -15,37 +15,25 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useCurrentProfile } from "@/hooks/useCurrentProfile";
|
||||
import {
|
||||
useDeleteLibraryPlaybackPreference,
|
||||
useLibraryPlaybackPreferences,
|
||||
useSetLibraryPlaybackPreference,
|
||||
} from "@/hooks/queries/libraryPlaybackPreferences";
|
||||
import {
|
||||
DISABLED_LIBRARY_IDS_SETTING_KEY,
|
||||
LIBRARY_ORDER_SETTING_KEY,
|
||||
applyLibraryOrder,
|
||||
parseDisabledLibraryIDs,
|
||||
parseLibraryOrder,
|
||||
serializeDisabledLibraryIDs,
|
||||
serializeLibraryOrder,
|
||||
normalizeLibraryIDs,
|
||||
useAvailableUserLibraries,
|
||||
useLibraryDisplayPreferences,
|
||||
} from "@/hooks/queries/libraries";
|
||||
import {
|
||||
useDeleteDeviceSetting,
|
||||
useClearSettingValue,
|
||||
useEffectiveSettings,
|
||||
useSetting,
|
||||
useSetDeviceSetting,
|
||||
useSetSetting,
|
||||
} from "@/hooks/queries/settings";
|
||||
import {
|
||||
LIBRARY_PAGE_STATE_SETTING_KEY,
|
||||
REMEMBER_LIBRARY_PAGE_STATE_SETTING_KEY,
|
||||
} from "@/hooks/queries/libraryPageState";
|
||||
useSetSettingValue,
|
||||
} from "@/hooks/queries/settingValues";
|
||||
import type { SettingIdentity } from "@/hooks/queries/settingValues";
|
||||
import { SETTING_KEYS, type SettingKey } from "@/lib/settingsContract";
|
||||
import { ApiClientError } from "@/api/client";
|
||||
import {
|
||||
buildInheritedLanguageLabel,
|
||||
buildInheritedShowForcedSubtitlesLabel,
|
||||
buildInheritedSubtitleLanguageLabel,
|
||||
buildInheritedSubtitleModeLabel,
|
||||
buildLibraryPlaybackRequest,
|
||||
buildLibraryPlaybackMutations,
|
||||
buildLibraryPlaybackSummaryFromState,
|
||||
createLibraryPlaybackEditorState,
|
||||
getProfileDefaultForcedSubtitlesHint,
|
||||
@@ -54,13 +42,13 @@ import {
|
||||
getProfileDefaultSubtitleModeHint,
|
||||
hasLibraryPlaybackOverride,
|
||||
INHERIT_VALUE,
|
||||
LANGUAGE_OPTIONS,
|
||||
LIBRARY_PLAYBACK_KEYS,
|
||||
libraryScope,
|
||||
type LibraryPlaybackEditorState,
|
||||
NONE_VALUE,
|
||||
ORIGINAL_LANGUAGE_LABEL,
|
||||
ORIGINAL_LANGUAGE_VALUE,
|
||||
SUBTITLE_MODE_OPTIONS,
|
||||
} from "./libraryPlaybackPreferences";
|
||||
import { NAMED_LANGUAGE_OPTIONS } from "@/lib/languageOptions";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronDown, ChevronRight, Eye, EyeOff, GripVertical, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
@@ -87,25 +75,48 @@ function sortLibrariesByOrder(libraries: UserLibrary[], ids: number[]) {
|
||||
return libraries.filter((library) => selected.has(library.id)).map((library) => library.id);
|
||||
}
|
||||
|
||||
function RememberLibraryPageStateSetting({ profileId }: { profileId: string }) {
|
||||
const { data: effective = {} } = useEffectiveSettings(profileId, [
|
||||
REMEMBER_LIBRARY_PAGE_STATE_SETTING_KEY,
|
||||
]);
|
||||
const setDeviceSetting = useSetDeviceSetting();
|
||||
const deleteDeviceSetting = useDeleteDeviceSetting();
|
||||
/** Both library page state keys are profile+device scoped in the contract. */
|
||||
const DEVICE_SCOPE: SettingIdentity = { scope: "profile_device" };
|
||||
|
||||
/** Visibility and order are profile-wide in the contract (no device scope). */
|
||||
const PROFILE_SCOPE: SettingIdentity = { scope: "profile" };
|
||||
|
||||
// The canonical DELETE answers 404 when nothing was stored at that scope,
|
||||
// which for a reset flow means "already done", not a failure.
|
||||
async function clearIgnoringUnset(clear: ReturnType<typeof useClearSettingValue>, key: SettingKey) {
|
||||
try {
|
||||
await clear.mutateAsync({ key, identity: DEVICE_SCOPE });
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError && error.status === 404) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function RememberLibraryPageStateSetting() {
|
||||
const { data: effective } = useEffectiveSettings({
|
||||
keys: [SETTING_KEYS.UI_REMEMBER_LIBRARY_PAGE_STATE],
|
||||
});
|
||||
const setValue = useSetSettingValue();
|
||||
const clearValue = useClearSettingValue();
|
||||
// Contract default is true; only an explicit false disables the feature.
|
||||
const rememberLibraryPages =
|
||||
effective[REMEMBER_LIBRARY_PAGE_STATE_SETTING_KEY]?.effective_value !== "false";
|
||||
const pending = setDeviceSetting.isPending || deleteDeviceSetting.isPending;
|
||||
effective?.[SETTING_KEYS.UI_REMEMBER_LIBRARY_PAGE_STATE]?.value !== false;
|
||||
const pending = setValue.isPending || clearValue.isPending;
|
||||
|
||||
async function handleChange(checked: boolean) {
|
||||
try {
|
||||
if (checked) {
|
||||
await deleteDeviceSetting.mutateAsync({ key: REMEMBER_LIBRARY_PAGE_STATE_SETTING_KEY });
|
||||
// Clear the device override so the setting inherits its default again.
|
||||
await clearIgnoringUnset(clearValue, SETTING_KEYS.UI_REMEMBER_LIBRARY_PAGE_STATE);
|
||||
} else {
|
||||
await deleteDeviceSetting.mutateAsync({ key: LIBRARY_PAGE_STATE_SETTING_KEY });
|
||||
await setDeviceSetting.mutateAsync({
|
||||
key: REMEMBER_LIBRARY_PAGE_STATE_SETTING_KEY,
|
||||
value: "false",
|
||||
// Turning the feature off also discards the state saved so far.
|
||||
await clearIgnoringUnset(clearValue, SETTING_KEYS.UI_LIBRARY_PAGE_STATE);
|
||||
await setValue.mutateAsync({
|
||||
key: SETTING_KEYS.UI_REMEMBER_LIBRARY_PAGE_STATE,
|
||||
value: false,
|
||||
identity: DEVICE_SCOPE,
|
||||
});
|
||||
}
|
||||
toast.success("Library page preference saved");
|
||||
@@ -194,71 +205,102 @@ function LibraryCard({
|
||||
library,
|
||||
enabled,
|
||||
visibilityDisabled = false,
|
||||
preference,
|
||||
profile,
|
||||
profileDefaults,
|
||||
onToggleVisibility,
|
||||
}: {
|
||||
library: UserLibrary;
|
||||
enabled: boolean;
|
||||
visibilityDisabled?: boolean;
|
||||
preference: LibraryPlaybackPreference | null;
|
||||
profile: Profile;
|
||||
/** The values this library falls back to, for the "Profile default" hints. */
|
||||
profileDefaults: {
|
||||
audioLanguage: string | null;
|
||||
subtitleLanguage: string | null;
|
||||
subtitleMode: string;
|
||||
showForcedSubtitles: boolean;
|
||||
};
|
||||
onToggleVisibility: (checked: boolean) => void;
|
||||
}) {
|
||||
const controlId = useId();
|
||||
const setPlaybackPreference = useSetLibraryPlaybackPreference();
|
||||
const deletePlaybackPreference = useDeleteLibraryPlaybackPreference();
|
||||
// Resolved with this library in context, so each key reports whether the
|
||||
// answer came from the library's own row or from a wider scope.
|
||||
const { data: effective } = useEffectiveSettings({
|
||||
keys: LIBRARY_PLAYBACK_KEYS,
|
||||
libraryIds: [library.id],
|
||||
});
|
||||
const setValue = useSetSettingValue();
|
||||
const clearValue = useClearSettingValue();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [editorState, setEditorState] = useState(() =>
|
||||
createLibraryPlaybackEditorState(preference),
|
||||
);
|
||||
const [editorState, setEditorState] = useState(() => createLibraryPlaybackEditorState(effective));
|
||||
|
||||
useEffect(() => {
|
||||
// Keep the inline editor aligned with cached mutations and profile switches.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setEditorState(createLibraryPlaybackEditorState(preference));
|
||||
}, [preference, profile.id]);
|
||||
// Keep the inline editor aligned with the resolved values, which change on
|
||||
// a profile switch as well as after a save.
|
||||
setEditorState(createLibraryPlaybackEditorState(effective));
|
||||
}, [effective]);
|
||||
|
||||
const playbackPending = setPlaybackPreference.isPending || deletePlaybackPreference.isPending;
|
||||
const playbackPending = setValue.isPending || clearValue.isPending;
|
||||
const summaryText = buildLibraryPlaybackSummaryFromState(editorState);
|
||||
const hasOverride = hasLibraryPlaybackOverride(editorState);
|
||||
|
||||
function savePlaybackState(
|
||||
/**
|
||||
* Applies an editor state as canonical per-key writes at profile_library.
|
||||
*
|
||||
* Each key moves independently: the legacy endpoint replaced one composite
|
||||
* row, so clearing one field meant re-sending the other three, and a
|
||||
* concurrent change to any of them was lost. One write per changed key has no
|
||||
* such coupling, and "inherit" is expressed by deleting that key's row rather
|
||||
* than by omitting a field from a composite body.
|
||||
*/
|
||||
async function savePlaybackState(
|
||||
nextState: LibraryPlaybackEditorState,
|
||||
rollbackState: LibraryPlaybackEditorState,
|
||||
) {
|
||||
setEditorState(nextState);
|
||||
const scope = libraryScope(library.id);
|
||||
|
||||
const onError = () => {
|
||||
try {
|
||||
for (const mutation of buildLibraryPlaybackMutations(nextState)) {
|
||||
if (mutation.value === undefined) {
|
||||
try {
|
||||
await clearValue.mutateAsync({ key: mutation.key, identity: scope });
|
||||
} catch (error) {
|
||||
// Nothing stored at this scope already inherits, which is the
|
||||
// state the clear asks for.
|
||||
if (!(error instanceof ApiClientError && error.status === 404)) throw error;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
await setValue.mutateAsync({
|
||||
key: mutation.key,
|
||||
value: mutation.value,
|
||||
identity: scope,
|
||||
});
|
||||
}
|
||||
if (!hasLibraryPlaybackOverride(nextState)) {
|
||||
setExpanded(false);
|
||||
}
|
||||
} catch {
|
||||
setEditorState(rollbackState);
|
||||
toast.error("Failed to update playback defaults");
|
||||
};
|
||||
|
||||
if (!hasLibraryPlaybackOverride(nextState)) {
|
||||
deletePlaybackPreference.mutate(library.id, {
|
||||
onError,
|
||||
onSuccess: () => setExpanded(false),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setPlaybackPreference.mutate(
|
||||
{
|
||||
libraryId: library.id,
|
||||
body: buildLibraryPlaybackRequest(nextState),
|
||||
},
|
||||
{ onError },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePlaybackChange(field: keyof LibraryPlaybackEditorState, value: string) {
|
||||
const rollbackState = editorState;
|
||||
const nextState = { ...editorState, [field]: value };
|
||||
savePlaybackState(nextState, rollbackState);
|
||||
void savePlaybackState(nextState, rollbackState);
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
savePlaybackState(createLibraryPlaybackEditorState(null), editorState);
|
||||
void savePlaybackState(
|
||||
{
|
||||
audioLanguage: INHERIT_VALUE,
|
||||
subtitleLanguage: INHERIT_VALUE,
|
||||
subtitleMode: INHERIT_VALUE,
|
||||
showForcedSubtitles: INHERIT_VALUE,
|
||||
},
|
||||
editorState,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -319,15 +361,14 @@ function LibraryCard({
|
||||
label="Spoken language"
|
||||
value={editorState.audioLanguage}
|
||||
disabled={playbackPending}
|
||||
hint={getProfileDefaultLanguageHint(profile.language)}
|
||||
hint={getProfileDefaultLanguageHint(profileDefaults.audioLanguage)}
|
||||
onChange={(value) => handlePlaybackChange("audioLanguage", value)}
|
||||
>
|
||||
<SelectItem value={INHERIT_VALUE}>
|
||||
{buildInheritedLanguageLabel(profile.language)}
|
||||
{buildInheritedLanguageLabel(profileDefaults.audioLanguage ?? "")}
|
||||
</SelectItem>
|
||||
<SelectItem value={ORIGINAL_LANGUAGE_VALUE}>{ORIGINAL_LANGUAGE_LABEL}</SelectItem>
|
||||
{LANGUAGE_OPTIONS.map((language) => (
|
||||
<SelectItem key={language.code} value={language.code}>
|
||||
{NAMED_LANGUAGE_OPTIONS.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -337,15 +378,15 @@ function LibraryCard({
|
||||
label="Subtitle language"
|
||||
value={editorState.subtitleLanguage}
|
||||
disabled={playbackPending}
|
||||
hint={getProfileDefaultSubtitleLanguageHint(profile.subtitle_language)}
|
||||
hint={getProfileDefaultSubtitleLanguageHint(profileDefaults.subtitleLanguage)}
|
||||
onChange={(value) => handlePlaybackChange("subtitleLanguage", value)}
|
||||
>
|
||||
<SelectItem value={INHERIT_VALUE}>
|
||||
{buildInheritedSubtitleLanguageLabel(profile.subtitle_language)}
|
||||
{buildInheritedSubtitleLanguageLabel(profileDefaults.subtitleLanguage ?? "")}
|
||||
</SelectItem>
|
||||
<SelectItem value={NONE_VALUE}>None</SelectItem>
|
||||
{LANGUAGE_OPTIONS.map((language) => (
|
||||
<SelectItem key={language.code} value={language.code}>
|
||||
{NAMED_LANGUAGE_OPTIONS.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -355,11 +396,11 @@ function LibraryCard({
|
||||
label="Subtitle behavior"
|
||||
value={editorState.subtitleMode}
|
||||
disabled={playbackPending}
|
||||
hint={getProfileDefaultSubtitleModeHint(profile.subtitle_mode)}
|
||||
hint={getProfileDefaultSubtitleModeHint(profileDefaults.subtitleMode)}
|
||||
onChange={(value) => handlePlaybackChange("subtitleMode", value)}
|
||||
>
|
||||
<SelectItem value={INHERIT_VALUE}>
|
||||
{buildInheritedSubtitleModeLabel(profile.subtitle_mode)}
|
||||
{buildInheritedSubtitleModeLabel(profileDefaults.subtitleMode)}
|
||||
</SelectItem>
|
||||
{SUBTITLE_MODE_OPTIONS.map((mode) => (
|
||||
<SelectItem key={mode.value} value={mode.value}>
|
||||
@@ -372,11 +413,11 @@ function LibraryCard({
|
||||
label="Forced subtitles"
|
||||
value={editorState.showForcedSubtitles}
|
||||
disabled={playbackPending}
|
||||
hint={getProfileDefaultForcedSubtitlesHint(profile.show_forced_subtitles)}
|
||||
hint={getProfileDefaultForcedSubtitlesHint(profileDefaults.showForcedSubtitles)}
|
||||
onChange={(value) => handlePlaybackChange("showForcedSubtitles", value)}
|
||||
>
|
||||
<SelectItem value={INHERIT_VALUE}>
|
||||
{buildInheritedShowForcedSubtitlesLabel(profile.show_forced_subtitles)}
|
||||
{buildInheritedShowForcedSubtitlesLabel(profileDefaults.showForcedSubtitles)}
|
||||
</SelectItem>
|
||||
<SelectItem value="on">On</SelectItem>
|
||||
<SelectItem value="off">Off</SelectItem>
|
||||
@@ -406,17 +447,20 @@ function LibraryCard({
|
||||
|
||||
export default function LibrarySettings() {
|
||||
const { data: libraries, isLoading: librariesLoading } = useAvailableUserLibraries();
|
||||
const { data: disabledSetting, isLoading: disabledSettingLoading } = useSetting(
|
||||
DISABLED_LIBRARY_IDS_SETTING_KEY,
|
||||
);
|
||||
const { data: orderSetting, isLoading: orderSettingLoading } =
|
||||
useSetting(LIBRARY_ORDER_SETTING_KEY);
|
||||
const {
|
||||
disabledLibraryIDs: savedDisabledLibraryIDs,
|
||||
libraryOrder: savedLibraryOrder,
|
||||
isLoading: libraryPrefsLoading,
|
||||
} = useLibraryDisplayPreferences();
|
||||
const { profile: currentProfile, isLoading: profileLoading } = useCurrentProfile();
|
||||
const { data: playbackPreferences, isLoading: playbackPrefsLoading } =
|
||||
useLibraryPlaybackPreferences({
|
||||
enabled: !!currentProfile,
|
||||
});
|
||||
const setSetting = useSetSetting();
|
||||
// Resolved with no library in context, so these are exactly the values a
|
||||
// library inherits when it holds no override of its own — which is what the
|
||||
// "Profile default" hints on each card have to name.
|
||||
const { data: profileDefaultSettings, isLoading: playbackPrefsLoading } = useEffectiveSettings({
|
||||
keys: LIBRARY_PLAYBACK_KEYS,
|
||||
enabled: !!currentProfile,
|
||||
});
|
||||
const setSetting = useSetSettingValue();
|
||||
const [disabledLibraryIDs, setDisabledLibraryIDs] = useState<number[]>([]);
|
||||
const [orderedLibraries, setOrderedLibraries] = useState<UserLibrary[]>([]);
|
||||
const [activeId, setActiveId] = useState<number | null>(null);
|
||||
@@ -426,26 +470,31 @@ export default function LibrarySettings() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const parsedIDs = parseDisabledLibraryIDs(disabledSetting);
|
||||
// Keep the editable local order in sync with the latest saved setting.
|
||||
// This supports optimistic updates and rollback without changing behavior.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setDisabledLibraryIDs(libraries ? sortLibrariesByOrder(libraries, parsedIDs) : parsedIDs);
|
||||
}, [disabledSetting, libraries]);
|
||||
setDisabledLibraryIDs(
|
||||
libraries
|
||||
? sortLibrariesByOrder(libraries, savedDisabledLibraryIDs)
|
||||
: savedDisabledLibraryIDs,
|
||||
);
|
||||
}, [savedDisabledLibraryIDs, libraries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraries) return;
|
||||
const order = parseLibraryOrder(orderSetting);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setOrderedLibraries(order.length > 0 ? applyLibraryOrder(libraries, order) : libraries);
|
||||
}, [libraries, orderSetting]);
|
||||
setOrderedLibraries(
|
||||
savedLibraryOrder.length > 0 ? applyLibraryOrder(libraries, savedLibraryOrder) : libraries,
|
||||
);
|
||||
}, [libraries, savedLibraryOrder]);
|
||||
|
||||
function saveDisabledLibraries(nextDisabledLibraryIDs: number[], rollbackIDs: number[]) {
|
||||
setDisabledLibraryIDs(nextDisabledLibraryIDs);
|
||||
setSetting.mutate(
|
||||
{
|
||||
key: DISABLED_LIBRARY_IDS_SETTING_KEY,
|
||||
value: serializeDisabledLibraryIDs(nextDisabledLibraryIDs),
|
||||
key: SETTING_KEYS.UI_DISABLED_LIBRARY_IDS,
|
||||
value: normalizeLibraryIDs(nextDisabledLibraryIDs),
|
||||
identity: PROFILE_SCOPE,
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
@@ -495,8 +544,9 @@ export default function LibrarySettings() {
|
||||
setOrderedLibraries(next);
|
||||
setSetting.mutate(
|
||||
{
|
||||
key: LIBRARY_ORDER_SETTING_KEY,
|
||||
value: serializeLibraryOrder(next.map((l) => l.id)),
|
||||
key: SETTING_KEYS.UI_LIBRARY_ORDER,
|
||||
value: normalizeLibraryIDs(next.map((l) => l.id)),
|
||||
identity: PROFILE_SCOPE,
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
@@ -513,13 +563,7 @@ export default function LibrarySettings() {
|
||||
|
||||
const activeLibrary = activeId != null ? orderedLibraries.find((l) => l.id === activeId) : null;
|
||||
|
||||
if (
|
||||
librariesLoading ||
|
||||
disabledSettingLoading ||
|
||||
orderSettingLoading ||
|
||||
profileLoading ||
|
||||
playbackPrefsLoading
|
||||
) {
|
||||
if (librariesLoading || libraryPrefsLoading || profileLoading || playbackPrefsLoading) {
|
||||
return <div className="text-muted-foreground pt-4">Loading libraries...</div>;
|
||||
}
|
||||
|
||||
@@ -539,9 +583,22 @@ export default function LibrarySettings() {
|
||||
const visibleCount = libraries.filter(
|
||||
(library) => !disabledLibraryIDs.includes(library.id),
|
||||
).length;
|
||||
const preferencesByLibraryId = new Map(
|
||||
(playbackPreferences ?? []).map((preference) => [preference.library_id, preference]),
|
||||
);
|
||||
const profileDefaults = {
|
||||
audioLanguage:
|
||||
(profileDefaultSettings?.[SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE]?.value as string | null) ??
|
||||
null,
|
||||
subtitleLanguage:
|
||||
(profileDefaultSettings?.[SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE]?.value as string | null) ??
|
||||
null,
|
||||
subtitleMode:
|
||||
(profileDefaultSettings?.[SETTING_KEYS.PLAYBACK_SUBTITLE_MODE]?.value as
|
||||
| string
|
||||
| undefined) ?? "auto",
|
||||
showForcedSubtitles:
|
||||
(profileDefaultSettings?.[SETTING_KEYS.PLAYBACK_SHOW_FORCED_SUBTITLES]?.value as
|
||||
| boolean
|
||||
| undefined) ?? true,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -557,7 +614,7 @@ export default function LibrarySettings() {
|
||||
title="Browsing"
|
||||
description="These preferences apply to this profile on the current device."
|
||||
>
|
||||
<RememberLibraryPageStateSetting profileId={currentProfile.id} />
|
||||
<RememberLibraryPageStateSetting />
|
||||
</SettingsGroup>
|
||||
|
||||
<div className="surface-panel-subtle flex flex-col gap-4 rounded-[1.4rem] p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -609,8 +666,7 @@ export default function LibrarySettings() {
|
||||
library={library}
|
||||
enabled={enabled}
|
||||
visibilityDisabled={setSetting.isPending}
|
||||
preference={preferencesByLibraryId.get(library.id) ?? null}
|
||||
profile={currentProfile}
|
||||
profileDefaults={profileDefaults}
|
||||
onToggleVisibility={(checked) => handleLibraryToggle(library.id, checked)}
|
||||
/>
|
||||
</SortableLibraryCard>
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { EffectiveSetting, EffectiveSettingsMap } from "@/hooks/queries/settingValues";
|
||||
import { SETTING_KEYS, type SettingKey } from "@/lib/settingsContract";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useEffectiveSettings: vi.fn(),
|
||||
useSetSettingValue: vi.fn(),
|
||||
useClearSettingValue: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/queries/settingValues", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/hooks/queries/settingValues")>(
|
||||
"@/hooks/queries/settingValues",
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useEffectiveSettings: (...args: unknown[]) => mocks.useEffectiveSettings(...args),
|
||||
useSetSettingValue: (...args: unknown[]) => mocks.useSetSettingValue(...args),
|
||||
useClearSettingValue: (...args: unknown[]) => mocks.useClearSettingValue(...args),
|
||||
};
|
||||
});
|
||||
|
||||
import PlaybackSettings from "./PlaybackSettings";
|
||||
|
||||
function resolved(
|
||||
key: SettingKey,
|
||||
value: unknown,
|
||||
source: EffectiveSetting["source"],
|
||||
): EffectiveSettingsMap {
|
||||
return { [key]: { key, value, source } };
|
||||
}
|
||||
|
||||
describe("PlaybackSettings", () => {
|
||||
let mutate: ReturnType<typeof vi.fn>;
|
||||
let mutateAsync: ReturnType<typeof vi.fn>;
|
||||
let clearMutateAsync: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.useEffectiveSettings.mockReset();
|
||||
mocks.useSetSettingValue.mockReset();
|
||||
mocks.useClearSettingValue.mockReset();
|
||||
mutate = vi.fn();
|
||||
mutateAsync = vi.fn().mockResolvedValue(undefined);
|
||||
clearMutateAsync = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mocks.useEffectiveSettings.mockReturnValue({ data: {}, isLoading: false });
|
||||
mocks.useSetSettingValue.mockReturnValue({ isPending: false, mutate, mutateAsync });
|
||||
mocks.useClearSettingValue.mockReturnValue({
|
||||
isPending: false,
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: clearMutateAsync,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("renders without a profile record, reading every value from the contract", () => {
|
||||
// The screen used to require the cached profile object and read its
|
||||
// preference columns; it now resolves them, so it renders from the
|
||||
// settings API alone.
|
||||
render(<PlaybackSettings />);
|
||||
|
||||
expect(screen.getByText("Spoken language")).toBeTruthy();
|
||||
expect(screen.getByText("Auto-play next episode")).toBeTruthy();
|
||||
expect(screen.getByText("Next up episodes")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reads its values in one batch rather than one request per control", () => {
|
||||
render(<PlaybackSettings />);
|
||||
|
||||
const batched = mocks.useEffectiveSettings.mock.calls.find(
|
||||
([options]) => (options?.keys?.length ?? 0) > 2,
|
||||
);
|
||||
expect(batched?.[0].keys).toContain(SETTING_KEYS.PLAYBACK_AUTO_SKIP_INTRO);
|
||||
expect(batched?.[0].keys).toContain(SETTING_KEYS.UI_NEXT_UP_MODE);
|
||||
expect(batched?.[0].keys).toContain(SETTING_KEYS.CATALOG_METADATA_LANGUAGE);
|
||||
});
|
||||
|
||||
it("saves a toggle as typed JSON at profile scope", () => {
|
||||
render(<PlaybackSettings />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Auto-skip intros"));
|
||||
|
||||
// Awaited rather than fire-and-forget: the write is followed by a clear of
|
||||
// any device override, which has to see whether the write landed.
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
key: SETTING_KEYS.PLAYBACK_AUTO_SKIP_INTRO,
|
||||
// Typed JSON, not the "true"/"false" strings the legacy endpoint took.
|
||||
value: true,
|
||||
identity: { scope: "profile" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a stored value in preference to the contract default", () => {
|
||||
// playback.auto_play_next defaults to true, so a stored false is the case
|
||||
// that proves the screen trusts the resolved answer: the bug this guards is
|
||||
// a default-on toggle that a client's own idea of the default flips back.
|
||||
render(<PlaybackSettings />);
|
||||
expect(screen.getByLabelText("Auto-play next episode").getAttribute("aria-checked")).toBe(
|
||||
"true",
|
||||
);
|
||||
|
||||
cleanup();
|
||||
mocks.useEffectiveSettings.mockReturnValue({
|
||||
data: resolved(SETTING_KEYS.PLAYBACK_AUTO_PLAY_NEXT, false, "profile"),
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<PlaybackSettings />);
|
||||
expect(screen.getByLabelText("Auto-play next episode").getAttribute("aria-checked")).toBe(
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("turning a default-on toggle off stores an explicit false", async () => {
|
||||
render(<PlaybackSettings />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Auto-play next episode"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
key: SETTING_KEYS.PLAYBACK_AUTO_PLAY_NEXT,
|
||||
value: false,
|
||||
identity: { scope: "profile" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears a shadowing device row when auto-play is saved", async () => {
|
||||
// The player's post-roll toggle and this switch edit the same setting, and
|
||||
// the contract resolves profile_device above profile. A device row left in
|
||||
// place would keep shadowing this save and snap the switch back, with no
|
||||
// other web affordance able to remove it — so the save clears it.
|
||||
mocks.useEffectiveSettings.mockReturnValue({
|
||||
data: resolved(SETTING_KEYS.PLAYBACK_AUTO_PLAY_NEXT, false, "profile_device"),
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<PlaybackSettings />);
|
||||
expect(screen.getByLabelText("Auto-play next episode").getAttribute("aria-checked")).toBe(
|
||||
"false",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Auto-play next episode"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
key: SETTING_KEYS.PLAYBACK_AUTO_PLAY_NEXT,
|
||||
value: true,
|
||||
identity: { scope: "profile" },
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(clearMutateAsync).toHaveBeenCalledWith({
|
||||
key: SETTING_KEYS.PLAYBACK_AUTO_PLAY_NEXT,
|
||||
identity: { scope: "profile_device" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves other scopes alone when no device row is shadowing auto-play", async () => {
|
||||
mocks.useEffectiveSettings.mockReturnValue({
|
||||
data: resolved(SETTING_KEYS.PLAYBACK_AUTO_PLAY_NEXT, false, "profile"),
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<PlaybackSettings />);
|
||||
fireEvent.click(screen.getByLabelText("Auto-play next episode"));
|
||||
|
||||
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
|
||||
expect(clearMutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers a reset only once the profile has stored a next-up choice", () => {
|
||||
// The resolved value is the contract default until a row exists, so the
|
||||
// affordance keys off the source rather than off the value.
|
||||
render(<PlaybackSettings />);
|
||||
expect(screen.queryByRole("button", { name: "Reset" })).toBeNull();
|
||||
|
||||
cleanup();
|
||||
mocks.useEffectiveSettings.mockReturnValue({
|
||||
data: resolved(SETTING_KEYS.UI_NEXT_UP_MODE, "separate", "profile"),
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<PlaybackSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reset" }));
|
||||
|
||||
// Reset is a delete of the profile row, so the setting inherits the
|
||||
// contract default again rather than storing the default as a value.
|
||||
expect(clearMutateAsync).toHaveBeenCalledWith({
|
||||
key: SETTING_KEYS.UI_NEXT_UP_MODE,
|
||||
identity: { scope: "profile" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,3 @@
|
||||
import { getProfileToken } from "@/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useUpdateProfile } from "@/hooks/queries/profiles";
|
||||
import {
|
||||
useDeleteSetting,
|
||||
useEffectiveSettings,
|
||||
useSetDeviceSetting,
|
||||
useSetSetting,
|
||||
useSetting,
|
||||
} from "@/hooks/queries/settings";
|
||||
import { SettingsGroup } from "@/components/settings/SettingsGroup";
|
||||
import { SettingRow } from "@/components/settings/SettingRow";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -19,15 +9,129 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { LANGUAGE_OPTIONS } from "@/lib/settingsManifest";
|
||||
import { optionsFor } from "@/lib/settingsDisplay";
|
||||
import { NAMED_LANGUAGE_OPTIONS } from "@/lib/languageOptions";
|
||||
import { SETTING_DEFINITIONS, SETTING_KEYS, type SettingKey } from "@/lib/settingsContract";
|
||||
import { QUALITY_PRESETS, describeQuality, presetById, presetIdFor } from "@/lib/qualityPresets";
|
||||
import { useEffectiveSettings } from "@/hooks/queries/settingValues";
|
||||
import { useAutoPlayNextSetting } from "@/hooks/queries/autoPlayNext";
|
||||
import { useProfileDefaultWriter } from "@/hooks/queries/profileDefaults";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const AUTO_PLAY_NEXT_KEY = "playback.auto_play_next";
|
||||
/**
|
||||
* Every preference on this screen is the profile's own choice. A device, a
|
||||
* library, or a series can override several of them, and the contract resolves
|
||||
* those above the profile row — so useProfileDefaultWriter writes the profile
|
||||
* layer and clears any device override that would otherwise keep shadowing it.
|
||||
*/
|
||||
/**
|
||||
* Read in one batch: the screen wants every key at once.
|
||||
*
|
||||
* playback.auto_play_next is deliberately absent — it is read and written
|
||||
* through useAutoPlayNextSetting, shared with the post-roll toggle so the two
|
||||
* surfaces cannot land on different scopes.
|
||||
*/
|
||||
const PLAYBACK_KEYS: SettingKey[] = [
|
||||
SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE,
|
||||
SETTING_KEYS.PLAYBACK_AUTO_PLAY_NEXT_PREVIEW,
|
||||
SETTING_KEYS.PLAYBACK_AUTO_SKIP_CREDITS,
|
||||
SETTING_KEYS.PLAYBACK_AUTO_SKIP_INTRO,
|
||||
SETTING_KEYS.PLAYBACK_AUTO_SKIP_RECAP,
|
||||
SETTING_KEYS.CATALOG_METADATA_LANGUAGE,
|
||||
SETTING_KEYS.UI_NEXT_UP_MODE,
|
||||
];
|
||||
|
||||
function AutoPlayNextSetting({ profileId }: { profileId: string }) {
|
||||
const { data: effective = {} } = useEffectiveSettings(profileId, [AUTO_PLAY_NEXT_KEY]);
|
||||
const setDeviceSetting = useSetDeviceSetting();
|
||||
const autoplay = effective[AUTO_PLAY_NEXT_KEY]?.effective_value !== "false";
|
||||
const NEXT_UP_MODES = optionsFor(SETTING_DEFINITIONS[SETTING_KEYS.UI_NEXT_UP_MODE]);
|
||||
|
||||
/**
|
||||
* Quality is two settings behind one picker.
|
||||
*
|
||||
* The server stores a resolution cap and a bandwidth cap independently, which
|
||||
* is what the player has always sent on the wire. Presenting them as one list
|
||||
* keeps the choice simple while leaving the two axes free: a preset is a
|
||||
* client-side pairing, so retuning what "High" means never needs the server to
|
||||
* agree, and someone who sets the axes separately through the API still gets a
|
||||
* truthful label rather than a picker showing the wrong entry.
|
||||
*/
|
||||
function QualitySetting() {
|
||||
const { data: effective } = useEffectiveSettings({
|
||||
keys: ["playback.preferred_quality", "playback.max_bitrate_kbps"],
|
||||
});
|
||||
const { save, reset, isSaving } = useProfileDefaultWriter(effective);
|
||||
|
||||
const resolution = effective?.["playback.preferred_quality"]?.value as string | undefined;
|
||||
const bitrate = effective?.["playback.max_bitrate_kbps"]?.value as number | null | undefined;
|
||||
const selected = presetIdFor(resolution, bitrate);
|
||||
|
||||
const apply = (presetId: string) => {
|
||||
const preset = presetById(presetId);
|
||||
if (!preset) return;
|
||||
|
||||
save("playback.preferred_quality", preset.resolution).catch(() =>
|
||||
toast.error("Failed to save video quality"),
|
||||
);
|
||||
|
||||
// An uncapped preset clears the bitrate rather than storing a sentinel, so
|
||||
// "no cap" stays the absence of a value at every layer. Both axes are
|
||||
// device-overridable, so the reset clears the device row too — otherwise
|
||||
// an override would keep capping playback after the picker said it did not.
|
||||
if (preset.bitrateKbps === null) {
|
||||
reset("playback.max_bitrate_kbps").catch(() =>
|
||||
toast.error("Failed to clear the bitrate cap"),
|
||||
);
|
||||
} else {
|
||||
save("playback.max_bitrate_kbps", preset.bitrateKbps).catch(() =>
|
||||
toast.error("Failed to save maximum bitrate"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const pending = isSaving;
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
label="Video quality"
|
||||
description="Choose the quality your profile should request when playback begins."
|
||||
control={(id) => (
|
||||
<div className="w-full">
|
||||
<Select value={selected ?? "__custom"} onValueChange={apply}>
|
||||
<SelectTrigger id={id} className="w-full sm:w-[260px]" disabled={pending}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selected === null && (
|
||||
<SelectItem value="__custom" disabled>
|
||||
{describeQuality(resolution, bitrate)}
|
||||
</SelectItem>
|
||||
)}
|
||||
{QUALITY_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.id} value={preset.id}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1.5 text-xs">
|
||||
{describeQuality(resolution, bitrate)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-play shares its hook with the post-roll toggle.
|
||||
*
|
||||
* Both surfaces render the resolved value, and the contract resolves
|
||||
* profile_device above profile — so if this screen wrote the profile row while
|
||||
* the player wrote the device row, this switch would save a value the device
|
||||
* row keeps shadowing and snap straight back. The shared hook writes the
|
||||
* profile and clears any device row, which is also the only way a migrated
|
||||
* per-device override becomes reachable from the web UI.
|
||||
*/
|
||||
function AutoPlayNextSetting() {
|
||||
const { enabled, setEnabled, isSaving } = useAutoPlayNextSetting();
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
@@ -36,99 +140,52 @@ function AutoPlayNextSetting({ profileId }: { profileId: string }) {
|
||||
control={(id) => (
|
||||
<Switch
|
||||
id={id}
|
||||
checked={autoplay}
|
||||
disabled={setDeviceSetting.isPending}
|
||||
onCheckedChange={(checked) =>
|
||||
setDeviceSetting.mutate(
|
||||
{ key: AUTO_PLAY_NEXT_KEY, value: checked ? "true" : "false" },
|
||||
{
|
||||
onSuccess: () => toast.success("Auto-play preference saved"),
|
||||
onError: () => toast.error("Failed to save auto-play preference"),
|
||||
},
|
||||
)
|
||||
}
|
||||
checked={enabled}
|
||||
disabled={isSaving}
|
||||
onCheckedChange={(checked) => {
|
||||
setEnabled(checked).catch(() => toast.error("Failed to save playback setting"));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NextUpSetting() {
|
||||
const { data: nextUpMode, isLoading } = useSetting("next_up_mode");
|
||||
const setSetting = useSetSetting();
|
||||
const deleteSetting = useDeleteSetting();
|
||||
const currentValue = nextUpMode || "combined";
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
label="Next up episodes"
|
||||
description="Choose whether upcoming episodes stay with Continue Watching or get their own row."
|
||||
control={(id) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={currentValue}
|
||||
onValueChange={(value) =>
|
||||
setSetting.mutate(
|
||||
{ key: "next_up_mode", value },
|
||||
{ onSuccess: () => toast.success("Next up preference saved") },
|
||||
)
|
||||
}
|
||||
disabled={isLoading || setSetting.isPending}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full sm:w-[240px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="combined">With Continue Watching</SelectItem>
|
||||
<SelectItem value="separate">Separate row</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{nextUpMode ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 rounded-full px-3"
|
||||
onClick={() => deleteSetting.mutate({ key: "next_up_mode" })}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlaybackSettings() {
|
||||
const { profile, selectProfile } = useAuth();
|
||||
const updateMutation = useUpdateProfile();
|
||||
const currentProfileToken = getProfileToken() ?? undefined;
|
||||
const { data: effective } = useEffectiveSettings({ keys: PLAYBACK_KEYS });
|
||||
const {
|
||||
save: saveProfileDefault,
|
||||
reset: resetProfileDefault,
|
||||
isSaving,
|
||||
} = useProfileDefaultWriter(effective);
|
||||
|
||||
if (!profile) return null;
|
||||
// The effective endpoint resolves an unset key to the contract default, so
|
||||
// every control below reads its value from the same answer rather than from
|
||||
// a local literal that could disagree with the server about "unset".
|
||||
const read = <T,>(key: SettingKey) =>
|
||||
(effective?.[key]?.value ?? SETTING_DEFINITIONS[key].defaultValue) as T;
|
||||
|
||||
const qualityPreference =
|
||||
profile.quality_preference?.toLowerCase() === "4k"
|
||||
? "2160p"
|
||||
: profile.quality_preference || "auto";
|
||||
|
||||
const saveProfileField = (body: {
|
||||
quality_preference?: string;
|
||||
language?: string;
|
||||
preferred_metadata_language?: string;
|
||||
auto_skip_intro?: boolean;
|
||||
auto_skip_credits?: boolean;
|
||||
auto_skip_recap?: boolean;
|
||||
auto_play_next_preview?: boolean;
|
||||
}) => {
|
||||
updateMutation.mutate(
|
||||
{ id: profile.id, body },
|
||||
{
|
||||
onSuccess: (updatedProfile) => selectProfile(updatedProfile, currentProfileToken),
|
||||
onError: () => toast.error("Failed to save profile setting"),
|
||||
},
|
||||
);
|
||||
// Saves at profile scope and clears any device override shadowing it —
|
||||
// several of these keys are device-overridable, and without the clear the
|
||||
// control would snap back to the override it cannot see.
|
||||
const saveValue = (key: SettingKey, value: unknown) => {
|
||||
saveProfileDefault(key, value).catch(() => toast.error("Failed to save playback setting"));
|
||||
};
|
||||
|
||||
const nextUpMode = read<string>(SETTING_KEYS.UI_NEXT_UP_MODE);
|
||||
// Whether the profile has actually chosen, which is what gates the reset
|
||||
// affordance: the resolved value is the default until a row exists.
|
||||
const nextUpChosen = effective?.[SETTING_KEYS.UI_NEXT_UP_MODE]?.source === "profile";
|
||||
const pending = isSaving;
|
||||
|
||||
async function resetNextUpMode() {
|
||||
// Nothing stored is the state a reset asks for, which the shared reset
|
||||
// already treats as success.
|
||||
await resetProfileDefault(SETTING_KEYS.UI_NEXT_UP_MODE).catch(() =>
|
||||
toast.error("Failed to reset the next up preference"),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
@@ -144,34 +201,7 @@ export default function PlaybackSettings() {
|
||||
title="Defaults"
|
||||
description="These preferences apply unless a library or item has a more specific playback choice."
|
||||
>
|
||||
<SettingRow
|
||||
label="Video quality"
|
||||
description="Choose the quality your profile should request when playback begins."
|
||||
control={(id) => (
|
||||
<div className="w-full">
|
||||
<Select
|
||||
value={qualityPreference}
|
||||
onValueChange={(value) => saveProfileField({ quality_preference: value })}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
className="w-full sm:w-[220px]"
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="original">Original</SelectItem>
|
||||
<SelectItem value="2160p">4K</SelectItem>
|
||||
<SelectItem value="1080p">1080p</SelectItem>
|
||||
<SelectItem value="720p">720p</SelectItem>
|
||||
<SelectItem value="480p">480p</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<QualitySetting />
|
||||
|
||||
<SettingRow
|
||||
label="Spoken language"
|
||||
@@ -179,21 +209,19 @@ export default function PlaybackSettings() {
|
||||
control={(id) => (
|
||||
<div className="w-full">
|
||||
<Select
|
||||
value={profile.language || "none"}
|
||||
value={read<string | null>(SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE) ?? "none"}
|
||||
onValueChange={(value) =>
|
||||
saveProfileField({ language: value === "none" ? "" : value })
|
||||
// The contract spells "no preference" as null, where the
|
||||
// legacy profile column spelled it as the empty string.
|
||||
saveValue(SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE, value === "none" ? null : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
className="w-full sm:w-[220px]"
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full sm:w-[220px]" disabled={pending}>
|
||||
<SelectValue placeholder="No preference" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No preference</SelectItem>
|
||||
{LANGUAGE_OPTIONS.filter((language) => language.value).map((language) => (
|
||||
{NAMED_LANGUAGE_OPTIONS.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
@@ -210,23 +238,17 @@ export default function PlaybackSettings() {
|
||||
control={(id) => (
|
||||
<div className="w-full">
|
||||
<Select
|
||||
value={profile.preferred_metadata_language || "none"}
|
||||
value={read<string | null>(SETTING_KEYS.CATALOG_METADATA_LANGUAGE) ?? "none"}
|
||||
onValueChange={(value) =>
|
||||
saveProfileField({
|
||||
preferred_metadata_language: value === "none" ? "" : value,
|
||||
})
|
||||
saveValue(SETTING_KEYS.CATALOG_METADATA_LANGUAGE, value === "none" ? null : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
className="w-full sm:w-[220px]"
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full sm:w-[220px]" disabled={pending}>
|
||||
<SelectValue placeholder="Library default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Library default</SelectItem>
|
||||
{LANGUAGE_OPTIONS.filter((language) => language.value).map((language) => (
|
||||
{NAMED_LANGUAGE_OPTIONS.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
@@ -243,9 +265,11 @@ export default function PlaybackSettings() {
|
||||
control={(id) => (
|
||||
<Switch
|
||||
id={id}
|
||||
checked={profile.auto_skip_intro ?? false}
|
||||
disabled={updateMutation.isPending}
|
||||
onCheckedChange={(checked) => saveProfileField({ auto_skip_intro: checked })}
|
||||
checked={read<boolean>(SETTING_KEYS.PLAYBACK_AUTO_SKIP_INTRO)}
|
||||
disabled={pending}
|
||||
onCheckedChange={(checked) =>
|
||||
saveValue(SETTING_KEYS.PLAYBACK_AUTO_SKIP_INTRO, checked)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -256,9 +280,11 @@ export default function PlaybackSettings() {
|
||||
control={(id) => (
|
||||
<Switch
|
||||
id={id}
|
||||
checked={profile.auto_skip_credits ?? false}
|
||||
disabled={updateMutation.isPending}
|
||||
onCheckedChange={(checked) => saveProfileField({ auto_skip_credits: checked })}
|
||||
checked={read<boolean>(SETTING_KEYS.PLAYBACK_AUTO_SKIP_CREDITS)}
|
||||
disabled={pending}
|
||||
onCheckedChange={(checked) =>
|
||||
saveValue(SETTING_KEYS.PLAYBACK_AUTO_SKIP_CREDITS, checked)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -269,9 +295,11 @@ export default function PlaybackSettings() {
|
||||
control={(id) => (
|
||||
<Switch
|
||||
id={id}
|
||||
checked={profile.auto_skip_recap ?? false}
|
||||
disabled={updateMutation.isPending}
|
||||
onCheckedChange={(checked) => saveProfileField({ auto_skip_recap: checked })}
|
||||
checked={read<boolean>(SETTING_KEYS.PLAYBACK_AUTO_SKIP_RECAP)}
|
||||
disabled={pending}
|
||||
onCheckedChange={(checked) =>
|
||||
saveValue(SETTING_KEYS.PLAYBACK_AUTO_SKIP_RECAP, checked)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -282,16 +310,52 @@ export default function PlaybackSettings() {
|
||||
control={(id) => (
|
||||
<Switch
|
||||
id={id}
|
||||
checked={profile.auto_play_next_preview ?? false}
|
||||
disabled={updateMutation.isPending}
|
||||
onCheckedChange={(checked) => saveProfileField({ auto_play_next_preview: checked })}
|
||||
checked={read<boolean>(SETTING_KEYS.PLAYBACK_AUTO_PLAY_NEXT_PREVIEW)}
|
||||
disabled={pending}
|
||||
onCheckedChange={(checked) =>
|
||||
saveValue(SETTING_KEYS.PLAYBACK_AUTO_PLAY_NEXT_PREVIEW, checked)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<AutoPlayNextSetting profileId={profile.id} />
|
||||
<AutoPlayNextSetting />
|
||||
|
||||
<NextUpSetting />
|
||||
<SettingRow
|
||||
label="Next up episodes"
|
||||
description="Choose whether upcoming episodes stay with Continue Watching or get their own row."
|
||||
control={(id) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={nextUpMode}
|
||||
onValueChange={(value) => saveValue(SETTING_KEYS.UI_NEXT_UP_MODE, value)}
|
||||
disabled={pending}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full sm:w-[240px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{NEXT_UP_MODES.map((mode) => (
|
||||
<SelectItem key={mode.value} value={mode.value}>
|
||||
{mode.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{nextUpChosen ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 rounded-full px-3"
|
||||
onClick={resetNextUpMode}
|
||||
disabled={pending}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { EffectiveSetting, EffectiveSettingsMap } from "@/hooks/queries/settingValues";
|
||||
import { SETTING_KEYS, type SettingKey } from "@/lib/settingsContract";
|
||||
import { DEFAULT_SUBTITLE_APPEARANCE } from "@/lib/subtitleAppearance";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useEffectiveSettings: vi.fn(),
|
||||
useSetSettingValue: vi.fn(),
|
||||
useClearSettingValue: vi.fn(),
|
||||
useSubtitleAppearanceSetting: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/queries/settingValues", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/hooks/queries/settingValues")>(
|
||||
"@/hooks/queries/settingValues",
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useEffectiveSettings: (...args: unknown[]) => mocks.useEffectiveSettings(...args),
|
||||
useSetSettingValue: (...args: unknown[]) => mocks.useSetSettingValue(...args),
|
||||
useClearSettingValue: (...args: unknown[]) => mocks.useClearSettingValue(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/queries/subtitleAppearance", () => ({
|
||||
useSubtitleAppearanceSetting: (...args: unknown[]) => mocks.useSubtitleAppearanceSetting(...args),
|
||||
}));
|
||||
|
||||
import SubtitleAppearanceSettings from "./SubtitleAppearanceSettings";
|
||||
|
||||
// The appearance panel renders a Slider, whose Radix primitive measures itself.
|
||||
// jsdom has no ResizeObserver, so stub one — the measurement is irrelevant to
|
||||
// what these tests assert.
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
globalThis.ResizeObserver ??= NoopResizeObserver as unknown as typeof ResizeObserver;
|
||||
|
||||
function resolved(
|
||||
key: SettingKey,
|
||||
value: unknown,
|
||||
source: EffectiveSetting["source"],
|
||||
): EffectiveSettingsMap {
|
||||
return { [key]: { key, value, source } };
|
||||
}
|
||||
|
||||
describe("SubtitleAppearanceSettings", () => {
|
||||
let mutate: ReturnType<typeof vi.fn>;
|
||||
let mutateAsync: ReturnType<typeof vi.fn>;
|
||||
let clearMutateAsync: ReturnType<typeof vi.fn>;
|
||||
let save: ReturnType<typeof vi.fn>;
|
||||
let reset: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.useEffectiveSettings.mockReset();
|
||||
mocks.useSetSettingValue.mockReset();
|
||||
mocks.useClearSettingValue.mockReset();
|
||||
mocks.useSubtitleAppearanceSetting.mockReset();
|
||||
mutate = vi.fn();
|
||||
mutateAsync = vi.fn().mockResolvedValue(undefined);
|
||||
clearMutateAsync = vi.fn().mockResolvedValue(undefined);
|
||||
save = vi.fn().mockResolvedValue(undefined);
|
||||
reset = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mocks.useEffectiveSettings.mockReturnValue({ data: {}, isLoading: false });
|
||||
mocks.useSetSettingValue.mockReturnValue({ isPending: false, mutate, mutateAsync });
|
||||
mocks.useClearSettingValue.mockReturnValue({
|
||||
isPending: false,
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: clearMutateAsync,
|
||||
});
|
||||
mocks.useSubtitleAppearanceSetting.mockReturnValue({
|
||||
appearance: DEFAULT_SUBTITLE_APPEARANCE,
|
||||
hasDeviceOverride: false,
|
||||
save,
|
||||
reset,
|
||||
isSaving: false,
|
||||
isResetting: false,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("reads the behavior triple from the contract in one batch", () => {
|
||||
render(<SubtitleAppearanceSettings />);
|
||||
|
||||
const [options] = mocks.useEffectiveSettings.mock.calls[0] ?? [];
|
||||
expect(options?.keys).toEqual([
|
||||
SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE,
|
||||
SETTING_KEYS.PLAYBACK_SUBTITLE_MODE,
|
||||
SETTING_KEYS.PLAYBACK_SHOW_FORCED_SUBTITLES,
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes forced subtitles at profile scope as a typed boolean", () => {
|
||||
render(<SubtitleAppearanceSettings />);
|
||||
|
||||
// Contract default is true, so the first click stores an explicit false —
|
||||
// the value the legacy profile column could not distinguish from unset.
|
||||
fireEvent.click(screen.getByLabelText("Show forced subtitles"));
|
||||
|
||||
// Awaited rather than fire-and-forget: the profile write is followed by a
|
||||
// clear of any device override that would otherwise keep shadowing it.
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
key: SETTING_KEYS.PLAYBACK_SHOW_FORCED_SUBTITLES,
|
||||
value: false,
|
||||
identity: { scope: "profile" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a stored forced-subtitle choice rather than the default", () => {
|
||||
mocks.useEffectiveSettings.mockReturnValue({
|
||||
data: resolved(SETTING_KEYS.PLAYBACK_SHOW_FORCED_SUBTITLES, false, "profile"),
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<SubtitleAppearanceSettings />);
|
||||
|
||||
expect(screen.getByLabelText("Show forced subtitles").getAttribute("aria-checked")).toBe(
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("offers the appearance reset only when this device holds an override", () => {
|
||||
render(<SubtitleAppearanceSettings />);
|
||||
expect(screen.queryByRole("button", { name: /Reset Appearance/ })).toBeNull();
|
||||
|
||||
cleanup();
|
||||
mocks.useSubtitleAppearanceSetting.mockReturnValue({
|
||||
appearance: DEFAULT_SUBTITLE_APPEARANCE,
|
||||
hasDeviceOverride: true,
|
||||
save,
|
||||
reset,
|
||||
isSaving: false,
|
||||
isResetting: false,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<SubtitleAppearanceSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Reset Appearance/ }));
|
||||
expect(reset).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useId, useMemo, useState, type ReactNode } from "react";
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { getProfileToken } from "@/api/client";
|
||||
import { SettingsGroup } from "@/components/settings/SettingsGroup";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -13,15 +12,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
useDeleteDeviceSetting,
|
||||
useEffectiveSettings,
|
||||
useSetDeviceSetting,
|
||||
} from "@/hooks/queries/settings";
|
||||
import { useUpdateProfile } from "@/hooks/queries/profiles";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useCurrentProfile } from "@/hooks/useCurrentProfile";
|
||||
import { LANGUAGE_OPTIONS } from "@/lib/settingsManifest";
|
||||
import { useSubtitleAppearanceSetting } from "@/hooks/queries/subtitleAppearance";
|
||||
import { useEffectiveSettings } from "@/hooks/queries/settingValues";
|
||||
import { useProfileDefaultWriter } from "@/hooks/queries/profileDefaults";
|
||||
import { SETTING_KEYS, type SettingKey } from "@/lib/settingsContract";
|
||||
import { optionsFor } from "@/lib/settingsDisplay";
|
||||
import { NAMED_LANGUAGE_OPTIONS } from "@/lib/languageOptions";
|
||||
import { SETTING_DEFINITIONS } from "@/lib/settingsContract";
|
||||
import {
|
||||
BACKGROUND_STYLE_OPTIONS,
|
||||
BG_COLOR_PALETTE,
|
||||
@@ -29,18 +26,23 @@ import {
|
||||
FONT_COLOR_PALETTE,
|
||||
FONT_FAMILY_OPTIONS,
|
||||
FONT_SIZE_OPTIONS,
|
||||
parseSubtitleAppearance,
|
||||
POSITION_OPTIONS,
|
||||
} from "@/lib/subtitleAppearance";
|
||||
import type { SubtitleAppearance } from "@/lib/subtitleAppearance";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const SETTINGS_KEY = "subtitle_appearance";
|
||||
const SUBTITLE_MODES = [
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "always", label: "Always" },
|
||||
{ value: "off", label: "Off" },
|
||||
] as const;
|
||||
/* Subtitle behavior is a profile preference; a device tunes only appearance. */
|
||||
/**
|
||||
* The behavior modes come from the contract rather than a literal list, so a
|
||||
* member added to the manifest appears here without a matching edit.
|
||||
*/
|
||||
const SUBTITLE_MODES = optionsFor(SETTING_DEFINITIONS[SETTING_KEYS.PLAYBACK_SUBTITLE_MODE]);
|
||||
|
||||
const BEHAVIOR_KEYS: SettingKey[] = [
|
||||
SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE,
|
||||
SETTING_KEYS.PLAYBACK_SUBTITLE_MODE,
|
||||
SETTING_KEYS.PLAYBACK_SHOW_FORCED_SUBTITLES,
|
||||
];
|
||||
|
||||
interface ColorPaletteProps {
|
||||
colors: { hex: string; label: string }[];
|
||||
@@ -123,17 +125,26 @@ function SettingRow({ label, description, labelForControl = true, children }: Se
|
||||
}
|
||||
|
||||
export default function SubtitleAppearanceSettings() {
|
||||
const { selectProfile } = useAuth();
|
||||
const { profile } = useCurrentProfile();
|
||||
const { data: effective = {} } = useEffectiveSettings(profile?.id, [SETTINGS_KEY]);
|
||||
const updateMutation = useUpdateProfile();
|
||||
const setDeviceSetting = useSetDeviceSetting();
|
||||
const deleteDeviceSetting = useDeleteDeviceSetting();
|
||||
const currentProfileToken = getProfileToken() ?? undefined;
|
||||
const effectiveEntry = effective[SETTINGS_KEY];
|
||||
const {
|
||||
appearance: effectiveSettings,
|
||||
hasDeviceOverride,
|
||||
save: saveAppearance,
|
||||
reset: resetAppearance,
|
||||
isSaving,
|
||||
isResetting,
|
||||
} = useSubtitleAppearanceSetting();
|
||||
const { data: behavior } = useEffectiveSettings({ keys: BEHAVIOR_KEYS });
|
||||
const { save: saveProfileDefault, isSaving: behaviorSaving } = useProfileDefaultWriter(behavior);
|
||||
|
||||
// The effective endpoint resolves an unset key to the contract default, so a
|
||||
// control can read its value straight off the answer without a local literal.
|
||||
const subtitleLanguage =
|
||||
(behavior?.[SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE]?.value as string | null | undefined) ?? "";
|
||||
const subtitleMode =
|
||||
(behavior?.[SETTING_KEYS.PLAYBACK_SUBTITLE_MODE]?.value as string | undefined) ?? "auto";
|
||||
const showForcedSubtitles =
|
||||
(behavior?.[SETTING_KEYS.PLAYBACK_SHOW_FORCED_SUBTITLES]?.value as boolean | undefined) ?? true;
|
||||
|
||||
const effectiveJson = effectiveEntry?.effective_value ?? null;
|
||||
const effectiveSettings = useMemo(() => parseSubtitleAppearance(effectiveJson), [effectiveJson]);
|
||||
const [draftState, setDraftState] = useState<{
|
||||
key: string;
|
||||
settings: SubtitleAppearance;
|
||||
@@ -162,43 +173,38 @@ export default function SubtitleAppearanceSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
setDeviceSetting.mutate(
|
||||
{ key: SETTINGS_KEY, value: JSON.stringify(settings) },
|
||||
{
|
||||
onSuccess: () => toast.success("Subtitle appearance saved"),
|
||||
onError: () => toast.error("Failed to save subtitle appearance"),
|
||||
},
|
||||
);
|
||||
async function handleSave() {
|
||||
try {
|
||||
await saveAppearance(settings);
|
||||
toast.success("Subtitle appearance saved");
|
||||
} catch {
|
||||
toast.error("Failed to save subtitle appearance");
|
||||
}
|
||||
}
|
||||
|
||||
function handleUseFallback() {
|
||||
deleteDeviceSetting.mutate(
|
||||
{ key: SETTINGS_KEY },
|
||||
{
|
||||
onSuccess: () => toast.success("Subtitle appearance reset"),
|
||||
onError: () => toast.error("Failed to reset subtitle appearance"),
|
||||
},
|
||||
);
|
||||
async function handleUseFallback() {
|
||||
try {
|
||||
await resetAppearance();
|
||||
toast.success("Subtitle appearance reset");
|
||||
} catch {
|
||||
toast.error("Failed to reset subtitle appearance");
|
||||
}
|
||||
}
|
||||
|
||||
function saveProfileField(body: {
|
||||
subtitle_language?: string;
|
||||
subtitle_mode?: string;
|
||||
show_forced_subtitles?: boolean;
|
||||
}) {
|
||||
if (!profile) return;
|
||||
updateMutation.mutate(
|
||||
{ id: profile.id, body },
|
||||
{
|
||||
onSuccess: (updatedProfile) => selectProfile(updatedProfile, currentProfileToken),
|
||||
onError: () => toast.error("Failed to save subtitle setting"),
|
||||
},
|
||||
);
|
||||
/**
|
||||
* Subtitle behavior writes at profile scope: it is the household member's
|
||||
* choice, not the screen's. A device override would otherwise keep shadowing
|
||||
* the write and snap the control back, so the shared writer clears one when
|
||||
* the resolved value came from this device. A library or series override
|
||||
* still ranks above the profile row, but those are edited where the content
|
||||
* is and stay deliberately untouched here.
|
||||
*/
|
||||
function saveBehavior(key: SettingKey, value: unknown) {
|
||||
saveProfileDefault(key, value).catch(() => toast.error("Failed to save subtitle setting"));
|
||||
}
|
||||
|
||||
const hasUnsavedChanges = JSON.stringify(settings) !== JSON.stringify(effectiveSettings);
|
||||
const hasDeviceOverride = effectiveEntry?.has_device_override ?? false;
|
||||
const behaviorPending = behaviorSaving;
|
||||
const usesTextOutline = settings.textOutline || settings.backgroundStyle === "outline";
|
||||
const isBoxStyle = settings.backgroundStyle === "box";
|
||||
const { containerStyle, cueStyle } = computeSubtitleStyles(settings);
|
||||
@@ -224,22 +230,27 @@ export default function SubtitleAppearanceSettings() {
|
||||
>
|
||||
{({ id, descriptionId }) => (
|
||||
<Select
|
||||
value={profile?.subtitle_language || "none"}
|
||||
value={subtitleLanguage || "none"}
|
||||
onValueChange={(value) =>
|
||||
saveProfileField({ subtitle_language: value === "none" ? "" : value })
|
||||
// The contract spells "no preference" as null, not the empty
|
||||
// string the legacy profile column used.
|
||||
saveBehavior(
|
||||
SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE,
|
||||
value === "none" ? null : value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
aria-describedby={descriptionId}
|
||||
className="w-full sm:w-[220px]"
|
||||
disabled={updateMutation.isPending}
|
||||
disabled={behaviorPending}
|
||||
>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
{LANGUAGE_OPTIONS.filter((language) => language.value).map((language) => (
|
||||
{NAMED_LANGUAGE_OPTIONS.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
@@ -252,14 +263,14 @@ export default function SubtitleAppearanceSettings() {
|
||||
<SettingRow label="Subtitle behavior" description="Decide when subtitles should appear.">
|
||||
{({ id, descriptionId }) => (
|
||||
<Select
|
||||
value={profile?.subtitle_mode || "auto"}
|
||||
onValueChange={(value) => saveProfileField({ subtitle_mode: value })}
|
||||
value={subtitleMode}
|
||||
onValueChange={(value) => saveBehavior(SETTING_KEYS.PLAYBACK_SUBTITLE_MODE, value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
aria-describedby={descriptionId}
|
||||
className="w-full sm:w-[220px]"
|
||||
disabled={updateMutation.isPending}
|
||||
disabled={behaviorPending}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -282,9 +293,11 @@ export default function SubtitleAppearanceSettings() {
|
||||
<Switch
|
||||
id={id}
|
||||
aria-describedby={descriptionId}
|
||||
checked={profile?.show_forced_subtitles ?? true}
|
||||
disabled={updateMutation.isPending}
|
||||
onCheckedChange={(checked) => saveProfileField({ show_forced_subtitles: checked })}
|
||||
checked={showForcedSubtitles}
|
||||
disabled={behaviorPending}
|
||||
onCheckedChange={(checked) =>
|
||||
saveBehavior(SETTING_KEYS.PLAYBACK_SHOW_FORCED_SUBTITLES, checked)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SettingRow>
|
||||
@@ -317,22 +330,18 @@ export default function SubtitleAppearanceSettings() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={handleSave} disabled={!hasUnsavedChanges || setDeviceSetting.isPending}>
|
||||
<Button onClick={handleSave} disabled={!hasUnsavedChanges || isSaving}>
|
||||
Save Appearance
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={discardLocalChanges}
|
||||
disabled={!hasUnsavedChanges || setDeviceSetting.isPending}
|
||||
disabled={!hasUnsavedChanges || isSaving}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
{hasDeviceOverride ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleUseFallback}
|
||||
disabled={deleteDeviceSetting.isPending}
|
||||
>
|
||||
<Button variant="ghost" onClick={handleUseFallback} disabled={isResetting}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Reset Appearance
|
||||
</Button>
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
import type { LibraryPlaybackPreference } from "@/api/types";
|
||||
import type { UpdateLibraryPlaybackPreferenceRequest } from "@/hooks/queries/libraryPlaybackPreferences";
|
||||
import { getLanguageName, LANGUAGES } from "@/player/utils/languageNames";
|
||||
import type { EffectiveSettingsMap, SettingIdentity } from "@/hooks/queries/settingValues";
|
||||
import { SETTING_KEYS, type SettingKey } from "@/lib/settingsContract";
|
||||
import { optionsFor } from "@/lib/settingsDisplay";
|
||||
import { SETTING_DEFINITIONS } from "@/lib/settingsContract";
|
||||
import { getLanguageName } from "@/player/utils/languageNames";
|
||||
|
||||
export const INHERIT_VALUE = "inherit";
|
||||
export const NONE_VALUE = "none";
|
||||
export const ORIGINAL_LANGUAGE_VALUE = "original";
|
||||
export const ORIGINAL_LANGUAGE_LABEL = "Original Language";
|
||||
export const DEFAULT_SUBTITLE_MODE = "auto";
|
||||
export const DEFAULT_SHOW_FORCED_SUBTITLES = true;
|
||||
|
||||
/**
|
||||
* Language choices for per-library playback overrides, re-exported from the
|
||||
* canonical language list so every supported language stays selectable.
|
||||
*/
|
||||
export const LANGUAGE_OPTIONS = LANGUAGES;
|
||||
/** Behavior modes, from the contract rather than a parallel literal list. */
|
||||
export const SUBTITLE_MODE_OPTIONS = optionsFor(
|
||||
SETTING_DEFINITIONS[SETTING_KEYS.PLAYBACK_SUBTITLE_MODE],
|
||||
);
|
||||
|
||||
export const SUBTITLE_MODE_OPTIONS = [
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "always", label: "Always" },
|
||||
{ value: "off", label: "Off" },
|
||||
] as const;
|
||||
/**
|
||||
* The four playback preferences a library can override. All four are declared
|
||||
* at profile_library in the manifest, resolving above the device and profile
|
||||
* rows and below a per-series choice.
|
||||
*/
|
||||
export const LIBRARY_PLAYBACK_KEYS: SettingKey[] = [
|
||||
SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE,
|
||||
SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE,
|
||||
SETTING_KEYS.PLAYBACK_SUBTITLE_MODE,
|
||||
SETTING_KEYS.PLAYBACK_SHOW_FORCED_SUBTITLES,
|
||||
];
|
||||
|
||||
/** The identity a per-library write addresses. */
|
||||
export function libraryScope(libraryId: number): SettingIdentity {
|
||||
return { scope: "profile_library", libraryId };
|
||||
}
|
||||
|
||||
export type LibraryPlaybackEditorState = {
|
||||
audioLanguage: string;
|
||||
@@ -28,14 +38,17 @@ export type LibraryPlaybackEditorState = {
|
||||
showForcedSubtitles: string;
|
||||
};
|
||||
|
||||
/** One canonical write implied by an editor change: a set, or a clear. */
|
||||
export interface LibraryPlaybackMutation {
|
||||
key: SettingKey;
|
||||
/** Absent means "clear the row at this scope so the library inherits again". */
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export function getLanguageLabel(code: string) {
|
||||
if (code === ORIGINAL_LANGUAGE_VALUE) {
|
||||
return ORIGINAL_LANGUAGE_LABEL;
|
||||
}
|
||||
if (!code) {
|
||||
return code;
|
||||
}
|
||||
|
||||
return getLanguageName(code);
|
||||
}
|
||||
|
||||
@@ -51,28 +64,6 @@ export function getForcedSubtitlesLabel(value: string) {
|
||||
return value === "on" ? "On" : "Off";
|
||||
}
|
||||
|
||||
export function buildLibraryPlaybackSummary(preference: LibraryPlaybackPreference | null) {
|
||||
if (!preference) {
|
||||
return "Uses profile defaults";
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
if (preference.audio_language !== undefined) {
|
||||
parts.push(`Audio: ${getLanguageLabel(preference.audio_language)}`);
|
||||
}
|
||||
if (preference.subtitle_language !== undefined) {
|
||||
parts.push(`Subtitles: ${getSubtitleLanguageLabel(preference.subtitle_language)}`);
|
||||
}
|
||||
if (preference.subtitle_mode !== undefined) {
|
||||
parts.push(`Behavior: ${getSubtitleModeLabel(preference.subtitle_mode)}`);
|
||||
}
|
||||
if (preference.show_forced_subtitles !== undefined) {
|
||||
parts.push(`Forced subtitles: ${preference.show_forced_subtitles ? "On" : "Off"}`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(" • ") : "Uses profile defaults";
|
||||
}
|
||||
|
||||
export function buildLibraryPlaybackSummaryFromState(state: LibraryPlaybackEditorState) {
|
||||
const parts: string[] = [];
|
||||
|
||||
@@ -94,33 +85,33 @@ export function buildLibraryPlaybackSummaryFromState(state: LibraryPlaybackEdito
|
||||
return parts.length > 0 ? parts.join(" • ") : "Uses profile defaults";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the editor state for one library out of a resolved settings map.
|
||||
*
|
||||
* The distinction the editor needs — "this library overrides the value" versus
|
||||
* "it inherits" — is the resolved source, not the value: a library row holding
|
||||
* the same value as the profile is still an override, and a library row holding
|
||||
* null is an explicit "no subtitles" rather than an absent choice. Reading
|
||||
* source rather than comparing values is what keeps those three cases apart.
|
||||
*/
|
||||
export function createLibraryPlaybackEditorState(
|
||||
preference: LibraryPlaybackPreference | null,
|
||||
effective: EffectiveSettingsMap | undefined,
|
||||
): LibraryPlaybackEditorState {
|
||||
if (!preference) {
|
||||
return {
|
||||
audioLanguage: INHERIT_VALUE,
|
||||
subtitleLanguage: INHERIT_VALUE,
|
||||
subtitleMode: INHERIT_VALUE,
|
||||
showForcedSubtitles: INHERIT_VALUE,
|
||||
};
|
||||
}
|
||||
const overridden = (key: SettingKey) =>
|
||||
effective?.[key]?.source === "profile_library" ? effective[key] : undefined;
|
||||
|
||||
const audio = overridden(SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE);
|
||||
const subtitleLanguage = overridden(SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE);
|
||||
const subtitleMode = overridden(SETTING_KEYS.PLAYBACK_SUBTITLE_MODE);
|
||||
const forced = overridden(SETTING_KEYS.PLAYBACK_SHOW_FORCED_SUBTITLES);
|
||||
|
||||
return {
|
||||
audioLanguage: preference.audio_language ?? INHERIT_VALUE,
|
||||
audioLanguage: (audio?.value as string | null) ?? (audio ? NONE_VALUE : INHERIT_VALUE),
|
||||
subtitleLanguage:
|
||||
preference.subtitle_language === undefined
|
||||
? INHERIT_VALUE
|
||||
: preference.subtitle_language === ""
|
||||
? NONE_VALUE
|
||||
: preference.subtitle_language,
|
||||
subtitleMode: preference.subtitle_mode ?? INHERIT_VALUE,
|
||||
(subtitleLanguage?.value as string | null) ?? (subtitleLanguage ? NONE_VALUE : INHERIT_VALUE),
|
||||
subtitleMode: (subtitleMode?.value as string | undefined) ?? INHERIT_VALUE,
|
||||
showForcedSubtitles:
|
||||
preference.show_forced_subtitles === undefined
|
||||
? INHERIT_VALUE
|
||||
: preference.show_forced_subtitles
|
||||
? "on"
|
||||
: "off",
|
||||
forced === undefined ? INHERIT_VALUE : (forced.value as boolean) ? "on" : "off",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,25 +124,37 @@ export function hasLibraryPlaybackOverride(state: LibraryPlaybackEditorState) {
|
||||
);
|
||||
}
|
||||
|
||||
export function buildLibraryPlaybackRequest(
|
||||
/**
|
||||
* The canonical writes one editor state implies, one per key.
|
||||
*
|
||||
* "Inherit" is the absence of a row, so it plans a clear rather than a stored
|
||||
* sentinel — which is what lets a later change to the profile default reach a
|
||||
* library the user never overrode.
|
||||
*/
|
||||
export function buildLibraryPlaybackMutations(
|
||||
state: LibraryPlaybackEditorState,
|
||||
): UpdateLibraryPlaybackPreferenceRequest {
|
||||
const request: UpdateLibraryPlaybackPreferenceRequest = {};
|
||||
): LibraryPlaybackMutation[] {
|
||||
return [
|
||||
planLanguage(SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE, state.audioLanguage),
|
||||
planLanguage(SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE, state.subtitleLanguage),
|
||||
state.subtitleMode === INHERIT_VALUE
|
||||
? { key: SETTING_KEYS.PLAYBACK_SUBTITLE_MODE }
|
||||
: { key: SETTING_KEYS.PLAYBACK_SUBTITLE_MODE, value: state.subtitleMode },
|
||||
state.showForcedSubtitles === INHERIT_VALUE
|
||||
? { key: SETTING_KEYS.PLAYBACK_SHOW_FORCED_SUBTITLES }
|
||||
: {
|
||||
key: SETTING_KEYS.PLAYBACK_SHOW_FORCED_SUBTITLES,
|
||||
value: state.showForcedSubtitles === "on",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (state.audioLanguage !== INHERIT_VALUE) {
|
||||
request.audio_language = state.audioLanguage;
|
||||
}
|
||||
if (state.subtitleLanguage !== INHERIT_VALUE) {
|
||||
request.subtitle_language = state.subtitleLanguage === NONE_VALUE ? "" : state.subtitleLanguage;
|
||||
}
|
||||
if (state.subtitleMode !== INHERIT_VALUE) {
|
||||
request.subtitle_mode = state.subtitleMode;
|
||||
}
|
||||
if (state.showForcedSubtitles !== INHERIT_VALUE) {
|
||||
request.show_forced_subtitles = state.showForcedSubtitles === "on";
|
||||
}
|
||||
|
||||
return request;
|
||||
// A language field has three states, and the contract spells each differently:
|
||||
// inherit is no row, "None" is a stored null, and a code is a stored tag.
|
||||
function planLanguage(key: SettingKey, value: string): LibraryPlaybackMutation {
|
||||
if (value === INHERIT_VALUE) return { key };
|
||||
if (value === NONE_VALUE) return { key, value: null };
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
export function buildInheritedLanguageLabel(_value: string) {
|
||||
@@ -170,15 +173,15 @@ export function buildInheritedShowForcedSubtitlesLabel(_value: boolean | undefin
|
||||
return "Profile default";
|
||||
}
|
||||
|
||||
export function getProfileDefaultLanguageHint(value: string) {
|
||||
return `Default: ${getLanguageLabel(value)}`;
|
||||
export function getProfileDefaultLanguageHint(value: string | null | undefined) {
|
||||
return `Default: ${value ? getLanguageLabel(value) : "No preference"}`;
|
||||
}
|
||||
|
||||
export function getProfileDefaultSubtitleLanguageHint(value: string) {
|
||||
export function getProfileDefaultSubtitleLanguageHint(value: string | null | undefined) {
|
||||
return `Default: ${value ? getLanguageLabel(value) : "None"}`;
|
||||
}
|
||||
|
||||
export function getProfileDefaultSubtitleModeHint(value: string) {
|
||||
export function getProfileDefaultSubtitleModeHint(value: string | null | undefined) {
|
||||
return `Default: ${getSubtitleModeLabel(value || DEFAULT_SUBTITLE_MODE)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -161,18 +161,34 @@ type DerivedWatchPageProps = Omit<
|
||||
| "onPlaybackTransportReady"
|
||||
>;
|
||||
|
||||
/**
|
||||
* The pre-cutover cap, used when no canonical value was passed. The settings
|
||||
* screen no longer writes this column, so it only carries a choice made before
|
||||
* the contract landed.
|
||||
*/
|
||||
function currentProfileQualityFallback(profile?: Profile | null): string | null {
|
||||
return profile?.quality_preference || null;
|
||||
}
|
||||
|
||||
export function buildWatchPageProps({
|
||||
request,
|
||||
item,
|
||||
currentProfile,
|
||||
seriesEpisodes,
|
||||
qualityPreference = currentProfileQualityFallback(currentProfile),
|
||||
}: {
|
||||
request: WatchRouteRequest;
|
||||
item: WatchDetail;
|
||||
currentProfile?: Profile | null;
|
||||
seriesEpisodes?: EpisodeRef[];
|
||||
/**
|
||||
* The resolution cap from the settings contract, via useQualityPreference.
|
||||
* Passed in rather than read here because this is a pure builder: the
|
||||
* canonical value is where the quality picker writes, and the profile column
|
||||
* it falls back to is no longer updated by that picker.
|
||||
*/
|
||||
qualityPreference?: string | null;
|
||||
}): DerivedWatchPageProps {
|
||||
const qualityPreference = currentProfile?.quality_preference || null;
|
||||
const basePreferredSubtitleLanguage =
|
||||
item.effective_subtitle_language !== undefined
|
||||
? item.effective_subtitle_language
|
||||
|
||||
@@ -19,7 +19,8 @@ import { Slider } from "@/components/ui/slider";
|
||||
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
|
||||
import { fetchCatalogItemDetail } from "@/hooks/queries/catalogRead";
|
||||
import { useContinueWatching } from "@/hooks/queries/progress";
|
||||
import { useEffectiveSettings } from "@/hooks/queries/settings";
|
||||
import { useEffectiveSettings } from "@/hooks/queries/settingValues";
|
||||
import { SETTING_KEYS, type SettingKey } from "@/lib/settingsContract";
|
||||
import { useWatchDetail } from "@/hooks/queries/items";
|
||||
import { catalogKeys } from "@/hooks/queries/keys";
|
||||
import { applyPlaybackProgressToCache } from "@/hooks/queries/playbackProgressCache";
|
||||
@@ -51,18 +52,6 @@ import {
|
||||
} from "@/pages/watchRouteHelpers";
|
||||
import { canEditMarkers as canEditMarkersForUser } from "@/lib/permissions";
|
||||
|
||||
const AUTO_SKIP_INTRO_KEY = "playback.auto_skip_intro";
|
||||
|
||||
function resolveDeviceOverrideBool(
|
||||
setting: { effective_value: string; has_device_override: boolean } | undefined,
|
||||
profileDefault: boolean,
|
||||
) {
|
||||
if (setting?.has_device_override) {
|
||||
return setting.effective_value === "true";
|
||||
}
|
||||
return profileDefault;
|
||||
}
|
||||
|
||||
function normalizeWatchPlaybackRequest(
|
||||
input: WatchPlaybackStartInput | WatchRouteRequest,
|
||||
): WatchRouteRequest {
|
||||
@@ -400,9 +389,25 @@ export function WatchPlaybackHost() {
|
||||
const { user } = useAuth();
|
||||
const { profile: currentProfile } = useCurrentProfile();
|
||||
const canEditMarkers = canEditMarkersForUser(user, currentProfile);
|
||||
const { data: effectivePlaybackSettings = {} } = useEffectiveSettings(currentProfile?.id, [
|
||||
AUTO_SKIP_INTRO_KEY,
|
||||
]);
|
||||
// Resolved through the contract, so a device override winning over the
|
||||
// profile's own choice is the manifest's resolution order rather than a
|
||||
// precedence rule spelled out here.
|
||||
//
|
||||
// All three skip preferences are read, not just intros. The manifest declares
|
||||
// every one of them at profile_device, but only the intro override was ever
|
||||
// consulted, so a recap or preview override an admin (or the user) set on a
|
||||
// device silently did nothing. One batched read costs no more than the single
|
||||
// key did and makes all three behave as the contract says they do.
|
||||
const { data: effectivePlaybackSettings } = useEffectiveSettings({
|
||||
keys: [
|
||||
SETTING_KEYS.PLAYBACK_AUTO_SKIP_INTRO,
|
||||
SETTING_KEYS.PLAYBACK_AUTO_SKIP_RECAP,
|
||||
SETTING_KEYS.PLAYBACK_AUTO_PLAY_NEXT_PREVIEW,
|
||||
// The resolution cap, which the quality picker writes canonically and
|
||||
// no longer mirrors into the profile column playback used to read.
|
||||
SETTING_KEYS.PLAYBACK_PREFERRED_QUALITY,
|
||||
],
|
||||
});
|
||||
const request = state.request;
|
||||
const isForegroundMode = request != null && state.mode === "foreground";
|
||||
const { data: item, error } = useWatchDetail(
|
||||
@@ -843,15 +848,39 @@ export function WatchPlaybackHost() {
|
||||
);
|
||||
}
|
||||
|
||||
const canonicalQuality = effectivePlaybackSettings?.[SETTING_KEYS.PLAYBACK_PREFERRED_QUALITY]
|
||||
?.value as string | undefined;
|
||||
const watchPageProps = buildWatchPageProps({
|
||||
request: activeRequest,
|
||||
item: activeItem,
|
||||
currentProfile,
|
||||
seriesEpisodes,
|
||||
// "original" means no cap, which every consumer already spells "auto".
|
||||
// Undefined until the read resolves, which leaves the profile-column
|
||||
// fallback in place rather than blocking playback on a settings fetch.
|
||||
qualityPreference:
|
||||
canonicalQuality === undefined
|
||||
? undefined
|
||||
: canonicalQuality === "original"
|
||||
? "auto"
|
||||
: canonicalQuality,
|
||||
});
|
||||
const autoSkipIntro = resolveDeviceOverrideBool(
|
||||
effectivePlaybackSettings[AUTO_SKIP_INTRO_KEY],
|
||||
watchPageProps.autoSkipIntro ?? false,
|
||||
// The resolved answer already folds in the profile layer, so the props built
|
||||
// from the profile record are only the pre-resolution fallback.
|
||||
const resolvedBool = (key: SettingKey, fallback: boolean | undefined) =>
|
||||
(effectivePlaybackSettings?.[key]?.value as boolean | undefined) ?? fallback ?? false;
|
||||
|
||||
const autoSkipIntro = resolvedBool(
|
||||
SETTING_KEYS.PLAYBACK_AUTO_SKIP_INTRO,
|
||||
watchPageProps.autoSkipIntro,
|
||||
);
|
||||
const autoSkipRecap = resolvedBool(
|
||||
SETTING_KEYS.PLAYBACK_AUTO_SKIP_RECAP,
|
||||
watchPageProps.autoSkipRecap,
|
||||
);
|
||||
const autoPlayNextPreview = resolvedBool(
|
||||
SETTING_KEYS.PLAYBACK_AUTO_PLAY_NEXT_PREVIEW,
|
||||
watchPageProps.autoPlayNextPreview,
|
||||
);
|
||||
|
||||
const playerDisplayMode =
|
||||
@@ -863,6 +892,8 @@ export function WatchPlaybackHost() {
|
||||
<WatchPage
|
||||
{...watchPageProps}
|
||||
autoSkipIntro={autoSkipIntro}
|
||||
autoSkipRecap={autoSkipRecap}
|
||||
autoPlayNextPreview={autoPlayNextPreview}
|
||||
canEditMarkers={canEditMarkers}
|
||||
playbackRequestKey={requestKeyValue}
|
||||
onNavigateEpisode={handleNavigateEpisode}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SETTING_KEYS } from "@/lib/settingsContract";
|
||||
import type { EffectiveSetting, EffectiveSettingsMap } from "@/hooks/queries/settingValues";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useEffectiveSettings: vi.fn(),
|
||||
useSetSettingValue: vi.fn(),
|
||||
useClearSettingValue: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/queries/settingValues", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/hooks/queries/settingValues")>(
|
||||
"@/hooks/queries/settingValues",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useEffectiveSettings: (...args: unknown[]) => mocks.useEffectiveSettings(...args),
|
||||
useSetSettingValue: (...args: unknown[]) => mocks.useSetSettingValue(...args),
|
||||
useClearSettingValue: (...args: unknown[]) => mocks.useClearSettingValue(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/useDateTimeFormat", () => ({ useDateTimeFormat: () => undefined }));
|
||||
vi.mock("@/hooks/useCarouselEmbla", () => ({
|
||||
useCarouselEmbla: () => ({
|
||||
emblaRef: () => {},
|
||||
canScrollPrev: false,
|
||||
canScrollNext: false,
|
||||
scrollPrev: () => {},
|
||||
scrollNext: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { PlayingNextScreen } from "./PlayingNextScreen";
|
||||
|
||||
const KEY = SETTING_KEYS.PLAYBACK_AUTO_PLAY_NEXT;
|
||||
|
||||
function resolved(value: unknown, source: EffectiveSetting["source"]): EffectiveSettingsMap {
|
||||
return { [KEY]: { key: KEY, value, source } };
|
||||
}
|
||||
|
||||
function renderScreen() {
|
||||
render(
|
||||
<PlayingNextScreen
|
||||
seriesId="series-1"
|
||||
seriesTitle="Test Show"
|
||||
nextEpisode={{
|
||||
contentId: "ep-2",
|
||||
title: "Episode Two",
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
runtime: 1800,
|
||||
}}
|
||||
continueWatchingItems={[]}
|
||||
videoEnded={false}
|
||||
onPlayItem={() => {}}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("PlayingNextScreen auto-play toggle", () => {
|
||||
let mutateAsync: ReturnType<typeof vi.fn>;
|
||||
let clearMutateAsync: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.useEffectiveSettings.mockReset();
|
||||
mocks.useSetSettingValue.mockReset();
|
||||
mocks.useClearSettingValue.mockReset();
|
||||
mutateAsync = vi.fn().mockResolvedValue(undefined);
|
||||
clearMutateAsync = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mocks.useEffectiveSettings.mockReturnValue({ data: {}, isLoading: false });
|
||||
mocks.useSetSettingValue.mockReturnValue({ isPending: false, mutate: vi.fn(), mutateAsync });
|
||||
mocks.useClearSettingValue.mockReturnValue({
|
||||
isPending: false,
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: clearMutateAsync,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("writes the profile row, the same scope the settings screen edits", async () => {
|
||||
// The two surfaces render the resolved value, and the contract resolves
|
||||
// profile_device above profile. Writing a device row here would leave the
|
||||
// Settings switch inert: it would save a profile value this row shadows.
|
||||
renderScreen();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Auto-play is on" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
key: KEY,
|
||||
value: false,
|
||||
identity: { scope: "profile" },
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
mutateAsync.mock.calls.some(
|
||||
([args]) => (args as { identity: { scope: string } }).identity.scope === "profile_device",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("clears a device override rather than writing another one", async () => {
|
||||
mocks.useEffectiveSettings.mockReturnValue({
|
||||
data: resolved(false, "profile_device"),
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderScreen();
|
||||
expect(screen.getByRole("button", { name: "Auto-play is off" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Auto-play is off" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
key: KEY,
|
||||
value: true,
|
||||
identity: { scope: "profile" },
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(clearMutateAsync).toHaveBeenCalledWith({
|
||||
key: KEY,
|
||||
identity: { scope: "profile_device" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,9 @@ import { motion } from "framer-motion";
|
||||
import { ChevronLeft, ChevronRight, Play, X } from "lucide-react";
|
||||
import type { EpisodeRef } from "../types";
|
||||
import type { ContinueWatchingItem } from "@/hooks/queries/progress";
|
||||
import { useEffectiveSettings, useSetDeviceSetting } from "@/hooks/queries/settings";
|
||||
import { useAutoPlayNextSetting } from "@/hooks/queries/autoPlayNext";
|
||||
import { decodeThumbhash } from "@/lib/thumbhash";
|
||||
import { useCarouselEmbla } from "@/hooks/useCarouselEmbla";
|
||||
import { useCurrentProfile } from "@/hooks/useCurrentProfile";
|
||||
import { preferredDateLocale } from "@/lib/datetime";
|
||||
import { useDateTimeFormat } from "@/hooks/useDateTimeFormat";
|
||||
|
||||
@@ -22,7 +21,6 @@ interface PlayingNextScreenProps {
|
||||
}
|
||||
|
||||
const COUNTDOWN_SECONDS = 10;
|
||||
const AUTOPLAY_SETTING_KEY = "playback.auto_play_next";
|
||||
|
||||
export function PlayingNextScreen({
|
||||
seriesId,
|
||||
@@ -36,15 +34,13 @@ export function PlayingNextScreen({
|
||||
}: PlayingNextScreenProps) {
|
||||
useDateTimeFormat();
|
||||
// -- Auto-play setting --
|
||||
const { profile } = useCurrentProfile();
|
||||
const { data: effectiveSettings } = useEffectiveSettings(profile?.id, [AUTOPLAY_SETTING_KEY]);
|
||||
const setDeviceSetting = useSetDeviceSetting();
|
||||
const autoplay = effectiveSettings?.[AUTOPLAY_SETTING_KEY]?.effective_value !== "false";
|
||||
// Shared with Settings → Playback: both surfaces edit the same profile row,
|
||||
// so a choice made here is the one that screen shows and vice versa.
|
||||
const { enabled: autoplay, setEnabled: setAutoplay } = useAutoPlayNextSetting();
|
||||
|
||||
const toggleAutoplay = useCallback(() => {
|
||||
const newValue = autoplay ? "false" : "true";
|
||||
setDeviceSetting.mutate({ key: AUTOPLAY_SETTING_KEY, value: newValue });
|
||||
}, [autoplay, setDeviceSetting]);
|
||||
void setAutoplay(!autoplay);
|
||||
}, [autoplay, setAutoplay]);
|
||||
|
||||
// -- Countdown (only starts after video has ended) --
|
||||
const [secondsRemaining, setSecondsRemaining] = useState(COUNTDOWN_SECONDS);
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
useDeleteDeviceSetting,
|
||||
useEffectiveSettings,
|
||||
useSetDeviceSetting,
|
||||
} from "@/hooks/queries/settings";
|
||||
import { useCurrentProfile } from "@/hooks/useCurrentProfile";
|
||||
import { parseSubtitleAppearance, type SubtitleAppearance } from "@/lib/subtitleAppearance";
|
||||
import { useCallback } from "react";
|
||||
import { useSubtitleAppearanceSetting } from "@/hooks/queries/subtitleAppearance";
|
||||
import type { SubtitleAppearance } from "@/lib/subtitleAppearance";
|
||||
import { SubtitleAppearancePanelView } from "@/components/settings/SubtitleAppearancePanelView";
|
||||
|
||||
const SETTINGS_KEY = "subtitle_appearance";
|
||||
|
||||
interface SubtitleAppearancePanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -18,41 +11,35 @@ interface SubtitleAppearancePanelProps {
|
||||
/**
|
||||
* In-player subtitle styling panel modelled on Plex's "Playback Settings"
|
||||
* sheet — lets a viewer tune font size, colour, position, background, and
|
||||
* outline without leaving the player. Every change writes to the current
|
||||
* profile + device subtitle appearance override so the rendered subtitles
|
||||
* update live via the shared effective-settings cache.
|
||||
* outline without leaving the player. Every change writes
|
||||
* playback.subtitle_appearance at profile_device scope, so the rendered
|
||||
* subtitles update live through the shared effective-settings cache and the
|
||||
* choice stays on this device.
|
||||
*
|
||||
* The visual chrome lives in {@link SubtitleAppearancePanelView}; this
|
||||
* wrapper is responsible solely for binding the panel to the current
|
||||
* profile/device and the device-setting mutations.
|
||||
* wrapper only binds the panel to the canonical setting.
|
||||
*/
|
||||
export function SubtitleAppearancePanel({ open, onClose }: SubtitleAppearancePanelProps) {
|
||||
const { profile } = useCurrentProfile();
|
||||
const { data } = useEffectiveSettings(profile?.id, [SETTINGS_KEY]);
|
||||
const { mutate: save } = useSetDeviceSetting();
|
||||
const { mutate: reset } = useDeleteDeviceSetting();
|
||||
|
||||
const settings = useMemo(
|
||||
() => parseSubtitleAppearance(data?.[SETTINGS_KEY]?.effective_value ?? null),
|
||||
[data],
|
||||
);
|
||||
const { appearance, save, reset } = useSubtitleAppearanceSetting();
|
||||
|
||||
const update = useCallback(
|
||||
(patch: Partial<SubtitleAppearance>) => {
|
||||
const next = { ...settings, ...patch };
|
||||
save({ key: SETTINGS_KEY, value: JSON.stringify(next) });
|
||||
// Best effort: a failed tweak in the player leaves the previous value in
|
||||
// place and the next change retries, which beats interrupting playback
|
||||
// with an error dialog.
|
||||
void save({ ...appearance, ...patch }).catch(() => undefined);
|
||||
},
|
||||
[settings, save],
|
||||
[appearance, save],
|
||||
);
|
||||
|
||||
const resetDefaults = useCallback(() => {
|
||||
reset({ key: SETTINGS_KEY });
|
||||
void reset().catch(() => undefined);
|
||||
}, [reset]);
|
||||
|
||||
return (
|
||||
<SubtitleAppearancePanelView
|
||||
open={open}
|
||||
value={settings}
|
||||
value={appearance}
|
||||
onChange={update}
|
||||
onClose={onClose}
|
||||
onReset={resetDefaults}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { PlayerFileVersion, PlayerSubtitleTrackSignature, WatchPageProps } from "../types";
|
||||
import type { PlayerFileVersion, WatchPageProps } from "../types";
|
||||
import type { PlaybackRealtimeEventEnvelope } from "../realtime-protocol";
|
||||
import { usePlaybackSession } from "../hooks/usePlaybackSession";
|
||||
import { usePlayerConfig } from "../context/PlayerConfigContext";
|
||||
import { playerFetch } from "../player-fetch";
|
||||
import { resolvePlayableSubtitles } from "../utils/playableSubtitles";
|
||||
import { derivePersistedSubtitleMode } from "../utils/subtitleMode";
|
||||
import { patchVersionMarkers, resolveActiveVersionMarkers } from "../utils/watchPageMarkers";
|
||||
import { buildSubtitleChoiceRequests } from "../utils/subtitleChoicePersistence";
|
||||
import { VideoPlayer } from "./VideoPlayer";
|
||||
import { fetchWatchDetail } from "@/hooks/queries/items";
|
||||
import { itemKeys } from "@/hooks/queries/keys";
|
||||
@@ -167,39 +167,31 @@ export function WatchPage({
|
||||
[session],
|
||||
);
|
||||
|
||||
/**
|
||||
* Persists an in-player subtitle choice for the whole series.
|
||||
*
|
||||
* buildSubtitleChoiceRequests decides what a pick is worth storing and
|
||||
* where; this only issues the requests. They are independent on purpose: a
|
||||
* failed settings write must not cost the user the track they picked, and a
|
||||
* failed track write must not cost them the language, so each is best effort
|
||||
* on its own rather than one composite request that half-applies.
|
||||
*/
|
||||
const handleSubtitleChanged = useCallback(
|
||||
(index: number | null) => {
|
||||
const seriesId = seriesContext?.seriesId ?? contentId;
|
||||
if (!seriesId) return;
|
||||
|
||||
const track = index !== null ? playableSubtitles.find((s) => s.index === index) : null;
|
||||
// Never persist an index we can't resolve to a real track (e.g. the
|
||||
// in-progress AI live track's sentinel index): it would store a
|
||||
// nonexistent track with empty language and clobber the saved preference.
|
||||
if (index !== null && !track) return;
|
||||
const trackSignature: PlayerSubtitleTrackSignature | null = track
|
||||
? {
|
||||
source: track.source,
|
||||
language: track.language,
|
||||
codec: track.codec,
|
||||
label: track.label,
|
||||
forced: track.forced,
|
||||
hearing_impaired: track.hearing_impaired,
|
||||
}
|
||||
: null;
|
||||
|
||||
playerFetch(config, `/subtitle-prefs/${seriesId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
subtitle_language: track?.language ?? "",
|
||||
subtitle_track_index: index ?? -1,
|
||||
subtitle_mode: derivePersistedSubtitleMode(index),
|
||||
track_signature: trackSignature,
|
||||
show_forced_subtitles: showForcedSubtitles,
|
||||
}),
|
||||
}).catch(() => {
|
||||
// Best effort.
|
||||
const requests = buildSubtitleChoiceRequests({
|
||||
seriesId: seriesContext?.seriesId ?? contentId,
|
||||
index,
|
||||
tracks: playableSubtitles,
|
||||
showForcedSubtitles,
|
||||
});
|
||||
for (const request of requests) {
|
||||
void playerFetch(config, request.path, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(request.body),
|
||||
}).catch(() => {
|
||||
// Best effort.
|
||||
});
|
||||
}
|
||||
},
|
||||
[config, seriesContext, contentId, playableSubtitles, showForcedSubtitles],
|
||||
);
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import { useMemo } from "react";
|
||||
import { useCurrentProfile } from "@/hooks/useCurrentProfile";
|
||||
import { useEffectiveSettings } from "@/hooks/queries/settings";
|
||||
import { parseSubtitleAppearance, computeSubtitleStyles } from "@/lib/subtitleAppearance";
|
||||
import { useSubtitleAppearanceSetting } from "@/hooks/queries/subtitleAppearance";
|
||||
import { computeSubtitleStyles } from "@/lib/subtitleAppearance";
|
||||
import type { SubtitleAppearance, SubtitleStyles } from "@/lib/subtitleAppearance";
|
||||
|
||||
export function useSubtitleAppearance(): SubtitleStyles & { settings: SubtitleAppearance } {
|
||||
const { profile } = useCurrentProfile();
|
||||
const { data } = useEffectiveSettings(profile?.id, ["subtitle_appearance"]);
|
||||
const { appearance } = useSubtitleAppearanceSetting();
|
||||
const styles = useMemo(() => computeSubtitleStyles(appearance), [appearance]);
|
||||
|
||||
const settings = useMemo(
|
||||
() => parseSubtitleAppearance(data?.subtitle_appearance?.effective_value ?? null),
|
||||
[data],
|
||||
);
|
||||
|
||||
const styles = useMemo(() => computeSubtitleStyles(settings), [settings]);
|
||||
|
||||
return { settings, ...styles };
|
||||
return { settings: appearance, ...styles };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SETTING_KEYS } from "@/lib/settingsContract";
|
||||
import { resolveSettingValues, type StoredSettingRow } from "@/lib/settingsResolve";
|
||||
import type { PlayerSubtitleInfo } from "../types";
|
||||
import { buildSubtitleChoiceRequests } from "./subtitleChoicePersistence";
|
||||
|
||||
const TRACKS: PlayerSubtitleInfo[] = [
|
||||
{
|
||||
index: 0,
|
||||
language: "ja",
|
||||
codec: "subrip",
|
||||
label: "Japanese",
|
||||
source: "embedded",
|
||||
forced: false,
|
||||
hearing_impaired: false,
|
||||
url: "",
|
||||
},
|
||||
];
|
||||
|
||||
function canonicalWrites(requests: ReturnType<typeof buildSubtitleChoiceRequests>) {
|
||||
return requests.filter((request) => request.path.startsWith("/settings/values/"));
|
||||
}
|
||||
|
||||
/** The key one canonical write addresses, parsed back out of its path. */
|
||||
function keyOf(path: string): string {
|
||||
return decodeURIComponent(path.slice("/settings/values/".length).split("?")[0]!);
|
||||
}
|
||||
|
||||
describe("buildSubtitleChoiceRequests", () => {
|
||||
it("writes the chosen language and mode at profile_series", () => {
|
||||
const requests = buildSubtitleChoiceRequests({
|
||||
seriesId: "series-1",
|
||||
index: 0,
|
||||
tracks: TRACKS,
|
||||
showForcedSubtitles: true,
|
||||
});
|
||||
|
||||
const canonical = canonicalWrites(requests);
|
||||
expect(canonical.map((request) => keyOf(request.path))).toEqual([
|
||||
SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE,
|
||||
SETTING_KEYS.PLAYBACK_SUBTITLE_MODE,
|
||||
]);
|
||||
for (const request of canonical) {
|
||||
expect(request.path).toContain("scope=profile_series&series_id=series-1");
|
||||
}
|
||||
expect(canonical[0]?.body).toEqual({ value: "ja" });
|
||||
expect(canonical[1]?.body).toEqual({ value: "always" });
|
||||
});
|
||||
|
||||
it("never writes show_forced_subtitles as a canonical row", () => {
|
||||
// The player has no forced-subtitle control: the prop it holds is the
|
||||
// *resolved* value, which is the contract default for anyone who never set
|
||||
// one. Persisting it at profile_series — the top of the resolution order —
|
||||
// would permanently shadow the Subtitles screen's profile-scope toggle for
|
||||
// that series, and no web surface can clear a profile_series row for it.
|
||||
for (const showForcedSubtitles of [true, false, undefined]) {
|
||||
const requests = buildSubtitleChoiceRequests({
|
||||
seriesId: "series-1",
|
||||
index: 0,
|
||||
tracks: TRACKS,
|
||||
showForcedSubtitles,
|
||||
});
|
||||
expect(canonicalWrites(requests).map((request) => keyOf(request.path))).not.toContain(
|
||||
SETTING_KEYS.PLAYBACK_SHOW_FORCED_SUBTITLES,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the forced-subtitle value on the legacy composite row", () => {
|
||||
// That row is keyed to a concrete track selection and is not part of the
|
||||
// canonical ladder, so echoing the resolved value there is harmless and
|
||||
// preserves the behavior the route has always had.
|
||||
const [legacy] = buildSubtitleChoiceRequests({
|
||||
seriesId: "series-1",
|
||||
index: 0,
|
||||
tracks: TRACKS,
|
||||
showForcedSubtitles: false,
|
||||
}).filter((request) => request.path.startsWith("/subtitle-prefs/"));
|
||||
|
||||
expect(legacy?.body).toMatchObject({
|
||||
subtitle_language: "ja",
|
||||
subtitle_track_index: 0,
|
||||
subtitle_mode: "always",
|
||||
show_forced_subtitles: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("turning subtitles off clears the language rather than storing an empty one", () => {
|
||||
const canonical = canonicalWrites(
|
||||
buildSubtitleChoiceRequests({ seriesId: "series-1", index: null, tracks: TRACKS }),
|
||||
);
|
||||
|
||||
expect(canonical[0]?.body).toEqual({ value: null });
|
||||
expect(canonical[1]?.body).toEqual({ value: "off" });
|
||||
});
|
||||
|
||||
it("persists nothing for an index that names no real track", () => {
|
||||
// The AI live track's sentinel index: storing it would clobber the saved
|
||||
// preference with a nonexistent track and an empty language.
|
||||
expect(
|
||||
buildSubtitleChoiceRequests({ seriesId: "series-1", index: 9999, tracks: TRACKS }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves the profile-scope forced-subtitle choice reachable afterwards", () => {
|
||||
// End to end through the shared resolver, which mirrors
|
||||
// internal/settingsresolve: the rows this pick stores must not shadow a
|
||||
// later profile-scope "off".
|
||||
const requests = canonicalWrites(
|
||||
buildSubtitleChoiceRequests({
|
||||
seriesId: "series-1",
|
||||
index: 0,
|
||||
tracks: TRACKS,
|
||||
showForcedSubtitles: true,
|
||||
}),
|
||||
);
|
||||
const stored: StoredSettingRow[] = requests.map((request) => ({
|
||||
key: keyOf(request.path),
|
||||
scope: "profile_series",
|
||||
profileId: "profile-1",
|
||||
seriesId: "series-1",
|
||||
value: (request.body as { value: unknown }).value,
|
||||
}));
|
||||
stored.push({
|
||||
key: SETTING_KEYS.PLAYBACK_SHOW_FORCED_SUBTITLES,
|
||||
scope: "profile",
|
||||
profileId: "profile-1",
|
||||
value: false,
|
||||
});
|
||||
|
||||
const [forced] = resolveSettingValues([SETTING_KEYS.PLAYBACK_SHOW_FORCED_SUBTITLES], stored, {
|
||||
profileId: "profile-1",
|
||||
seriesIds: ["series-1"],
|
||||
});
|
||||
expect(forced?.value).toBe(false);
|
||||
expect(forced?.source).toBe("profile");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
SERIES_SUBTITLE_SETTING_KEYS,
|
||||
seriesSubtitleSettingPath,
|
||||
seriesSubtitleSettingValues,
|
||||
} from "@/lib/seriesSubtitleSettings";
|
||||
import type { PlayerSubtitleInfo, PlayerSubtitleTrackSignature } from "../types";
|
||||
import { derivePersistedSubtitleMode } from "./subtitleMode";
|
||||
|
||||
/** One PUT the player issues to persist a subtitle choice. */
|
||||
export interface SubtitleChoiceRequest {
|
||||
path: string;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* The requests an in-player subtitle pick turns into.
|
||||
*
|
||||
* The choice splits across two stores, along the line the contract draws. The
|
||||
* language and the on/off mode are preferences — "this show, in English,
|
||||
* always on" — so they are canonical settings written at profile_series, where
|
||||
* the manifest resolves them above a library, a device, and the profile. The
|
||||
* track index and signature are not preferences: they identify one concrete
|
||||
* track in one file, so they stay on the specialized per-series route that has
|
||||
* always held them.
|
||||
*
|
||||
* Only values the viewer actually chose become canonical rows. The keys come
|
||||
* from SERIES_SUBTITLE_SETTING_KEYS, the same list the item page's "Auto"
|
||||
* reset clears — profile_series outranks every other scope, so a row written
|
||||
* here that the reset does not know about would shadow every later choice with
|
||||
* nothing in the UI able to remove it.
|
||||
*
|
||||
* playback.show_forced_subtitles is deliberately not among them. The player
|
||||
* holds only the *resolved* value and has no control that edits it, so for a
|
||||
* viewer who never expressed a preference it is the contract default; writing
|
||||
* that back would pin the default at the top of the ladder and permanently
|
||||
* shadow the profile-scope toggle on the Subtitles settings screen. It still
|
||||
* rides along on the legacy composite row, which is keyed to a concrete track
|
||||
* selection and is not part of the canonical resolution ladder.
|
||||
*
|
||||
* Returns an empty list when the index names no real track (e.g. the
|
||||
* in-progress AI live track's sentinel): persisting it would store a
|
||||
* nonexistent track with an empty language and clobber the saved preference.
|
||||
*/
|
||||
export function buildSubtitleChoiceRequests({
|
||||
seriesId,
|
||||
index,
|
||||
tracks,
|
||||
showForcedSubtitles,
|
||||
}: {
|
||||
seriesId: string;
|
||||
/** The chosen track's backend index, or null for "subtitles off". */
|
||||
index: number | null;
|
||||
tracks: readonly PlayerSubtitleInfo[];
|
||||
/** The resolved forced-subtitle behavior, preserved in the legacy row. */
|
||||
showForcedSubtitles?: boolean;
|
||||
}): SubtitleChoiceRequest[] {
|
||||
if (!seriesId) return [];
|
||||
|
||||
const track = index !== null ? tracks.find((candidate) => candidate.index === index) : null;
|
||||
if (index !== null && !track) return [];
|
||||
|
||||
const trackSignature: PlayerSubtitleTrackSignature | null = track
|
||||
? {
|
||||
source: track.source,
|
||||
language: track.language,
|
||||
codec: track.codec,
|
||||
label: track.label,
|
||||
forced: track.forced,
|
||||
hearing_impaired: track.hearing_impaired,
|
||||
}
|
||||
: null;
|
||||
|
||||
const mode = derivePersistedSubtitleMode(index);
|
||||
// Turning subtitles off stores mode "off" and clears the language rather
|
||||
// than storing an empty one.
|
||||
const chosen = seriesSubtitleSettingValues({ language: track?.language ?? null, mode });
|
||||
|
||||
const requests: SubtitleChoiceRequest[] = SERIES_SUBTITLE_SETTING_KEYS.map((key) => ({
|
||||
path: seriesSubtitleSettingPath(key, seriesId),
|
||||
body: { value: chosen[key] },
|
||||
}));
|
||||
|
||||
requests.push({
|
||||
path: `/subtitle-prefs/${seriesId}`,
|
||||
body: {
|
||||
subtitle_language: track?.language ?? "",
|
||||
subtitle_track_index: index ?? -1,
|
||||
subtitle_mode: mode,
|
||||
track_signature: trackSignature,
|
||||
show_forced_subtitles: showForcedSubtitles,
|
||||
},
|
||||
});
|
||||
|
||||
return requests;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { appearanceCache, storage } from "./storage";
|
||||
|
||||
const KEYS = storage.KEYS;
|
||||
|
||||
describe("appearance cache namespacing", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("reads back what the same account wrote", () => {
|
||||
appearanceCache.set(KEYS.THEME, "cobalt-studio", "1");
|
||||
|
||||
expect(appearanceCache.get(KEYS.THEME, "1")).toBe("cobalt-studio");
|
||||
});
|
||||
|
||||
it("hides another account's cached values", () => {
|
||||
appearanceCache.set(KEYS.THEME, "cobalt-studio", "1");
|
||||
appearanceCache.set(KEYS.UI_TEXT_SCALE, "large", "1");
|
||||
|
||||
expect(appearanceCache.get(KEYS.THEME, "2")).toBeNull();
|
||||
expect(appearanceCache.get(KEYS.UI_TEXT_SCALE, "2")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps both accounts' values, so returning to the first still warm starts", () => {
|
||||
appearanceCache.set(KEYS.THEME, "cobalt-studio", "1");
|
||||
appearanceCache.set(KEYS.THEME, "oxblood-noir", "2");
|
||||
|
||||
expect(appearanceCache.get(KEYS.THEME, "1")).toBe("cobalt-studio");
|
||||
expect(appearanceCache.get(KEYS.THEME, "2")).toBe("oxblood-noir");
|
||||
});
|
||||
|
||||
it("ignores values written before namespacing existed", () => {
|
||||
storage.set(KEYS.THEME, "cobalt-studio");
|
||||
|
||||
expect(appearanceCache.get(KEYS.THEME, "1")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the last account that wrote while nobody is signed in", () => {
|
||||
appearanceCache.set(KEYS.THEME, "cobalt-studio", "1");
|
||||
|
||||
expect(appearanceCache.get(KEYS.THEME, null)).toBe("cobalt-studio");
|
||||
});
|
||||
|
||||
it("follows the pointer to the most recent account, not the first", () => {
|
||||
appearanceCache.set(KEYS.THEME, "cobalt-studio", "1");
|
||||
appearanceCache.set(KEYS.THEME, "oxblood-noir", "2");
|
||||
|
||||
expect(appearanceCache.get(KEYS.THEME, null)).toBe("oxblood-noir");
|
||||
});
|
||||
|
||||
it("keeps a never-signed-in device's values in their own namespace", () => {
|
||||
appearanceCache.set(KEYS.THEME, "cobalt-studio", null);
|
||||
|
||||
expect(appearanceCache.get(KEYS.THEME, null)).toBe("cobalt-studio");
|
||||
// Not the bare key, and not visible to a real account.
|
||||
expect(storage.get(KEYS.THEME)).toBeNull();
|
||||
expect(appearanceCache.get(KEYS.THEME, "1")).toBeNull();
|
||||
});
|
||||
|
||||
it("routes a signed-out write into the last account's namespace without moving the pointer", () => {
|
||||
appearanceCache.set(KEYS.THEME, "cobalt-studio", "1");
|
||||
appearanceCache.set(KEYS.THEME, "evergreen-studio", null);
|
||||
|
||||
// Reads and writes resolve their namespace the same way, so a theme change
|
||||
// made on the login screen is the one the next read sees. It lands in the
|
||||
// last account's cache, which is only a warm start: their server value
|
||||
// still wins once the settings request resolves.
|
||||
expect(storage.get(KEYS.UI_CACHE_OWNER)).toBe("1");
|
||||
expect(appearanceCache.get(KEYS.THEME, null)).toBe("evergreen-studio");
|
||||
expect(appearanceCache.get(KEYS.THEME, "1")).toBe("evergreen-studio");
|
||||
expect(appearanceCache.get(KEYS.THEME, "2")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps every key in the group independently namespaced", () => {
|
||||
appearanceCache.set(KEYS.THEME, "cobalt-studio", "1");
|
||||
appearanceCache.set(KEYS.UI_CUSTOM_CSS, "body{}", "1");
|
||||
appearanceCache.set(KEYS.UI_DATE_FORMAT, "iso", "2");
|
||||
|
||||
expect(appearanceCache.get(KEYS.UI_CUSTOM_CSS, "1")).toBe("body{}");
|
||||
expect(appearanceCache.get(KEYS.UI_CUSTOM_CSS, "2")).toBeNull();
|
||||
expect(appearanceCache.get(KEYS.UI_DATE_FORMAT, "2")).toBe("iso");
|
||||
expect(appearanceCache.get(KEYS.UI_DATE_FORMAT, "1")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -18,14 +18,14 @@ const STORAGE_KEYS = {
|
||||
UI_CUSTOM_THEME_VARS: "silo-custom-theme-vars",
|
||||
UI_DATE_FORMAT: "silo-ui-date-format",
|
||||
UI_TIME_FORMAT: "silo-ui-time-format",
|
||||
UI_DATETIME_FORMAT_OWNER: "silo-ui-datetime-format-owner",
|
||||
UI_CACHE_OWNER: "silo-ui-cache-owner",
|
||||
UI_CUSTOM_CSS: "silo-custom-css",
|
||||
CALENDAR_PRESET: "calendar:preset",
|
||||
} as const;
|
||||
|
||||
type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS];
|
||||
export type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS];
|
||||
|
||||
function get(key: StorageKey): string | null {
|
||||
function getRaw(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
@@ -33,7 +33,7 @@ function get(key: StorageKey): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function set(key: StorageKey, value: string): void {
|
||||
function setRaw(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
@@ -41,6 +41,22 @@ function set(key: StorageKey, value: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function removeRaw(key: string): void {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Storage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function get(key: StorageKey): string | null {
|
||||
return getRaw(key);
|
||||
}
|
||||
|
||||
function set(key: StorageKey, value: string): void {
|
||||
setRaw(key, value);
|
||||
}
|
||||
|
||||
function remove(key: StorageKey): void {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
@@ -50,3 +66,72 @@ function remove(key: StorageKey): void {
|
||||
}
|
||||
|
||||
export const storage = { KEYS: STORAGE_KEYS, get, set, remove };
|
||||
|
||||
/**
|
||||
* Namespace used before anyone has ever signed in on this browser. Values
|
||||
* written here are the device's own defaults, not any account's.
|
||||
*/
|
||||
const DEVICE_NAMESPACE = "device";
|
||||
|
||||
/**
|
||||
* Which namespace a read or write belongs to.
|
||||
*
|
||||
* A known account always uses its own. A `null` owner means auth is still
|
||||
* bootstrapping or nobody is signed in, and we fall back to the last account
|
||||
* that wrote here so the login screen and the pre-auth first paint keep the
|
||||
* look this device last used. That fallback cannot leak into a signed-in
|
||||
* session: the moment auth resolves, the owner is exact.
|
||||
*/
|
||||
function namespaceFor(owner: string | null): string {
|
||||
if (owner !== null) return owner;
|
||||
return getRaw(STORAGE_KEYS.UI_CACHE_OWNER) ?? DEVICE_NAMESPACE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device-local mirrors of server-side, per-account settings, so the UI can
|
||||
* paint before the settings request resolves. Covers theme, text scale, text
|
||||
* weight, high contrast, custom theme tokens, custom CSS, and date/time format.
|
||||
*
|
||||
* Values are namespaced by the identity that owns them — user id plus active
|
||||
* profile id (`silo-theme:7:p1`) — so a second account or a sibling profile
|
||||
* signing in on a shared browser simply finds nothing where the first one's
|
||||
* values would have been. A miss is just a miss: every caller
|
||||
* already parses a missing value into the correct default, and the settings
|
||||
* response repopulates the namespace when it lands.
|
||||
*
|
||||
* Namespacing rather than tagging-and-clearing matters for three reasons.
|
||||
* Nothing is ever deleted, so returning to the first account still paints their
|
||||
* look with no cold start. There is no shared stamp for a second tab, a stale
|
||||
* debounce timer, or an out-of-order effect to race on. And widening ownership
|
||||
* — appearance moved from user scope to profile scope with the settings
|
||||
* contract, and the owner token widened with it — is a change to
|
||||
* `appearanceCacheOwner` alone, which no caller can forget to apply.
|
||||
*
|
||||
* Values written before namespacing existed sit at the bare key and are simply
|
||||
* ignored. Those users take one cold paint, after which the mirror below has
|
||||
* repopulated their namespace from the server, which holds all of these
|
||||
* settings anyway.
|
||||
*/
|
||||
export const appearanceCache = {
|
||||
/** The cached value for `owner`, or null when they have none. */
|
||||
get(key: StorageKey, owner: string | null): string | null {
|
||||
return getRaw(`${key}:${namespaceFor(owner)}`);
|
||||
},
|
||||
/** Write a value into `owner`'s namespace. */
|
||||
set(key: StorageKey, value: string, owner: string | null): void {
|
||||
setRaw(`${key}:${namespaceFor(owner)}`, value);
|
||||
if (owner !== null) setRaw(STORAGE_KEYS.UI_CACHE_OWNER, owner);
|
||||
},
|
||||
/**
|
||||
* Drop `owner`'s cached value, so the next cold start falls back rather than
|
||||
* painting a preference the server no longer holds. Needed because the
|
||||
* server's answer is authoritative once loaded: when another client deletes
|
||||
* an appearance setting, the effective response simply omits it, and a cache
|
||||
* that only ever grows would keep the removed value alive on this browser
|
||||
* forever. Deliberately scoped to one owner — clearing another identity's
|
||||
* namespace is what the ownership tests exist to prevent.
|
||||
*/
|
||||
remove(key: StorageKey, owner: string | null): void {
|
||||
removeRaw(`${key}:${namespaceFor(owner)}`);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user