feat(settings): sync navigation and card customization by client family (#538)

* test(web): use safe auth placeholders

* feat(settings): sync navigation and card customization

* fix(settings): address customization review feedback

* fix(settings): address customization review feedback

* fix(settings): harden customization capability handling
This commit is contained in:
Quick
2026-08-04 08:20:41 -04:00
committed by GitHub
parent 4cd51d8f6c
commit 3bdfc58512
98 changed files with 8641 additions and 619 deletions
+4
View File
@@ -212,6 +212,10 @@ If you prefer running Silo without Docker:
## Reporting Issues
Client implementers can use the [Canonical Settings API guide](docs/settings-api.md)
for contract discovery, contextual headers, remote scopes, effective reads, and
the admin projection.
If you are reporting a bug, install problem, or performance issue, start with the admin workflow and reproduction steps, not Claude/Codex analysis.
Please include:
+1
View File
@@ -157,6 +157,7 @@ func generateTypeScript(contract *settingscontract.Manifest) ([]byte, error) {
}
out.WriteString(" */\n\n")
fmt.Fprintf(&out, "export const SETTINGS_API_VERSION = %d;\n", contract.APIVersion)
fmt.Fprintf(&out, "export const SETTINGS_REVISION = %d;\n\n", contract.Revision)
out.WriteString("export interface SettingSuggestedOption {\n")
+75 -1
View File
@@ -1,8 +1,82 @@
{
"fixture_version": 1,
"manifest_revision": 4,
"manifest_revision": 5,
"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": "profile_client_sits_between_device_and_profile",
"description": "Presentation values roam across like clients, while an exact-device override remains more specific and a profile value remains the cross-family fallback.",
"keys": ["ui.card_presentation"],
"context": { "profile_id": "p1", "client_family": "tv", "device_id": "living-room" },
"stored": [
{
"key": "ui.card_presentation",
"scope": "profile",
"profile_id": "p1",
"value": { "poster_size": "standard", "caption": "title" }
},
{
"key": "ui.card_presentation",
"scope": "profile_client",
"profile_id": "p1",
"client_family": "tv",
"value": { "poster_size": "large", "caption": "artwork" }
},
{
"key": "ui.card_presentation",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "living-room",
"value": { "poster_size": "compact", "caption": "title_metadata" }
}
],
"expected": [
{
"key": "ui.card_presentation",
"value": { "poster_size": "compact", "caption": "title_metadata" },
"source": "profile_device"
}
]
},
{
"name": "profile_client_roams_only_within_its_family",
"description": "A television menu applies to another television identity but not to mobile; the family is explicit resolution context rather than inferred from device metadata.",
"keys": ["nav.primary_menu"],
"context": { "profile_id": "p1", "client_family": "tv", "device_id": "bedroom-tv" },
"stored": [
{
"key": "nav.primary_menu",
"scope": "profile_client",
"profile_id": "p1",
"client_family": "mobile",
"value": { "items": [{ "type": "builtin", "destination": "home" }] }
},
{
"key": "nav.primary_menu",
"scope": "profile_client",
"profile_id": "p1",
"client_family": "tv",
"value": {
"items": [
{ "type": "builtin", "destination": "home" },
{ "type": "builtin", "destination": "movies" }
]
}
}
],
"expected": [
{
"key": "nav.primary_menu",
"value": {
"items": [
{ "type": "builtin", "destination": "home" },
{ "type": "builtin", "destination": "movies" }
]
},
"source": "profile_client"
}
]
},
{
"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.",
+54 -1
View File
@@ -1,6 +1,6 @@
{
"api_version": 1,
"revision": 4,
"revision": 5,
"option_sets": {
"playback_audio_languages": {
"type": "language_tag",
@@ -827,6 +827,59 @@
"recommended_control": "select",
"notes": "Registered from the legacy unprefixed key next_up_mode. The server reads it directly when assembling home sections, so it cannot be client-local; that read moves to the canonical resolver at cutover. The legacy value was untyped and absent meant combined, which is why combined is the default rather than a third \"unset\" member."
},
{
"key": "nav.primary_menu",
"introduced_in": 5,
"persistence": "remote",
"allowed_scopes": ["profile_client", "profile_device"],
"resolution_order": ["profile_device", "profile_client", "default"],
"value_schema": {
"type": "object",
"schema_ref": "primary-menu.json",
"nullable": true
},
"default_value": null,
"platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"],
"category": "navigation",
"label": "Primary menu",
"description": "The ordered visible destinations shared by like clients.",
"recommended_control": "panel",
"notes": "Search and profile remain fixed client utilities. Home is required by primary-menu.json; omitting any other supported built-in hides it. Semantic destination identities are unique even when labels differ. A null default lets each family keep its native baseline until the user customizes it. Family scope synchronizes like clients while profile_device remains an explicit escape hatch for one screen; clients ignore built-in destinations they do not support."
},
{
"key": "nav.shortcuts",
"introduced_in": 5,
"persistence": "remote",
"allowed_scopes": ["profile"],
"resolution_order": ["profile", "default"],
"value_schema": {
"type": "object",
"schema_ref": "navigation-shortcuts.json"
},
"default_value": { "items": [] },
"category": "navigation",
"label": "Navigation shortcuts",
"description": "Libraries, sections, and collections pinned for use across navigation surfaces.",
"notes": "Profile-wide catalog; individual client families decide which shortcuts to place in their primary menu. Semantic destination identities are unique even when labels differ. The profile_client migration keeps ui.sidebar_pins unchanged and seeds this key from convertible legacy web pins only when this key has no authored row."
},
{
"key": "ui.card_presentation",
"introduced_in": 5,
"persistence": "remote",
"allowed_scopes": ["profile", "profile_client", "profile_device"],
"resolution_order": ["profile_device", "profile_client", "profile", "default"],
"value_schema": {
"type": "object",
"schema_ref": "card-presentation.json"
},
"default_value": { "poster_size": "standard", "caption": "title_metadata" },
"platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"],
"category": "appearance",
"label": "Media cards",
"description": "Poster size and caption detail used by media cards.",
"recommended_control": "panel",
"notes": "Semantic presets roam between like devices without forcing identical pixel dimensions across platforms. A profile fallback can opt into one presentation everywhere; family and exact-device values remain more specific."
},
{
"key": "ui.sidebar_pins",
"introduced_in": 1,
@@ -76,6 +76,7 @@
"enum": [
"account",
"profile",
"profile_client",
"profile_device",
"profile_library",
"profile_series",
@@ -291,6 +292,7 @@
"enum": [
"account",
"profile",
"profile_client",
"profile_device",
"profile_library",
"profile_series",
@@ -0,0 +1,19 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/card-presentation.json",
"title": "Media card presentation",
"description": "Semantic density and caption presets that each client family maps to platform-appropriate card dimensions.",
"type": "object",
"additionalProperties": false,
"required": ["poster_size", "caption"],
"properties": {
"poster_size": {
"type": "string",
"enum": ["compact", "standard", "large"]
},
"caption": {
"type": "string",
"enum": ["title_metadata", "title", "artwork"]
}
}
}
@@ -0,0 +1,62 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/navigation-shortcuts.json",
"title": "Navigation shortcuts",
"description": "Profile-wide catalog of pinned libraries, sections, and collections that client-family menus may surface.",
"$comment": "Contract validation also rejects repeated semantic destination identities even when labels differ: library(library_id), section(library_id, section_id), collection(optional library_id, collection_id).",
"type": "object",
"additionalProperties": false,
"required": ["items"],
"properties": {
"items": {
"description": "Pinned targets. Each semantic destination identity may appear at most once; changing its label does not create a distinct destination.",
"type": "array",
"maxItems": 256,
"uniqueItems": true,
"items": {
"oneOf": [
{ "$ref": "#/$defs/library" },
{ "$ref": "#/$defs/section" },
{ "$ref": "#/$defs/collection" }
]
}
}
},
"$defs": {
"label": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "\\S" },
"libraryId": { "type": "integer", "minimum": 1 },
"targetId": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "\\S" },
"library": {
"type": "object",
"additionalProperties": false,
"required": ["type", "library_id", "label"],
"properties": {
"type": { "const": "library" },
"library_id": { "$ref": "#/$defs/libraryId" },
"label": { "$ref": "#/$defs/label" }
}
},
"section": {
"type": "object",
"additionalProperties": false,
"required": ["type", "library_id", "section_id", "label"],
"properties": {
"type": { "const": "section" },
"library_id": { "$ref": "#/$defs/libraryId" },
"section_id": { "$ref": "#/$defs/targetId" },
"label": { "$ref": "#/$defs/label" }
}
},
"collection": {
"type": "object",
"additionalProperties": false,
"required": ["type", "collection_id", "label"],
"properties": {
"type": { "const": "collection" },
"collection_id": { "$ref": "#/$defs/targetId" },
"library_id": { "$ref": "#/$defs/libraryId" },
"label": { "$ref": "#/$defs/label" }
}
}
}
}
@@ -0,0 +1,86 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/primary-menu.json",
"title": "Primary navigation menu",
"description": "Ordered visible destinations for one client family. Search and profile controls are fixed client utilities and are intentionally absent.",
"$comment": "Contract validation also rejects repeated semantic destination identities even when labels differ: builtin(destination), library(library_id), section(library_id, section_id), collection(optional library_id, collection_id).",
"type": "object",
"additionalProperties": false,
"required": ["items"],
"properties": {
"items": {
"description": "Ordered destinations. Each semantic destination identity may appear at most once; changing its label does not create a distinct destination.",
"type": "array",
"minItems": 1,
"maxItems": 64,
"uniqueItems": true,
"contains": {
"type": "object",
"required": ["type", "destination"],
"properties": {
"type": { "const": "builtin" },
"destination": { "const": "home" }
}
},
"minContains": 1,
"maxContains": 1,
"items": {
"oneOf": [
{ "$ref": "#/$defs/builtin" },
{ "$ref": "#/$defs/library" },
{ "$ref": "#/$defs/section" },
{ "$ref": "#/$defs/collection" }
]
}
}
},
"$defs": {
"label": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "\\S" },
"libraryId": { "type": "integer", "minimum": 1 },
"targetId": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "\\S" },
"builtin": {
"type": "object",
"additionalProperties": false,
"required": ["type", "destination"],
"properties": {
"type": { "const": "builtin" },
"destination": {
"type": "string",
"enum": ["home", "movies", "series", "music", "audiobooks", "for_you", "calendar"]
}
}
},
"library": {
"type": "object",
"additionalProperties": false,
"required": ["type", "library_id", "label"],
"properties": {
"type": { "const": "library" },
"library_id": { "$ref": "#/$defs/libraryId" },
"label": { "$ref": "#/$defs/label" }
}
},
"section": {
"type": "object",
"additionalProperties": false,
"required": ["type", "library_id", "section_id", "label"],
"properties": {
"type": { "const": "section" },
"library_id": { "$ref": "#/$defs/libraryId" },
"section_id": { "$ref": "#/$defs/targetId" },
"label": { "$ref": "#/$defs/label" }
}
},
"collection": {
"type": "object",
"additionalProperties": false,
"required": ["type", "collection_id", "label"],
"properties": {
"type": { "const": "collection" },
"collection_id": { "$ref": "#/$defs/targetId" },
"library_id": { "$ref": "#/$defs/libraryId" },
"label": { "$ref": "#/$defs/label" }
}
}
}
}
+198
View File
@@ -0,0 +1,198 @@
# Canonical Settings API
The canonical settings API stores typed user preferences from the shared
settings contract. Client implementations should discover the server contract
before rendering controls or writing a value; do not keep a separate list of
keys, scopes, enum members, or defaults.
All paths below are relative to `/api/v1`.
## Contract discovery
| Method and path | Purpose |
| -------------------------- | ------------------------------------------------------------------------------------------------------- |
| `GET /settings/manifest` | Public client projection of the current manifest. Supports `If-None-Match`. |
| `GET /settings/capability` | Contract API version, revision, remote scopes, supported client families, and batch/write capabilities. |
`/settings/contract` and `/settings/contract/capabilities` are equivalent
aliases. A client whose vendored contract is newer than the advertised server
revision must hide definitions and features introduced after that revision.
Navigation shortcut mutation controls additionally require
`supports_atomic_shortcuts: true`. Revision-5 customization also requires
`supports_batched_effective: true` and `supports_idempotent_writes: true` so
batched resolution and replayed writes have the semantics the clients depend
on. Any missing flag fails closed.
## Request identity headers
Authenticated settings routes use the active account and profile from the
normal Silo session. The contextual headers below identify which client is
resolving or writing an override.
| Header | When required | Meaning |
| ---------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-Profile-Id` | All `/settings/values` routes | Active household profile. |
| `X-Silo-Device-Id` | A `profile_device` explicit request, or an effective request containing a key that permits `profile_device` | Stable exact-device identity. |
| `X-Silo-Client-Family` | Required for a `profile_client` explicit request; optional on effective reads | Closed like-client identity: `tv`, `mobile`, `tablet`, `desktop`, or `web`. A valid value includes the like-client layer; absence skips it. |
| `X-Silo-Mutation-Id` | Optional on `PUT` | Idempotency key for safe retries. Reusing it for a different identity or value returns a conflict. |
Client family is intentionally independent of the free-form
`X-Silo-Device-Platform` display metadata. The server never guesses one from
the other. Send the exact lower-case family:
| Client | Family |
| ----------------------- | --------- |
| tvOS or Android TV | `tv` |
| iPhone or Android phone | `mobile` |
| iPad or Android tablet | `tablet` |
| macOS | `desktop` |
| Browser | `web` |
## Remote scopes
Every stored value has exactly one identity. Context fields not named by the
selected scope must be absent.
| Scope | Identity after account |
| ----------------- | ----------------------------- |
| `account` | none |
| `profile` | `profile_id` |
| `profile_client` | `profile_id`, `client_family` |
| `profile_device` | `profile_id`, `device_id` |
| `profile_library` | `profile_id`, `library_id` |
| `profile_series` | `profile_id`, `series_id` |
The manifest's `allowed_scopes` decides where each key may be written, and its
`resolution_order` decides precedence. `profile_client` values roam only among
like clients. For example, a TV value applies to tvOS and Android TV but not to
a phone or browser.
## Explicit values
Use explicit endpoints to edit or clear one scope, without resolving inherited
values:
- `GET /settings/values?keys=<csv>&scope=<scope>` returns every requested key
with `is_set`; unset rows remain in the response.
- `GET /settings/values/{key}?scope=<scope>` returns one stored value or `404`.
- `PUT /settings/values/{key}?scope=<scope>` accepts `{"value": <typed JSON>}`.
- `DELETE /settings/values/{key}?scope=<scope>` removes the row so inheritance
applies again.
`library_id` and `series_id` are query parameters for their matching scopes.
An explicitly managed device may be named with `device_id`, subject to the
profile/device authorization checks. The self-service `profile_client` scope
takes its family only from `X-Silo-Client-Family`.
Example: share a TV menu between tvOS and Android TV clients.
```http
PUT /api/v1/settings/values/nav.primary_menu?scope=profile_client
Authorization: Bearer <token>
X-Profile-Id: <profile-id>
X-Silo-Client-Family: tv
Content-Type: application/json
{"value":{"items":[{"type":"builtin","destination":"home"},{"type":"library","library_id":7,"label":"Movies"}]}}
```
Stored-value responses include `client_family` when the source or explicit row
is at `profile_client`.
### Atomic navigation shortcuts
`nav.shortcuts` is a profile-wide catalog shared by TV, mobile, desktop, and
web clients. Self-service clients must mutate one destination at a time instead
of replacing that shared document:
```http
PUT /api/v1/settings/values/nav.shortcuts/item
Authorization: Bearer <token>
X-Profile-Id: <profile-id>
X-Silo-Mutation-Id: <stable-uuid-for-this-intent>
Content-Type: application/json
{"item":{"type":"section","library_id":7,"section_id":"recent","label":"Recently Added"},"present":true}
```
The item is one member of `navigation-shortcuts.json`: a library, section, or
collection (whose `collection_id` is a string and whose `library_id` is
optional). Identity is exactly the schema's semantic identity:
- library: `type + library_id`
- section: `type + library_id + section_id`
- collection: `type + optional library_id + collection_id`
`present: true` appends an absent item or refreshes the label of an existing
identity in place without reordering it. `present: false` removes that identity;
the supplied label is validated but ignored for matching. The server atomically
rebases on concurrent edits, enforces the full schema and 256-item cap, and
returns the normal stored-value object containing the complete resulting
`value`, row `revision`, and `updated_at`. An already-satisfied operation is a
successful no-op and does not increment the revision. Removing from a catalog
that has never been stored returns `{"items":[]}` at revision `0` with no
`updated_at`.
Retry with the same `X-Silo-Mutation-Id`. A recorded retry returns the original
response with `X-Silo-Idempotent-Replay: true`; reusing the id for a different
semantic operation returns `409 mutation_id_conflict`. The mutation ID is
serialized before the setting write, and the setting plus its replay receipt
commit in one database transaction, so concurrent reuse cannot apply twice and
a crash cannot persist only one half. A rare exhausted
contention loop returns retryable `409 setting_update_conflict`. Malformed
envelopes or unknown envelope fields return `400 bad_request`, while item
schema failures (including unknown item fields) and cap failures return
`400 invalid_value`.
Ordinary session `PUT` and `DELETE` at
`/settings/values/nav.shortcuts?scope=profile` are rejected with
`400 atomic_update_required` so a whole-document mutation cannot erase
concurrent item edits or reset the row revision. The admin endpoint retains
whole-document `PUT` for explicit repair work, but rejects physical `DELETE`
for the same revision-history reason. An admin clears the catalog with
`{"value":{"items":[]}}`, which advances the row revision, or replaces it with
another validated document.
## Effective values
- `GET /settings/values/effective?keys=<csv>` resolves several keys for the
request profile, device, and client family. Omitting `keys` resolves all
remote definitions.
- `POST /settings/values/effective` resolves a bounded list of content contexts
in one store read. Use it for grids or lists rather than issuing one request
per item.
An effective request must include the device header when any requested
definition permits an exact-device override. The family header is optional for
backward compatibility: absence skips the `profile_client` layer, while a
non-empty invalid family is rejected. First-party clients should send their
family so like-device preferences participate in resolution. Each response
includes its source scope and source context; `client_family` is included for a
family-scoped winner.
## Admin projection
Admin routes are mounted behind the normal acting-admin authorization:
- `GET /admin/users/{id}/settings/values` lists every stored row across all
scopes.
- `PUT /admin/users/{id}/settings/values/{key}?scope=<scope>` writes through the
same contract validation and normalization as the self-service endpoint.
- `DELETE /admin/users/{id}/settings/values/{key}?scope=<scope>` clears the
selected row.
Admin requests name profile/context identity with query parameters because the
target user is not the admin's active session. In particular,
`scope=profile_client` requires both `profile_id` and `client_family` query
parameters; it does not use `X-Silo-Client-Family`.
```http
PUT /api/v1/admin/users/42/settings/values/ui.card_presentation?scope=profile_client&profile_id=main&client_family=tv
Authorization: Bearer <admin-token>
Content-Type: application/json
{"value":{"poster_size":"large","caption":"artwork"}}
```
The five accepted `client_family` values are also returned by the capability
endpoint so admin tooling does not need to invent them.
@@ -416,6 +416,7 @@ The remote scopes are:
|---|---|---|
| `account` | `(user_id)` | Same for every profile and signed-in client on the account. |
| `profile` | `(user_id, profile_id)` | Roams with one profile. |
| `profile_client` | `(user_id, profile_id, client_family)` | Roams between like clients: `tv`, `mobile`, `tablet`, `desktop`, or `web`. |
| `profile_device` | `(user_id, profile_id, device_id)` | Override for one profile on one device identity. |
| `profile_library` | `(user_id, profile_id, library_id)` | Content preference for one library. |
| `profile_series` | `(user_id, profile_id, series_id)` | Content preference for one series. |
@@ -423,6 +424,10 @@ The remote scopes are:
`client_local` definitions use a single logical `client_local` scope and are never addressed by the
server values API.
Clients send the family explicitly as `X-Silo-Client-Family`; the server never infers it from the
free-form `X-Silo-Device-Platform` registry label. tvOS and Android TV use `tv`, phones use
`mobile`, tablets use `tablet`, native desktop clients use `desktop`, and browsers use `web`.
All remote mutations carry their complete identity explicitly. The server authorizes that the
profile, library, series, and device belong to the authenticated user. A queued operation must not
derive its profile or server from whichever account happens to be active when the retry runs.
@@ -448,6 +453,8 @@ Examples:
|---|---|
| Audio/subtitle selection | series → library → device → profile → default |
| Playback behavior with device override | device → profile → default |
| Family-synchronized presentation | device → client family → profile → default |
| Family-synchronized navigation | device → client family → default |
| Device playback capability | device → default |
| Account UI preference | account → default |
| Client-local OS behavior | local value → default |
@@ -625,8 +632,10 @@ to check compatibility without transferring the manifest body.
- Returns the explicit value and revision at exactly one requested scope; it does not resolve
fallbacks.
- Context parameters are required by scope: `profile_id`, `device_id`, `library_id`, or `series_id`
as defined by the identity table above.
- Context parameters are required by scope: `profile_id`, `client_family`, `device_id`,
`library_id`, or `series_id` as defined by the identity table above. Self-service
`profile_client` requests take client family from `X-Silo-Client-Family`; admin explicit-value
routes name it with the `client_family` query parameter.
- An unset value is represented as `is_set: false` with no `value` member, never as an empty string
or JSON `null`.
- Settings screens use this endpoint to show profile defaults and device overrides independently.
@@ -637,7 +646,8 @@ to check compatibility without transferring the manifest body.
`GET /api/v1/settings/values/effective?keys=<comma-separated-keys>`
- Requires the active profile and device identity headers for definitions that can resolve those
scopes.
scopes. `X-Silo-Client-Family` is optional for effective reads so older callers remain compatible:
absence skips `profile_client`, a valid value includes it, and a non-empty invalid value is rejected.
- Rejects unknown keys rather than fabricating defaults.
- Returns native typed values, resolution source, source context, definition revision,
`updated_at`, and any policy constraint.
@@ -789,6 +799,7 @@ CREATE TABLE user_setting_values (
key text NOT NULL,
scope text NOT NULL,
profile_id text,
client_family text,
device_id text,
library_id integer,
series_id text,
@@ -796,13 +807,15 @@ CREATE TABLE user_setting_values (
revision bigint NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (scope IN ('account', 'profile', 'profile_device', 'profile_library', 'profile_series')),
CHECK (scope IN ('account', 'profile', 'profile_client', 'profile_device', 'profile_library', 'profile_series')),
CHECK (client_family IS NULL OR client_family IN ('tv', 'mobile', 'tablet', 'desktop', 'web')),
CHECK (
(scope = 'account' AND profile_id IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_device' AND profile_id IS NOT NULL AND device_id IS NOT NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_library' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NOT NULL AND series_id IS NULL) OR
(scope = 'profile_series' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NOT NULL)
(scope = 'account' AND profile_id IS NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_client' AND profile_id IS NOT NULL AND client_family IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_device' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NOT NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_library' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NOT NULL AND series_id IS NULL) OR
(scope = 'profile_series' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NOT NULL)
)
);
```
@@ -819,6 +832,8 @@ CREATE UNIQUE INDEX user_setting_values_account_uq
ON user_setting_values (user_id, key) WHERE scope = 'account';
CREATE UNIQUE INDEX user_setting_values_profile_uq
ON user_setting_values (user_id, profile_id, key) WHERE scope = 'profile';
CREATE UNIQUE INDEX user_setting_values_profile_client_uq
ON user_setting_values (user_id, profile_id, client_family, key) WHERE scope = 'profile_client';
CREATE UNIQUE INDEX user_setting_values_profile_device_uq
ON user_setting_values (user_id, profile_id, device_id, key) WHERE scope = 'profile_device';
CREATE UNIQUE INDEX user_setting_values_profile_library_uq
@@ -949,8 +964,19 @@ client. The following decisions resolve today's duplicate semantics:
| Date/time format | remote: **profile** | Existing account rows are copied to every profile. |
| Search media scope | remote: profile | Preserve strict enums. |
| `ui.library_page_state` | remote: profile_device | Keep navigation state tied to one profile/device. |
| `nav.primary_menu` | remote: profile_client, profile_device | Family menu ordering/visibility with an optional exact-device escape hatch; null means use the family-native baseline. |
| `nav.shortcuts` | remote: profile | Profile-wide catalog of library, section, and collection pins; seed convertible legacy `ui.sidebar_pins` without deleting the old row. |
| `ui.card_presentation` | remote: profile, profile_client, profile_device | Semantic size/caption presets; device → family → profile → default. |
| OS caption mirroring, platform decoder diagnostics, temporary sleep timers | client_local or private `local.*` | Production caption-mirroring UI is contract-known local; diagnostics/timers remain private local. |
Navigation destinations are unique by semantic identity, not by the complete JSON object: built-ins
use `destination`, libraries use `library_id`, sections use `(library_id, section_id)`, and collections
use `(optional library_id, collection_id)`. Changing a label therefore cannot create a duplicate menu
entry. Labels and string target ids must contain a non-whitespace character; labels are bounded to
256 characters and target ids to 128. The JSON schemas document these rules and the canonical
validator enforces the cross-item identity check that JSON Schema `uniqueItems` cannot express by
itself.
### Appearance belongs to the profile, not the account
Theme, text scale, contrast, custom CSS, and date/time format are stored today in `user_settings`
+1
View File
@@ -46,6 +46,7 @@ const (
deviceIDHeader = "X-Silo-Device-Id"
deviceNameHeader = "X-Silo-Device-Name"
devicePlatformHeader = "X-Silo-Device-Platform"
clientFamilyHeader = "X-Silo-Client-Family"
)
// ServerSettingReader reads individual keys from the server_settings table.
+8 -1
View File
@@ -23,7 +23,10 @@ import (
// activitylog is an HTTP request-log middleware whose schema has no profile or
// body, so it cannot express "actor P changed key K for profile Q". Persisting
// these needs its own table and migration.
const settingsAuditMsg = "settings changed for another profile"
const (
settingsAuditMsg = "settings changed for another profile"
settingsAuditActionSet = "set"
)
// logComponentKey is the structured-log attribute every handler in this package
// tags itself with.
@@ -37,6 +40,7 @@ type settingsAuditRecord struct {
// actor's own account only on the admin routes, where profile ids alone
// would not say whose settings moved.
TargetUserID int
ClientFamily string
DeviceID string
Key string
Scope string
@@ -67,6 +71,9 @@ func auditSettingsForOther(ctx context.Context, record settingsAuditRecord) {
if record.Scope != "" {
attrs = append(attrs, "scope", record.Scope)
}
if record.ClientFamily != "" {
attrs = append(attrs, "client_family", record.ClientFamily)
}
if record.DeviceID != "" {
attrs = append(attrs, "device_id", record.DeviceID)
}
+566 -91
View File
@@ -7,6 +7,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
@@ -118,35 +119,99 @@ const fieldValues = "values"
// the request boundary keeps a crafted batch from failing resolution outright.
const maxEffectiveContentIDs = 200
// maxShortcutMutationRetries bounds contention retries while still making a
// normal burst of edits effectively wait-free for clients. Each failed CAS
// means another writer made progress; exhausting this limit is therefore a
// retryable conflict, never permission to overwrite the newer document.
const maxShortcutMutationRetries = 32
const jsonNullLiteral = "null"
const navigationShortcutAtomicUpdateMessage = "Use PUT /settings/values/nav.shortcuts/item to change navigation shortcuts"
var (
errMutationIDConflict = errors.New("setting mutation id conflict")
errMutationReplayRollback = errors.New("setting mutation replay requires rollback")
errMutationTransactionRequired = errors.New("settings store does not support atomic idempotent mutations")
errShortcutMutationContention = errors.New("navigation shortcuts changed too quickly")
errShortcutMutationInvalidValue = errors.New("invalid navigation shortcut value")
)
type idempotentSettingMutationOutcome struct {
result json.RawMessage
stored *userstore.SettingValue
replay bool
changed bool
}
type shortcutMutationStore interface {
GetSettingValue(context.Context, userstore.SettingIdentity) (*userstore.SettingValue, error)
CompareAndSetSettingValue(
context.Context,
userstore.SettingIdentity,
json.RawMessage,
int64,
) (*userstore.SettingValue, error)
}
// settingValueResponse is one explicit stored value.
type settingValueResponse struct {
Key string `json:"key"`
Scope string `json:"scope"`
ProfileID string `json:"profile_id,omitempty"`
DeviceID string `json:"device_id,omitempty"`
LibraryID int `json:"library_id,omitempty"`
SeriesID string `json:"series_id,omitempty"`
Value json.RawMessage `json:"value"`
Revision int64 `json:"revision"`
UpdatedAt string `json:"updated_at,omitempty"`
Key string `json:"key"`
Scope string `json:"scope"`
ProfileID string `json:"profile_id,omitempty"`
ClientFamily string `json:"client_family,omitempty"`
DeviceID string `json:"device_id,omitempty"`
LibraryID int `json:"library_id,omitempty"`
SeriesID string `json:"series_id,omitempty"`
Value json.RawMessage `json:"value"`
Revision int64 `json:"revision"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type navigationShortcutMutationRequest struct {
Item json.RawMessage `json:"item"`
Present *bool `json:"present"`
}
type navigationShortcutDocument struct {
Items []navigationShortcutItem `json:"items"`
}
// navigationShortcutItem mirrors navigation-shortcuts.json. LibraryID is a
// pointer because its presence is part of collection identity: a global
// collection and a library-specific collection with the same collection_id
// are different destinations.
type navigationShortcutItem struct {
Type string `json:"type"`
LibraryID *int `json:"library_id,omitempty"`
SectionID string `json:"section_id,omitempty"`
CollectionID string `json:"collection_id,omitempty"`
Label string `json:"label"`
}
type navigationShortcutIdentity struct {
Type string
LibraryID int
HasLibraryID bool
SectionID string
CollectionID string
}
// explicitSettingValueResponse is one entry from the collection GET. Unset is
// represented explicitly rather than as a 404 so a settings screen can fetch
// several profile defaults or device overrides in one request.
type explicitSettingValueResponse struct {
Key string `json:"key"`
Scope string `json:"scope"`
ProfileID string `json:"profile_id,omitempty"`
DeviceID string `json:"device_id,omitempty"`
LibraryID int `json:"library_id,omitempty"`
SeriesID string `json:"series_id,omitempty"`
IsSet bool `json:"is_set"`
Value json.RawMessage `json:"value,omitempty"`
Revision int64 `json:"revision,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
Key string `json:"key"`
Scope string `json:"scope"`
ProfileID string `json:"profile_id,omitempty"`
ClientFamily string `json:"client_family,omitempty"`
DeviceID string `json:"device_id,omitempty"`
LibraryID int `json:"library_id,omitempty"`
SeriesID string `json:"series_id,omitempty"`
IsSet bool `json:"is_set"`
Value json.RawMessage `json:"value,omitempty"`
Revision int64 `json:"revision,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// effectiveSettingValueResponse is one resolved value plus where it came from.
@@ -176,18 +241,20 @@ type effectiveSettingValueResponse struct {
// Scope locates the row the value came from, so a client can offer a reset
// against exactly that scope. Empty for a contract default.
Scope string `json:"scope,omitempty"`
ProfileID string `json:"profile_id,omitempty"`
DeviceID string `json:"device_id,omitempty"`
LibraryID int `json:"library_id,omitempty"`
SeriesID string `json:"series_id,omitempty"`
Scope string `json:"scope,omitempty"`
ProfileID string `json:"profile_id,omitempty"`
ClientFamily string `json:"client_family,omitempty"`
DeviceID string `json:"device_id,omitempty"`
LibraryID int `json:"library_id,omitempty"`
SeriesID string `json:"series_id,omitempty"`
}
type effectiveSourceContextResponse struct {
ProfileID string `json:"profile_id,omitempty"`
DeviceID string `json:"device_id,omitempty"`
LibraryID int `json:"library_id,omitempty"`
SeriesID string `json:"series_id,omitempty"`
ProfileID string `json:"profile_id,omitempty"`
ClientFamily string `json:"client_family,omitempty"`
DeviceID string `json:"device_id,omitempty"`
LibraryID int `json:"library_id,omitempty"`
SeriesID string `json:"series_id,omitempty"`
}
// HandleGetContract serves the public manifest.
@@ -235,12 +302,21 @@ func (h *SettingValuesHandler) HandleGetCapabilities(w http.ResponseWriter, r *h
"scopes": []string{
string(settingscontract.ScopeAccount),
string(settingscontract.ScopeProfile),
string(settingscontract.ScopeProfileClient),
string(settingscontract.ScopeProfileDevice),
string(settingscontract.ScopeProfileLibrary),
string(settingscontract.ScopeProfileSeries),
},
"client_families": []string{
string(settingscontract.ClientFamilyTV),
string(settingscontract.ClientFamilyMobile),
string(settingscontract.ClientFamilyTablet),
string(settingscontract.ClientFamilyDesktop),
string(settingscontract.ClientFamilyWeb),
},
"supports_batched_effective": true,
"supports_idempotent_writes": true,
"supports_atomic_shortcuts": true,
})
}
@@ -306,6 +382,9 @@ func (h *SettingValuesHandler) HandleGetValues(w http.ResponseWriter, r *http.Re
switch identity.Scope {
case settingscontract.ScopeProfile:
query.ProfileIDs = []string{identity.ProfileID}
case settingscontract.ScopeProfileClient:
query.ProfileIDs = []string{identity.ProfileID}
query.ClientFamily = identity.ClientFamily
case settingscontract.ScopeProfileDevice:
query.ProfileIDs = []string{identity.ProfileID}
query.DeviceID = identity.DeviceID
@@ -332,7 +411,8 @@ func (h *SettingValuesHandler) HandleGetValues(w http.ResponseWriter, r *http.Re
for _, key := range keys {
entry := explicitSettingValueResponse{
Key: key, Scope: string(identity.Scope), ProfileID: identity.ProfileID,
DeviceID: identity.DeviceID, LibraryID: identity.LibraryID, SeriesID: identity.SeriesID,
ClientFamily: string(identity.ClientFamily), DeviceID: identity.DeviceID,
LibraryID: identity.LibraryID, SeriesID: identity.SeriesID,
}
if value, exists := byKey[key]; exists {
entry.IsSet = true
@@ -363,9 +443,333 @@ func (h *SettingValuesHandler) HandleSetValue(w http.ResponseWriter, r *http.Req
if !ok {
return
}
if identity.Key == settingskeys.NavShortcuts {
writeError(w, http.StatusBadRequest, "atomic_update_required",
navigationShortcutAtomicUpdateMessage)
return
}
h.setValueAt(w, r, store, apimw.GetUserID(r.Context()), identity)
}
// HandleSetNavigationShortcut applies one desired-state edit to the shared
// profile shortcut catalog. Unlike the generic whole-document PUT, two clients
// adding different destinations cannot overwrite one another: the handler
// rebases after an internal compare-and-set conflict until its semantic edit
// lands on the newest document.
func (h *SettingValuesHandler) HandleSetNavigationShortcut(w http.ResponseWriter, r *http.Request) {
store, ok := h.storeFor(w, r)
if !ok {
return
}
shortcutStore, ok := store.(shortcutMutationStore)
if !ok {
writeError(w, http.StatusInternalServerError, "internal_error",
"This settings store does not support atomic shortcut updates")
return
}
profileID := strings.TrimSpace(apimw.GetProfileID(r.Context()))
identity := userstore.SettingIdentity{
Key: settingskeys.NavShortcuts, Scope: settingscontract.ScopeProfile, ProfileID: profileID,
}
if err := identity.Validate(); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "X-Profile-Id header is required")
return
}
def, ok := h.definitionFor(w, identity.Key)
if !ok {
return
}
var body navigationShortcutMutationRequest
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "bad_request",
"Body must be {\"item\": {…}, \"present\": true|false}")
return
}
if _, err := decoder.Token(); !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, "bad_request", "Body must be a single JSON document")
return
}
if body.Present == nil {
writeError(w, http.StatusBadRequest, "bad_request", "present is required")
return
}
item, err := normalizeNavigationShortcutItem(def, body.Item)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_value", err.Error())
return
}
requestHash := hashNavigationShortcutMutation(identity, item, *body.Present)
mutationID := strings.TrimSpace(r.Header.Get(mutationIDHeader))
var stored *userstore.SettingValue
var idempotentResult json.RawMessage
var changed bool
if mutationID == "" {
stored, changed, err = mutateNavigationShortcut(
r.Context(), shortcutStore, def, identity, item, *body.Present)
} else {
var outcome idempotentSettingMutationOutcome
outcome, err = runIdempotentSettingMutation(
r.Context(), store, mutationID, requestHash,
func(writer userstore.SettingMutationWriter) (*userstore.SettingValue, bool, error) {
return mutateNavigationShortcut(r.Context(), writer, def, identity, item, *body.Present)
},
)
if err == nil && outcome.replay {
w.Header().Set("X-Silo-Idempotent-Replay", "true")
writeRawJSON(w, http.StatusOK, outcome.result)
return
}
stored, changed = outcome.stored, outcome.changed
idempotentResult = outcome.result
}
if err != nil {
switch {
case errors.Is(err, errMutationIDConflict):
writeError(w, http.StatusConflict, "mutation_id_conflict",
"This mutation id was used for a different write")
case errors.Is(err, errShortcutMutationContention):
writeError(w, http.StatusConflict, "setting_update_conflict",
"Navigation shortcuts changed too quickly; retry this mutation")
case errors.Is(err, errShortcutMutationInvalidValue):
writeError(w, http.StatusBadRequest, "invalid_value", err.Error())
case errors.Is(err, userstore.ErrInvalidSettingIdentity),
errors.Is(err, userstore.ErrInvalidSettingValue):
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store navigation shortcuts")
}
return
}
response := settingValueToResponse(*stored)
if changed {
publishUserSettingsEvent(r.Context(), h.EventsHub,
apimw.GetUserID(r.Context()), identity.ProfileID, identity.Key, string(identity.Scope))
auditSettingsForOther(r.Context(), settingsAuditRecord{
Action: settingsAuditActionSet,
ActorProfileID: actingProfileID(r.Context()),
TargetProfileID: identity.ProfileID,
TargetUserID: apimw.GetUserID(r.Context()),
Key: identity.Key,
Scope: string(identity.Scope),
})
}
if idempotentResult != nil {
writeRawJSON(w, http.StatusOK, idempotentResult)
} else {
writeJSON(w, http.StatusOK, response)
}
}
func normalizeNavigationShortcutItem(
def *settingscontract.Definition,
item json.RawMessage,
) (navigationShortcutItem, error) {
if len(item) == 0 {
return navigationShortcutItem{}, errors.New("item is required")
}
raw, err := json.Marshal(struct {
Items []json.RawMessage `json:"items"`
}{Items: []json.RawMessage{item}})
if err != nil {
return navigationShortcutItem{}, fmt.Errorf("encoding shortcut: %w", err)
}
normalized, err := def.ValueSchema.NormalizeValue(raw, settingscontract.ObjectSchemas())
if err != nil {
return navigationShortcutItem{}, err
}
var document navigationShortcutDocument
if err := json.Unmarshal(normalized, &document); err != nil || len(document.Items) != 1 {
return navigationShortcutItem{}, errors.New("shortcut did not normalize to one item")
}
return document.Items[0], nil
}
func applyNavigationShortcutMutation(
def *settingscontract.Definition,
current *userstore.SettingValue,
item navigationShortcutItem,
present bool,
) (json.RawMessage, bool, error) {
document := navigationShortcutDocument{Items: []navigationShortcutItem{}}
if current != nil {
if err := json.Unmarshal(current.Value, &document); err != nil {
return nil, false, fmt.Errorf("decoding stored navigation shortcuts: %w", err)
}
}
target := item.identity()
match := -1
for index, candidate := range document.Items {
if candidate.identity() == target {
match = index
break
}
}
if present {
if match >= 0 {
if document.Items[match].equal(item) {
return current.Value, false, nil
}
document.Items[match] = item
} else {
document.Items = append(document.Items, item)
}
} else {
if match < 0 {
if current == nil {
return json.RawMessage(`{"items":[]}`), false, nil
}
return current.Value, false, nil
}
document.Items = append(document.Items[:match], document.Items[match+1:]...)
}
raw, err := json.Marshal(document)
if err != nil {
return nil, false, fmt.Errorf("encoding navigation shortcuts: %w", err)
}
normalized, err := def.ValueSchema.NormalizeValue(raw, settingscontract.ObjectSchemas())
if err != nil {
return nil, false, fmt.Errorf("%w: %w", errShortcutMutationInvalidValue, err)
}
return normalized, true, nil
}
func (item navigationShortcutItem) identity() navigationShortcutIdentity {
identity := navigationShortcutIdentity{
Type: item.Type, SectionID: item.SectionID, CollectionID: item.CollectionID,
}
if item.LibraryID != nil {
identity.LibraryID = *item.LibraryID
identity.HasLibraryID = true
}
return identity
}
func (item navigationShortcutItem) equal(other navigationShortcutItem) bool {
return item.identity() == other.identity() && item.Label == other.Label
}
func mutateNavigationShortcut(
ctx context.Context,
store shortcutMutationStore,
def *settingscontract.Definition,
identity userstore.SettingIdentity,
item navigationShortcutItem,
present bool,
) (*userstore.SettingValue, bool, error) {
for attempt := 0; attempt < maxShortcutMutationRetries; attempt++ {
current, err := store.GetSettingValue(ctx, identity)
if err != nil {
return nil, false, fmt.Errorf("reading navigation shortcuts: %w", err)
}
next, changed, err := applyNavigationShortcutMutation(def, current, item, present)
if err != nil {
return nil, false, err
}
if !changed {
if current != nil {
return current, false, nil
}
return &userstore.SettingValue{
SettingIdentity: identity,
Value: json.RawMessage(`{"items":[]}`),
}, false, nil
}
expectedRevision := int64(0)
if current != nil {
expectedRevision = current.Revision
}
stored, err := store.CompareAndSetSettingValue(ctx, identity, next, expectedRevision)
if errors.Is(err, userstore.ErrSettingValueRevisionConflict) {
continue
}
if err != nil {
return nil, false, err
}
return stored, true, nil
}
return nil, false, errShortcutMutationContention
}
func runIdempotentSettingMutation(
ctx context.Context,
store userstore.UserStore,
mutationID string,
requestHash string,
mutate func(userstore.SettingMutationWriter) (*userstore.SettingValue, bool, error),
) (idempotentSettingMutationOutcome, error) {
transactioner, ok := store.(userstore.SettingMutationTransactioner)
if !ok {
return idempotentSettingMutationOutcome{}, errMutationTransactionRequired
}
var outcome idempotentSettingMutationOutcome
err := transactioner.WithSettingMutationTransaction(ctx, mutationID,
func(writer userstore.SettingMutationWriter) error {
prior, err := writer.GetSettingMutation(ctx, mutationID)
if err != nil {
return fmt.Errorf("checking setting mutation: %w", err)
}
if prior != nil {
if prior.RequestHash != requestHash {
return errMutationIDConflict
}
outcome.result = slices.Clone(prior.Result)
outcome.replay = true
return nil
}
stored, changed, err := mutate(writer)
if err != nil {
return err
}
response := settingValueToResponse(*stored)
result, err := json.Marshal(response)
if err != nil {
return fmt.Errorf("encoding setting mutation receipt: %w", err)
}
record, inserted, err := writer.PutSettingMutation(ctx, userstore.SettingMutationRecord{
MutationID: mutationID,
RequestHash: requestHash,
Result: result,
ExpiresAt: time.Now().UTC().Add(30 * 24 * time.Hour),
})
if err != nil {
return fmt.Errorf("recording setting mutation: %w", err)
}
if !inserted {
if record.RequestHash != requestHash {
return errMutationIDConflict
}
outcome.result = slices.Clone(record.Result)
outcome.replay = true
// A legacy writer could have inserted between the initial read and
// this insert. Roll back our setting write before serving its receipt.
return errMutationReplayRollback
}
outcome.result = slices.Clone(record.Result)
outcome.stored = stored
outcome.changed = changed
return nil
})
if errors.Is(err, errMutationReplayRollback) {
return outcome, nil
}
return outcome, err
}
// setValueAt is the write path shared by the session route and the admin
// route: validation, normalization, idempotency and the change event are one
// implementation regardless of who addresses the store. eventUserID names the
@@ -413,27 +817,34 @@ func (h *SettingValuesHandler) setValueAt(
// not double-apply it, and must be able to tell "already done" from "that
// id means something else".
mutationID := strings.TrimSpace(r.Header.Get(mutationIDHeader))
var requestHash string
if mutationID != "" {
requestHash = hashMutationRequest(identity, normalized)
prior, err := store.GetSettingMutation(r.Context(), mutationID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check the mutation id")
var stored *userstore.SettingValue
var idempotentResult json.RawMessage
if mutationID == "" {
stored, err = store.UpsertSettingValue(r.Context(), identity, normalized)
} else {
outcome, mutationErr := runIdempotentSettingMutation(
r.Context(), store, mutationID, hashMutationRequest(identity, normalized),
func(writer userstore.SettingMutationWriter) (*userstore.SettingValue, bool, error) {
value, err := writer.UpsertSettingValue(r.Context(), identity, normalized)
return value, true, err
},
)
if errors.Is(mutationErr, errMutationIDConflict) {
writeError(w, http.StatusConflict, "mutation_id_conflict",
"This mutation id was used for a different write")
return
}
if prior != nil {
if prior.RequestHash != requestHash {
writeError(w, http.StatusConflict, "mutation_id_conflict",
"This mutation id was used for a different write")
return
}
if mutationErr != nil {
err = mutationErr
} else if outcome.replay {
w.Header().Set("X-Silo-Idempotent-Replay", "true")
writeRawJSON(w, http.StatusOK, prior.Result)
writeRawJSON(w, http.StatusOK, outcome.result)
return
} else {
stored = outcome.stored
idempotentResult = outcome.result
}
}
stored, err := store.UpsertSettingValue(r.Context(), identity, normalized)
if err != nil {
if errors.Is(err, userstore.ErrInvalidSettingIdentity) ||
errors.Is(err, userstore.ErrInvalidSettingValue) {
@@ -445,14 +856,6 @@ func (h *SettingValuesHandler) setValueAt(
}
response := settingValueToResponse(*stored)
if mutationID != "" {
// Recorded only now, after the write landed: a receipt written for a
// failed upsert would turn the client's retry of a 500 into a silent
// 200 replay of a write that never happened. Storing the actual
// response also makes the replay byte-identical — revision and
// updated_at included — rather than a reconstruction of the input.
h.recordMutation(r, store, mutationID, requestHash, response)
}
acting := actingProfileID(r.Context())
if identity.Scope == settingscontract.ScopeProfileDevice {
// A device that only ever writes canonically must still appear in
@@ -472,15 +875,20 @@ func (h *SettingValuesHandler) setValueAt(
publishUserSettingsEvent(r.Context(), h.EventsHub,
eventUserID, identity.ProfileID, identity.Key, string(identity.Scope))
auditSettingsForOther(r.Context(), settingsAuditRecord{
Action: "set",
Action: settingsAuditActionSet,
ActorProfileID: acting,
TargetProfileID: identity.ProfileID,
TargetUserID: eventUserID,
ClientFamily: string(identity.ClientFamily),
DeviceID: identity.DeviceID,
Key: identity.Key,
Scope: string(identity.Scope),
})
writeJSON(w, http.StatusOK, response)
if idempotentResult != nil {
writeRawJSON(w, http.StatusOK, idempotentResult)
} else {
writeJSON(w, http.StatusOK, response)
}
}
// registerWritingDevice refreshes the device registry from the request's
@@ -531,6 +939,11 @@ func (h *SettingValuesHandler) HandleDeleteValue(w http.ResponseWriter, r *http.
if !ok {
return
}
if identity.Key == settingskeys.NavShortcuts {
writeError(w, http.StatusBadRequest, "atomic_update_required",
navigationShortcutAtomicUpdateMessage)
return
}
h.deleteValueAt(w, r, store, apimw.GetUserID(r.Context()), identity)
}
@@ -557,6 +970,7 @@ func (h *SettingValuesHandler) deleteValueAt(
ActorProfileID: actingProfileID(r.Context()),
TargetProfileID: identity.ProfileID,
TargetUserID: eventUserID,
ClientFamily: string(identity.ClientFamily),
DeviceID: identity.DeviceID,
Key: identity.Key,
Scope: string(identity.Scope),
@@ -608,6 +1022,11 @@ func (h *SettingValuesHandler) HandleGetEffective(w http.ResponseWriter, r *http
LibraryIDs: parseIntCSV(r.URL.Query().Get("library_ids")),
SeriesIDs: splitCSV(r.URL.Query().Get("series_ids")),
}
if family, needed, ok := h.clientFamilyForKeys(w, r, keys); !ok {
return
} else if needed {
rc.ClientFamily = family
}
// A device-settings screen resolves what some *other* device sees, so this
// read accepts the same explicit identity the write path does, under the
@@ -714,6 +1133,10 @@ func (h *SettingValuesHandler) HandlePostEffective(w http.ResponseWriter, r *htt
profileID := strings.TrimSpace(apimw.GetProfileID(r.Context()))
deviceID := deviceMetadataFromRequest(r).DeviceID
clientFamily, familyNeeded, ok := h.clientFamilyForKeys(w, r, keys)
if !ok {
return
}
if deviceID == "" {
for _, key := range keys {
if def, exists := h.contract.Lookup(key); exists && def.AllowsScope(settingscontract.ScopeProfileDevice) {
@@ -749,6 +1172,9 @@ func (h *SettingValuesHandler) HandlePostEffective(w http.ResponseWriter, r *htt
return
}
rc := settingsresolve.Context{ProfileID: profileID, DeviceID: deviceID}
if familyNeeded {
rc.ClientFamily = clientFamily
}
if libraryID > 0 {
rc.LibraryIDs = []int{libraryID}
contentIDs++
@@ -862,28 +1288,6 @@ func (h *SettingValuesHandler) constraintsFor(r *http.Request) settingsresolve.C
return settingsresolve.Constraints{policyInputMaxPlaybackQuality: limit}
}
// recordMutation stores the idempotency receipt after a successful write. The
// receipt is the response the original request returned, so a replay serves
// exactly what the client would have received.
func (h *SettingValuesHandler) recordMutation(
r *http.Request,
store userstore.UserStore,
mutationID, requestHash string,
response settingValueResponse,
) {
receipt, _ := json.Marshal(response)
// Best effort: the write already succeeded, and failing the request now
// would tell the client the opposite of the truth. A missing receipt costs
// at most a duplicate write on retry, which upsert makes harmless.
_, _, _ = store.PutSettingMutation(r.Context(), userstore.SettingMutationRecord{
MutationID: mutationID,
RequestHash: requestHash,
Result: receipt,
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(30 * 24 * time.Hour),
})
}
func (h *SettingValuesHandler) storeFor(w http.ResponseWriter, r *http.Request) (userstore.UserStore, bool) {
store, err := h.storeProvider.ForUser(r.Context(), apimw.GetUserID(r.Context()))
if err != nil {
@@ -908,6 +1312,37 @@ func (h *SettingValuesHandler) definitionFor(w http.ResponseWriter, key string)
return def, true
}
// clientFamilyForKeys validates an optional family header for effective reads.
// An absent header deliberately drops the profile_client layer so pre-revision
// 5 callers keep resolving broader fallbacks; explicit profile_client reads and
// writes still require the header in identityForSessionKey. The server never
// guesses this identity from X-Silo-Device-Platform: that header is free-form
// display metadata, while client_family is a closed storage key shared by like
// clients.
func (h *SettingValuesHandler) clientFamilyForKeys(
w http.ResponseWriter, r *http.Request, keys []string,
) (settingscontract.ClientFamily, bool, bool) {
eligible := false
for _, key := range keys {
if def, ok := h.contract.Lookup(key); ok && def.AllowsScope(settingscontract.ScopeProfileClient) {
eligible = true
break
}
}
value := strings.TrimSpace(r.Header.Get(clientFamilyHeader))
if value == "" {
return "", false, true
}
family := settingscontract.ClientFamily(value)
if !family.Valid() {
writeError(w, http.StatusBadRequest, "bad_request",
"X-Silo-Client-Family header must be one of tv, mobile, tablet, desktop or web")
return "", false, false
}
return family, eligible, true
}
// identityFromRequest builds and validates the scope identity a request names.
//
// Scope comes from the query string rather than the path so one route serves
@@ -947,6 +1382,15 @@ func (h *SettingValuesHandler) identityForSessionKey(
identity.ProfileID = named
}
}
if scope == settingscontract.ScopeProfileClient {
family := settingscontract.ClientFamily(strings.TrimSpace(r.Header.Get(clientFamilyHeader)))
if !family.Valid() {
writeError(w, http.StatusBadRequest, "bad_request",
"X-Silo-Client-Family header must be one of tv, mobile, tablet, desktop or web")
return userstore.SettingIdentity{}, false
}
identity.ClientFamily = family
}
if scope == settingscontract.ScopeProfileDevice {
// A device may be named explicitly so one device can manage another's
// settings — the screen that lists your devices edits them in place.
@@ -1064,7 +1508,7 @@ func (h *SettingValuesHandler) keyedScope(
scope := settingscontract.Scope(strings.TrimSpace(query.Get("scope")))
if scope == "" {
writeError(w, http.StatusBadRequest, "bad_request",
"A scope is required: account, profile, profile_device, profile_library or profile_series")
"A scope is required: account, profile, profile_client, profile_device, profile_library or profile_series")
return "", "", false
}
return key, scope, true
@@ -1133,20 +1577,22 @@ func (h *SettingValuesHandler) libraryContextExists(
func sameSettingContext(a, b userstore.SettingIdentity) bool {
return a.Scope == b.Scope && a.ProfileID == b.ProfileID &&
a.DeviceID == b.DeviceID && a.LibraryID == b.LibraryID && a.SeriesID == b.SeriesID
a.ClientFamily == b.ClientFamily && a.DeviceID == b.DeviceID &&
a.LibraryID == b.LibraryID && a.SeriesID == b.SeriesID
}
func settingValueToResponse(value userstore.SettingValue) settingValueResponse {
return settingValueResponse{
Key: value.Key,
Scope: string(value.Scope),
ProfileID: value.ProfileID,
DeviceID: value.DeviceID,
LibraryID: value.LibraryID,
SeriesID: value.SeriesID,
Value: value.Value,
Revision: value.Revision,
UpdatedAt: value.UpdatedAt,
Key: value.Key,
Scope: string(value.Scope),
ProfileID: value.ProfileID,
ClientFamily: string(value.ClientFamily),
DeviceID: value.DeviceID,
LibraryID: value.LibraryID,
SeriesID: value.SeriesID,
Value: value.Value,
Revision: value.Revision,
UpdatedAt: value.UpdatedAt,
}
}
@@ -1169,14 +1615,16 @@ func effectiveToResponse(eff settingsresolve.Effective) effectiveSettingValueRes
if eff.Identity != nil {
out.Scope = string(eff.Identity.Scope)
out.ProfileID = eff.Identity.ProfileID
out.ClientFamily = string(eff.Identity.ClientFamily)
out.DeviceID = eff.Identity.DeviceID
out.LibraryID = eff.Identity.LibraryID
out.SeriesID = eff.Identity.SeriesID
out.SourceContext = &effectiveSourceContextResponse{
ProfileID: eff.Identity.ProfileID,
DeviceID: eff.Identity.DeviceID,
LibraryID: eff.Identity.LibraryID,
SeriesID: eff.Identity.SeriesID,
ProfileID: eff.Identity.ProfileID,
ClientFamily: string(eff.Identity.ClientFamily),
DeviceID: eff.Identity.DeviceID,
LibraryID: eff.Identity.LibraryID,
SeriesID: eff.Identity.SeriesID,
}
}
return out
@@ -1307,9 +1755,36 @@ func hashMutationRequest(identity userstore.SettingIdentity, value json.RawMessa
sum.Write([]byte(identity.SeriesID))
sum.Write([]byte{0})
sum.Write(value)
// Preserve the established fingerprint for the five pre-existing scopes so
// an in-flight retry made across this server upgrade still replays. Only the
// new profile_client identity appends a family discriminator.
if identity.ClientFamily != "" {
sum.Write([]byte{0})
sum.Write([]byte(identity.ClientFamily))
}
return hex.EncodeToString(sum.Sum(nil))
}
func hashNavigationShortcutMutation(
identity userstore.SettingIdentity,
item navigationShortcutItem,
present bool,
) string {
// Removing a destination ignores presentation fields, so the fingerprint
// does too. Reusing one mutation id for the same remove with a refreshed
// label is the same operation; adding includes the label because it can
// update that field in place.
if !present {
item.Label = ""
}
canonical, _ := json.Marshal(struct {
Operation string `json:"operation"`
Item navigationShortcutItem `json:"item"`
Present bool `json:"present"`
}{Operation: "set_navigation_shortcut_presence", Item: item, Present: present})
return hashMutationRequest(identity, canonical)
}
func writeRawJSON(w http.ResponseWriter, status int, body []byte) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
@@ -5,9 +5,12 @@ import (
"strings"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
const adminNavigationShortcutRepairMessage = "Use whole-document PUT on this admin resource to clear or replace navigation shortcuts"
// Admin projections of the canonical settings API. These replace the string
// registry's /admin/users/{id}/settings* and device-settings* routes with the
// same typed surface clients use on /settings/values: the same validation, the
@@ -81,6 +84,11 @@ func (h *SettingValuesHandler) HandleAdminDeleteUserSettingValue(w http.Response
if !ok {
return
}
if identity.Key == settingskeys.NavShortcuts {
writeError(w, http.StatusBadRequest, "atomic_update_required",
adminNavigationShortcutRepairMessage)
return
}
h.deleteValueAt(w, r, store, userID, identity)
}
@@ -128,6 +136,14 @@ func (h *SettingValuesHandler) adminIdentityFromRequest(
return userstore.SettingIdentity{}, false
}
}
if scope == settingscontract.ScopeProfileClient {
identity.ClientFamily = settingscontract.ClientFamily(strings.TrimSpace(query.Get("client_family")))
if !identity.ClientFamily.Valid() {
writeError(w, http.StatusBadRequest, "bad_request",
"client_family must be one of tv, mobile, tablet, desktop or web")
return userstore.SettingIdentity{}, false
}
}
if scope == settingscontract.ScopeProfileDevice {
identity.DeviceID = strings.TrimSpace(query.Get("device_id"))
if identity.DeviceID == "" {
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
@@ -129,6 +130,10 @@ func TestAdminListShowsAnotherUsersValuesAcrossScopes(t *testing.T) {
settingscontract.ScopeProfile: {
Key: "playback.subtitle_mode", Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
},
settingscontract.ScopeProfileClient: {
Key: "nav.primary_menu", Scope: settingscontract.ScopeProfileClient,
ProfileID: "profile-1", ClientFamily: settingscontract.ClientFamilyTV,
},
settingscontract.ScopeProfileDevice: {
Key: "playback.subtitle_language", Scope: settingscontract.ScopeProfileDevice,
ProfileID: "profile-1", DeviceID: "tv-1",
@@ -145,6 +150,7 @@ func TestAdminListShowsAnotherUsersValuesAcrossScopes(t *testing.T) {
values := map[settingscontract.Scope]string{
settingscontract.ScopeAccount: `"de"`,
settingscontract.ScopeProfile: `"always"`,
settingscontract.ScopeProfileClient: `{"items":[{"type":"builtin","destination":"home"}]}`,
settingscontract.ScopeProfileDevice: `"en"`,
settingscontract.ScopeProfileLibrary: `"fr"`,
settingscontract.ScopeProfileSeries: `"ja"`,
@@ -186,6 +192,7 @@ func TestAdminListShowsAnotherUsersValuesAcrossScopes(t *testing.T) {
continue
}
if got.Key != want.Key || got.ProfileID != want.ProfileID ||
got.ClientFamily != string(want.ClientFamily) ||
got.DeviceID != want.DeviceID || got.LibraryID != want.LibraryID ||
got.SeriesID != want.SeriesID {
t.Errorf("listed identity at %s = %+v, want %+v", got.Scope, got, want)
@@ -243,6 +250,83 @@ func TestAdminSetAndDeleteAtExplicitScopeRoundTrip(t *testing.T) {
}
}
func TestAdminNavigationShortcutRepairPreservesRevisionHistory(t *testing.T) {
env := newAdminValuesEnv(t)
ctx := context.Background()
identity := userstore.SettingIdentity{
Key: "nav.shortcuts", Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
}
seeded, err := env.targetStore.UpsertSettingValue(ctx, identity,
json.RawMessage(`{"items":[{"type":"library","library_id":42,"label":"Movies"}]}`))
if err != nil {
t.Fatalf("seed shortcuts: %v", err)
}
if seeded.Revision != 1 {
t.Fatalf("seed revision = %d, want 1", seeded.Revision)
}
target := "/admin/users/7/settings/values/nav.shortcuts?scope=profile&profile_id=profile-1"
if rec := env.do(t, "admin", http.MethodDelete, target, nil); rec.Code != http.StatusBadRequest {
t.Fatalf("admin repair DELETE = %d, want 400: %s", rec.Code, rec.Body.String())
} else if !bytes.Contains(rec.Body.Bytes(), []byte(`"error":"atomic_update_required"`)) {
t.Fatalf("admin repair DELETE body = %s, want atomic_update_required", rec.Body.String())
}
if got, err := env.targetStore.GetSettingValue(ctx, identity); err != nil || got == nil || got.Revision != 1 {
t.Fatalf("shortcut after rejected DELETE = %+v err=%v, want revision 1 intact", got, err)
}
rec := env.do(t, "admin", http.MethodPut, target, []byte(`{"value":{"items":[]}}`))
if rec.Code != http.StatusOK {
t.Fatalf("admin repair PUT empty = %d: %s", rec.Code, rec.Body.String())
}
var repaired settingValueResponse
if err := json.Unmarshal(rec.Body.Bytes(), &repaired); err != nil {
t.Fatalf("decode repair response: %v", err)
}
if repaired.Revision != 2 || string(repaired.Value) != `{"items":[]}` {
t.Fatalf("repair response = revision %d value %s, want empty revision 2",
repaired.Revision, repaired.Value)
}
cas, ok := env.targetStore.(userstore.SettingValueCompareAndSetter)
if !ok {
t.Fatal("target store does not support compare-and-set")
}
_, err = cas.CompareAndSetSettingValue(ctx, identity,
json.RawMessage(`{"items":[{"type":"library","library_id":99,"label":"Shows"}]}`),
seeded.Revision)
if !errors.Is(err, userstore.ErrSettingValueRevisionConflict) {
t.Fatalf("stale CAS after admin repair = %v, want revision conflict", err)
}
if got, err := env.targetStore.GetSettingValue(ctx, identity); err != nil || got == nil ||
got.Revision != 2 || string(got.Value) != `{"items":[]}` {
t.Fatalf("shortcut after stale CAS = %+v err=%v, want empty revision 2", got, err)
}
}
func TestAdminProfileClientUsesExplicitQueryIdentity(t *testing.T) {
env := newAdminValuesEnv(t)
target := "/admin/users/7/settings/values/nav.primary_menu" +
"?scope=profile_client&profile_id=profile-1&client_family=tv"
body := []byte(`{"value":{"items":[{"type":"builtin","destination":"home"}]}}`)
rec := env.do(t, "admin", http.MethodPut, target, body)
if rec.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
var stored settingValueResponse
if err := json.Unmarshal(rec.Body.Bytes(), &stored); err != nil {
t.Fatalf("decoding: %v", err)
}
if stored.Scope != "profile_client" || stored.ProfileID != "profile-1" || stored.ClientFamily != "tv" {
t.Fatalf("stored identity = %+v, want profile-1/tv", stored)
}
missing := "/admin/users/7/settings/values/nav.primary_menu?scope=profile_client&profile_id=profile-1"
if rec := env.do(t, "admin", http.MethodPut, missing, body); rec.Code != http.StatusBadRequest {
t.Fatalf("PUT without family = %d, want 400: %s", rec.Code, rec.Body.String())
}
}
// TestAdminSetRejectsInvalidValueLikeTheSessionRoute pins that the admin write
// is the same validation path as /settings/values, not a second validator: an
// invalid value fails with the identical status, code and message.
+691 -5
View File
@@ -3,14 +3,19 @@ package handlers
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/go-chi/chi/v5"
@@ -20,6 +25,7 @@ import (
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/settingsresolve"
@@ -36,6 +42,7 @@ func newValuesTestHandler(t *testing.T) (*SettingValuesHandler, userstore.UserSt
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
if err := userdb.InitSchema(db); err != nil {
t.Fatalf("init schema: %v", err)
@@ -65,6 +72,7 @@ func valuesRequest(method, target string, body []byte) *http.Request {
req = httptest.NewRequest(method, target, bytes.NewReader(body))
}
req.Header.Set(deviceIDHeader, "device-1")
req.Header.Set(clientFamilyHeader, "web")
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
return req.WithContext(apimw.SetProfileID(ctx, "profile-1"))
}
@@ -94,6 +102,499 @@ func routeValues(t *testing.T, h *SettingValuesHandler, method, key, query strin
return rec
}
func routeNavigationShortcutMutation(
t *testing.T,
h *SettingValuesHandler,
body string,
mutationID string,
) *httptest.ResponseRecorder {
t.Helper()
req := valuesRequest(http.MethodPut, "/settings/values/nav.shortcuts/item", []byte(body))
if mutationID != "" {
req.Header.Set(mutationIDHeader, mutationID)
}
rec := httptest.NewRecorder()
h.HandleSetNavigationShortcut(rec, req)
return rec
}
func decodeShortcutResponse(t *testing.T, rec *httptest.ResponseRecorder) (settingValueResponse, navigationShortcutDocument) {
t.Helper()
var response settingValueResponse
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("decode shortcut response: %v; body=%s", err, rec.Body.String())
}
var document navigationShortcutDocument
if err := json.Unmarshal(response.Value, &document); err != nil {
t.Fatalf("decode shortcut document: %v; value=%s", err, response.Value)
}
return response, document
}
func TestNavigationShortcutMutationLifecycle(t *testing.T) {
handler, _ := newValuesTestHandler(t)
addLibrary := `{"item":{"type":"library","library_id":42,"label":"Movies"},"present":true}`
rec := routeNavigationShortcutMutation(t, handler, addLibrary, "")
if rec.Code != http.StatusOK {
t.Fatalf("add library = %d: %s", rec.Code, rec.Body.String())
}
response, document := decodeShortcutResponse(t, rec)
if response.Key != settingskeys.NavShortcuts || response.Scope != "profile" || response.Revision != 1 {
t.Fatalf("add response = %+v, want profile nav.shortcuts revision 1", response)
}
if len(document.Items) != 1 || document.Items[0].Label != "Movies" {
t.Fatalf("add document = %+v, want Movies", document)
}
addSection := `{"item":{"type":"section","library_id":42,"section_id":"recent","label":"Recent"},"present":true}`
rec = routeNavigationShortcutMutation(t, handler, addSection, "")
if rec.Code != http.StatusOK {
t.Fatalf("add section = %d: %s", rec.Code, rec.Body.String())
}
response, document = decodeShortcutResponse(t, rec)
if response.Revision != 2 || len(document.Items) != 2 || document.Items[1].SectionID != "recent" {
t.Fatalf("add section = revision %d document %+v, want both items at revision 2", response.Revision, document)
}
refreshLibrary := `{"item":{"type":"library","library_id":42,"label":"Films"},"present":true}`
rec = routeNavigationShortcutMutation(t, handler, refreshLibrary, "")
response, document = decodeShortcutResponse(t, rec)
if response.Revision != 3 || len(document.Items) != 2 || document.Items[0].Label != "Films" || document.Items[1].SectionID != "recent" {
t.Fatalf("label refresh reordered or dropped items: revision %d document %+v", response.Revision, document)
}
// Exact desired state is a no-op: no revision churn and no duplicate.
rec = routeNavigationShortcutMutation(t, handler, refreshLibrary, "")
response, document = decodeShortcutResponse(t, rec)
if response.Revision != 3 || len(document.Items) != 2 {
t.Fatalf("repeat add = revision %d document %+v, want unchanged revision 3", response.Revision, document)
}
// Removal matches semantic identity only; a stale label cannot stop it.
removeLibrary := `{"item":{"type":"library","library_id":42,"label":"Old label"},"present":false}`
rec = routeNavigationShortcutMutation(t, handler, removeLibrary, "")
response, document = decodeShortcutResponse(t, rec)
if response.Revision != 4 || len(document.Items) != 1 || document.Items[0].SectionID != "recent" {
t.Fatalf("remove library = revision %d document %+v, want section preserved", response.Revision, document)
}
rec = routeNavigationShortcutMutation(t, handler, removeLibrary, "")
response, _ = decodeShortcutResponse(t, rec)
if response.Revision != 4 {
t.Fatalf("repeat remove revision = %d, want unchanged 4", response.Revision)
}
}
func TestNavigationShortcutCollectionIdentityIncludesOptionalLibrary(t *testing.T) {
handler, _ := newValuesTestHandler(t)
global := `{"item":{"type":"collection","collection_id":"favorites","label":"All Favorites"},"present":true}`
withinLibrary := `{"item":{"type":"collection","library_id":42,"collection_id":"favorites","label":"Movie Favorites"},"present":true}`
for _, body := range []string{global, withinLibrary} {
if rec := routeNavigationShortcutMutation(t, handler, body, ""); rec.Code != http.StatusOK {
t.Fatalf("add collection = %d: %s", rec.Code, rec.Body.String())
}
}
removeGlobal := `{"item":{"type":"collection","collection_id":"favorites","label":"Ignored"},"present":false}`
rec := routeNavigationShortcutMutation(t, handler, removeGlobal, "")
response, document := decodeShortcutResponse(t, rec)
if response.Revision != 3 || len(document.Items) != 1 || document.Items[0].LibraryID == nil || *document.Items[0].LibraryID != 42 {
t.Fatalf("global removal = revision %d document %+v, want scoped collection preserved", response.Revision, document)
}
}
func TestNavigationShortcutCollectionLibraryIDValidationCannotMutateExistingTargets(t *testing.T) {
handler, store := newValuesTestHandler(t)
global := `{"item":{"type":"collection","collection_id":"favorites","label":"All Favorites"},"present":true}`
withinLibrary := `{"item":{"type":"collection","library_id":42,"collection_id":"favorites","label":"Movie Favorites"},"present":true}`
for _, tc := range []struct {
name string
body string
}{
{"omitted library id is global", global},
{"positive library id", withinLibrary},
} {
t.Run(tc.name, func(t *testing.T) {
rec := routeNavigationShortcutMutation(t, handler, tc.body, "")
if rec.Code != http.StatusOK {
t.Fatalf("valid collection = %d: %s", rec.Code, rec.Body.String())
}
})
}
invalidLibraryIDs := map[string]string{
"explicit null": `null`,
"string": `"42"`,
"zero": `0`,
}
for name, libraryID := range invalidLibraryIDs {
t.Run(name, func(t *testing.T) {
body := fmt.Sprintf(
`{"item":{"type":"collection","library_id":%s,"collection_id":"favorites","label":"Ignored"},"present":false}`,
libraryID,
)
rec := routeNavigationShortcutMutation(t, handler, body, "")
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "invalid_value") {
t.Fatalf("invalid collection library_id = %d: %s", rec.Code, rec.Body.String())
}
stored, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: settingskeys.NavShortcuts, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
})
if err != nil || stored == nil {
t.Fatalf("read catalog after rejected remove: value=%+v err=%v", stored, err)
}
var document navigationShortcutDocument
if err := json.Unmarshal(stored.Value, &document); err != nil {
t.Fatalf("decode catalog after rejected remove: %v", err)
}
if stored.Revision != 2 || len(document.Items) != 2 {
t.Fatalf("rejected remove mutated catalog: revision %d items %+v", stored.Revision, document.Items)
}
if document.Items[0].LibraryID != nil {
t.Fatalf("global collection gained library identity: %+v", document.Items[0])
}
if document.Items[1].LibraryID == nil || *document.Items[1].LibraryID != 42 {
t.Fatalf("library-specific collection changed identity: %+v", document.Items[1])
}
})
}
}
func TestNavigationShortcutMutationIdempotency(t *testing.T) {
handler, _ := newValuesTestHandler(t)
const mutationID = "d615e91d-988f-4928-bb32-8956a26c7608"
body := `{"item":{"type":"collection","collection_id":"watchlist","label":"Watchlist"},"present":true}`
first := routeNavigationShortcutMutation(t, handler, body, mutationID)
if first.Code != http.StatusOK {
t.Fatalf("first mutation = %d: %s", first.Code, first.Body.String())
}
replay := routeNavigationShortcutMutation(t, handler, body, mutationID)
if replay.Code != http.StatusOK || replay.Header().Get("X-Silo-Idempotent-Replay") != "true" {
t.Fatalf("replay = %d header %q: %s", replay.Code, replay.Header().Get("X-Silo-Idempotent-Replay"), replay.Body.String())
}
if strings.TrimSpace(replay.Body.String()) != strings.TrimSpace(first.Body.String()) {
t.Fatalf("replay body = %s, want exact %s", replay.Body.String(), first.Body.String())
}
conflict := routeNavigationShortcutMutation(t, handler,
`{"item":{"type":"collection","collection_id":"watchlist","label":"Renamed"},"present":true}`,
mutationID)
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "mutation_id_conflict") {
t.Fatalf("mutation id reuse = %d: %s", conflict.Code, conflict.Body.String())
}
// A remove's label is not part of its semantics or request fingerprint.
const removeID = "f2f65f5e-918a-46bf-bee0-65a84491d298"
removed := routeNavigationShortcutMutation(t, handler,
`{"item":{"type":"collection","collection_id":"watchlist","label":"First"},"present":false}`,
removeID)
removeReplay := routeNavigationShortcutMutation(t, handler,
`{"item":{"type":"collection","collection_id":"watchlist","label":"Second"},"present":false}`,
removeID)
if removed.Code != http.StatusOK || removeReplay.Code != http.StatusOK || removeReplay.Header().Get("X-Silo-Idempotent-Replay") != "true" {
t.Fatalf("semantic remove replay = %d/%d header %q", removed.Code, removeReplay.Code, removeReplay.Header().Get("X-Silo-Idempotent-Replay"))
}
}
func TestNavigationShortcutMutationThroughNotificationWrappedProvider(t *testing.T) {
baseHandler, store := newValuesTestHandler(t)
provider := notifications.WrapUserStoreProvider(
testUserStoreProvider{store: store},
&notifications.System{},
)
handler := NewSettingValuesHandler(provider, baseHandler.contract)
const mutationID = "wrapped-provider-mutation"
body := `{"item":{"type":"collection","collection_id":"watchlist","label":"Watchlist"},"present":true}`
first := routeNavigationShortcutMutation(t, handler, body, mutationID)
if first.Code != http.StatusOK {
t.Fatalf("wrapped first mutation = %d: %s", first.Code, first.Body.String())
}
replay := routeNavigationShortcutMutation(t, handler, body, mutationID)
if replay.Code != http.StatusOK || replay.Header().Get("X-Silo-Idempotent-Replay") != "true" {
t.Fatalf("wrapped replay = %d header %q: %s",
replay.Code, replay.Header().Get("X-Silo-Idempotent-Replay"), replay.Body.String())
}
if !bytes.Equal(bytes.TrimSpace(first.Body.Bytes()), bytes.TrimSpace(replay.Body.Bytes())) {
t.Fatalf("wrapped replay body = %s, want %s", replay.Body.String(), first.Body.String())
}
}
func TestNavigationShortcutConcurrentSameMutationID(t *testing.T) {
for _, tc := range []struct {
name string
bodies []string
wantConflicts int
}{
{
name: "same hash replays one receipt",
bodies: []string{
`{"item":{"type":"library","library_id":42,"label":"Movies"},"present":true}`,
`{"item":{"type":"library","library_id":42,"label":"Movies"},"present":true}`,
},
},
{
name: "different hash conflicts before CAS",
bodies: []string{
`{"item":{"type":"library","library_id":42,"label":"Movies"},"present":true}`,
`{"item":{"type":"library","library_id":99,"label":"Shows"},"present":true}`,
},
wantConflicts: 1,
},
} {
t.Run(tc.name, func(t *testing.T) {
handler, store := newValuesTestHandler(t)
const mutationID = "concurrent-same-id"
start := make(chan struct{})
responses := make(chan *httptest.ResponseRecorder, len(tc.bodies))
var ready sync.WaitGroup
ready.Add(len(tc.bodies))
for _, body := range tc.bodies {
body := body
go func() {
ready.Done()
<-start
responses <- routeNavigationShortcutMutation(t, handler, body, mutationID)
}()
}
ready.Wait()
close(start)
conflicts := 0
replays := 0
var successes [][]byte
for range tc.bodies {
rec := <-responses
switch rec.Code {
case http.StatusOK:
successes = append(successes, bytes.TrimSpace(rec.Body.Bytes()))
if rec.Header().Get("X-Silo-Idempotent-Replay") == "true" {
replays++
}
case http.StatusConflict:
if !strings.Contains(rec.Body.String(), "mutation_id_conflict") {
t.Fatalf("conflict = %s, want mutation_id_conflict", rec.Body.String())
}
conflicts++
default:
t.Fatalf("concurrent mutation = %d: %s", rec.Code, rec.Body.String())
}
}
if conflicts != tc.wantConflicts {
t.Fatalf("conflicts = %d, want %d", conflicts, tc.wantConflicts)
}
if tc.wantConflicts == 0 {
if len(successes) != 2 || replays != 1 || !bytes.Equal(successes[0], successes[1]) {
t.Fatalf("same-hash outcomes: successes=%d replays=%d bodies=%q", len(successes), replays, successes)
}
} else if len(successes) != 1 || replays != 0 {
t.Fatalf("different-hash outcomes: successes=%d replays=%d", len(successes), replays)
}
stored, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: settingskeys.NavShortcuts, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
})
if err != nil || stored == nil || stored.Revision != 1 {
t.Fatalf("stored shortcut = %+v (%v), want exactly one CAS revision", stored, err)
}
receipt, err := store.GetSettingMutation(context.Background(), mutationID)
if err != nil || receipt == nil {
t.Fatalf("stored receipt = %+v (%v)", receipt, err)
}
if len(successes) > 0 && !bytes.Equal(successes[0], bytes.TrimSpace(receipt.Result)) {
t.Fatalf("success = %s, receipt = %s", successes[0], receipt.Result)
}
})
}
}
func TestNavigationShortcutMutationValidationAndWholeDocumentGuard(t *testing.T) {
handler, store := newValuesTestHandler(t)
wholeDocument := routeValues(t, handler, http.MethodPut, settingskeys.NavShortcuts,
"scope=profile", []byte(`{"value":{"items":[]}}`))
if wholeDocument.Code != http.StatusBadRequest || !strings.Contains(wholeDocument.Body.String(), "atomic_update_required") {
t.Fatalf("whole-document PUT = %d: %s", wholeDocument.Code, wholeDocument.Body.String())
}
if value, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: settingskeys.NavShortcuts, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
}); err != nil || value != nil {
t.Fatalf("guarded PUT stored %+v, err=%v", value, err)
}
invalidBodies := []string{
`{"item":{"type":"builtin","destination":"home","label":"Home"},"present":true}`,
`{"item":{"type":"library","library_id":42,"label":"Movies","extra":true},"present":true}`,
`{"item":{"type":"library","library_id":42,"label":"Movies"},"present":true,"extra":true}`,
`{"item":{"type":"section","library_id":42,"section_id":"","label":"Recent"},"present":true}`,
`{"item":{"type":"collection","collection_id":"favorites","label":" "},"present":true}`,
`{"item":{"type":"library","library_id":0,"label":"Movies"},"present":true}`,
`{"item":{"type":"library","library_id":42,"label":"Movies"}}`,
}
for _, body := range invalidBodies {
rec := routeNavigationShortcutMutation(t, handler, body, "")
if rec.Code != http.StatusBadRequest {
t.Errorf("invalid body %s = %d: %s", body, rec.Code, rec.Body.String())
}
}
}
func TestNavigationShortcutWholeDocumentDeleteCannotCreateRevisionABA(t *testing.T) {
handler, store := newValuesTestHandler(t)
first := routeNavigationShortcutMutation(t, handler,
`{"item":{"type":"library","library_id":42,"label":"Movies"},"present":true}`, "")
firstResponse, _ := decodeShortcutResponse(t, first)
if first.Code != http.StatusOK || firstResponse.Revision != 1 {
t.Fatalf("seed shortcut = %d revision %d: %s", first.Code, firstResponse.Revision, first.Body.String())
}
identity := userstore.SettingIdentity{
Key: settingskeys.NavShortcuts, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
}
beforeDelete, err := store.GetSettingValue(context.Background(), identity)
if err != nil || beforeDelete == nil {
t.Fatalf("read shortcut before guarded delete: value=%+v err=%v", beforeDelete, err)
}
deleteRec := routeValues(t, handler, http.MethodDelete, settingskeys.NavShortcuts,
"scope=profile", nil)
if deleteRec.Code != http.StatusBadRequest || !strings.Contains(deleteRec.Body.String(), "atomic_update_required") {
t.Fatalf("whole-document DELETE = %d: %s", deleteRec.Code, deleteRec.Body.String())
}
second := routeNavigationShortcutMutation(t, handler,
`{"item":{"type":"library","library_id":99,"label":"Shows"},"present":true}`, "")
secondResponse, secondDocument := decodeShortcutResponse(t, second)
if second.Code != http.StatusOK || secondResponse.Revision != 2 || len(secondDocument.Items) != 2 {
t.Fatalf("second shortcut = %d revision %d document %+v", second.Code, secondResponse.Revision, secondDocument)
}
// If session DELETE had removed and recreated the row, its revision would
// return to one and this stale writer could pass an ABA-shaped precondition.
// Keeping the row alive makes the stale revision conflict instead.
cas := store.(userstore.SettingValueCompareAndSetter)
if _, err := cas.CompareAndSetSettingValue(context.Background(), identity,
beforeDelete.Value, beforeDelete.Revision); !errors.Is(err, userstore.ErrSettingValueRevisionConflict) {
t.Fatalf("stale CAS after guarded DELETE error = %v, want revision conflict", err)
}
final, err := store.GetSettingValue(context.Background(), identity)
if err != nil || final == nil || final.Revision != 2 {
t.Fatalf("final shortcut row = %+v err=%v, want revision 2", final, err)
}
}
func TestNavigationShortcutMutationEnforcesDocumentCap(t *testing.T) {
handler, store := newValuesTestHandler(t)
items := make([]navigationShortcutItem, 0, 256)
for id := 1; id <= 256; id++ {
libraryID := id
items = append(items, navigationShortcutItem{Type: "library", LibraryID: &libraryID, Label: fmt.Sprintf("Library %d", id)})
}
raw, err := json.Marshal(navigationShortcutDocument{Items: items})
if err != nil {
t.Fatal(err)
}
def, _ := handler.contract.Lookup(settingskeys.NavShortcuts)
normalized, err := def.ValueSchema.NormalizeValue(raw, settingscontract.ObjectSchemas())
if err != nil {
t.Fatalf("normalize seed: %v", err)
}
identity := userstore.SettingIdentity{
Key: settingskeys.NavShortcuts, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
}
seed, err := store.UpsertSettingValue(context.Background(), identity, normalized)
if err != nil {
t.Fatalf("seed full catalog: %v", err)
}
rec := routeNavigationShortcutMutation(t, handler,
`{"item":{"type":"library","library_id":257,"label":"Too Many"},"present":true}`, "")
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "invalid_value") {
t.Fatalf("257th shortcut = %d: %s", rec.Code, rec.Body.String())
}
got, err := store.GetSettingValue(context.Background(), identity)
if err != nil || got == nil || got.Revision != seed.Revision {
t.Fatalf("catalog after rejected add = %+v, err=%v; want unchanged revision %d", got, err, seed.Revision)
}
}
type synchronizedShortcutStore struct {
userstore.UserStore
cas userstore.SettingValueCompareAndSetter
reads atomic.Int32
ready chan struct{}
once sync.Once
casMu sync.Mutex
}
func (s *synchronizedShortcutStore) GetSettingValue(ctx context.Context, id userstore.SettingIdentity) (*userstore.SettingValue, error) {
value, err := s.UserStore.GetSettingValue(ctx, id)
if err == nil && id.Key == settingskeys.NavShortcuts {
read := s.reads.Add(1)
if read <= 2 {
if read == 2 {
s.once.Do(func() { close(s.ready) })
}
<-s.ready
}
}
return value, err
}
func (s *synchronizedShortcutStore) CompareAndSetSettingValue(
ctx context.Context,
id userstore.SettingIdentity,
value json.RawMessage,
expectedRevision int64,
) (*userstore.SettingValue, error) {
s.casMu.Lock()
defer s.casMu.Unlock()
return s.cas.CompareAndSetSettingValue(ctx, id, value, expectedRevision)
}
func TestNavigationShortcutConcurrentAddsMerge(t *testing.T) {
baseHandler, baseStore := newValuesTestHandler(t)
wrapped := &synchronizedShortcutStore{
UserStore: baseStore,
cas: baseStore.(userstore.SettingValueCompareAndSetter),
ready: make(chan struct{}),
}
handler := NewSettingValuesHandler(testUserStoreProvider{store: wrapped}, baseHandler.contract)
bodies := []string{
`{"item":{"type":"library","library_id":42,"label":"Movies"},"present":true}`,
`{"item":{"type":"library","library_id":99,"label":"Shows"},"present":true}`,
}
responses := make(chan *httptest.ResponseRecorder, len(bodies))
for _, body := range bodies {
body := body
go func() {
responses <- routeNavigationShortcutMutation(t, handler, body, "")
}()
}
for range bodies {
rec := <-responses
if rec.Code != http.StatusOK {
t.Fatalf("concurrent add = %d: %s", rec.Code, rec.Body.String())
}
}
got, err := baseStore.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: settingskeys.NavShortcuts, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
})
if err != nil || got == nil {
t.Fatalf("read merged catalog: value=%+v err=%v", got, err)
}
var document navigationShortcutDocument
if err := json.Unmarshal(got.Value, &document); err != nil {
t.Fatalf("decode merged catalog: %v", err)
}
if got.Revision != 2 || len(document.Items) != 2 {
t.Fatalf("merged catalog = revision %d items %+v, want both adds at revision 2", got.Revision, document.Items)
}
}
func TestSettingValuesRoundTrip(t *testing.T) {
handler, _ := newValuesTestHandler(t)
@@ -131,6 +632,161 @@ func TestSettingValuesRoundTrip(t *testing.T) {
}
}
func TestProfileClientValueUsesExplicitFamilyHeader(t *testing.T) {
handler, _ := newValuesTestHandler(t)
key := settingskeys.NavPrimaryMenu
body := []byte(`{"value":{"items":[{"type":"builtin","destination":"home"},` +
`{"type":"library","library_id":42,"label":"Movies"}]}}`)
rec := routeValues(t, handler, http.MethodPut, key, "scope=profile_client", body)
if rec.Code != http.StatusOK {
t.Fatalf("PUT profile_client = %d: %s", rec.Code, rec.Body.String())
}
var stored settingValueResponse
if err := json.Unmarshal(rec.Body.Bytes(), &stored); err != nil {
t.Fatalf("decoding response: %v", err)
}
if stored.Scope != "profile_client" || stored.ClientFamily != "web" {
t.Fatalf("stored scope/family = %q/%q, want profile_client/web", stored.Scope, stored.ClientFamily)
}
var menu navigationShortcutDocument
if err := json.Unmarshal(stored.Value, &menu); err != nil {
t.Fatalf("decoding stored primary menu: %v", err)
}
if len(menu.Items) != 2 || menu.Items[1].Label != "Movies" {
t.Fatalf("stored non-builtin menu item = %+v, want labeled Movies library", menu.Items)
}
for _, tc := range []struct {
name string
header string
}{
{"missing", ""},
{"not canonical", "TV"},
{"unknown", "car"},
} {
t.Run(tc.name, func(t *testing.T) {
req := valuesRequest(http.MethodPut,
"/settings/values/"+key+"?scope=profile_client", body)
if tc.header == "" {
req.Header.Del(clientFamilyHeader)
} else {
req.Header.Set(clientFamilyHeader, tc.header)
}
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("key", key)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rec := httptest.NewRecorder()
handler.HandleSetValue(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("PUT with family %q = %d, want 400: %s", tc.header, rec.Code, rec.Body.String())
}
})
}
}
func TestMutationHashPreservesExistingScopesAndSeparatesClientFamilies(t *testing.T) {
identity := userstore.SettingIdentity{
Key: "playback.subtitle_mode", Scope: settingscontract.ScopeProfileDevice,
ProfileID: "profile-1", DeviceID: "device-1",
}
value := json.RawMessage(`"always"`)
legacyPayload := append([]byte(
"playback.subtitle_mode\x00profile_device\x00profile-1\x00device-1\x000\x00\x00",
), value...)
want := sha256.Sum256(legacyPayload)
if got := hashMutationRequest(identity, value); got != hex.EncodeToString(want[:]) {
t.Fatalf("existing-scope mutation hash changed across the profile_client rollout: %s", got)
}
client := userstore.SettingIdentity{
Key: settingskeys.NavPrimaryMenu, Scope: settingscontract.ScopeProfileClient,
ProfileID: "profile-1", ClientFamily: settingscontract.ClientFamilyTV,
}
tv := hashMutationRequest(client, json.RawMessage(`null`))
client.ClientFamily = settingscontract.ClientFamilyMobile
if mobile := hashMutationRequest(client, json.RawMessage(`null`)); mobile == tv {
t.Fatal("profile_client mutation hashes do not distinguish client families")
}
}
func TestEffectiveClientFamilyIsOptionalButValidated(t *testing.T) {
handler, _ := newValuesTestHandler(t)
key := settingskeys.UiCardPresentation
if rec := routeValues(t, handler, http.MethodPut, key, "scope=profile",
[]byte(`{"value":{"poster_size":"compact","caption":"title"}}`)); rec.Code != http.StatusOK {
t.Fatalf("seed profile fallback = %d: %s", rec.Code, rec.Body.String())
}
if rec := routeValues(t, handler, http.MethodPut, key, "scope=profile_client",
[]byte(`{"value":{"poster_size":"large","caption":"artwork"}}`)); rec.Code != http.StatusOK {
t.Fatalf("seed family value = %d: %s", rec.Code, rec.Body.String())
}
effective := func(t *testing.T, family *string) (int, effectiveSettingValueResponse, string) {
t.Helper()
req := valuesRequest(http.MethodGet, "/settings/values/effective?keys="+key, nil)
if family == nil {
req.Header.Del(clientFamilyHeader)
} else {
req.Header.Set(clientFamilyHeader, *family)
}
rec := httptest.NewRecorder()
handler.HandleGetEffective(rec, req)
if rec.Code != http.StatusOK {
return rec.Code, effectiveSettingValueResponse{}, rec.Body.String()
}
var body struct {
Settings []effectiveSettingValueResponse `json:"settings"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode effective response: %v", err)
}
if len(body.Settings) != 1 {
t.Fatalf("settings = %d, want 1", len(body.Settings))
}
return rec.Code, body.Settings[0], rec.Body.String()
}
t.Run("absent skips profile client layer", func(t *testing.T) {
code, got, body := effective(t, nil)
if code != http.StatusOK {
t.Fatalf("effective without client family = %d: %s", code, body)
}
if got.Source != string(settingscontract.ScopeProfile) || string(got.Value) != `{"poster_size":"compact","caption":"title"}` {
t.Fatalf("effective without family = %s from %s, want profile fallback", got.Value, got.Source)
}
})
t.Run("absent remains compatible with resolve all", func(t *testing.T) {
req := valuesRequest(http.MethodGet, "/settings/values/effective", nil)
req.Header.Del(clientFamilyHeader)
rec := httptest.NewRecorder()
handler.HandleGetEffective(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("resolve all without client family = %d: %s", rec.Code, rec.Body.String())
}
})
t.Run("valid selects profile client layer", func(t *testing.T) {
family := "web"
code, got, body := effective(t, &family)
if code != http.StatusOK {
t.Fatalf("effective with client family = %d: %s", code, body)
}
if got.Source != string(settingscontract.ScopeProfileClient) || got.ClientFamily != family {
t.Fatalf("effective with family = %s/%s, want profile_client/web", got.Source, got.ClientFamily)
}
})
t.Run("nonempty invalid is rejected", func(t *testing.T) {
family := "TV"
code, _, _ := effective(t, &family)
if code != http.StatusBadRequest {
t.Fatalf("effective with invalid client family = %d, want 400", code)
}
})
}
func TestGetSettingValuesReportsSetAndUnsetAtOneScope(t *testing.T) {
handler, store := newValuesTestHandler(t)
if _, err := store.UpsertSettingValue(context.Background(), userstore.SettingIdentity{
@@ -697,12 +1353,34 @@ type failingUpsertStore struct {
userstore.UserStore
}
type failingUpsertMutationWriter struct {
userstore.SettingMutationWriter
}
func (failingUpsertStore) UpsertSettingValue(
context.Context, userstore.SettingIdentity, json.RawMessage,
) (*userstore.SettingValue, error) {
return nil, errors.New("simulated write failure")
}
func (s failingUpsertStore) WithSettingMutationTransaction(
ctx context.Context,
mutationID string,
fn func(userstore.SettingMutationWriter) error,
) error {
transactioner := s.UserStore.(userstore.SettingMutationTransactioner)
return transactioner.WithSettingMutationTransaction(ctx, mutationID,
func(writer userstore.SettingMutationWriter) error {
return fn(failingUpsertMutationWriter{SettingMutationWriter: writer})
})
}
func (failingUpsertMutationWriter) UpsertSettingValue(
context.Context, userstore.SettingIdentity, json.RawMessage,
) (*userstore.SettingValue, error) {
return nil, errors.New("simulated write failure")
}
// TestFailedWritesLeaveNoReceipt: a receipt for a write that never landed
// would turn the client's retry of a 500 into a silent success replay.
func TestFailedWritesLeaveNoReceipt(t *testing.T) {
@@ -815,9 +1493,10 @@ func TestCapabilitiesReportTheContractRevision(t *testing.T) {
}
var body struct {
APIVersion int `json:"api_version"`
Revision int `json:"revision"`
Scopes []string `json:"scopes"`
APIVersion int `json:"api_version"`
Revision int `json:"revision"`
Scopes []string `json:"scopes"`
ClientFamilies []string `json:"client_families"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decoding: %v", err)
@@ -827,8 +1506,15 @@ func TestCapabilitiesReportTheContractRevision(t *testing.T) {
t.Errorf("reported %d/%d, want %d/%d",
body.APIVersion, body.Revision, contract.APIVersion, contract.Revision)
}
if len(body.Scopes) != 5 {
t.Errorf("reported %d scopes, want 5", len(body.Scopes))
wantScopes := []string{
"account", "profile", "profile_client", "profile_device", "profile_library", "profile_series",
}
if !slices.Equal(body.Scopes, wantScopes) {
t.Errorf("reported scopes %v, want %v", body.Scopes, wantScopes)
}
wantFamilies := []string{"tv", "mobile", "tablet", "desktop", "web"}
if !slices.Equal(body.ClientFamilies, wantFamilies) {
t.Errorf("reported client families %v, want %v", body.ClientFamilies, wantFamilies)
}
}
+1
View File
@@ -2402,6 +2402,7 @@ func NewRouter(deps Dependencies) chi.Router {
r.Get("/values", settingValuesHandler.HandleGetValues)
r.Get("/values/effective", settingValuesHandler.HandleGetEffective)
r.Post("/values/effective", settingValuesHandler.HandlePostEffective)
r.Put("/values/nav.shortcuts/item", settingValuesHandler.HandleSetNavigationShortcut)
r.Get("/values/{key}", settingValuesHandler.HandleGetValue)
r.Put("/values/{key}", settingValuesHandler.HandleSetValue)
r.Delete("/values/{key}", settingValuesHandler.HandleDeleteValue)
+1 -1
View File
@@ -19,7 +19,7 @@ import (
// The planner's rules are unit-tested in internal/settingsmigrate. What this
// covers is everything only a live database can show: that the Go migration is
// registered and actually runs, that the rows satisfy the scope CHECK, the
// composite profile foreign key and the five partial unique indexes, and that
// composite profile foreign key and the six partial unique indexes, and that
// jsonb accepts the values the planner encodes.
func TestPostgresSettingsBackfill(t *testing.T) {
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
+37
View File
@@ -2,6 +2,7 @@ package notifications
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
@@ -64,6 +65,11 @@ type interestTrackingStoreWithDevices struct {
userstore.DeviceRegistry
}
var _ userstore.SettingValueCompareAndSetter = (*interestTrackingStore)(nil)
var _ userstore.SettingMutationTransactioner = (*interestTrackingStore)(nil)
var _ userstore.SettingValueCompareAndSetter = (*interestTrackingStoreWithDevices)(nil)
var _ userstore.SettingMutationTransactioner = (*interestTrackingStoreWithDevices)(nil)
// WithPreferenceSettingsTransaction preserves the optional atomic-settings
// capability of the wrapped store. Preference writes do not affect interest
// signals, so the transaction can pass through unchanged; keeping the method
@@ -80,6 +86,37 @@ func (s *interestTrackingStore) WithPreferenceSettingsTransaction(
return transactioner.WithPreferenceSettingsTransaction(ctx, fn)
}
// CompareAndSetSettingValue preserves the semantic-document CAS capability of
// the wrapped store. Settings writes do not affect notification interests, so
// this decorator must not intercept or downgrade the backend primitive.
func (s *interestTrackingStore) CompareAndSetSettingValue(
ctx context.Context,
id userstore.SettingIdentity,
value json.RawMessage,
expectedRevision int64,
) (*userstore.SettingValue, error) {
cas, ok := s.UserStore.(userstore.SettingValueCompareAndSetter)
if !ok {
return nil, fmt.Errorf("wrapped user store does not support atomic setting updates")
}
return cas.CompareAndSetSettingValue(ctx, id, value, expectedRevision)
}
// WithSettingMutationTransaction preserves the durable setting+receipt
// transaction used by mutation IDs. Passing the transaction-scoped writer
// through unchanged keeps both operations on the concrete backend transaction.
func (s *interestTrackingStore) WithSettingMutationTransaction(
ctx context.Context,
mutationID string,
fn func(userstore.SettingMutationWriter) error,
) error {
transactioner, ok := s.UserStore.(userstore.SettingMutationTransactioner)
if !ok {
return fmt.Errorf("wrapped user store does not support atomic idempotent setting mutations")
}
return transactioner.WithSettingMutationTransaction(ctx, mutationID, fn)
}
// progressState is the transition-relevant projection of a progress row.
type progressState struct {
exists bool
+47 -1
View File
@@ -3,8 +3,11 @@ package notifications
import (
"context"
"database/sql"
"encoding/json"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/userdb"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -19,7 +22,7 @@ func (p preferenceTransactionTestProvider) ForUser(context.Context, int) (userst
func (preferenceTransactionTestProvider) Close() error { return nil }
func TestInterestTrackingStorePreservesPreferenceSettingsTransactions(t *testing.T) {
func TestInterestTrackingStorePreservesSettingCapabilities(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
@@ -53,4 +56,47 @@ func TestInterestTrackingStorePreservesPreferenceSettingsTransactions(t *testing
if !called {
t.Fatal("transaction callback was not invoked")
}
cas, ok := wrapped.(userstore.SettingValueCompareAndSetter)
if !ok {
t.Fatal("interest-tracking wrapper dropped SettingValueCompareAndSetter")
}
identity := userstore.SettingIdentity{Key: "ui.test", Scope: settingscontract.ScopeAccount}
first, err := cas.CompareAndSetSettingValue(
context.Background(), identity, json.RawMessage(`"first"`), 0)
if err != nil {
t.Fatalf("CompareAndSetSettingValue: %v", err)
}
mutationTx, ok := wrapped.(userstore.SettingMutationTransactioner)
if !ok {
t.Fatal("interest-tracking wrapper dropped SettingMutationTransactioner")
}
called = false
if err := mutationTx.WithSettingMutationTransaction(context.Background(), "wrapped-mutation",
func(writer userstore.SettingMutationWriter) error {
called = true
if _, err := writer.CompareAndSetSettingValue(
context.Background(), identity, json.RawMessage(`"second"`), first.Revision); err != nil {
return err
}
_, _, err := writer.PutSettingMutation(context.Background(), userstore.SettingMutationRecord{
MutationID: "wrapped-mutation", RequestHash: "hash",
Result: json.RawMessage(`{"ok":true}`), ExpiresAt: time.Now().UTC().Add(time.Hour),
})
return err
}); err != nil {
t.Fatalf("WithSettingMutationTransaction: %v", err)
}
if !called {
t.Fatal("mutation transaction callback was not invoked")
}
stored, err := wrapped.GetSettingValue(context.Background(), identity)
if err != nil || stored == nil || stored.Revision != 2 {
t.Fatalf("wrapped setting after transaction = %+v (%v), want revision 2", stored, err)
}
receipt, err := wrapped.GetSettingMutation(context.Background(), "wrapped-mutation")
if err != nil || receipt == nil || receipt.RequestHash != "hash" {
t.Fatalf("wrapped receipt = %+v (%v)", receipt, err)
}
}
+27
View File
@@ -22,6 +22,7 @@ type Scope string
const (
ScopeAccount Scope = "account"
ScopeProfile Scope = "profile"
ScopeProfileClient Scope = "profile_client"
ScopeProfileDevice Scope = "profile_device"
ScopeProfileLibrary Scope = "profile_library"
ScopeProfileSeries Scope = "profile_series"
@@ -37,6 +38,7 @@ const (
var remoteScopes = map[Scope]struct{}{
ScopeAccount: {},
ScopeProfile: {},
ScopeProfileClient: {},
ScopeProfileDevice: {},
ScopeProfileLibrary: {},
ScopeProfileSeries: {},
@@ -48,6 +50,31 @@ func (s Scope) IsRemote() bool {
return ok
}
// ClientFamily identifies a class of like clients whose presentation and
// navigation preferences should roam together. It is explicit request
// metadata rather than an inference from the device platform string: platform
// names are free-form registry metadata, while this value participates in a
// canonical storage identity.
type ClientFamily string
const (
ClientFamilyTV ClientFamily = "tv"
ClientFamilyMobile ClientFamily = "mobile"
ClientFamilyTablet ClientFamily = "tablet"
ClientFamilyDesktop ClientFamily = "desktop"
ClientFamilyWeb ClientFamily = "web"
)
// Valid reports whether f is one of the canonical lower-case wire values.
func (f ClientFamily) Valid() bool {
switch f {
case ClientFamilyTV, ClientFamilyMobile, ClientFamilyTablet, ClientFamilyDesktop, ClientFamilyWeb:
return true
default:
return false
}
}
// Persistence declares who stores a setting's value.
type Persistence string
+113
View File
@@ -1588,6 +1588,119 @@ func TestObjectSchemasAcceptTheShapesClientsStore(t *testing.T) {
}
}
func TestUICustomizationObjectSchemasAreStrict(t *testing.T) {
manifest, err := Load()
if err != nil {
t.Fatalf("loading manifest: %v", err)
}
valid := map[string]string{
"nav.primary_menu": `{"items":[` +
`{"type":"builtin","destination":"home"},` +
`{"type":"library","library_id":7,"label":"Movies"},` +
`{"type":"section","library_id":7,"section_id":"recently-added","label":"Recently Added"},` +
`{"type":"collection","collection_id":"horror","library_id":7,"label":"Horror"}]}`,
"nav.shortcuts": `{"items":[` +
`{"type":"library","library_id":7,"label":"Movies"},` +
`{"type":"section","library_id":7,"section_id":"recently-added","label":"Recently Added"},` +
`{"type":"collection","collection_id":"horror","label":"Horror"}]}`,
"ui.card_presentation": `{"poster_size":"large","caption":"artwork"}`,
}
for key, value := range valid {
t.Run(key+" accepts canonical shape", func(t *testing.T) {
def, ok := manifest.Lookup(key)
if !ok {
t.Fatalf("%s is not registered", key)
}
if err := def.ValueSchema.ValidateValue(json.RawMessage(value), objSchemas); err != nil {
t.Fatalf("valid %s value rejected: %v", key, err)
}
})
}
invalid := []struct {
name string
key string
value string
}{
{
name: "primary menu requires home",
key: "nav.primary_menu",
value: `{"items":[{"type":"builtin","destination":"movies"}]}`,
},
{
name: "primary menu allows home exactly once",
key: "nav.primary_menu",
value: `{"items":[{"type":"builtin","destination":"home"},{"type":"builtin","destination":"home"}]}`,
},
{
name: "menu labels cannot be empty",
key: "nav.primary_menu",
value: `{"items":[{"type":"builtin","destination":"home"},{"type":"library","library_id":7,"label":""}]}`,
},
{
name: "menu labels cannot be only whitespace",
key: "nav.primary_menu",
value: `{"items":[{"type":"builtin","destination":"home"},{"type":"library","library_id":7,"label":" "}]}`,
},
{
name: "menu target ids cannot be only whitespace",
key: "nav.primary_menu",
value: `{"items":[{"type":"builtin","destination":"home"},{"type":"section","library_id":7,"section_id":" ","label":"Recent"}]}`,
},
{
name: "menu destination identity ignores labels",
key: "nav.primary_menu",
value: `{"items":[{"type":"builtin","destination":"home"},` +
`{"type":"library","library_id":7,"label":"Movies"},` +
`{"type":"library","library_id":7,"label":"Films"}]}`,
},
{
name: "shortcuts cannot contain builtins",
key: "nav.shortcuts",
value: `{"items":[{"type":"builtin","destination":"home"}]}`,
},
{
name: "shortcuts require positive library ids",
key: "nav.shortcuts",
value: `{"items":[{"type":"library","library_id":0,"label":"Movies"}]}`,
},
{
name: "shortcut target ids cannot be only whitespace",
key: "nav.shortcuts",
value: `{"items":[{"type":"collection","collection_id":"\t ","label":"Collection"}]}`,
},
{
name: "shortcut destination identity includes collection context",
key: "nav.shortcuts",
value: `{"items":[` +
`{"type":"collection","collection_id":"horror","library_id":7,"label":"Horror"},` +
`{"type":"collection","collection_id":"horror","library_id":7,"label":"Scary"}]}`,
},
{
name: "card presets reject unknown sizes",
key: "ui.card_presentation",
value: `{"poster_size":"huge","caption":"artwork"}`,
},
{
name: "card presets reject extension fields",
key: "ui.card_presentation",
value: `{"poster_size":"large","caption":"artwork","columns":8}`,
},
}
for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) {
def, ok := manifest.Lookup(tc.key)
if !ok {
t.Fatalf("%s is not registered", tc.key)
}
if err := def.ValueSchema.ValidateValue(json.RawMessage(tc.value), objSchemas); err == nil {
t.Fatalf("invalid %s value was accepted: %s", tc.key, tc.value)
}
})
}
}
// TestQualityIsTwoIndependentAxes pins the split that replaced the compound
// ladder values. A client composes its own presets from these two, so the
// contract must not constrain them jointly.
+83
View File
@@ -553,6 +553,9 @@ func (v *ValueSchema) ValidateValue(raw json.RawMessage, objectSchemas map[strin
if err := schema.Validate(doc); err != nil {
return fmt.Errorf("does not satisfy %s: %w", v.SchemaRef, err)
}
if err := validateObjectSemantics(v.SchemaRef, trimmed); err != nil {
return fmt.Errorf("does not satisfy %s: %w", v.SchemaRef, err)
}
default:
return fmt.Errorf("unknown value type %q", v.Type)
@@ -561,6 +564,86 @@ func (v *ValueSchema) ValidateValue(raw json.RawMessage, objectSchemas map[strin
return nil
}
type navigationDocument struct {
Items []navigationItem `json:"items"`
}
type navigationItem struct {
Type string `json:"type"`
Destination string `json:"destination"`
LibraryID *int `json:"library_id"`
SectionID string `json:"section_id"`
CollectionID string `json:"collection_id"`
Label string `json:"label"`
}
type navigationIdentity struct {
Type string
Destination string
LibraryID int
HasLibraryID bool
SectionID string
CollectionID string
}
// validateObjectSemantics holds the few cross-item invariants JSON Schema
// cannot express. uniqueItems rejects byte-for-byte duplicate objects, but a
// renamed library is still the same destination and must not appear twice in a
// menu. Keeping this beside ValidateValue means API writes, migrations, and
// manifest defaults all enforce the same identity rule.
func validateObjectSemantics(schemaRef string, raw json.RawMessage) error {
if schemaRef != "primary-menu.json" && schemaRef != "navigation-shortcuts.json" {
return nil
}
var document navigationDocument
if err := strictUnmarshal(raw, &document); err != nil {
return fmt.Errorf("decoding navigation document: %w", err)
}
seen := make(map[navigationIdentity]int, len(document.Items))
for index, item := range document.Items {
identity, err := item.identity()
if err != nil {
return fmt.Errorf("items[%d]: %w", index, err)
}
if first, duplicate := seen[identity]; duplicate {
return fmt.Errorf("items[%d] repeats the destination from items[%d]", index, first)
}
seen[identity] = index
}
return nil
}
func (item navigationItem) identity() (navigationIdentity, error) {
identity := navigationIdentity{Type: item.Type}
switch item.Type {
case "builtin":
identity.Destination = item.Destination
case "library":
if item.LibraryID == nil {
return navigationIdentity{}, errors.New("library is missing library_id")
}
identity.LibraryID = *item.LibraryID
identity.HasLibraryID = true
case "section":
if item.LibraryID == nil {
return navigationIdentity{}, errors.New("section is missing library_id")
}
identity.LibraryID = *item.LibraryID
identity.HasLibraryID = true
identity.SectionID = item.SectionID
case "collection":
identity.CollectionID = item.CollectionID
if item.LibraryID != nil {
identity.LibraryID = *item.LibraryID
identity.HasLibraryID = true
}
default:
return navigationIdentity{}, fmt.Errorf("unknown navigation item type %q", item.Type)
}
return identity, nil
}
// NormalizeValue validates a value and returns the form that should be stored.
//
// Anything that persists a value goes through here rather than ValidateValue,
+10 -1
View File
@@ -9,7 +9,7 @@
package settingskeys
// Revision is the manifest revision these bindings were generated from.
const Revision = 4
const Revision = 5
// Setting keys, one constant per definition.
const (
@@ -23,6 +23,10 @@ const (
DownloadsKeepWatched = "downloads.keep_watched"
// Download over Wi-Fi only
DownloadsWifiOnly = "downloads.wifi_only"
// Primary menu
NavPrimaryMenu = "nav.primary_menu"
// Navigation shortcuts
NavShortcuts = "nav.shortcuts"
// Show audiobooks
NavShowAudiobooks = "nav.show_audiobooks"
// Preferred audio language
@@ -85,6 +89,8 @@ const (
SubtitleMatchesDevice = "subtitle.matches_device"
// Poster badges
UiCardOverlays = "ui.card_overlays"
// Media cards
UiCardPresentation = "ui.card_presentation"
// Custom CSS
UiCustomCss = "ui.custom_css"
// Custom theme variables
@@ -119,6 +125,8 @@ const (
var Remote = []string{
CatalogMetadataLanguage,
CatalogMetadataLanguageOverrides,
NavPrimaryMenu,
NavShortcuts,
PlaybackAudioLanguage,
PlaybackAutoPlayNext,
PlaybackAutoPlayNextPreview,
@@ -145,6 +153,7 @@ var Remote = []string{
PlayerVideoGravity,
SearchMediaScope,
UiCardOverlays,
UiCardPresentation,
UiCustomCss,
UiCustomThemeVars,
UiDateFormat,
+25 -21
View File
@@ -47,20 +47,22 @@ type conformanceCase struct {
}
type conformanceContext struct {
ProfileID string `json:"profile_id"`
DeviceID string `json:"device_id"`
LibraryIDs []int `json:"library_ids"`
SeriesIDs []string `json:"series_ids"`
ProfileID string `json:"profile_id"`
ClientFamily string `json:"client_family"`
DeviceID string `json:"device_id"`
LibraryIDs []int `json:"library_ids"`
SeriesIDs []string `json:"series_ids"`
}
type conformanceRow struct {
Key string `json:"key"`
Scope string `json:"scope"`
ProfileID string `json:"profile_id"`
DeviceID string `json:"device_id"`
LibraryID int `json:"library_id"`
SeriesID string `json:"series_id"`
Value json.RawMessage `json:"value"`
Key string `json:"key"`
Scope string `json:"scope"`
ProfileID string `json:"profile_id"`
ClientFamily string `json:"client_family"`
DeviceID string `json:"device_id"`
LibraryID int `json:"library_id"`
SeriesID string `json:"series_id"`
Value json.RawMessage `json:"value"`
}
type conformanceBinding struct {
@@ -146,10 +148,11 @@ func runConformanceCase(t *testing.T, manifest *settingscontract.Manifest, tc co
rc := Context{}
if tc.Context != nil {
rc = Context{
ProfileID: tc.Context.ProfileID,
DeviceID: tc.Context.DeviceID,
LibraryIDs: tc.Context.LibraryIDs,
SeriesIDs: tc.Context.SeriesIDs,
ProfileID: tc.Context.ProfileID,
ClientFamily: settingscontract.ClientFamily(tc.Context.ClientFamily),
DeviceID: tc.Context.DeviceID,
LibraryIDs: tc.Context.LibraryIDs,
SeriesIDs: tc.Context.SeriesIDs,
}
}
@@ -160,12 +163,13 @@ func runConformanceCase(t *testing.T, manifest *settingscontract.Manifest, tc co
}
rows = append(rows, userstore.SettingValue{
SettingIdentity: userstore.SettingIdentity{
Key: stored.Key,
Scope: settingscontract.Scope(stored.Scope),
ProfileID: stored.ProfileID,
DeviceID: stored.DeviceID,
LibraryID: stored.LibraryID,
SeriesID: stored.SeriesID,
Key: stored.Key,
Scope: settingscontract.Scope(stored.Scope),
ProfileID: stored.ProfileID,
ClientFamily: settingscontract.ClientFamily(stored.ClientFamily),
DeviceID: stored.DeviceID,
LibraryID: stored.LibraryID,
SeriesID: stored.SeriesID,
},
Value: stored.Value,
})
+18 -8
View File
@@ -32,8 +32,9 @@ import (
// profile_series. That is what lets one code path serve an identified client,
// an anonymous jellycompat seed, and a batch spanning many series.
type Context struct {
ProfileID string
DeviceID string
ProfileID string
ClientFamily settingscontract.ClientFamily
DeviceID string
// LibraryIDs and SeriesIDs are the content contexts in play. A batch
// resolving a season passes every id once rather than resolving per item.
LibraryIDs []int
@@ -131,11 +132,12 @@ func (r *Resolver) Resolve(
}
stored, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: known,
ProfileIDs: nonEmpty(rc.ProfileID),
DeviceID: rc.DeviceID,
LibraryIDs: rc.LibraryIDs,
SeriesIDs: rc.SeriesIDs,
Keys: known,
ProfileIDs: nonEmpty(rc.ProfileID),
ClientFamily: rc.ClientFamily,
DeviceID: rc.DeviceID,
LibraryIDs: rc.LibraryIDs,
SeriesIDs: rc.SeriesIDs,
})
if err != nil {
return nil, fmt.Errorf("settingsresolve: reading candidates: %w", err)
@@ -168,11 +170,15 @@ func (r *Resolver) ResolveContexts(
}
deviceID := contexts[0].DeviceID
query := userstore.SettingResolutionQuery{Keys: known, DeviceID: deviceID}
clientFamily := contexts[0].ClientFamily
query := userstore.SettingResolutionQuery{Keys: known, ClientFamily: clientFamily, DeviceID: deviceID}
for _, rc := range contexts {
if rc.DeviceID != deviceID {
return nil, fmt.Errorf("settingsresolve: batched contexts use different devices")
}
if rc.ClientFamily != clientFamily {
return nil, fmt.Errorf("settingsresolve: batched contexts use different client families")
}
if rc.ProfileID != "" {
query.ProfileIDs = append(query.ProfileIDs, rc.ProfileID)
}
@@ -352,6 +358,10 @@ func pickForScope(
if row.ProfileID == rc.ProfileID {
matches = append(matches, row)
}
case settingscontract.ScopeProfileClient:
if row.ProfileID == rc.ProfileID && row.ClientFamily == rc.ClientFamily && rc.ClientFamily.Valid() {
matches = append(matches, row)
}
case settingscontract.ScopeProfileDevice:
if row.ProfileID == rc.ProfileID && row.DeviceID == rc.DeviceID && rc.DeviceID != "" {
matches = append(matches, row)
+4 -7
View File
@@ -2,7 +2,7 @@ package userdb
import (
"context"
"database/sql"
"path/filepath"
"testing"
"time"
@@ -12,15 +12,12 @@ import (
func newConformanceStore(t *testing.T) userstore.UserStore {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
db, err := NewUserDB(filepath.Join(t.TempDir(), "user.db"), 1)
if err != nil {
t.Fatalf("open sqlite: %v", err)
t.Fatalf("open user database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := InitSchema(db); err != nil {
t.Fatalf("InitSchema: %v", err)
}
return NewSQLiteUserStore(db)
return NewSQLiteUserStore(db.DB)
}
// TestSQLiteProgressSince runs the offline-sync progress-reconciliation
+19 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt"
)
const schemaVersion = 17
const schemaVersion = 18
func runMigrations(db *sql.DB) error {
version, err := userVersion(db)
@@ -169,9 +169,27 @@ func runMigrations(db *sql.DB) error {
}
}
if version < 18 {
if err := migrateToV18(tx); err != nil {
return err
}
if _, err := tx.Exec("PRAGMA user_version = 18"); err != nil {
return fmt.Errorf("setting sqlite user_version 18: %w", err)
}
}
return tx.Commit()
}
// migrateToV18 seeds the family-neutral navigation shortcut catalog from the
// existing web sidebar pins. InitSchema has already rebuilt
// user_setting_values with the profile_client identity before this versioned
// migration runs; keeping the data conversion here makes it one-time and
// transactional with the schema-version bump.
func migrateToV18(tx *sql.Tx) error {
return migrateSidebarPinsToNavigationShortcuts(tx)
}
// migrateToV17 rehomes the Jellyfin DisplayPreferences blobs from
// user_settings (jellycompat:* keys) into the dedicated
// jellycompat_displayprefs table, removing the last non-settings tenant of the
+220
View File
@@ -0,0 +1,220 @@
package userdb
import (
"context"
"database/sql"
"encoding/json"
"reflect"
"testing"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/userstore"
)
const settingValuesSchemaV17 = `
CREATE TABLE user_setting_values (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL,
scope TEXT NOT NULL,
profile_id TEXT,
device_id TEXT,
library_id INTEGER,
series_id TEXT,
value TEXT NOT NULL CHECK (json_valid(value)),
revision INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (scope IN ('account', 'profile', 'profile_device', 'profile_library', 'profile_series')),
CHECK (
(scope = 'account' AND profile_id IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_device' AND profile_id IS NOT NULL AND device_id IS NOT NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_library' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NOT NULL AND series_id IS NULL) OR
(scope = 'profile_series' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NOT NULL)
)
);`
func TestMigrateToV18AddsClientFamilyAndSeedsShortcuts(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := InitSchema(db); err != nil {
t.Fatalf("initial InitSchema: %v", err)
}
// Replace only the canonical values table with its released v17 shape. On a
// real open, InitSchema sees exactly this table before runMigrations.
for _, index := range []string{
"user_setting_values_account_uq", "user_setting_values_profile_uq",
"user_setting_values_profile_client_uq", "user_setting_values_profile_device_uq",
"user_setting_values_profile_library_uq", "user_setting_values_profile_series_uq",
"user_setting_values_resolution_idx", "user_setting_values_series_idx",
"user_setting_values_library_idx",
} {
if _, err := db.Exec("DROP INDEX IF EXISTS " + index); err != nil {
t.Fatalf("drop %s: %v", index, err)
}
}
if _, err := db.Exec("DROP TABLE user_setting_values"); err != nil {
t.Fatalf("drop current values table: %v", err)
}
if _, err := db.Exec(settingValuesSchemaV17); err != nil {
t.Fatalf("create v17 values table: %v", err)
}
if _, err := db.Exec("PRAGMA user_version = 17"); err != nil {
t.Fatalf("set v17: %v", err)
}
const created = "2026-07-28T10:00:00Z"
const updated = "2026-08-01T12:00:00Z"
legacy := `{"12":[{"type":"collection","id":"classics","label":"Classics"}],"7":[{"type":"section","id":"recently-added","label":"Recently Added"},{"type":"section","id":" ","label":"Whitespace ID"},{"type":"collection","id":"horror","label":"Horror"}],"favorites":[{"type":"collection","id":"keep-me","label":"Legacy group"}]}`
encodedLegacy, err := json.Marshal(legacy)
if err != nil {
t.Fatalf("encode legacy JSON string: %v", err)
}
if _, err := db.Exec(`
INSERT INTO user_setting_values
(key, scope, profile_id, value, revision, created_at, updated_at)
VALUES ('ui.sidebar_pins', 'profile', 'p1', ?, 4, ?, ?),
('ui.sidebar_pins', 'profile', 'p2', ?, 2, ?, ?),
('ui.sidebar_pins', 'profile', 'p3', ?, 5, ?, ?),
('nav.shortcuts', 'profile', 'p2', '{"items":[]}', 3, ?, ?)`,
legacy, created, updated,
legacy, created, updated,
string(encodedLegacy), created, updated,
created, updated,
); err != nil {
t.Fatalf("seed v17 rows: %v", err)
}
if err := InitSchema(db); err != nil {
t.Fatalf("upgrade InitSchema: %v", err)
}
if err := runMigrations(db); err != nil {
t.Fatalf("runMigrations: %v", err)
}
version, err := userVersion(db)
if err != nil || version != schemaVersion {
t.Fatalf("user_version = %d (%v), want %d", version, err, schemaVersion)
}
var columnCount int
if err := db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('user_setting_values')
WHERE name = 'client_family'`).Scan(&columnCount); err != nil || columnCount != 1 {
t.Fatalf("client_family column count = %d (%v), want 1", columnCount, err)
}
// The legacy row is preserved byte-for-byte and keeps its revision.
var kept string
var revision int
if err := db.QueryRow(`SELECT value, revision FROM user_setting_values
WHERE key = 'ui.sidebar_pins' AND profile_id = 'p1'`).Scan(&kept, &revision); err != nil {
t.Fatalf("read legacy row: %v", err)
}
if kept != legacy || revision != 4 {
t.Fatalf("legacy row changed to %s at revision %d", kept, revision)
}
var migrated string
if err := db.QueryRow(`SELECT value FROM user_setting_values
WHERE key = 'nav.shortcuts' AND profile_id = 'p1'`).Scan(&migrated); err != nil {
t.Fatalf("read migrated shortcuts: %v", err)
}
assertJSONValueEqual(t, migrated, `{"items":[{"label":"Recently Added","library_id":7,"section_id":"recently-added","type":"section"},{"collection_id":"horror","label":"Horror","library_id":7,"type":"collection"},{"collection_id":"classics","label":"Classics","library_id":12,"type":"collection"}]}`)
// Legacy web builds also wrote the grouped object as one JSON string. It is
// preserved as authored while seeding the same canonical shortcut document.
if err := db.QueryRow(`SELECT value, revision FROM user_setting_values
WHERE key = 'ui.sidebar_pins' AND profile_id = 'p3'`).Scan(&kept, &revision); err != nil {
t.Fatalf("read JSON-string legacy row: %v", err)
}
if kept != string(encodedLegacy) || revision != 5 {
t.Fatalf("JSON-string legacy row changed to %s at revision %d", kept, revision)
}
if err := db.QueryRow(`SELECT value FROM user_setting_values
WHERE key = 'nav.shortcuts' AND profile_id = 'p3'`).Scan(&migrated); err != nil {
t.Fatalf("read shortcuts migrated from JSON string: %v", err)
}
assertJSONValueEqual(t, migrated, `{"items":[{"label":"Recently Added","library_id":7,"section_id":"recently-added","type":"section"},{"collection_id":"horror","label":"Horror","library_id":7,"type":"collection"},{"collection_id":"classics","label":"Classics","library_id":12,"type":"collection"}]}`)
// Existing new-format data wins rather than being overwritten by legacy.
if err := db.QueryRow(`SELECT value, revision FROM user_setting_values
WHERE key = 'nav.shortcuts' AND profile_id = 'p2'`).Scan(&migrated, &revision); err != nil {
t.Fatalf("read pre-existing shortcuts: %v", err)
}
assertJSONValueEqual(t, migrated, `{"items":[]}`)
if revision != 3 {
t.Fatalf("pre-existing shortcut revision = %d, want 3", revision)
}
store := NewSQLiteUserStore(db)
id := userstore.SettingIdentity{
Key: "nav.primary_menu", Scope: settingscontract.ScopeProfileClient,
ProfileID: "p1", ClientFamily: settingscontract.ClientFamilyTV,
}
if _, err := store.UpsertSettingValue(context.Background(), id,
json.RawMessage(`{"items":[{"type":"builtin","destination":"home"}]}`)); err != nil {
t.Fatalf("write profile_client value: %v", err)
}
got, err := store.GetSettingValue(context.Background(), id)
if err != nil || got == nil || got.ClientFamily != settingscontract.ClientFamilyTV {
t.Fatalf("profile_client round trip = %+v (%v)", got, err)
}
}
func TestNavigationShortcutsFromSidebarPinsSkipsMalformedSiblingsAndBoundsLibraryIDs(t *testing.T) {
raw := `{
"7":[
{"type":"section","id":"recent","label":"Recent"},
{"type":"section","id":99,"label":"Wrong ID type"},
{"type":"collection","id":"horror","label":"Horror"},
{"type":"collection","id":"bad-label","label":42}
],
"8":{"type":"collection","id":"not-an-array","label":"Skipped group"},
"9":[null,{"type":"collection","id":"drama","label":"Drama"}],
"2147483647":[{"type":"section","id":"boundary","label":"Boundary"}],
"2147483648":[{"type":"section","id":"overflow","label":"Overflow"}],
"999999999999999999999999999999":[{"type":"section","id":"huge","label":"Huge"}],
"07":[{"type":"section","id":"leading-zero","label":"Leading Zero"}]
}`
value, ok := navigationShortcutsFromSidebarPins(raw)
if !ok {
t.Fatal("migration rejected valid siblings because malformed groups/items were present")
}
assertJSONValueEqual(t, string(value), `{"items":[
{"type":"section","library_id":7,"section_id":"recent","label":"Recent"},
{"type":"collection","library_id":7,"collection_id":"horror","label":"Horror"},
{"type":"collection","library_id":9,"collection_id":"drama","label":"Drama"},
{"type":"section","library_id":2147483647,"section_id":"boundary","label":"Boundary"}
]}`)
}
func TestNavigationShortcutsFromSidebarPinsRejectsOnlyOverflowLibraryIDs(t *testing.T) {
for name, raw := range map[string]string{
"one over int32": `{"2147483648":[{"type":"section","id":"overflow","label":"Overflow"}]}`,
"parse overflow": `{"999999999999999999999999999999":[{"type":"section","id":"huge","label":"Huge"}]}`,
} {
t.Run(name, func(t *testing.T) {
if value, ok := navigationShortcutsFromSidebarPins(raw); ok || value != nil {
t.Fatalf("overflow-only pins migrated as %s", value)
}
})
}
}
func assertJSONValueEqual(t *testing.T, got, want string) {
t.Helper()
var gotValue, wantValue any
if err := json.Unmarshal([]byte(got), &gotValue); err != nil {
t.Fatalf("decode got JSON: %v", err)
}
if err := json.Unmarshal([]byte(want), &wantValue); err != nil {
t.Fatalf("decode want JSON: %v", err)
}
if !reflect.DeepEqual(gotValue, wantValue) {
t.Fatalf("JSON = %s, want %s", got, want)
}
}
@@ -0,0 +1,171 @@
package userdb
import (
"database/sql"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
)
const (
maxNavigationShortcuts = 256
maxMigratedNavigationLibraryID = int64(2_147_483_647)
)
type legacySidebarPin struct {
Type string `json:"type"`
ID string `json:"id"`
Label string `json:"label"`
}
// migrateSidebarPinsToNavigationShortcuts copies the subset of the legacy web
// map that has a concrete positive library id into the new cross-client item
// catalog. The old ui.sidebar_pins row remains untouched, including
// non-numeric well-known groups; a pre-existing nav.shortcuts row always wins.
func migrateSidebarPinsToNavigationShortcuts(tx *sql.Tx) error {
rows, err := tx.Query(`
SELECT legacy.profile_id, legacy.value, legacy.created_at, legacy.updated_at
FROM user_setting_values AS legacy
WHERE legacy.key = 'ui.sidebar_pins'
AND legacy.scope = 'profile'
AND legacy.value <> 'null'
AND NOT EXISTS (
SELECT 1
FROM user_setting_values AS current
WHERE current.key = 'nav.shortcuts'
AND current.scope = 'profile'
AND current.profile_id = legacy.profile_id
)
ORDER BY legacy.profile_id`)
if err != nil {
return fmt.Errorf("reading sidebar pins for navigation shortcut migration: %w", err)
}
type source struct {
profileID, raw, createdAt, updatedAt string
}
var sources []source
for rows.Next() {
var entry source
if err := rows.Scan(&entry.profileID, &entry.raw, &entry.createdAt, &entry.updatedAt); err != nil {
_ = rows.Close()
return fmt.Errorf("scanning sidebar pins for navigation shortcut migration: %w", err)
}
sources = append(sources, entry)
}
if err := rows.Close(); err != nil {
return fmt.Errorf("closing sidebar pins migration rows: %w", err)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterating sidebar pins for navigation shortcut migration: %w", err)
}
for _, source := range sources {
value, ok := navigationShortcutsFromSidebarPins(source.raw)
if !ok {
continue
}
if _, err := tx.Exec(`
INSERT OR IGNORE INTO user_setting_values
(key, scope, profile_id, client_family, device_id, library_id, series_id,
value, revision, created_at, updated_at)
VALUES ('nav.shortcuts', 'profile', ?, NULL, NULL, NULL, NULL, ?, 1, ?, ?)`,
source.profileID, string(value), source.createdAt, source.updatedAt,
); err != nil {
return fmt.Errorf("seeding navigation shortcuts for profile %q: %w", source.profileID, err)
}
}
return nil
}
func navigationShortcutsFromSidebarPins(raw string) (json.RawMessage, bool) {
payload := []byte(raw)
// The legacy web client sometimes persisted the whole object as a JSON
// string. Match parseSidebarPins by unwrapping exactly one such layer before
// interpreting the grouped pin map.
var encoded string
if err := json.Unmarshal(payload, &encoded); err == nil {
payload = []byte(encoded)
}
// Decode the object one group at a time. A typed map[string][]T makes one
// non-array group or one malformed member reject every valid sibling in the
// profile. Raw group/member boundaries match PostgreSQL's jsonb_each plus
// jsonb_array_elements behavior: malformed siblings are skipped in place.
var legacy map[string]json.RawMessage
if err := json.Unmarshal(payload, &legacy); err != nil {
return nil, false
}
items := make([]map[string]any, 0)
seen := make(map[string]struct{})
// JSON object order is not semantic and Go deliberately randomizes map
// iteration. Sort valid numeric groups by library id, matching the
// PostgreSQL migration's ORDER BY rather than lexical string order.
type libraryGroup struct {
key string
id int
}
groups := make([]libraryGroup, 0, len(legacy))
for group := range legacy {
libraryID, err := strconv.ParseInt(group, 10, 32)
if err != nil || libraryID <= 0 || libraryID > maxMigratedNavigationLibraryID ||
strconv.FormatInt(libraryID, 10) != group {
continue
}
groups = append(groups, libraryGroup{key: group, id: int(libraryID)})
}
sort.Slice(groups, func(i, j int) bool { return groups[i].id < groups[j].id })
for _, group := range groups {
var encodedPins []json.RawMessage
if err := json.Unmarshal(legacy[group.key], &encodedPins); err != nil {
continue
}
for _, encodedPin := range encodedPins {
var pin legacySidebarPin
if err := json.Unmarshal(encodedPin, &pin); err != nil {
continue
}
id := pin.ID
label := pin.Label
if strings.TrimSpace(id) == "" || len([]rune(id)) > 128 ||
strings.TrimSpace(label) == "" || len([]rune(label)) > 256 {
continue
}
identity := pin.Type + "\x00" + strconv.Itoa(group.id) + "\x00" + id
if _, duplicate := seen[identity]; duplicate {
continue
}
var item map[string]any
switch pin.Type {
case "section":
item = map[string]any{
"type": "section", "library_id": group.id,
"section_id": id, "label": label,
}
case "collection":
item = map[string]any{
"type": "collection", "library_id": group.id,
"collection_id": id, "label": label,
}
default:
continue
}
seen[identity] = struct{}{}
items = append(items, item)
if len(items) == maxNavigationShortcuts {
break
}
}
if len(items) == maxNavigationShortcuts {
break
}
}
if len(items) == 0 {
return nil, false
}
value, err := json.Marshal(map[string]any{"items": items})
return value, err == nil
}
@@ -16,8 +16,11 @@ import (
// ordinary store calls.
type preferenceSettingsExecutor interface {
Exec(string, ...any) (sql.Result, error)
ExecContext(context.Context, string, ...any) (sql.Result, error)
Query(string, ...any) (*sql.Rows, error)
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
QueryRow(string, ...any) *sql.Row
QueryRowContext(context.Context, string, ...any) *sql.Row
}
type preferenceSettingsTx struct {
+101 -6
View File
@@ -301,6 +301,7 @@ CREATE TABLE IF NOT EXISTS user_setting_values (
key TEXT NOT NULL,
scope TEXT NOT NULL,
profile_id TEXT,
client_family TEXT,
device_id TEXT,
library_id INTEGER,
series_id TEXT,
@@ -308,13 +309,15 @@ CREATE TABLE IF NOT EXISTS user_setting_values (
revision INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (scope IN ('account', 'profile', 'profile_device', 'profile_library', 'profile_series')),
CHECK (scope IN ('account', 'profile', 'profile_client', 'profile_device', 'profile_library', 'profile_series')),
CHECK (client_family IS NULL OR client_family IN ('tv', 'mobile', 'tablet', 'desktop', 'web')),
CHECK (
(scope = 'account' AND profile_id IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_device' AND profile_id IS NOT NULL AND device_id IS NOT NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_library' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NOT NULL AND series_id IS NULL) OR
(scope = 'profile_series' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NOT NULL)
(scope = 'account' AND profile_id IS NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_client' AND profile_id IS NOT NULL AND client_family IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_device' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NOT NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_library' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NOT NULL AND series_id IS NULL) OR
(scope = 'profile_series' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NOT NULL)
)
);
@@ -367,6 +370,9 @@ func InitSchema(db *sql.DB) error {
if err != nil {
return err
}
if err := ensureSettingValuesClientFamily(db); err != nil {
return err
}
if err := ensureProfileSectionOverridesRemovedColumn(db); err != nil {
return err
}
@@ -400,6 +406,95 @@ func InitSchema(db *sql.DB) error {
return backfillUserDevices(db)
}
// ensureSettingValuesClientFamily upgrades the canonical settings table before
// runMigrations reads it. InitSchema runs first on every open, and SQLite
// cannot widen a table CHECK constraint with ALTER TABLE, so an existing table
// must be rebuilt here rather than merely gaining a nullable column. The
// rebuild is transactional and preserves ids, revisions, values, and
// timestamps verbatim.
func ensureSettingValuesClientFamily(db *sql.DB) error {
var count int
if err := db.QueryRow(
"SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?",
"user_setting_values", "client_family",
).Scan(&count); err != nil {
return fmt.Errorf("checking user_setting_values.client_family column: %w", err)
}
if count > 0 {
_, err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS user_setting_values_profile_client_uq
ON user_setting_values (profile_id, client_family, key) WHERE scope = 'profile_client'`)
if err != nil {
return fmt.Errorf("creating profile_client setting index: %w", err)
}
return nil
}
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("beginning profile_client settings migration: %w", err)
}
defer tx.Rollback() //nolint:errcheck
if _, err := tx.Exec(`
CREATE TABLE user_setting_values_profile_client_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL,
scope TEXT NOT NULL,
profile_id TEXT,
client_family TEXT,
device_id TEXT,
library_id INTEGER,
series_id TEXT,
value TEXT NOT NULL CHECK (json_valid(value)),
revision INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (scope IN ('account', 'profile', 'profile_client', 'profile_device', 'profile_library', 'profile_series')),
CHECK (client_family IS NULL OR client_family IN ('tv', 'mobile', 'tablet', 'desktop', 'web')),
CHECK (
(scope = 'account' AND profile_id IS NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_client' AND profile_id IS NOT NULL AND client_family IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_device' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NOT NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_library' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NOT NULL AND series_id IS NULL) OR
(scope = 'profile_series' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NOT NULL)
)
);
INSERT INTO user_setting_values_profile_client_new
(id, key, scope, profile_id, client_family, device_id, library_id, series_id,
value, revision, created_at, updated_at)
SELECT id, key, scope, profile_id, NULL, device_id, library_id, series_id,
value, revision, created_at, updated_at
FROM user_setting_values;
DROP TABLE user_setting_values;
ALTER TABLE user_setting_values_profile_client_new RENAME TO user_setting_values;
CREATE UNIQUE INDEX user_setting_values_account_uq
ON user_setting_values (key) WHERE scope = 'account';
CREATE UNIQUE INDEX user_setting_values_profile_uq
ON user_setting_values (profile_id, key) WHERE scope = 'profile';
CREATE UNIQUE INDEX user_setting_values_profile_client_uq
ON user_setting_values (profile_id, client_family, key) WHERE scope = 'profile_client';
CREATE UNIQUE INDEX user_setting_values_profile_device_uq
ON user_setting_values (profile_id, device_id, key) WHERE scope = 'profile_device';
CREATE UNIQUE INDEX user_setting_values_profile_library_uq
ON user_setting_values (profile_id, library_id, key) WHERE scope = 'profile_library';
CREATE UNIQUE INDEX user_setting_values_profile_series_uq
ON user_setting_values (profile_id, series_id, key) WHERE scope = 'profile_series';
CREATE INDEX user_setting_values_resolution_idx
ON user_setting_values (profile_id, key, scope);
CREATE INDEX user_setting_values_series_idx
ON user_setting_values (profile_id, series_id);
CREATE INDEX user_setting_values_library_idx
ON user_setting_values (profile_id, library_id);
`); err != nil {
return fmt.Errorf("rebuilding user_setting_values for profile_client: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("committing profile_client settings migration: %w", err)
}
return nil
}
// watchProgressSyncTriggers stamps the server-owned cursor (synced_seq) on every
// watch_progress write and owns the LWW key: event_at defaults to the write
// time, and an UPDATE that changed updated_at without explicitly changing
+136
View File
@@ -0,0 +1,136 @@
package userdb
import (
"context"
"database/sql"
"database/sql/driver"
"encoding/json"
"fmt"
"log/slog"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// settingMutationConnExecutor pins every statement to one database/sql
// connection. SQLite cannot request BEGIN IMMEDIATE through sql.Tx without a
// DSN-wide policy, so this adapter lets the mutation transaction acquire its
// write reservation before it reads the receipt.
type settingMutationConnExecutor struct {
ctx context.Context
conn *sql.Conn
}
func (e settingMutationConnExecutor) Exec(query string, args ...any) (sql.Result, error) {
return e.conn.ExecContext(e.ctx, query, args...)
}
func (e settingMutationConnExecutor) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
return e.conn.ExecContext(ctx, query, args...)
}
func (e settingMutationConnExecutor) Query(query string, args ...any) (*sql.Rows, error) {
return e.conn.QueryContext(e.ctx, query, args...)
}
func (e settingMutationConnExecutor) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
return e.conn.QueryContext(ctx, query, args...)
}
func (e settingMutationConnExecutor) QueryRow(query string, args ...any) *sql.Row {
return e.conn.QueryRowContext(e.ctx, query, args...)
}
func (e settingMutationConnExecutor) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
return e.conn.QueryRowContext(ctx, query, args...)
}
type sqliteSettingMutationWriter struct {
exec preferenceSettingsExecutor
}
var _ userstore.SettingMutationTransactioner = (*SQLiteUserStore)(nil)
var _ userstore.SettingMutationWriter = (*sqliteSettingMutationWriter)(nil)
// WithSettingMutationTransaction uses BEGIN IMMEDIATE so same-database
// contenders serialize before checking mutation_id. The setting row and its
// receipt then commit together; rollback covers callback errors and crashes.
func (s *SQLiteUserStore) WithSettingMutationTransaction(
ctx context.Context,
mutationID string,
fn func(userstore.SettingMutationWriter) error,
) (err error) {
if mutationID == "" {
return fmt.Errorf("setting mutation transaction requires a mutation id")
}
conn, err := s.db.Conn(ctx)
if err != nil {
return fmt.Errorf("opening setting mutation connection: %w", err)
}
defer conn.Close() //nolint:errcheck
exec := settingMutationConnExecutor{ctx: ctx, conn: conn}
if _, err := exec.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil {
return fmt.Errorf("beginning setting mutation transaction: %w", err)
}
committed := false
defer func() {
if !committed {
rollbackCtx := context.WithoutCancel(ctx)
if _, rollbackErr := exec.ExecContext(rollbackCtx, "ROLLBACK"); rollbackErr != nil {
slog.ErrorContext(rollbackCtx, "rolling back setting mutation transaction failed",
"component", "userdb.settings", "error", rollbackErr)
// A failed rollback can leave SQLite's write reservation attached to
// the pooled driver connection. Mark it bad so database/sql discards
// it instead of returning a poisoned connection to the pool.
_ = conn.Raw(func(any) error { return driver.ErrBadConn })
}
}
}()
if err := fn(&sqliteSettingMutationWriter{exec: exec}); err != nil {
return err
}
if _, err := exec.ExecContext(ctx, "COMMIT"); err != nil {
return fmt.Errorf("committing setting mutation transaction: %w", err)
}
committed = true
return nil
}
func (w *sqliteSettingMutationWriter) GetSettingValue(
_ context.Context,
id userstore.SettingIdentity,
) (*userstore.SettingValue, error) {
return getSettingValue(w.exec, id)
}
func (w *sqliteSettingMutationWriter) UpsertSettingValue(
_ context.Context,
id userstore.SettingIdentity,
value json.RawMessage,
) (*userstore.SettingValue, error) {
return upsertSettingValue(w.exec, id, value)
}
func (w *sqliteSettingMutationWriter) CompareAndSetSettingValue(
ctx context.Context,
id userstore.SettingIdentity,
value json.RawMessage,
expectedRevision int64,
) (*userstore.SettingValue, error) {
return compareAndSetSettingValue(ctx, w.exec, id, value, expectedRevision)
}
func (w *sqliteSettingMutationWriter) GetSettingMutation(
_ context.Context,
mutationID string,
) (*userstore.SettingMutationRecord, error) {
return getSettingMutation(w.exec, mutationID)
}
func (w *sqliteSettingMutationWriter) PutSettingMutation(
_ context.Context,
record userstore.SettingMutationRecord,
) (userstore.SettingMutationRecord, bool, error) {
return putSettingMutation(w.exec, record)
}
+116 -23
View File
@@ -1,6 +1,7 @@
package userdb
import (
"context"
"database/sql"
"encoding/json"
"errors"
@@ -14,7 +15,7 @@ import (
// settingValueColumns is the projection every read shares, in the order
// scanSettingValue expects.
const settingValueColumns = `key, scope, profile_id, device_id, library_id, series_id,
const settingValueColumns = `key, scope, profile_id, client_family, device_id, library_id, series_id,
value, revision, created_at, updated_at`
// settingConflictTargets maps a scope to the partial unique index that enforces
@@ -23,6 +24,7 @@ const settingValueColumns = `key, scope, profile_id, device_id, library_id, seri
var settingConflictTargets = map[settingscontract.Scope]string{
settingscontract.ScopeAccount: "(key) WHERE scope = 'account'",
settingscontract.ScopeProfile: "(profile_id, key) WHERE scope = 'profile'",
settingscontract.ScopeProfileClient: "(profile_id, client_family, key) WHERE scope = 'profile_client'",
settingscontract.ScopeProfileDevice: "(profile_id, device_id, key) WHERE scope = 'profile_device'",
settingscontract.ScopeProfileLibrary: "(profile_id, library_id, key) WHERE scope = 'profile_library'",
settingscontract.ScopeProfileSeries: "(profile_id, series_id, key) WHERE scope = 'profile_series'",
@@ -38,6 +40,9 @@ func settingIdentityPredicate(id userstore.SettingIdentity) (string, []any) {
case settingscontract.ScopeProfile:
args = append(args, id.ProfileID)
clause += " AND profile_id = ?"
case settingscontract.ScopeProfileClient:
args = append(args, id.ProfileID, string(id.ClientFamily))
clause += " AND profile_id = ? AND client_family = ?"
case settingscontract.ScopeProfileDevice:
args = append(args, id.ProfileID, id.DeviceID)
clause += " AND profile_id = ? AND device_id = ?"
@@ -54,11 +59,15 @@ func settingIdentityPredicate(id userstore.SettingIdentity) (string, []any) {
// GetSettingValue returns the explicit value at exactly one scope, or nil when
// that identity is unset.
func GetSettingValue(db *sql.DB, id userstore.SettingIdentity) (*userstore.SettingValue, error) {
return getSettingValue(db, id)
}
func getSettingValue(exec preferenceSettingsExecutor, id userstore.SettingIdentity) (*userstore.SettingValue, error) {
if err := id.Validate(); err != nil {
return nil, err
}
clause, args := settingIdentityPredicate(id)
row := db.QueryRow("SELECT "+settingValueColumns+" FROM user_setting_values WHERE "+clause, args...)
row := exec.QueryRow("SELECT "+settingValueColumns+" FROM user_setting_values WHERE "+clause, args...)
value, err := scanSettingValue(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
@@ -70,9 +79,9 @@ func GetSettingValue(db *sql.DB, id userstore.SettingIdentity) (*userstore.Setti
}
// ListSettingValuesForResolution collects every candidate row for a resolution
// request in one query. The predicate covers all five scopes at once: ranking by
// each definition's resolution order happens in Go, so a four-scope chain still
// costs one round trip rather than four lookups per key.
// request in one query. The predicate covers all six scopes at once: ranking by
// each definition's resolution order happens in Go, so a multi-scope chain still
// costs one round trip rather than one lookup per scope and key.
func ListSettingValuesForResolution(
db *sql.DB,
query userstore.SettingResolutionQuery,
@@ -82,14 +91,14 @@ func ListSettingValuesForResolution(
return nil, nil
}
args := make([]any, 0, len(q.Keys)+len(q.ProfileIDs)+len(q.LibraryIDs)+len(q.SeriesIDs)+1)
args := make([]any, 0, len(q.Keys)+len(q.ProfileIDs)+len(q.LibraryIDs)+len(q.SeriesIDs)+2)
for _, key := range q.Keys {
args = append(args, key)
}
for _, profileID := range q.ProfileIDs {
args = append(args, profileID)
}
args = append(args, q.DeviceID)
args = append(args, string(q.ClientFamily), q.DeviceID)
for _, libraryID := range q.LibraryIDs {
args = append(args, libraryID)
}
@@ -123,13 +132,14 @@ func ListSettingValuesForResolution(
`+profileClause+`
AND (
scope = 'profile'
OR (scope = 'profile_client' AND client_family = ?)
OR (scope = 'profile_device' AND device_id = ?)
OR (`+libraryClause+`)
OR (`+seriesClause+`)
)
)
)
ORDER BY key, scope, COALESCE(profile_id, ''), COALESCE(device_id, ''),
ORDER BY key, scope, COALESCE(profile_id, ''), COALESCE(client_family, ''), COALESCE(device_id, ''),
COALESCE(library_id, 0), COALESCE(series_id, '')`,
args...,
)
@@ -157,7 +167,7 @@ func ListAllSettingValues(db *sql.DB) ([]userstore.SettingValue, error) {
rows, err := db.Query(`
SELECT ` + settingValueColumns + `
FROM user_setting_values
ORDER BY key, scope, COALESCE(profile_id, ''), COALESCE(device_id, ''),
ORDER BY key, scope, COALESCE(profile_id, ''), COALESCE(client_family, ''), COALESCE(device_id, ''),
COALESCE(library_id, 0), COALESCE(series_id, '')`)
if err != nil {
return nil, fmt.Errorf("listing all setting values: %w", err)
@@ -204,15 +214,15 @@ func upsertSettingValue(
now := nowRFC3339()
row := exec.QueryRow(fmt.Sprintf(`
INSERT INTO user_setting_values
(key, scope, profile_id, device_id, library_id, series_id, value, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
(key, scope, profile_id, client_family, device_id, library_id, series_id, value, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT %s DO UPDATE SET
value = excluded.value,
revision = user_setting_values.revision + 1,
updated_at = excluded.updated_at
RETURNING %s`, target, settingValueColumns),
id.Key, string(id.Scope),
nullableText(id.ProfileID), nullableText(id.DeviceID),
nullableText(id.ProfileID), nullableText(string(id.ClientFamily)), nullableText(id.DeviceID),
nullableInt(id.LibraryID), nullableText(id.SeriesID),
string(value), now, now,
)
@@ -223,6 +233,76 @@ func upsertSettingValue(
return &stored, nil
}
// CompareAndSetSettingValue writes only when expectedRevision still names the
// current row. Revision zero is the insert-if-absent case. Each branch is one
// SQLite statement, so the comparison and write are indivisible even when two
// clients read the same starting document.
func CompareAndSetSettingValue(
ctx context.Context,
db *sql.DB,
id userstore.SettingIdentity,
value json.RawMessage,
expectedRevision int64,
) (*userstore.SettingValue, error) {
return compareAndSetSettingValue(ctx, db, id, value, expectedRevision)
}
func compareAndSetSettingValue(
ctx context.Context,
exec preferenceSettingsExecutor,
id userstore.SettingIdentity,
value json.RawMessage,
expectedRevision int64,
) (*userstore.SettingValue, error) {
if err := id.Validate(); err != nil {
return nil, err
}
if err := userstore.ValidateSettingValueJSON(value); err != nil {
return nil, err
}
if expectedRevision < 0 {
return nil, fmt.Errorf("%w: expected revision must be non-negative", userstore.ErrInvalidSettingIdentity)
}
now := nowRFC3339()
var row *sql.Row
if expectedRevision == 0 {
target, ok := settingConflictTargets[id.Scope]
if !ok {
return nil, fmt.Errorf("%w: %q has no storage identity", userstore.ErrInvalidSettingIdentity, id.Scope)
}
row = exec.QueryRowContext(ctx, fmt.Sprintf(`
INSERT INTO user_setting_values
(key, scope, profile_id, client_family, device_id, library_id, series_id, value, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT %s DO NOTHING
RETURNING %s`, target, settingValueColumns),
id.Key, string(id.Scope),
nullableText(id.ProfileID), nullableText(string(id.ClientFamily)), nullableText(id.DeviceID),
nullableInt(id.LibraryID), nullableText(id.SeriesID),
string(value), now, now,
)
} else {
clause, args := settingIdentityPredicate(id)
args = append([]any{string(value), now}, args...)
args = append(args, expectedRevision)
row = exec.QueryRowContext(ctx, `
UPDATE user_setting_values
SET value = ?, revision = revision + 1, updated_at = ?
WHERE `+clause+` AND revision = ?
RETURNING `+settingValueColumns, args...)
}
stored, err := scanSettingValue(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, userstore.ErrSettingValueRevisionConflict
}
if err != nil {
return nil, fmt.Errorf("compare-and-setting value %q at %s: %w", id.Key, id.Scope, err)
}
return &stored, nil
}
// DeleteSettingValue removes the explicit value at one scope — the `unset`
// operation — and reports whether a row existed.
func DeleteSettingValue(db *sql.DB, id userstore.SettingIdentity) (bool, error) {
@@ -286,7 +366,11 @@ func execSettingValueDelete(db *sql.DB, query, subject string, args ...any) (int
// GetSettingMutation returns a recorded idempotency receipt, or nil.
func GetSettingMutation(db *sql.DB, mutationID string) (*userstore.SettingMutationRecord, error) {
row := db.QueryRow(`
return getSettingMutation(db, mutationID)
}
func getSettingMutation(exec preferenceSettingsExecutor, mutationID string) (*userstore.SettingMutationRecord, error) {
row := exec.QueryRow(`
SELECT mutation_id, request_hash, result, created_at, expires_at
FROM user_setting_mutations
WHERE mutation_id = ?`, mutationID)
@@ -307,12 +391,19 @@ func GetSettingMutation(db *sql.DB, mutationID string) (*userstore.SettingMutati
func PutSettingMutation(
db *sql.DB,
record userstore.SettingMutationRecord,
) (userstore.SettingMutationRecord, bool, error) {
return putSettingMutation(db, record)
}
func putSettingMutation(
exec preferenceSettingsExecutor,
record userstore.SettingMutationRecord,
) (userstore.SettingMutationRecord, bool, error) {
if err := record.Validate(); err != nil {
return userstore.SettingMutationRecord{}, false, err
}
row := db.QueryRow(`
row := exec.QueryRow(`
INSERT INTO user_setting_mutations (mutation_id, request_hash, result, created_at, expires_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (mutation_id) DO NOTHING
@@ -328,7 +419,7 @@ func PutSettingMutation(
return userstore.SettingMutationRecord{}, false, fmt.Errorf("recording setting mutation %q: %w", record.MutationID, err)
}
existing, err := GetSettingMutation(db, record.MutationID)
existing, err := getSettingMutation(exec, record.MutationID)
if err != nil {
return userstore.SettingMutationRecord{}, false, err
}
@@ -365,22 +456,24 @@ type sqlRow interface {
func scanSettingValue(row sqlRow) (userstore.SettingValue, error) {
var (
value userstore.SettingValue
scope string
profileID sql.NullString
deviceID sql.NullString
libraryID sql.NullInt64
seriesID sql.NullString
raw string
value userstore.SettingValue
scope string
profileID sql.NullString
clientFamily sql.NullString
deviceID sql.NullString
libraryID sql.NullInt64
seriesID sql.NullString
raw string
)
if err := row.Scan(
&value.Key, &scope, &profileID, &deviceID, &libraryID, &seriesID,
&value.Key, &scope, &profileID, &clientFamily, &deviceID, &libraryID, &seriesID,
&raw, &value.Revision, &value.CreatedAt, &value.UpdatedAt,
); err != nil {
return userstore.SettingValue{}, err
}
value.Scope = settingscontract.Scope(scope)
value.ProfileID = profileID.String
value.ClientFamily = settingscontract.ClientFamily(clientFamily.String)
value.DeviceID = deviceID.String
value.LibraryID = int(libraryID.Int64)
value.SeriesID = seriesID.String
+4
View File
@@ -466,6 +466,10 @@ func (s *SQLiteUserStore) UpsertSettingValue(_ context.Context, id userstore.Set
return UpsertSettingValue(s.db, id, value)
}
func (s *SQLiteUserStore) CompareAndSetSettingValue(ctx context.Context, id userstore.SettingIdentity, value json.RawMessage, expectedRevision int64) (*userstore.SettingValue, error) {
return CompareAndSetSettingValue(ctx, s.db, id, value, expectedRevision)
}
func (s *SQLiteUserStore) DeleteSettingValue(_ context.Context, id userstore.SettingIdentity) (bool, error) {
return DeleteSettingValue(s.db, id)
}
@@ -0,0 +1,96 @@
package pgstore
import (
"context"
"encoding/json"
"fmt"
"hash/fnv"
"strconv"
"github.com/Silo-Server/silo-server/internal/userstore"
)
const settingMutationAdvisoryClass int32 = 0x534d5554 // "SMUT"
type postgresSettingMutationWriter struct {
exec preferenceSettingsExecutor
userID int
}
var _ userstore.SettingMutationTransactioner = (*PostgresUserStore)(nil)
var _ userstore.SettingMutationWriter = (*postgresSettingMutationWriter)(nil)
// WithSettingMutationTransaction takes a transaction-scoped advisory lock
// before reading mutation_id. The hash includes the user, so unrelated
// accounts and mutation ids remain concurrent; a hash collision only causes
// harmless extra serialization. The setting and receipt commit together.
func (s *PostgresUserStore) WithSettingMutationTransaction(
ctx context.Context,
mutationID string,
fn func(userstore.SettingMutationWriter) error,
) error {
if mutationID == "" {
return fmt.Errorf("setting mutation transaction requires a mutation id")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning setting mutation transaction: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
lockHash := fnv.New32a()
_, _ = lockHash.Write([]byte(strconv.Itoa(s.userID)))
_, _ = lockHash.Write([]byte{0})
_, _ = lockHash.Write([]byte(mutationID))
if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1, $2)",
settingMutationAdvisoryClass, int32(lockHash.Sum32())); err != nil {
return fmt.Errorf("locking setting mutation transaction: %w", err)
}
writer := &postgresSettingMutationWriter{exec: tx, userID: s.userID}
if err := fn(writer); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("committing setting mutation transaction: %w", err)
}
return nil
}
func (w *postgresSettingMutationWriter) GetSettingValue(
ctx context.Context,
id userstore.SettingIdentity,
) (*userstore.SettingValue, error) {
return getSettingValue(ctx, w.exec, w.userID, id)
}
func (w *postgresSettingMutationWriter) UpsertSettingValue(
ctx context.Context,
id userstore.SettingIdentity,
value json.RawMessage,
) (*userstore.SettingValue, error) {
return upsertSettingValue(ctx, w.exec, w.userID, id, value)
}
func (w *postgresSettingMutationWriter) CompareAndSetSettingValue(
ctx context.Context,
id userstore.SettingIdentity,
value json.RawMessage,
expectedRevision int64,
) (*userstore.SettingValue, error) {
return compareAndSetSettingValue(ctx, w.exec, w.userID, id, value, expectedRevision)
}
func (w *postgresSettingMutationWriter) GetSettingMutation(
ctx context.Context,
mutationID string,
) (*userstore.SettingMutationRecord, error) {
return getSettingMutation(ctx, w.exec, w.userID, mutationID)
}
func (w *postgresSettingMutationWriter) PutSettingMutation(
ctx context.Context,
record userstore.SettingMutationRecord,
) (userstore.SettingMutationRecord, bool, error) {
return putSettingMutation(ctx, w.exec, w.userID, record)
}
+140 -35
View File
@@ -15,7 +15,7 @@ import (
// settingValueColumns is the projection every read shares, in the order
// scanSettingValue expects.
const settingValueColumns = `key, scope, profile_id, device_id, library_id, series_id,
const settingValueColumns = `key, scope, profile_id, client_family, device_id, library_id, series_id,
value, revision, created_at, updated_at`
// settingConflictTargets maps a scope to the partial unique index that enforces
@@ -24,6 +24,7 @@ const settingValueColumns = `key, scope, profile_id, device_id, library_id, seri
var settingConflictTargets = map[settingscontract.Scope]string{
settingscontract.ScopeAccount: "(user_id, key) WHERE scope = 'account'",
settingscontract.ScopeProfile: "(user_id, profile_id, key) WHERE scope = 'profile'",
settingscontract.ScopeProfileClient: "(user_id, profile_id, client_family, key) WHERE scope = 'profile_client'",
settingscontract.ScopeProfileDevice: "(user_id, profile_id, device_id, key) WHERE scope = 'profile_device'",
settingscontract.ScopeProfileLibrary: "(user_id, profile_id, library_id, key) WHERE scope = 'profile_library'",
settingscontract.ScopeProfileSeries: "(user_id, profile_id, series_id, key) WHERE scope = 'profile_series'",
@@ -39,6 +40,9 @@ func settingIdentityPredicate(userID int, id userstore.SettingIdentity) (string,
case settingscontract.ScopeProfile:
args = append(args, id.ProfileID)
clause += " AND profile_id = $4"
case settingscontract.ScopeProfileClient:
args = append(args, id.ProfileID, string(id.ClientFamily))
clause += " AND profile_id = $4 AND client_family = $5"
case settingscontract.ScopeProfileDevice:
args = append(args, id.ProfileID, id.DeviceID)
clause += " AND profile_id = $4 AND device_id = $5"
@@ -55,12 +59,21 @@ func settingIdentityPredicate(userID int, id userstore.SettingIdentity) (string,
func (s *PostgresUserStore) GetSettingValue(
ctx context.Context,
id userstore.SettingIdentity,
) (*userstore.SettingValue, error) {
return getSettingValue(ctx, s.pool, s.userID, id)
}
func getSettingValue(
ctx context.Context,
exec preferenceSettingsExecutor,
userID int,
id userstore.SettingIdentity,
) (*userstore.SettingValue, error) {
if err := id.Validate(); err != nil {
return nil, err
}
clause, args := settingIdentityPredicate(s.userID, id)
row := s.pool.QueryRow(ctx,
clause, args := settingIdentityPredicate(userID, id)
row := exec.QueryRow(ctx,
"SELECT "+settingValueColumns+" FROM user_setting_values WHERE "+clause,
args...,
)
@@ -75,9 +88,9 @@ func (s *PostgresUserStore) GetSettingValue(
}
// ListSettingValuesForResolution collects every candidate row for a resolution
// request in one query. The predicate covers all five scopes at once: ranking by
// each definition's resolution order happens in Go, so a four-scope chain still
// costs one round trip and one index scan rather than four lookups per key.
// request in one query. The predicate covers all six scopes at once: ranking by
// each definition's resolution order happens in Go, so a multi-scope chain still
// costs one round trip and one index scan rather than one lookup per scope and key.
func (s *PostgresUserStore) ListSettingValuesForResolution(
ctx context.Context,
query userstore.SettingResolutionQuery,
@@ -96,17 +109,18 @@ func (s *PostgresUserStore) ListSettingValuesForResolution(
scope = 'account'
OR (
profile_id = ANY($3::text[])
AND (
scope = 'profile'
OR (scope = 'profile_device' AND device_id = $4)
OR (scope = 'profile_library' AND library_id = ANY($5::int[]))
OR (scope = 'profile_series' AND series_id = ANY($6::text[]))
)
)
)
ORDER BY key, scope, COALESCE(profile_id, ''), COALESCE(device_id, ''),
AND (
scope = 'profile'
OR (scope = 'profile_client' AND client_family = $4)
OR (scope = 'profile_device' AND device_id = $5)
OR (scope = 'profile_library' AND library_id = ANY($6::int[]))
OR (scope = 'profile_series' AND series_id = ANY($7::text[]))
)
)
)
ORDER BY key, scope, COALESCE(profile_id, ''), COALESCE(client_family, ''), COALESCE(device_id, ''),
COALESCE(library_id, 0), COALESCE(series_id, '')`,
s.userID, q.Keys, q.ProfileIDs, q.DeviceID, q.LibraryIDs, q.SeriesIDs,
s.userID, q.Keys, q.ProfileIDs, string(q.ClientFamily), q.DeviceID, q.LibraryIDs, q.SeriesIDs,
)
if err != nil {
return nil, fmt.Errorf("listing setting values for resolution: %w", err)
@@ -133,7 +147,7 @@ func (s *PostgresUserStore) ListAllSettingValues(ctx context.Context) ([]usersto
SELECT `+settingValueColumns+`
FROM user_setting_values
WHERE user_id = $1
ORDER BY key, scope, COALESCE(profile_id, ''), COALESCE(device_id, ''),
ORDER BY key, scope, COALESCE(profile_id, ''), COALESCE(client_family, ''), COALESCE(device_id, ''),
COALESCE(library_id, 0), COALESCE(series_id, '')`,
s.userID,
)
@@ -181,15 +195,15 @@ func upsertSettingValue(
row := exec.QueryRow(ctx, fmt.Sprintf(`
INSERT INTO user_setting_values
(user_id, key, scope, profile_id, device_id, library_id, series_id, value)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
(user_id, key, scope, profile_id, client_family, device_id, library_id, series_id, value)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT %s DO UPDATE SET
value = excluded.value,
revision = user_setting_values.revision + 1,
updated_at = now()
RETURNING %s`, target, settingValueColumns),
userID, id.Key, string(id.Scope),
nullableText(id.ProfileID), nullableText(id.DeviceID),
nullableText(id.ProfileID), nullableText(string(id.ClientFamily)), nullableText(id.DeviceID),
nullableInt(id.LibraryID), nullableText(id.SeriesID),
[]byte(value),
)
@@ -200,6 +214,75 @@ func upsertSettingValue(
return &stored, nil
}
// CompareAndSetSettingValue writes only when expectedRevision still names the
// current row. Revision zero is insert-if-absent. PostgreSQL performs either
// comparison and write in one statement, so concurrent document mutations can
// retry without overwriting one another.
func (s *PostgresUserStore) CompareAndSetSettingValue(
ctx context.Context,
id userstore.SettingIdentity,
value json.RawMessage,
expectedRevision int64,
) (*userstore.SettingValue, error) {
return compareAndSetSettingValue(ctx, s.pool, s.userID, id, value, expectedRevision)
}
func compareAndSetSettingValue(
ctx context.Context,
exec preferenceSettingsExecutor,
userID int,
id userstore.SettingIdentity,
value json.RawMessage,
expectedRevision int64,
) (*userstore.SettingValue, error) {
if err := id.Validate(); err != nil {
return nil, err
}
if err := userstore.ValidateSettingValueJSON(value); err != nil {
return nil, err
}
if expectedRevision < 0 {
return nil, fmt.Errorf("%w: expected revision must be non-negative", userstore.ErrInvalidSettingIdentity)
}
var row pgx.Row
if expectedRevision == 0 {
target, ok := settingConflictTargets[id.Scope]
if !ok {
return nil, fmt.Errorf("%w: %q has no storage identity", userstore.ErrInvalidSettingIdentity, id.Scope)
}
row = exec.QueryRow(ctx, fmt.Sprintf(`
INSERT INTO user_setting_values
(user_id, key, scope, profile_id, client_family, device_id, library_id, series_id, value)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT %s DO NOTHING
RETURNING %s`, target, settingValueColumns),
userID, id.Key, string(id.Scope),
nullableText(id.ProfileID), nullableText(string(id.ClientFamily)), nullableText(id.DeviceID),
nullableInt(id.LibraryID), nullableText(id.SeriesID), []byte(value),
)
} else {
clause, args := settingIdentityPredicate(userID, id)
valuePosition := len(args) + 1
revisionPosition := valuePosition + 1
args = append(args, []byte(value), expectedRevision)
row = exec.QueryRow(ctx, fmt.Sprintf(`
UPDATE user_setting_values
SET value = $%d, revision = revision + 1, updated_at = now()
WHERE %s AND revision = $%d
RETURNING %s`, valuePosition, clause, revisionPosition, settingValueColumns), args...)
}
stored, err := scanSettingValue(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, userstore.ErrSettingValueRevisionConflict
}
if err != nil {
return nil, fmt.Errorf("compare-and-setting value %q at %s: %w", id.Key, id.Scope, err)
}
return &stored, nil
}
func (s *PostgresUserStore) DeleteSettingValue(ctx context.Context, id userstore.SettingIdentity) (bool, error) {
return deleteSettingValue(ctx, s.pool, s.userID, id)
}
@@ -275,11 +358,20 @@ func (s *PostgresUserStore) GetSettingMutation(
ctx context.Context,
mutationID string,
) (*userstore.SettingMutationRecord, error) {
row := s.pool.QueryRow(ctx, `
return getSettingMutation(ctx, s.pool, s.userID, mutationID)
}
func getSettingMutation(
ctx context.Context,
exec preferenceSettingsExecutor,
userID int,
mutationID string,
) (*userstore.SettingMutationRecord, error) {
row := exec.QueryRow(ctx, `
SELECT mutation_id, request_hash, result, created_at, expires_at
FROM user_setting_mutations
WHERE user_id = $1 AND mutation_id = $2`,
s.userID, mutationID,
userID, mutationID,
)
record, err := scanSettingMutation(row)
if errors.Is(err, pgx.ErrNoRows) {
@@ -298,17 +390,26 @@ func (s *PostgresUserStore) GetSettingMutation(
func (s *PostgresUserStore) PutSettingMutation(
ctx context.Context,
record userstore.SettingMutationRecord,
) (userstore.SettingMutationRecord, bool, error) {
return putSettingMutation(ctx, s.pool, s.userID, record)
}
func putSettingMutation(
ctx context.Context,
exec preferenceSettingsExecutor,
userID int,
record userstore.SettingMutationRecord,
) (userstore.SettingMutationRecord, bool, error) {
if err := record.Validate(); err != nil {
return userstore.SettingMutationRecord{}, false, err
}
row := s.pool.QueryRow(ctx, `
row := exec.QueryRow(ctx, `
INSERT INTO user_setting_mutations (user_id, mutation_id, request_hash, result, expires_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, mutation_id) DO NOTHING
RETURNING mutation_id, request_hash, result, created_at, expires_at`,
s.userID, record.MutationID, record.RequestHash, []byte(record.Result), record.ExpiresAt,
userID, record.MutationID, record.RequestHash, []byte(record.Result), record.ExpiresAt,
)
stored, err := scanSettingMutation(row)
if err == nil {
@@ -318,7 +419,7 @@ func (s *PostgresUserStore) PutSettingMutation(
return userstore.SettingMutationRecord{}, false, fmt.Errorf("recording setting mutation %q: %w", record.MutationID, err)
}
existing, err := s.GetSettingMutation(ctx, record.MutationID)
existing, err := getSettingMutation(ctx, exec, userID, record.MutationID)
if err != nil {
return userstore.SettingMutationRecord{}, false, err
}
@@ -349,18 +450,19 @@ type pgxRow interface {
func scanSettingValue(row pgxRow) (userstore.SettingValue, error) {
var (
value userstore.SettingValue
scope string
profileID *string
deviceID *string
libraryID *int
seriesID *string
raw []byte
createdAt time.Time
updatedAt time.Time
value userstore.SettingValue
scope string
profileID *string
clientFamily *string
deviceID *string
libraryID *int
seriesID *string
raw []byte
createdAt time.Time
updatedAt time.Time
)
if err := row.Scan(
&value.Key, &scope, &profileID, &deviceID, &libraryID, &seriesID,
&value.Key, &scope, &profileID, &clientFamily, &deviceID, &libraryID, &seriesID,
&raw, &value.Revision, &createdAt, &updatedAt,
); err != nil {
return userstore.SettingValue{}, err
@@ -369,6 +471,9 @@ func scanSettingValue(row pgxRow) (userstore.SettingValue, error) {
if profileID != nil {
value.ProfileID = *profileID
}
if clientFamily != nil {
value.ClientFamily = settingscontract.ClientFamily(*clientFamily)
}
if deviceID != nil {
value.DeviceID = *deviceID
}
+86 -13
View File
@@ -1,6 +1,7 @@
package userstore
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -23,6 +24,13 @@ var ErrInvalidSettingIdentity = errors.New("invalid setting identity")
// validation path.
var ErrInvalidSettingValue = errors.New("invalid setting value")
// ErrSettingValueRevisionConflict reports that a compare-and-set write did
// not observe the revision it expected. It is deliberately a storage-level
// primitive rather than a public settings precondition: semantic mutation
// endpoints use it to merge concurrent edits without making every client
// implement a read/rebase/retry loop.
var ErrSettingValueRevisionConflict = errors.New("setting value revision conflict")
// SettingIdentity addresses exactly one canonical setting row: the key plus the
// context columns its scope requires.
//
@@ -33,10 +41,11 @@ type SettingIdentity struct {
Key string
Scope settingscontract.Scope
ProfileID string
DeviceID string
LibraryID int
SeriesID string
ProfileID string
ClientFamily settingscontract.ClientFamily
DeviceID string
LibraryID int
SeriesID string
}
// Validate reports whether the identity is addressable. It mirrors the scope
@@ -51,10 +60,11 @@ type SettingIdentity struct {
// p1 — a silently orphaned row.
func (id SettingIdentity) Validate() error {
for field, value := range map[string]string{
"key": id.Key,
"profile id": id.ProfileID,
"device id": id.DeviceID,
"series id": id.SeriesID,
"key": id.Key,
"profile id": id.ProfileID,
"client family": string(id.ClientFamily),
"device id": id.DeviceID,
"series id": id.SeriesID,
} {
if value != strings.TrimSpace(value) {
return fmt.Errorf("%w: %s %q has surrounding whitespace",
@@ -76,6 +86,15 @@ func (id SettingIdentity) Validate() error {
return fmt.Errorf("%w: scope %q must not carry a profile id", ErrInvalidSettingIdentity, id.Scope)
}
wantClientFamily := id.Scope == settingscontract.ScopeProfileClient
if wantClientFamily && !id.ClientFamily.Valid() {
return fmt.Errorf("%w: scope %q requires client family tv, mobile, tablet, desktop or web",
ErrInvalidSettingIdentity, id.Scope)
}
if !wantClientFamily && id.ClientFamily != "" {
return fmt.Errorf("%w: scope %q must not carry a client family", ErrInvalidSettingIdentity, id.Scope)
}
wantDevice := id.Scope == settingscontract.ScopeProfileDevice
if wantDevice && id.DeviceID == "" {
return fmt.Errorf("%w: scope %q requires a device id", ErrInvalidSettingIdentity, id.Scope)
@@ -117,6 +136,55 @@ type SettingValue struct {
UpdatedAt string
}
// SettingValueCompareAndSetter is the optional atomic-write capability used
// by settings whose public API mutates one member of a shared document. An
// expected revision of zero means the row must not exist; a positive revision
// means that exact row revision must still be current.
//
// UserStore intentionally does not embed this interface. Transaction-scoped
// and test-only stores that never serve semantic mutation endpoints need not
// expose an operation they cannot perform, while both production backends do.
type SettingValueCompareAndSetter interface {
CompareAndSetSettingValue(
ctx context.Context,
id SettingIdentity,
value json.RawMessage,
expectedRevision int64,
) (*SettingValue, error)
}
// SettingMutationWriter is the transaction-scoped settings surface used by an
// idempotent mutation. Implementations must keep every call made by one
// WithSettingMutationTransaction callback on the same database transaction.
// That makes the setting write and its replay receipt one durable unit.
type SettingMutationWriter interface {
GetSettingValue(ctx context.Context, id SettingIdentity) (*SettingValue, error)
UpsertSettingValue(ctx context.Context, id SettingIdentity, value json.RawMessage) (*SettingValue, error)
CompareAndSetSettingValue(
ctx context.Context,
id SettingIdentity,
value json.RawMessage,
expectedRevision int64,
) (*SettingValue, error)
GetSettingMutation(ctx context.Context, mutationID string) (*SettingMutationRecord, error)
PutSettingMutation(ctx context.Context, record SettingMutationRecord) (SettingMutationRecord, bool, error)
}
// SettingMutationTransactioner atomically serializes one mutation id, applies
// its setting write, and records its receipt. The callback commits only when it
// returns nil. A crash or callback error therefore leaves neither half applied.
// Concurrent callbacks for the same mutationID must execute one at a time.
//
// This is optional rather than embedded in UserStore because only production
// stores serving idempotent mutation routes need to expose transaction state.
type SettingMutationTransactioner interface {
WithSettingMutationTransaction(
ctx context.Context,
mutationID string,
fn func(SettingMutationWriter) error,
) error
}
// SettingResolutionQuery describes one resolution request: the keys to resolve
// and every identity they may resolve against.
//
@@ -138,6 +206,10 @@ type SettingResolutionQuery struct {
// DeviceID drops profile_device candidates when empty, which is what an
// unidentified client (jellycompat's DisplayPreferences seed) needs.
DeviceID string
// ClientFamily drops profile_client candidates when empty. Unlike the
// device registry's platform metadata, this is a closed canonical enum and
// is supplied explicitly by first-party clients.
ClientFamily settingscontract.ClientFamily
// LibraryIDs and SeriesIDs carry the content contexts of a batch. Empty
// slices drop their scope from the candidate set.
LibraryIDs []int
@@ -150,11 +222,12 @@ type SettingResolutionQuery struct {
// the same predicate for the same request.
func (q SettingResolutionQuery) Normalized() SettingResolutionQuery {
return SettingResolutionQuery{
Keys: compactStrings(q.Keys),
ProfileIDs: compactStrings(q.ProfileIDs),
DeviceID: strings.TrimSpace(q.DeviceID),
LibraryIDs: compactPositiveInts(q.LibraryIDs),
SeriesIDs: compactStrings(q.SeriesIDs),
Keys: compactStrings(q.Keys),
ProfileIDs: compactStrings(q.ProfileIDs),
ClientFamily: settingscontract.ClientFamily(strings.TrimSpace(string(q.ClientFamily))),
DeviceID: strings.TrimSpace(q.DeviceID),
LibraryIDs: compactPositiveInts(q.LibraryIDs),
SeriesIDs: compactStrings(q.SeriesIDs),
}
}
+293 -33
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"reflect"
"sort"
"sync"
"testing"
"time"
@@ -28,6 +29,9 @@ func RunSettingValues(t *testing.T, newStore func(t *testing.T) userstore.UserSt
t.Run("RevisionIncrements", func(t *testing.T) {
testSettingValueRevisions(t, newStore)
})
t.Run("CompareAndSet", func(t *testing.T) {
testSettingValueCompareAndSet(t, newStore)
})
t.Run("PartialUniqueness", func(t *testing.T) {
testSettingValuePartialUniqueness(t, newStore)
})
@@ -49,11 +53,62 @@ func RunSettingValues(t *testing.T, newStore func(t *testing.T) userstore.UserSt
t.Run("MutationIdempotency", func(t *testing.T) {
testSettingMutationIdempotency(t, newStore)
})
t.Run("MutationTransaction", func(t *testing.T) {
testSettingMutationTransaction(t, newStore)
})
t.Run("DeviceExists", func(t *testing.T) {
testDeviceExists(t, newStore)
})
}
// testSettingValueCompareAndSet pins the internal primitive used by semantic
// document mutations. A stale writer must lose without changing the row, and
// an insert-if-absent race must have exactly one winner.
func testSettingValueCompareAndSet(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
ctx := context.Background()
store := newStore(t)
cas, ok := store.(userstore.SettingValueCompareAndSetter)
if !ok {
t.Skip("store does not implement SettingValueCompareAndSetter")
}
seedSettingProfiles(t, ctx, store, "p1")
id := profileID(audioKey, "p1")
first, err := cas.CompareAndSetSettingValue(ctx, id, json.RawMessage(`"en"`), 0)
if err != nil {
t.Fatalf("insert-if-absent: %v", err)
}
if first == nil || first.Revision != 1 || !jsonEqual(first.Value, json.RawMessage(`"en"`)) {
t.Fatalf("insert-if-absent = %+v, want en at revision 1", first)
}
if _, err := cas.CompareAndSetSettingValue(ctx, id, json.RawMessage(`"fr"`), 0); !errors.Is(err, userstore.ErrSettingValueRevisionConflict) {
t.Fatalf("second insert-if-absent error = %v, want ErrSettingValueRevisionConflict", err)
}
if _, err := cas.CompareAndSetSettingValue(ctx, id, json.RawMessage(`"de"`), first.Revision+1); !errors.Is(err, userstore.ErrSettingValueRevisionConflict) {
t.Fatalf("future revision error = %v, want ErrSettingValueRevisionConflict", err)
}
second, err := cas.CompareAndSetSettingValue(ctx, id, json.RawMessage(`"ja"`), first.Revision)
if err != nil {
t.Fatalf("compare-and-set current revision: %v", err)
}
if second == nil || second.Revision != 2 || !jsonEqual(second.Value, json.RawMessage(`"ja"`)) {
t.Fatalf("compare-and-set = %+v, want ja at revision 2", second)
}
if _, err := cas.CompareAndSetSettingValue(ctx, id, json.RawMessage(`"it"`), first.Revision); !errors.Is(err, userstore.ErrSettingValueRevisionConflict) {
t.Fatalf("stale revision error = %v, want ErrSettingValueRevisionConflict", err)
}
got, err := store.GetSettingValue(ctx, id)
if err != nil {
t.Fatalf("GetSettingValue: %v", err)
}
if got == nil || got.Revision != 2 || !jsonEqual(got.Value, json.RawMessage(`"ja"`)) {
t.Fatalf("row after stale CAS = %+v, want ja at revision 2", got)
}
}
// testDeviceExists pins the check that authorizes a device named in a request
// rather than taken from its headers. It must be scoped to the profile, not
// merely to the account: two profiles in one household register the same TV
@@ -204,6 +259,12 @@ func profileID(key, profile string) userstore.SettingIdentity {
return userstore.SettingIdentity{Key: key, Scope: settingscontract.ScopeProfile, ProfileID: profile}
}
func clientID(key, profile string, family settingscontract.ClientFamily) userstore.SettingIdentity {
return userstore.SettingIdentity{
Key: key, Scope: settingscontract.ScopeProfileClient, ProfileID: profile, ClientFamily: family,
}
}
func deviceID(key, profile, device string) userstore.SettingIdentity {
return userstore.SettingIdentity{
Key: key, Scope: settingscontract.ScopeProfileDevice, ProfileID: profile, DeviceID: device,
@@ -261,6 +322,7 @@ func testSettingValueScopes(t *testing.T, newStore func(t *testing.T) userstore.
}{
{"account", accountID(audioKey), `"en"`},
{"profile", profileID(audioKey, "p1"), `"fr"`},
{"profile_client", clientID(audioKey, "p1", settingscontract.ClientFamilyTV), `"it"`},
{"profile_device", deviceID(audioKey, "p1", deviceApple), `"de"`},
{"profile_library", libraryID(audioKey, "p1", 42), `"es"`},
{"profile_series", seriesID(audioKey, "p1", "series-1"), `"ja"`},
@@ -307,6 +369,7 @@ func testSettingValueScopes(t *testing.T, newStore func(t *testing.T) userstore.
seedSettingProfiles(t, ctx, store, "p2")
for _, id := range []userstore.SettingIdentity{
profileID(audioKey, "p2"),
clientID(audioKey, "p1", settingscontract.ClientFamilyMobile),
deviceID(audioKey, "p1", "iphone"),
libraryID(audioKey, "p1", 43),
seriesID(audioKey, "p1", "series-2"),
@@ -338,12 +401,13 @@ func testSettingValueListAll(t *testing.T, newStore func(t *testing.T) userstore
seedSettingProfiles(t, ctx, store, "p1", "p2")
seeded := map[userstore.SettingIdentity]string{
accountID(audioKey): `"en"`,
profileID(audioKey, "p1"): `"fr"`,
profileID(subtitleKey, "p2"): `"always"`,
deviceID(audioKey, "p1", deviceApple): `"de"`,
libraryID(audioKey, "p1", 42): `"es"`,
seriesID(audioKey, "p1", seriesOne): `"ja"`,
accountID(audioKey): `"en"`,
profileID(audioKey, "p1"): `"fr"`,
profileID(subtitleKey, "p2"): `"always"`,
clientID(audioKey, "p1", settingscontract.ClientFamilyTV): `"it"`,
deviceID(audioKey, "p1", deviceApple): `"de"`,
libraryID(audioKey, "p1", 42): `"es"`,
seriesID(audioKey, "p1", seriesOne): `"ja"`,
}
for id, value := range seeded {
mustUpsert(t, ctx, store, id, value)
@@ -476,7 +540,7 @@ func testSettingValueRevisions(t *testing.T, newStore func(t *testing.T) usersto
}
}
// testSettingValuePartialUniqueness pins the five partial unique indexes: one
// testSettingValuePartialUniqueness pins the six partial unique indexes: one
// explicit value per identity, and identities that differ in any one context
// column coexist.
func testSettingValuePartialUniqueness(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
@@ -488,6 +552,9 @@ func testSettingValuePartialUniqueness(t *testing.T, newStore func(t *testing.T)
accountID(audioKey),
profileID(audioKey, "p1"),
profileID(audioKey, "p2"),
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
clientID(audioKey, "p1", settingscontract.ClientFamilyMobile),
clientID(audioKey, "p2", settingscontract.ClientFamilyTV),
deviceID(audioKey, "p1", deviceApple),
deviceID(audioKey, "p1", "iphone"),
deviceID(audioKey, "p2", deviceApple),
@@ -505,19 +572,22 @@ func testSettingValuePartialUniqueness(t *testing.T, newStore func(t *testing.T)
}
rows, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: []string{audioKey},
ProfileIDs: []string{"p1"},
DeviceID: deviceApple,
LibraryIDs: []int{1, 2},
SeriesIDs: []string{seriesOne, seriesTwo},
Keys: []string{audioKey},
ProfileIDs: []string{"p1"},
ClientFamily: settingscontract.ClientFamilyTV,
DeviceID: deviceApple,
LibraryIDs: []int{1, 2},
SeriesIDs: []string{seriesOne, seriesTwo},
})
if err != nil {
t.Fatalf("ListSettingValuesForResolution: %v", err)
}
// p1's candidates: account, profile, one device, two libraries, two series.
// p1's candidates: account, profile, one client family, one device, two
// libraries, and two series.
want := []userstore.SettingIdentity{
accountID(audioKey),
profileID(audioKey, "p1"),
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
deviceID(audioKey, "p1", deviceApple),
libraryID(audioKey, "p1", 1),
libraryID(audioKey, "p1", 2),
@@ -555,6 +625,15 @@ func testSettingValueIdentityValidation(t *testing.T, newStore func(t *testing.T
{"account scope with profile", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeAccount, ProfileID: "p1",
}},
{"client scope without family", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeProfileClient, ProfileID: "p1",
}},
{"client scope with unknown family", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeProfileClient, ProfileID: "p1", ClientFamily: "car",
}},
{"profile scope with family", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeProfile, ProfileID: "p1", ClientFamily: settingscontract.ClientFamilyTV,
}},
{"device scope without device", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeProfileDevice, ProfileID: "p1",
}},
@@ -582,6 +661,9 @@ func testSettingValueIdentityValidation(t *testing.T, newStore func(t *testing.T
{"padded device id", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeProfileDevice, ProfileID: "p1", DeviceID: deviceApple + " ",
}},
{"padded client family", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeProfileClient, ProfileID: "p1", ClientFamily: " tv ",
}},
{"padded series id", userstore.SettingIdentity{
Key: audioKey, Scope: settingscontract.ScopeProfileSeries, ProfileID: "p1", SeriesID: " " + seriesOne,
}},
@@ -624,6 +706,7 @@ func testSettingValueResolution(t *testing.T, newStore func(t *testing.T) userst
mustUpsert(t, ctx, store, accountID(audioKey), `"en"`)
mustUpsert(t, ctx, store, profileID(audioKey, "p1"), `"fr"`)
mustUpsert(t, ctx, store, clientID(audioKey, "p1", settingscontract.ClientFamilyTV), `"it"`)
mustUpsert(t, ctx, store, deviceID(audioKey, "p1", deviceApple), `"de"`)
mustUpsert(t, ctx, store, libraryID(audioKey, "p1", 42), `"es"`)
mustUpsert(t, ctx, store, seriesID(audioKey, "p1", seriesOne), `"ja"`)
@@ -633,6 +716,8 @@ func testSettingValueResolution(t *testing.T, newStore func(t *testing.T) userst
// Decoys: another profile, another device, another library, another series,
// and a key nobody asked for.
mustUpsert(t, ctx, store, profileID(audioKey, "p2"), `"pt"`)
mustUpsert(t, ctx, store, clientID(audioKey, "p1", settingscontract.ClientFamilyMobile), `"pl"`)
mustUpsert(t, ctx, store, clientID(audioKey, "p2", settingscontract.ClientFamilyTV), `"cs"`)
mustUpsert(t, ctx, store, deviceID(audioKey, "p1", "iphone"), `"nl"`)
mustUpsert(t, ctx, store, libraryID(audioKey, "p1", 43), `"sv"`)
mustUpsert(t, ctx, store, seriesID(audioKey, "p1", "s-3"), `"da"`)
@@ -640,11 +725,12 @@ func testSettingValueResolution(t *testing.T, newStore func(t *testing.T) userst
// The batched shape: two content contexts resolved in one call.
rows, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: []string{audioKey, subtitleKey},
ProfileIDs: []string{"p1"},
DeviceID: deviceApple,
LibraryIDs: []int{42},
SeriesIDs: []string{seriesOne, seriesTwo},
Keys: []string{audioKey, subtitleKey},
ProfileIDs: []string{"p1"},
ClientFamily: settingscontract.ClientFamilyTV,
DeviceID: deviceApple,
LibraryIDs: []int{42},
SeriesIDs: []string{seriesOne, seriesTwo},
})
if err != nil {
t.Fatalf("ListSettingValuesForResolution: %v", err)
@@ -652,6 +738,7 @@ func testSettingValueResolution(t *testing.T, newStore func(t *testing.T) userst
assertIdentitySet(t, rows, []userstore.SettingIdentity{
accountID(audioKey),
profileID(audioKey, "p1"),
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
deviceID(audioKey, "p1", deviceApple),
libraryID(audioKey, "p1", 42),
seriesID(audioKey, "p1", seriesOne),
@@ -663,10 +750,11 @@ func testSettingValueResolution(t *testing.T, newStore func(t *testing.T) userst
// lets a list view make one round trip instead of one per item.
for _, series := range []string{seriesOne, seriesTwo} {
single, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: []string{audioKey},
ProfileIDs: []string{"p1"},
DeviceID: deviceApple,
SeriesIDs: []string{series},
Keys: []string{audioKey},
ProfileIDs: []string{"p1"},
ClientFamily: settingscontract.ClientFamilyTV,
DeviceID: deviceApple,
SeriesIDs: []string{series},
})
if err != nil {
t.Fatalf("ListSettingValuesForResolution(%s): %v", series, err)
@@ -674,13 +762,30 @@ func testSettingValueResolution(t *testing.T, newStore func(t *testing.T) userst
assertIdentitySet(t, single, []userstore.SettingIdentity{
accountID(audioKey),
profileID(audioKey, "p1"),
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
deviceID(audioKey, "p1", deviceApple),
seriesID(audioKey, "p1", series),
})
}
// No device identity — an incognito window, or jellycompat's seed — drops
// profile_device candidates without touching the roaming ones.
// A family without a device still sees the like-client value while dropping
// exact-device candidates.
familyOnly, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: []string{audioKey},
ProfileIDs: []string{"p1"},
ClientFamily: settingscontract.ClientFamilyTV,
})
if err != nil {
t.Fatalf("ListSettingValuesForResolution(family only): %v", err)
}
assertIdentitySet(t, familyOnly, []userstore.SettingIdentity{
accountID(audioKey),
profileID(audioKey, "p1"),
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
})
// No device or family identity — an incognito window, or jellycompat's seed
// — drops both contextual candidates without touching profile roaming.
noDevice, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: []string{audioKey},
ProfileIDs: []string{"p1"},
@@ -722,11 +827,12 @@ func testSettingValueResolution(t *testing.T, newStore func(t *testing.T) userst
// Blank and duplicate context ids are compacted rather than bound as
// literals, so they neither match a '' row nor multiply the result set.
dirty, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: []string{audioKey, "", audioKey, " "},
ProfileIDs: []string{"p1", "p1", "", " "},
DeviceID: deviceApple,
LibraryIDs: []int{42, 42, 0, -1},
SeriesIDs: []string{seriesOne, seriesOne, "", " "},
Keys: []string{audioKey, "", audioKey, " "},
ProfileIDs: []string{"p1", "p1", "", " "},
ClientFamily: settingscontract.ClientFamilyTV,
DeviceID: deviceApple,
LibraryIDs: []int{42, 42, 0, -1},
SeriesIDs: []string{seriesOne, seriesOne, "", " "},
})
if err != nil {
t.Fatalf("ListSettingValuesForResolution(dirty): %v", err)
@@ -734,6 +840,7 @@ func testSettingValueResolution(t *testing.T, newStore func(t *testing.T) userst
assertIdentitySet(t, dirty, []userstore.SettingIdentity{
accountID(audioKey),
profileID(audioKey, "p1"),
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
deviceID(audioKey, "p1", deviceApple),
libraryID(audioKey, "p1", 42),
seriesID(audioKey, "p1", seriesOne),
@@ -780,6 +887,9 @@ func testSettingValueDeletePaths(t *testing.T, newStore func(t *testing.T) users
accountID(audioKey),
profileID(audioKey, "p1"),
profileID(audioKey, "p2"),
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
clientID(audioKey, "p1", settingscontract.ClientFamilyMobile),
clientID(audioKey, "p2", settingscontract.ClientFamilyTV),
deviceID(audioKey, "p1", deviceApple),
deviceID(audioKey, "p1", "iphone"),
deviceID(audioKey, "p2", deviceApple),
@@ -887,11 +997,13 @@ func testSettingValueDeletePaths(t *testing.T, newStore func(t *testing.T) users
if err != nil {
t.Fatalf("DeleteSettingValuesForProfile: %v", err)
}
if removed != 7 {
t.Fatalf("DeleteSettingValuesForProfile removed %d rows, want 7", removed)
if removed != 9 {
t.Fatalf("DeleteSettingValuesForProfile removed %d rows, want 9", removed)
}
assertRemaining(t, store, all, []userstore.SettingIdentity{
profileID(audioKey, "p1"),
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
clientID(audioKey, "p1", settingscontract.ClientFamilyMobile),
deviceID(audioKey, "p1", deviceApple),
deviceID(audioKey, "p1", "iphone"),
libraryID(audioKey, "p1", 1),
@@ -909,6 +1021,8 @@ func testSettingValueDeletePaths(t *testing.T, newStore func(t *testing.T) users
// Account scope belongs to the account, not to any one household member.
assertRemaining(t, store, all, []userstore.SettingIdentity{
profileID(audioKey, "p1"),
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
clientID(audioKey, "p1", settingscontract.ClientFamilyMobile),
deviceID(audioKey, "p1", deviceApple),
deviceID(audioKey, "p1", "iphone"),
libraryID(audioKey, "p1", 1),
@@ -1029,6 +1143,152 @@ func testSettingMutationIdempotency(t *testing.T, newStore func(t *testing.T) us
}
}
// testSettingMutationTransaction proves the storage primitive that handlers
// rely on: setting + receipt roll back together, and same-id contenders check
// the winning receipt before either can apply a second setting write.
func testSettingMutationTransaction(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
ctx := context.Background()
t.Run("RollbackIsAtomic", func(t *testing.T) {
store := newStore(t)
transactioner, ok := store.(userstore.SettingMutationTransactioner)
if !ok {
t.Skip("store does not implement SettingMutationTransactioner")
}
seedSettingProfiles(t, ctx, store, "p1")
id := profileID(audioKey, "p1")
rollbackErr := errors.New("simulate process failure before commit")
err := transactioner.WithSettingMutationTransaction(ctx, "tx-rollback",
func(writer userstore.SettingMutationWriter) error {
if _, err := writer.UpsertSettingValue(ctx, id, json.RawMessage(`"en"`)); err != nil {
return err
}
if _, _, err := writer.PutSettingMutation(ctx, userstore.SettingMutationRecord{
MutationID: "tx-rollback", RequestHash: mutationHash,
Result: json.RawMessage(`{"winner":"en"}`), ExpiresAt: time.Now().UTC().Add(time.Hour),
}); err != nil {
return err
}
return rollbackErr
})
if !errors.Is(err, rollbackErr) {
t.Fatalf("transaction error = %v, want rollback sentinel", err)
}
if got, err := store.GetSettingValue(ctx, id); err != nil || got != nil {
t.Fatalf("setting after rollback = %+v (%v), want nil", got, err)
}
if got, err := store.GetSettingMutation(ctx, "tx-rollback"); err != nil || got != nil {
t.Fatalf("receipt after rollback = %+v (%v), want nil", got, err)
}
})
for _, tc := range []struct {
name string
secondHash string
second json.RawMessage
wantReplay int
wantConflict int
}{
{name: "SameHashReplays", secondHash: "hash-a", second: json.RawMessage(`"en"`), wantReplay: 1},
{name: "DifferentHashConflicts", secondHash: "hash-b", second: json.RawMessage(`"fr"`), wantConflict: 1},
} {
t.Run(tc.name, func(t *testing.T) {
store := newStore(t)
transactioner, ok := store.(userstore.SettingMutationTransactioner)
if !ok {
t.Skip("store does not implement SettingMutationTransactioner")
}
seedSettingProfiles(t, ctx, store, "p1")
id := profileID(audioKey, "p1")
type attempt struct {
hash string
value json.RawMessage
}
attempts := []attempt{{hash: "hash-a", value: json.RawMessage(`"en"`)}, {hash: tc.secondHash, value: tc.second}}
type result struct {
status string
receipt json.RawMessage
err error
}
results := make(chan result, len(attempts))
start := make(chan struct{})
var ready sync.WaitGroup
ready.Add(len(attempts))
for _, candidate := range attempts {
candidate := candidate
go func() {
ready.Done()
<-start
out := result{}
out.err = transactioner.WithSettingMutationTransaction(ctx, "tx-concurrent",
func(writer userstore.SettingMutationWriter) error {
prior, err := writer.GetSettingMutation(ctx, "tx-concurrent")
if err != nil {
return err
}
if prior != nil {
out.receipt = prior.Result
if prior.RequestHash == candidate.hash {
out.status = "replay"
} else {
out.status = "conflict"
}
return nil
}
stored, err := writer.UpsertSettingValue(ctx, id, candidate.value)
if err != nil {
return err
}
out.receipt, err = json.Marshal(map[string]any{"revision": stored.Revision, "value": candidate.value})
if err != nil {
return err
}
_, inserted, err := writer.PutSettingMutation(ctx, userstore.SettingMutationRecord{
MutationID: "tx-concurrent", RequestHash: candidate.hash,
Result: out.receipt, ExpiresAt: time.Now().UTC().Add(time.Hour),
})
if err == nil && !inserted {
return errors.New("serialized transaction unexpectedly lost receipt insert")
}
out.status = "applied"
return err
})
results <- out
}()
}
ready.Wait()
close(start)
counts := map[string]int{}
var receipts []json.RawMessage
for range attempts {
out := <-results
if out.err != nil {
t.Fatalf("concurrent transaction: %v", out.err)
}
counts[out.status]++
receipts = append(receipts, out.receipt)
}
if counts["applied"] != 1 || counts["replay"] != tc.wantReplay || counts["conflict"] != tc.wantConflict {
t.Fatalf("outcomes = %+v, want one applied, %d replay, %d conflict", counts, tc.wantReplay, tc.wantConflict)
}
stored, err := store.GetSettingValue(ctx, id)
if err != nil || stored == nil || stored.Revision != 1 {
t.Fatalf("stored setting = %+v (%v), want exactly one revision", stored, err)
}
receipt, err := store.GetSettingMutation(ctx, "tx-concurrent")
if err != nil || receipt == nil {
t.Fatalf("stored receipt = %+v (%v)", receipt, err)
}
for _, got := range receipts {
if !jsonEqual(got, receipt.Result) {
t.Fatalf("attempt receipt = %s, want coherent winner %s", got, receipt.Result)
}
}
})
}
}
// assertIdentitySet compares the returned rows to the expected identities as a
// set. Row order is deliberately not asserted: the two backends sort text under
// different collations, and ranking is the resolver's job anyway.
@@ -1050,8 +1310,8 @@ func assertIdentitySet(t *testing.T, rows []userstore.SettingValue, want []users
}
func identityToken(id userstore.SettingIdentity) string {
return fmt.Sprintf("%s|%s|%s|%s|%d|%s",
id.Key, id.Scope, id.ProfileID, id.DeviceID, id.LibraryID, id.SeriesID)
return fmt.Sprintf("%s|%s|%s|%s|%s|%d|%s",
id.Key, id.Scope, id.ProfileID, id.ClientFamily, id.DeviceID, id.LibraryID, id.SeriesID)
}
// jsonEqual compares two JSON documents by value. PostgreSQL stores jsonb, which
@@ -0,0 +1,200 @@
-- Add a profile-and-client-family identity to the canonical settings store.
-- Unlike device_platform, client_family is a closed request-supplied enum and
-- therefore safe to use as part of resolution and uniqueness.
-- +goose Up
ALTER TABLE public.user_setting_values
ADD COLUMN client_family text;
ALTER TABLE public.user_setting_values
DROP CONSTRAINT user_setting_values_scope_check,
DROP CONSTRAINT user_setting_values_identity_check,
ADD CONSTRAINT user_setting_values_client_family_check
CHECK (client_family IS NULL OR client_family IN ('tv', 'mobile', 'tablet', 'desktop', 'web')),
ADD CONSTRAINT user_setting_values_scope_check
CHECK (scope IN ('account', 'profile', 'profile_client', 'profile_device', 'profile_library', 'profile_series')),
ADD CONSTRAINT user_setting_values_identity_check CHECK (
(scope = 'account' AND profile_id IS NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_client' AND profile_id IS NOT NULL AND client_family IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_device' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NOT NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_library' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NOT NULL AND series_id IS NULL) OR
(scope = 'profile_series' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NOT NULL)
);
CREATE UNIQUE INDEX user_setting_values_profile_client_uq
ON public.user_setting_values (user_id, profile_id, client_family, key)
WHERE scope = 'profile_client';
-- Decode either the canonical object or the one-layer JSON-string encoding
-- written by legacy web clients. Invalid string contents return NULL instead
-- of aborting the whole migration.
-- +goose StatementBegin
CREATE FUNCTION pg_temp.decode_legacy_sidebar_pins(candidate jsonb)
RETURNS jsonb
LANGUAGE plpgsql
IMMUTABLE
STRICT
AS $$
DECLARE
decoded jsonb;
BEGIN
IF jsonb_typeof(candidate) = 'object' THEN
RETURN candidate;
END IF;
IF jsonb_typeof(candidate) = 'string' THEN
BEGIN
decoded := (candidate #>> '{}')::jsonb;
EXCEPTION WHEN invalid_text_representation THEN
RETURN NULL;
END;
IF jsonb_typeof(decoded) = 'object' THEN
RETURN decoded;
END IF;
END IF;
RETURN NULL;
END;
$$;
-- +goose StatementEnd
-- Seed the new profile-wide shortcut catalog from convertible legacy web
-- sidebar pins. ui.sidebar_pins remains untouched, rows with non-numeric
-- well-known groups remain there, and an already-authored nav.shortcuts row is
-- never overwritten. The new schema bounds the catalog at 256 entries.
WITH legacy AS (
SELECT value_row.id AS legacy_id,
value_row.user_id,
value_row.profile_id,
value_row.created_at,
value_row.updated_at,
decoded.value
FROM public.user_setting_values AS value_row
CROSS JOIN LATERAL (
SELECT pg_temp.decode_legacy_sidebar_pins(value_row.value) AS value
) AS decoded
WHERE value_row.key = 'ui.sidebar_pins'
AND value_row.scope = 'profile'
AND decoded.value IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM public.user_setting_values AS current
WHERE current.user_id = value_row.user_id
AND current.profile_id = value_row.profile_id
AND current.key = 'nav.shortcuts'
AND current.scope = 'profile'
)
), library_groups AS (
SELECT legacy.legacy_id,
legacy.user_id,
legacy.profile_id,
legacy.created_at,
legacy.updated_at,
groups.pins,
CASE
WHEN groups.group_key ~ '^[1-9][0-9]{0,9}$' THEN
CASE
WHEN groups.group_key::bigint <= 2147483647 THEN groups.group_key::integer
END
END AS library_id
FROM legacy
CROSS JOIN LATERAL jsonb_each(legacy.value) AS groups(group_key, pins)
), valid_pins AS (
SELECT group_row.legacy_id,
group_row.user_id,
group_row.profile_id,
group_row.created_at,
group_row.updated_at,
group_row.library_id,
pin.pin_value->>'type' AS pin_type,
pin.pin_value->>'id' AS pin_id,
pin.pin_value->>'label' AS pin_label,
pin.ordinality
FROM library_groups AS group_row
CROSS JOIN LATERAL jsonb_array_elements(
CASE WHEN jsonb_typeof(group_row.pins) = 'array' THEN group_row.pins ELSE '[]'::jsonb END
) WITH ORDINALITY AS pin(pin_value, ordinality)
WHERE group_row.library_id IS NOT NULL
AND jsonb_typeof(pin.pin_value) = 'object'
AND pin.pin_value->>'type' IN ('section', 'collection')
AND jsonb_typeof(pin.pin_value->'id') = 'string'
AND jsonb_typeof(pin.pin_value->'label') = 'string'
AND char_length(COALESCE(pin.pin_value->>'id', '')) BETWEEN 1 AND 128
AND pin.pin_value->>'id' ~ '[^[:space:]]'
AND char_length(COALESCE(pin.pin_value->>'label', '')) BETWEEN 1 AND 256
AND pin.pin_value->>'label' ~ '[^[:space:]]'
), deduplicated AS (
SELECT valid_pins.*,
row_number() OVER (
PARTITION BY legacy_id, library_id, pin_type, pin_id
ORDER BY ordinality
) AS duplicate_rank
FROM valid_pins
), ranked AS (
SELECT deduplicated.*,
row_number() OVER (
PARTITION BY legacy_id
ORDER BY library_id, ordinality, pin_type, pin_id
) AS item_rank
FROM deduplicated
WHERE duplicate_rank = 1
), shortcut_values AS (
SELECT legacy_id,
user_id,
profile_id,
created_at,
updated_at,
jsonb_build_object(
'items',
jsonb_agg(
CASE pin_type
WHEN 'section' THEN jsonb_build_object(
'type', 'section',
'library_id', library_id,
'section_id', pin_id,
'label', pin_label
)
ELSE jsonb_build_object(
'type', 'collection',
'library_id', library_id,
'collection_id', pin_id,
'label', pin_label
)
END
ORDER BY item_rank
)
) AS value
FROM ranked
WHERE item_rank <= 256
GROUP BY legacy_id, user_id, profile_id, created_at, updated_at
)
INSERT INTO public.user_setting_values
(user_id, key, scope, profile_id, client_family, device_id, library_id, series_id,
value, revision, created_at, updated_at)
SELECT user_id, 'nav.shortcuts', 'profile', profile_id, NULL, NULL, NULL, NULL,
value, 1, created_at, updated_at
FROM shortcut_values
ON CONFLICT (user_id, profile_id, key) WHERE scope = 'profile' DO NOTHING;
DROP FUNCTION pg_temp.decode_legacy_sidebar_pins(jsonb);
-- +goose Down
DELETE FROM public.user_setting_values WHERE scope = 'profile_client';
DROP INDEX IF EXISTS public.user_setting_values_profile_client_uq;
ALTER TABLE public.user_setting_values
DROP CONSTRAINT user_setting_values_client_family_check,
DROP CONSTRAINT user_setting_values_scope_check,
DROP CONSTRAINT user_setting_values_identity_check,
ADD CONSTRAINT user_setting_values_scope_check
CHECK (scope IN ('account', 'profile', 'profile_device', 'profile_library', 'profile_series')),
ADD CONSTRAINT user_setting_values_identity_check CHECK (
(scope = 'account' AND profile_id IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_device' AND profile_id IS NOT NULL AND device_id IS NOT NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_library' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NOT NULL AND series_id IS NULL) OR
(scope = 'profile_series' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NOT NULL)
);
ALTER TABLE public.user_setting_values DROP COLUMN client_family;
+47 -3
View File
@@ -6,9 +6,10 @@ import (
)
// TestUserSettingValuesMigrationContract pins the parts of the canonical
// settings storage that the store code and the design both depend on: 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.
// settings storage that the store code and the design both depend on: the five
// original scope CHECK/index identities, and the covering indexes the one-query
// read path needs. TestProfileClientSettingsMigrationContract pins the sixth
// identity added by the later extension migration.
// A silent edit to any of them would not fail a store test until a duplicate row
// or a sequential-scan regression reached production.
func TestUserSettingValuesMigrationContract(t *testing.T) {
@@ -68,6 +69,49 @@ func TestUserSettingValuesMigrationContract(t *testing.T) {
}
}
func TestProfileClientSettingsMigrationContract(t *testing.T) {
migration := readMigration(t, "sql/20260803191207_add_profile_client_settings_scope.sql")
for _, want := range []string{
"ADD COLUMN client_family text",
"CHECK (client_family IS NULL OR client_family IN ('tv', 'mobile', 'tablet', 'desktop', 'web'))",
"CHECK (scope IN ('account', 'profile', 'profile_client', 'profile_device', 'profile_library', 'profile_series'))",
"(scope = 'profile_client' AND profile_id IS NOT NULL AND client_family IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL)",
"CREATE UNIQUE INDEX user_setting_values_profile_client_uq\n ON public.user_setting_values (user_id, profile_id, client_family, key)",
// Legacy pins are copied only when the new profile row is absent. Blank
// labels never become values that the new contract would reject, and the
// old row is not deleted.
"current.key = 'nav.shortcuts'",
"CREATE FUNCTION pg_temp.decode_legacy_sidebar_pins(candidate jsonb)",
"jsonb_typeof(candidate) = 'string'",
"decoded := (candidate #>> '{}')::jsonb",
"EXCEPTION WHEN invalid_text_representation",
"pg_temp.decode_legacy_sidebar_pins(value_row.value)",
"WHEN groups.group_key ~ '^[1-9][0-9]{0,9}$' THEN",
"WHEN groups.group_key::bigint <= 2147483647 THEN groups.group_key::integer",
"group_row.library_id IS NOT NULL",
"CASE WHEN jsonb_typeof(group_row.pins) = 'array' THEN group_row.pins ELSE '[]'::jsonb END",
"jsonb_typeof(pin.pin_value) = 'object'",
"jsonb_typeof(pin.pin_value->'id') = 'string'",
"jsonb_typeof(pin.pin_value->'label') = 'string'",
"pin.pin_value->>'id' ~ '[^[:space:]]'",
"pin.pin_value->>'label' ~ '[^[:space:]]'",
"ON CONFLICT (user_id, profile_id, key) WHERE scope = 'profile' DO NOTHING",
} {
if !strings.Contains(migration, want) {
t.Fatalf("profile_client migration missing %q", want)
}
}
if strings.Contains(migration, "DELETE FROM public.user_setting_values WHERE key = 'ui.sidebar_pins'") {
t.Fatal("profile_client migration deletes the legacy sidebar pins it must preserve")
}
if strings.Contains(migration, "999999999") || strings.Contains(migration, "{0,8}") {
t.Fatal("profile_client migration retains the obsolete nine-digit library id cap")
}
}
func readMigration(t *testing.T, path string) string {
t.Helper()
contents, err := FS.ReadFile(path)
+16 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import {
BrowserRouter,
Routes,
@@ -18,6 +18,7 @@ import { ThemeProvider } from "@/hooks/useTheme";
import { DateTimeFormatProvider, useDateTimeFormat } from "@/hooks/useDateTimeFormat";
import { CustomThemeProvider } from "@/contexts/CustomThemeProvider";
import { BrandingProvider } from "@/contexts/BrandingProvider";
import { UICustomizationProvider } from "@/contexts/UICustomizationProvider";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import ImpersonationBanner from "@/components/ImpersonationBanner";
import { loadStoredImpersonationAdminSession } from "@/lib/impersonationSession";
@@ -100,6 +101,7 @@ import ThemeEditorSettings from "@/pages/settings/ThemeEditorSettings";
import CardOverlaySettings from "@/pages/settings/CardOverlaySettings";
import PersonalizeSettings from "@/pages/settings/PersonalizeSettings";
import ConnectAppsSettings from "@/pages/settings/ConnectAppsSettings";
import InterfaceSettings from "@/pages/settings/InterfaceSettings";
import WatchTogetherJoin from "@/pages/WatchTogetherJoin";
import WatchTogetherRoomPage from "@/pages/WatchTogetherRoomPage";
import WatchRoute from "@/pages/WatchRoute";
@@ -110,7 +112,6 @@ import {
WatchPlaybackProvider,
} from "@/playback/WatchPlaybackChrome";
import { AudiobookPlaybackProvider } from "@/pages/audiobooks/player/audiobookPlaybackContext";
import type { ReactNode } from "react";
import {
buildLegacyBrowseCatalogHref,
buildPersonalCatalogHref,
@@ -375,6 +376,14 @@ function ReactiveAppRoutes() {
return <AppRoutes />;
}
function UICustomizedLayout({ children }: { children: ReactNode }) {
return (
<UICustomizationProvider>
<Layout>{children}</Layout>
</UICustomizationProvider>
);
}
function AppRoutes() {
return (
<Routes>
@@ -462,14 +471,15 @@ function AppRoutes() {
path="/settings/*"
element={
<RequireProfile>
<Layout>
<UICustomizedLayout>
<SettingsLayout />
</Layout>
</UICustomizedLayout>
</RequireProfile>
}
>
<Route index element={null} />
<Route path="appearance" element={<AppearanceSettings />} />
<Route path="interface" element={<InterfaceSettings />} />
<Route path="theme-editor" element={<ThemeEditorSettings />} />
<Route path="accessibility" element={<AccessibilitySettings />} />
<Route path="playback" element={<PlaybackSettings />} />
@@ -499,7 +509,7 @@ function AppRoutes() {
path="/*"
element={
<RequireProfile>
<Layout>
<UICustomizedLayout>
<Routes>
<Route
path="/"
@@ -595,7 +605,7 @@ function AppRoutes() {
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
</UICustomizedLayout>
</RequireProfile>
}
/>
+345 -8
View File
@@ -2,8 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
api,
apiBlob,
apiWithProfileRequestContext,
bootstrapAccessToken,
captureProfileRequestContext,
getAccessToken,
getProfileToken,
getPersonCatalogItems,
onProfileUnverified,
setAccessToken,
@@ -42,13 +45,13 @@ describe("bootstrapAccessToken", () => {
});
it("refreshes the access token before protected requests on startup", async () => {
setRefreshToken("startup-refresh");
setRefreshToken("fake");
const fetchMock = vi.fn<typeof fetch>(async (input) => {
expect(String(input)).toBe("/api/v1/auth/refresh");
return new Response(
JSON.stringify({
access_token: "startup-access",
refresh_token: "rotated-refresh",
access_token: "dummy",
refresh_token: "example",
expires_in: 3600,
}),
{
@@ -61,19 +64,19 @@ describe("bootstrapAccessToken", () => {
await expect(bootstrapAccessToken(fetchMock)).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(getAccessToken()).toBe("startup-access");
expect(localStorage.getItem("refresh_token")).toBe("rotated-refresh");
expect(getAccessToken()).toBe("dummy");
expect(localStorage.getItem("refresh_token")).toBe("example");
});
it("does not refresh when an access token is already present", async () => {
setAccessToken("already-present");
setRefreshToken("startup-refresh");
setAccessToken("sample");
setRefreshToken("fake");
const fetchMock = vi.fn<typeof fetch>();
await expect(bootstrapAccessToken(fetchMock)).resolves.toBe(true);
expect(fetchMock).not.toHaveBeenCalled();
expect(getAccessToken()).toBe("already-present");
expect(getAccessToken()).toBe("sample");
});
});
@@ -265,6 +268,340 @@ describe("api", () => {
onProfileUnverified(null);
});
it("preserves an explicitly captured profile identity", async () => {
Object.defineProperty(globalThis, "sessionStorage", {
value: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
},
configurable: true,
});
setProfileId("profile-new");
const fetchMock = vi.fn<typeof fetch>(
async () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
await api("/test", { headers: { "X-Profile-Id": "profile-old" } });
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>;
expect(headers["X-Profile-Id"]).toBe("profile-old");
});
it("preserves the PIN token captured with an explicit profile identity", async () => {
Object.defineProperty(globalThis, "sessionStorage", {
value: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
},
configurable: true,
});
setProfileId("profile-new");
setProfileToken("dummy");
const fetchMock = vi.fn<typeof fetch>(
async () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
try {
await api("/test", {
headers: {
"X-Profile-Id": "profile-old",
"X-Profile-Token": "fake",
},
});
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>;
expect(headers["X-Profile-Id"]).toBe("profile-old");
expect(headers["X-Profile-Token"]).toBe("fake");
} finally {
setProfileToken(null);
}
});
it("safely refreshes a captured request without rebasing its profile authority", async () => {
Object.defineProperty(globalThis, "sessionStorage", {
value: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
},
configurable: true,
});
setAccessToken("fake");
setProfileId("profile-old");
setProfileToken("fake");
setRefreshToken("dummy");
const snapshot = captureProfileRequestContext();
expect(snapshot).not.toBeNull();
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
const url = String(input);
if (url === "/api/v1/auth/refresh") {
expect(JSON.parse(String(init?.body))).toEqual({ refresh_token: "dummy" });
return Response.json({
access_token: "example",
refresh_token: "sample",
expires_in: 3600,
});
}
const headers = init?.headers as Record<string, string>;
expect(headers["X-Profile-Id"]).toBe("profile-old");
expect(headers["X-Profile-Token"]).toBe("fake");
if (headers.Authorization === "Bearer fake") {
return Response.json({ error: "unauthorized", message: "expired" }, { status: 401 });
}
expect(headers.Authorization).toBe("Bearer example");
return Response.json({ ok: true });
});
vi.stubGlobal("fetch", fetchMock);
try {
await expect(
apiWithProfileRequestContext("/test", snapshot!, { method: "PUT" }),
).resolves.toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(getAccessToken()).toBe("example");
expect(localStorage.getItem("refresh_token")).toBe("sample");
} finally {
setRefreshToken(null);
setProfileToken(null);
setAccessToken(null);
}
});
it("clears the active PIN when its captured profile authority is rejected", async () => {
setAccessToken("fake");
setProfileId("profile-old");
setProfileToken("pin-old");
const snapshot = captureProfileRequestContext();
expect(snapshot).not.toBeNull();
const listener = vi.fn();
onProfileUnverified(listener);
vi.stubGlobal(
"fetch",
vi.fn<typeof fetch>(async () =>
Response.json({ error: "profile_unverified", message: "PIN required" }, { status: 403 }),
),
);
try {
await expect(apiWithProfileRequestContext("/test", snapshot!)).rejects.toMatchObject({
status: 403,
code: "profile_unverified",
});
expect(getProfileToken()).toBeNull();
expect(listener).toHaveBeenCalledOnce();
} finally {
onProfileUnverified(null);
setProfileToken(null);
setProfileId(null);
setAccessToken(null);
}
});
it.each([
{
authorityChange: "active profile",
changeAuthority: () => {
setProfileId("profile-new");
setProfileToken("pin-new");
},
expectedToken: "pin-new",
},
{
authorityChange: "active PIN",
changeAuthority: () => setProfileToken("pin-new"),
expectedToken: "pin-new",
},
])(
"does not clear the $authorityChange for a delayed rejection of captured authority",
async ({ changeAuthority, expectedToken }) => {
setAccessToken("fake");
setProfileId("profile-old");
setProfileToken("pin-old");
const snapshot = captureProfileRequestContext();
expect(snapshot).not.toBeNull();
const listener = vi.fn();
onProfileUnverified(listener);
let resolveResponse!: (response: Response) => void;
const response = new Promise<Response>((resolve) => {
resolveResponse = resolve;
});
const fetchMock = vi.fn<typeof fetch>(() => response);
vi.stubGlobal("fetch", fetchMock);
try {
const request = apiWithProfileRequestContext("/test", snapshot!);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
changeAuthority();
resolveResponse(
Response.json({ error: "profile_unverified", message: "PIN required" }, { status: 403 }),
);
await expect(request).rejects.toMatchObject({
status: 403,
code: "profile_unverified",
});
expect(getProfileToken()).toBe(expectedToken);
expect(listener).not.toHaveBeenCalled();
} finally {
onProfileUnverified(null);
setProfileToken(null);
setProfileId(null);
setAccessToken(null);
}
},
);
it.each(["account", "origin"] as const)(
"cancels captured retry when the %s changes during refresh",
async (changedContext) => {
Object.defineProperty(globalThis, "sessionStorage", {
value: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
},
configurable: true,
});
const originalLocation = globalThis.location;
if (changedContext === "origin") {
Object.defineProperty(globalThis, "location", {
value: { origin: "https://server-old.example" },
configurable: true,
});
}
setAccessToken("fake");
setProfileId("profile-old");
setProfileToken("fake");
setRefreshToken("dummy");
const snapshot = captureProfileRequestContext();
expect(snapshot).not.toBeNull();
let resolveRefresh!: (response: Response) => void;
const refreshResponse = new Promise<Response>((resolve) => {
resolveRefresh = resolve;
});
const fetchMock = vi.fn<typeof fetch>(async (input) => {
if (String(input) === "/api/v1/auth/refresh") return refreshResponse;
return Response.json({ error: "unauthorized", message: "expired" }, { status: 401 });
});
vi.stubGlobal("fetch", fetchMock);
try {
const request = apiWithProfileRequestContext("/test", snapshot!, { method: "PUT" });
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
if (changedContext === "account") {
setAccessToken("sample");
setRefreshToken("placeholder");
} else {
Object.defineProperty(globalThis, "location", {
value: { origin: "https://server-new.example" },
configurable: true,
});
}
resolveRefresh(
Response.json({
access_token: "example",
refresh_token: "redacted",
expires_in: 3600,
}),
);
await expect(request).rejects.toMatchObject({ name: "StaleApiRequestContextError" });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(getAccessToken()).toBe(changedContext === "account" ? "sample" : "fake");
expect(localStorage.getItem("refresh_token")).toBe(
changedContext === "account" ? "placeholder" : "dummy",
);
} finally {
if (changedContext === "origin") {
Object.defineProperty(globalThis, "location", {
value: originalLocation,
configurable: true,
});
}
setRefreshToken(null);
setProfileToken(null);
setAccessToken(null);
}
},
);
it("refuses a captured request after the account context changes", async () => {
Object.defineProperty(globalThis, "sessionStorage", {
value: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
},
configurable: true,
});
setAccessToken("fake");
setProfileId("profile-old");
setProfileToken("fake");
const snapshot = captureProfileRequestContext();
expect(snapshot).not.toBeNull();
setAccessToken("dummy");
setProfileId("profile-new");
setProfileToken("dummy");
const fetchMock = vi.fn<typeof fetch>();
vi.stubGlobal("fetch", fetchMock);
try {
await expect(apiWithProfileRequestContext("/test", snapshot!)).rejects.toMatchObject({
name: "StaleApiRequestContextError",
});
expect(fetchMock).not.toHaveBeenCalled();
} finally {
setProfileToken(null);
}
});
it("keeps the browser client family canonical", async () => {
Object.defineProperty(globalThis, "sessionStorage", {
value: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
},
configurable: true,
});
const fetchMock = vi.fn<typeof fetch>(
async () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
await api("/test", { headers: { "x-silo-client-family": "tv" } });
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>;
expect(headers["X-Silo-Client-Family"]).toBe("web");
expect(
Object.keys(headers).filter((key) => key.toLowerCase() === "x-silo-client-family"),
).toEqual(["X-Silo-Client-Family"]);
});
it("forwards AbortSignal from options to fetch", async () => {
Object.defineProperty(globalThis, "sessionStorage", {
value: {
+171 -13
View File
@@ -10,9 +10,18 @@ export function onProfileUnverified(listener: ProfileUnverifiedListener | null)
}
let accessToken: string | null = null;
let authContextVersion = 0;
let refreshPromise: Promise<boolean> | null = null;
export function setAccessToken(token: string | null) {
if (accessToken !== token) authContextVersion += 1;
accessToken = token;
}
function refreshCurrentAccessToken(token: string): void {
// Token rotation preserves the authenticated account/server context. A
// queued request may use its captured predecessor once, safely refresh, and
// retry with this successor without changing account authority.
accessToken = token;
}
@@ -83,6 +92,59 @@ export function getProfileToken(): string | null {
return profileToken;
}
/**
* Complete request authority for one queued profile intent. It is deliberately
* an in-memory value: access and PIN tokens must never enter storage, query
* caches, logs, or persisted mutation state through this snapshot.
*/
export interface ProfileRequestContextSnapshot {
accessToken: string;
authContextVersion: number;
serverOrigin: string;
profileId: string;
profileToken: string | null;
}
function currentServerOrigin(): string {
return typeof globalThis.location === "undefined" ? "" : globalThis.location.origin;
}
/** Capture account, server, profile, and PIN authority in one synchronous turn. */
export function captureProfileRequestContext(): ProfileRequestContextSnapshot | null {
const profileId = getProfileId();
if (!accessToken || !profileId) return null;
return {
accessToken,
authContextVersion,
serverOrigin: currentServerOrigin(),
profileId,
profileToken: getProfileToken(),
};
}
/**
* A session-authority change advances authContextVersion even if an account
* later happens to return to the same token. Queued work can therefore never
* cross a logout, impersonation, account switch, or server-origin switch
* unnoticed. Automatic token rotation deliberately preserves the context.
* Profile/PIN changes are excluded so an already-created intent remains bound
* to its captured profile authority through a same-account refresh.
*/
export function isProfileRequestContextCurrent(snapshot: ProfileRequestContextSnapshot): boolean {
return (
snapshot.authContextVersion === authContextVersion &&
snapshot.serverOrigin === currentServerOrigin()
);
}
function isCapturedProfileAuthorityActive(snapshot: ProfileRequestContextSnapshot): boolean {
return (
isProfileRequestContextCurrent(snapshot) &&
getProfileId() === snapshot.profileId &&
getProfileToken() === snapshot.profileToken
);
}
function getOrCreateDeviceId(): string {
const existing = storage.get(storage.KEYS.DEVICE_ID);
if (existing) {
@@ -131,6 +193,9 @@ function getDeviceHeaders(): Record<string, string> {
"X-Silo-Device-Id": deviceId,
"X-Silo-Device-Name": detectDeviceName(),
"X-Silo-Device-Platform": detectDevicePlatform(),
// Browser preferences roam between browsers without changing TV, mobile,
// tablet, or desktop-native layouts.
"X-Silo-Client-Family": "web",
};
}
@@ -138,10 +203,22 @@ async function attemptRefresh(): Promise<boolean> {
const rt = getRefreshToken();
if (!rt) return false;
// A refresh response belongs only to the account/server that started it.
// Discarding it after a context switch prevents a delayed old-account
// response from overwriting the new account's access or refresh token.
const startingAuthContextVersion = authContextVersion;
const startingServerOrigin = currentServerOrigin();
try {
const data = await refreshAccessToken(rt, fetch);
if (!data) return false;
setAccessToken(data.access_token);
if (
startingAuthContextVersion !== authContextVersion ||
startingServerOrigin !== currentServerOrigin()
) {
return false;
}
refreshCurrentAccessToken(data.access_token);
setRefreshToken(data.refresh_token);
return true;
} catch {
@@ -251,6 +328,14 @@ function hasHeader(headers: Record<string, string>, name: string): boolean {
return Object.keys(headers).some((key) => key.toLowerCase() === target);
}
function setHeader(headers: Record<string, string>, name: string, value: string): void {
const target = name.toLowerCase();
for (const key of Object.keys(headers)) {
if (key.toLowerCase() === target) delete headers[key];
}
headers[name] = value;
}
interface ParsedApiError {
/** Normalized error with guaranteed `error`/`message` fields. */
apiErr: ApiError;
@@ -326,9 +411,7 @@ export async function restoreUserSession<TUser>({
};
}
export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await apiResponse(path, options);
async function readApiResponse<T>(res: Response): Promise<T> {
// Handle empty successful responses.
if (res.status === 204 || res.status === 205) {
return undefined as T;
@@ -340,22 +423,89 @@ export async function api<T>(path: string, options: RequestInit = {}): Promise<T
return JSON.parse(text) as T;
}
export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
return readApiResponse<T>(await apiResponse(path, options));
}
/**
* Sends a request with one captured account/profile authority. The explicit
* headers cannot be replaced by the current session, and a stale snapshot is
* rejected before fetch.
*/
export async function apiWithProfileRequestContext<T>(
path: string,
snapshot: ProfileRequestContextSnapshot,
options: RequestInit = {},
): Promise<T> {
if (!isProfileRequestContextCurrent(snapshot)) {
throw new StaleApiRequestContextError();
}
const headers = { ...(options.headers as Record<string, string>) };
setHeader(headers, "Authorization", `Bearer ${snapshot.accessToken}`);
setHeader(headers, "X-Profile-Id", snapshot.profileId);
setHeader(headers, "X-Profile-Token", snapshot.profileToken ?? "");
const response = await apiResponseInternal(path, { ...options, headers }, snapshot);
if (!isProfileRequestContextCurrent(snapshot)) {
throw new StaleApiRequestContextError();
}
return readApiResponse<T>(response);
}
export class StaleApiRequestContextError extends Error {
constructor() {
super("The account or server changed before the queued request could be sent.");
this.name = "StaleApiRequestContextError";
}
}
/** Performs an authenticated API request while leaving the successful body unread. */
export async function apiResponse(path: string, options: RequestInit = {}): Promise<Response> {
return apiResponseInternal(path, options);
}
async function apiResponseInternal(
path: string,
options: RequestInit,
snapshot?: ProfileRequestContextSnapshot,
): Promise<Response> {
if (snapshot && !isProfileRequestContextCurrent(snapshot)) {
throw new StaleApiRequestContextError();
}
const explicitAuthorization = hasHeader(
(options.headers as Record<string, string> | undefined) ?? {},
"Authorization",
);
const headers = buildApiHeaders(options);
const requestProfileId = headers["X-Profile-Id"] ?? null;
const requestProfileToken = headers["X-Profile-Token"] ?? null;
let res = await fetch(`/api/v1${path}`, { ...options, headers });
// Auto-refresh on 401
if (res.status === 401 && getRefreshToken()) {
if (snapshot && !isProfileRequestContextCurrent(snapshot)) {
throw new StaleApiRequestContextError();
}
// Auto-refresh on 401. An ordinary explicit Authorization header opts out,
// but a captured profile request is a stronger contract: it may rotate the
// token only while its account/server generation remains current, then retry
// with the new access token and the exact captured profile/PIN headers.
if (
res.status === 401 &&
getRefreshToken() &&
(snapshot !== undefined || !explicitAuthorization)
) {
if (snapshot && !isProfileRequestContextCurrent(snapshot)) {
throw new StaleApiRequestContextError();
}
if (!refreshPromise) {
refreshPromise = attemptRefresh().finally(() => {
refreshPromise = null;
});
}
const refreshed = await refreshPromise;
if (snapshot && !isProfileRequestContextCurrent(snapshot)) {
throw new StaleApiRequestContextError();
}
if (refreshed) {
// Keep the profile and device identity captured for the original
// request. A household profile can change while refresh is pending;
@@ -364,11 +514,16 @@ export async function apiResponse(path: string, options: RequestInit = {}): Prom
// is allowed to change for this retry.
const refreshedHeaders = { ...headers };
if (accessToken) {
refreshedHeaders.Authorization = `Bearer ${accessToken}`;
setHeader(refreshedHeaders, "Authorization", `Bearer ${accessToken}`);
} else if (snapshot) {
throw new StaleApiRequestContextError();
} else {
delete refreshedHeaders.Authorization;
}
res = await fetch(`/api/v1${path}`, { ...options, headers: refreshedHeaders });
if (snapshot && !isProfileRequestContextCurrent(snapshot)) {
throw new StaleApiRequestContextError();
}
}
}
@@ -377,8 +532,9 @@ export async function apiResponse(path: string, options: RequestInit = {}): Prom
if (
res.status === 403 &&
parsed.apiErr.error === "profile_unverified" &&
getProfileId() === requestProfileId &&
getProfileToken() === requestProfileToken
(snapshot
? isCapturedProfileAuthorityActive(snapshot)
: getProfileId() === requestProfileId && getProfileToken() === requestProfileToken)
) {
setProfileToken(null);
profileUnverifiedListener?.();
@@ -395,18 +551,20 @@ function buildApiHeaders(options: RequestInit = {}): Record<string, string> {
if (!(options.body instanceof FormData) && !hasHeader(headers, "Content-Type")) {
headers["Content-Type"] = "application/json";
}
if (accessToken) {
if (accessToken && !hasHeader(headers, "Authorization")) {
headers["Authorization"] = `Bearer ${accessToken}`;
}
const profileId = getProfileId();
if (profileId) {
if (profileId && !hasHeader(headers, "X-Profile-Id")) {
headers["X-Profile-Id"] = profileId;
}
const profToken = getProfileToken();
if (profToken) {
if (profToken && !hasHeader(headers, "X-Profile-Token")) {
headers["X-Profile-Token"] = profToken;
}
Object.assign(headers, getDeviceHeaders());
for (const [name, value] of Object.entries(getDeviceHeaders())) {
setHeader(headers, name, value);
}
return headers;
}
+147 -1
View File
@@ -21,6 +21,22 @@ import {
const mockLogout = vi.fn();
const mockClearProfile = vi.fn();
const mockTogglePin = vi.fn();
let mockPrimaryMenu: {
items: Array<
| {
type: "builtin";
destination: "home" | "movies" | "series" | "music" | "audiobooks";
}
| { type: "section"; library_id: number; section_id: string; label: string }
| {
type: "collection";
library_id?: number;
collection_id: string;
label: string;
}
>;
} | null = null;
let mockLibraries = [{ id: 7, name: "Movies", type: "movies" }];
vi.mock("@/hooks/useAuth", () => {
const useAuth = () => ({
@@ -39,7 +55,7 @@ vi.mock("@/hooks/useCurrentProfile", () => ({
vi.mock("@/hooks/queries/libraries", () => ({
useUserLibraries: () => ({
data: [{ id: 7, name: "Movies", type: "movies" }],
data: mockLibraries,
}),
}));
@@ -51,6 +67,16 @@ vi.mock("@/hooks/queries/sidebarPins", () => ({
}),
useToggleSidebarPin: () => ({
togglePin: mockTogglePin,
canToggle: true,
}),
}));
vi.mock("@/hooks/useUICustomization", () => ({
useUICustomization: () => ({
primaryMenu: mockPrimaryMenu,
shortcuts: { items: [] },
cardPresentation: { poster_size: "standard", caption: "title_metadata" },
isLoading: false,
}),
}));
@@ -161,6 +187,8 @@ describe("AppSidebar", () => {
mockClearProfile.mockReset();
mockTogglePin.mockReset();
mockPluginInstallations = [];
mockPrimaryMenu = null;
mockLibraries = [{ id: 7, name: "Movies", type: "movies" }];
});
it("uses the cinema highlight text color for active catalog source links", () => {
@@ -228,6 +256,124 @@ describe("AppSidebar", () => {
expect(markup).not.toContain("text-sidebar-primary-foreground bg-sidebar-accent");
});
it("drops library-scoped menu targets when their library is not visible", () => {
mockPrimaryMenu = {
items: [
{ type: "builtin", destination: "home" },
{
type: "section",
library_id: 99,
section_id: "hidden-section",
label: "Hidden section",
},
{
type: "collection",
library_id: 99,
collection_id: "hidden-collection",
label: "Hidden collection",
},
{
type: "collection",
collection_id: "global-collection",
label: "Global collection",
},
],
};
const markup = renderSidebar("/");
expect(markup).not.toContain("Hidden section");
expect(markup).not.toContain("Hidden collection");
expect(markup).toContain("Global collection");
});
it("does not reinterpret global media built-ins as the first matching library", () => {
mockLibraries = [
{ id: 7, name: "Movies A", type: "movies" },
{ id: 8, name: "Movies B", type: "movie" },
{ id: 9, name: "TV A", type: "series" },
{ id: 10, name: "TV B", type: "shows" },
{ id: 11, name: "Books A", type: "audiobooks" },
{ id: 12, name: "Books B", type: "audiobook" },
{ id: 13, name: "Music A", type: "music" },
{ id: 14, name: "Music B", type: "music" },
];
mockPrimaryMenu = {
items: [
{ type: "builtin", destination: "home" },
{ type: "builtin", destination: "movies" },
{ type: "builtin", destination: "series" },
{ type: "builtin", destination: "audiobooks" },
{ type: "builtin", destination: "music" },
],
};
const document = parseMarkup(renderSidebar("/"));
const primaryMenu = document.querySelector('nav[aria-label="Main navigation"] > ul');
const primaryHrefs = [...(primaryMenu?.querySelectorAll("a") ?? [])].map((link) =>
link.getAttribute("href"),
);
expect(primaryHrefs).toEqual(["/"]);
expect(document.querySelector('a[href="/library/7"]')).not.toBeNull();
expect(document.querySelector('a[href="/library/14"]')).not.toBeNull();
});
it("keeps a custom section active while sort and filter params change", () => {
mockPrimaryMenu = {
items: [
{
type: "section",
library_id: 7,
section_id: "recent",
label: "Custom Recently Added",
},
],
};
const document = parseMarkup(
renderSidebar(
"/catalog?source=section&scope=library&library_id=7&section_id=recent&sort=title&order=asc&genre=Drama",
),
);
const link = [...document.querySelectorAll("a")].find((candidate) =>
candidate.textContent?.includes("Custom Recently Added"),
);
expect(link?.getAttribute("aria-current")).toBe("page");
});
it("matches custom collections by source, library, and collection id", () => {
mockPrimaryMenu = {
items: [
{
type: "collection",
library_id: 7,
collection_id: "favorites",
label: "Custom Favorites",
},
],
};
const filtered = parseMarkup(
renderSidebar(
"/catalog?source=library_collection&library_id=7&collection_id=favorites&type=movie&sort=year&order=desc",
),
);
const otherLibrary = parseMarkup(
renderSidebar(
"/catalog?source=library_collection&library_id=8&collection_id=favorites&sort=year",
),
);
const findCustomLink = (document: Document) =>
[...document.querySelectorAll("a")].find((candidate) =>
candidate.textContent?.includes("Custom Favorites"),
);
expect(findCustomLink(filtered)?.getAttribute("aria-current")).toBe("page");
expect(findCustomLink(otherLibrary)?.hasAttribute("aria-current")).toBe(false);
});
it("preserves the current library query when linking to the active library", () => {
const markup = renderSidebar("/library/7?tab=library&sort=year&order=desc");
+232 -71
View File
@@ -29,7 +29,9 @@ import {
buildPersonalCatalogHref,
buildQueryCatalogHref,
buildSectionCatalogHref,
buildUserCollectionCatalogHref,
parseCatalogSearchParams,
sameCatalogDestination,
} from "@/pages/catalogSearchParams";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
@@ -69,6 +71,8 @@ import {
import { useTheme } from "@/hooks/useTheme";
import { CURATED_THEME_IDS, THEMES } from "@/lib/themes";
import { cn } from "@/lib/utils";
import { useUICustomization } from "@/hooks/useUICustomization";
import { menuItemKey } from "@/lib/uiCustomization";
function getLibraryIcon(type: string) {
switch (type) {
@@ -176,6 +180,13 @@ interface AppSidebarProps {
collapsed?: boolean;
}
interface ResolvedPrimaryMenuLink {
key: string;
href: string;
label: string;
icon: ReactNode;
}
export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebarProps) {
const location = useLocation();
const navigate = useViewTransitionNavigate();
@@ -186,7 +197,8 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar
const showAdminNav = useIsActingAdmin();
const { data: libraries } = useUserLibraries();
const { pins } = useSidebarPins();
const { togglePin } = useToggleSidebarPin();
const { togglePin, canToggle } = useToggleSidebarPin();
const { primaryMenu } = useUICustomization();
const { data: pluginSettings } = usePluginSettingsList();
const requestStatus = useRequestFeatureStatus();
const showRequestsNav = requestStatus.data?.requests_enabled === true;
@@ -220,6 +232,104 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar
// Grouped view of the Apps entries (null → keep the flat list under the
// single "Apps" header). See groupAppNavLinks for the SDK category contract.
const pluginNavGroups = useMemo(() => groupAppNavLinks(pluginNavLinks), [pluginNavLinks]);
const primaryMenuLinks = useMemo<ResolvedPrimaryMenuLink[] | null>(() => {
if (!primaryMenu) return null;
return primaryMenu.items.flatMap((item): ResolvedPrimaryMenuLink[] => {
const key = menuItemKey(item);
if (item.type === "library") {
const library = libraries?.find((candidate) => candidate.id === item.library_id);
if (!library) return [];
return [
{
key,
href: `/library/${library.id}`,
label: item.label || library.name,
icon: getLibraryIcon(library.type),
},
];
}
if (item.type === "section") {
if (!libraries?.some((candidate) => candidate.id === item.library_id)) return [];
return [
{
key,
href: buildSectionCatalogHref({
scope: "library",
libraryId: item.library_id,
sectionId: item.section_id,
title: item.label,
}),
label: item.label,
icon: <LayoutGrid className="h-[18px] w-[18px] shrink-0" />,
},
];
}
if (item.type === "collection") {
if (
item.library_id !== undefined &&
!libraries?.some((candidate) => candidate.id === item.library_id)
) {
return [];
}
return [
{
key,
href:
item.library_id === undefined
? buildUserCollectionCatalogHref(item.collection_id, item.label)
: buildLibraryCollectionCatalogHref(
item.collection_id,
item.label,
item.library_id,
),
label: item.label,
icon: <FolderOpen className="h-[18px] w-[18px] shrink-0" />,
},
];
}
switch (item.destination) {
case "home":
return [
{
key,
href: "/",
label: "Home",
icon: <Home className="h-[18px] w-[18px] shrink-0" />,
},
];
case "for_you":
return [
{
key,
href: "/recommendations",
label: "For You",
icon: <Sparkles className="h-[18px] w-[18px] shrink-0" />,
},
];
case "calendar":
return [
{
key,
href: "/calendar",
label: "Calendar",
icon: <CalendarDays className="h-[18px] w-[18px] shrink-0" />,
},
];
// These contract built-ins identify global media destinations, not a
// particular library. Web does not currently have complete global
// routes for all four media families (notably music), so omit them
// instead of silently changing their meaning to the first matching
// library. An explicit `library` menu item remains fully supported.
case "movies":
case "series":
case "music":
case "audiobooks":
return [];
}
});
}, [libraries, primaryMenu]);
const catalogState = useMemo(
() =>
@@ -300,7 +410,11 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar
}
if (pin.type === "collection") {
return catalogState.source === "library_collection" && catalogState.collection_id === pin.id;
return (
catalogState.source === "library_collection" &&
catalogState.collection_id === pin.id &&
catalogState.library_id === libId
);
}
return (
@@ -382,26 +496,67 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar
aria-label="Main navigation"
className="sidebar-nav sidebar-scroll flex-1 space-y-7 overflow-y-auto px-3 pb-5"
>
{/* Home */}
<ul className="list-none space-y-0.5">
<li>
<ViewTransitionLink
to="/"
onClick={onNavigate}
className={navLinkClass("/", true)}
aria-current={isActive("/", true) ? "page" : undefined}
>
{isActive("/", true) && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<Home className="h-[18px] w-[18px] shrink-0" />
<SidebarLabel show={showLabels}>Home</SidebarLabel>
</ViewTransitionLink>
</li>
</ul>
{/* A customized primary menu is an ordered shortcut rail. Search and
profile controls remain fixed elsewhere, and the full library
tree remains available below as a safe browsing fallback. */}
{primaryMenuLinks ? (
<ul className="list-none space-y-0.5">
{primaryMenuLinks.map((link) => {
const [targetPath, targetQuery] = link.href.split("?", 2);
const active =
targetPath === "/"
? isActive("/", true)
: targetPath === "/catalog" && targetQuery !== undefined && catalogState
? sameCatalogDestination(
catalogState,
parseCatalogSearchParams(new URLSearchParams(targetQuery)),
)
: targetQuery === undefined
? location.pathname === targetPath ||
location.pathname.startsWith(`${targetPath}/`)
: location.pathname === targetPath && location.search === `?${targetQuery}`;
return (
<li key={link.key}>
<ViewTransitionLink
to={link.href}
onClick={onNavigate}
className={navLinkClassForState(active)}
aria-current={active ? "page" : undefined}
>
{active ? (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
) : null}
{link.icon}
<SidebarLabel show={showLabels}>{link.label}</SidebarLabel>
</ViewTransitionLink>
</li>
);
})}
</ul>
) : (
<ul className="list-none space-y-0.5">
<li>
<ViewTransitionLink
to="/"
onClick={onNavigate}
className={navLinkClass("/", true)}
aria-current={isActive("/", true) ? "page" : undefined}
>
{isActive("/", true) && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<Home className="h-[18px] w-[18px] shrink-0" />
<SidebarLabel show={showLabels}>Home</SidebarLabel>
</ViewTransitionLink>
</li>
</ul>
)}
{/* Libraries */}
{libraries && libraries.length > 0 && (
@@ -497,7 +652,7 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar
{libraryPins.map((pin) => {
const pinHref =
pin.type === "collection"
? buildLibraryCollectionCatalogHref(pin.id, pin.label)
? buildLibraryCollectionCatalogHref(pin.id, pin.label, lib.id)
: buildSectionCatalogHref({
scope: "library",
libraryId: lib.id,
@@ -527,20 +682,22 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar
)}
<span className="truncate">{pin.label}</span>
</ViewTransitionLink>
<button
type="button"
onClick={() =>
togglePin(lib.id, {
type: pin.type,
id: pin.id,
label: pin.label,
})
}
className="text-muted-foreground hover:text-destructive absolute right-1 rounded p-1 opacity-0 transition-opacity group-hover/pin:opacity-100"
title="Unpin"
>
<PinOff className="h-3 w-3" />
</button>
{canToggle ? (
<button
type="button"
onClick={() =>
togglePin(lib.id, {
type: pin.type,
id: pin.id,
label: pin.label,
})
}
className="text-muted-foreground hover:text-destructive absolute right-1 rounded p-1 opacity-0 transition-opacity group-hover/pin:opacity-100"
title="Unpin"
>
<PinOff className="h-3 w-3" />
</button>
) : null}
</div>
);
})}
@@ -583,23 +740,25 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar
</kbd>
</ViewTransitionLink>
</li>
<li>
<ViewTransitionLink
to="/recommendations"
onClick={onNavigate}
className={navLinkClass("/recommendations")}
aria-current={isActive("/recommendations") ? "page" : undefined}
>
{isActive("/recommendations") && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<Sparkles className="h-[18px] w-[18px] shrink-0" />
<SidebarLabel show={showLabels}>Recommendations</SidebarLabel>
</ViewTransitionLink>
</li>
{primaryMenuLinks === null ? (
<li>
<ViewTransitionLink
to="/recommendations"
onClick={onNavigate}
className={navLinkClass("/recommendations")}
aria-current={isActive("/recommendations") ? "page" : undefined}
>
{isActive("/recommendations") && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<Sparkles className="h-[18px] w-[18px] shrink-0" />
<SidebarLabel show={showLabels}>Recommendations</SidebarLabel>
</ViewTransitionLink>
</li>
) : null}
{showRequestsNav && (
<li>
<ViewTransitionLink
@@ -619,23 +778,25 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar
</ViewTransitionLink>
</li>
)}
<li>
<ViewTransitionLink
to="/calendar"
onClick={onNavigate}
className={navLinkClass("/calendar")}
aria-current={isActive("/calendar") ? "page" : undefined}
>
{isActive("/calendar") && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<CalendarDays className="h-[18px] w-[18px] shrink-0" />
<SidebarLabel show={showLabels}>Calendar</SidebarLabel>
</ViewTransitionLink>
</li>
{primaryMenuLinks === null ? (
<li>
<ViewTransitionLink
to="/calendar"
onClick={onNavigate}
className={navLinkClass("/calendar")}
aria-current={isActive("/calendar") ? "page" : undefined}
>
{isActive("/calendar") && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<CalendarDays className="h-[18px] w-[18px] shrink-0" />
<SidebarLabel show={showLabels}>Calendar</SidebarLabel>
</ViewTransitionLink>
</li>
) : null}
{showNotificationsNav && (
<li>
<ViewTransitionLink
+40 -32
View File
@@ -12,6 +12,8 @@ import { upcomingBadgeClass, upcomingBadgeLabel } from "@/lib/upcomingEventPrese
import { useWatchPlaybackController } from "@/playback/watchPlaybackContext";
import { parseWatchHref } from "@/pages/watchRouteHelpers";
import { buildItemHref, buildMediaPlayHref, isVideoWatchHref } from "@/lib/mediaNavigation";
import { useUICustomization } from "@/hooks/useUICustomization";
import { carouselCardWidthClasses } from "@/lib/uiCustomization";
type ContinueWatchingCardProps = (
| {
@@ -33,6 +35,7 @@ type ContinueWatchingCardProps = (
export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
const location = useLocation();
const playbackController = useWatchPlaybackController();
const { cardPresentation } = useUICustomization();
const card =
"sectionItem" in props && props.sectionItem
? {
@@ -185,8 +188,10 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
const variant = props.variant ?? "wide";
const isPoster = variant === "poster";
const containerWidth = isPoster
? "w-[140px] shrink-0 sm:w-[160px] lg:w-[185px]"
? carouselCardWidthClasses(cardPresentation.poster_size)
: "w-[260px] shrink-0 sm:w-[315px]";
const showCaption = cardPresentation.caption !== "artwork";
const showMetadata = cardPresentation.caption === "title_metadata";
// Audiobook covers are square (Audible-style); use 1:1 for the poster
// variant so they don't get top/bottom-cropped into a 2:3 frame.
const imageAspect = isPoster
@@ -291,44 +296,47 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
</div>
{/* Info */}
<div className="px-0.5 pt-2.5">
<ViewTransitionLink
to={headingHref}
className="block truncate text-[13px] font-semibold hover:underline"
>
{heading}
</ViewTransitionLink>
{episodeMeta && (
{showCaption ? (
<div className="px-0.5 pt-2.5">
<ViewTransitionLink
to={isMangaChapter ? card.watchHref : card.itemHref}
className="text-muted-foreground block truncate text-xs hover:underline"
to={headingHref}
className="block truncate text-[13px] font-semibold hover:underline"
>
{episodeMeta}
{heading}
</ViewTransitionLink>
)}
{premiereBadge && (
<div className="mt-1">
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] leading-none font-semibold tracking-wide uppercase backdrop-blur-sm ${upcomingBadgeClass(
premiereBadge,
)}`}
>
{upcomingBadgeLabel(premiereBadge)}
</span>
</div>
)}
{timeLeftLabel &&
(timeLeftLabel === "\u00A0" ? (
<div className="text-muted-foreground text-xs">{timeLeftLabel}</div>
) : (
{showMetadata && episodeMeta && (
<ViewTransitionLink
to={isMangaChapter ? card.watchHref : card.itemHref}
className="text-muted-foreground block w-fit text-xs hover:underline"
className="text-muted-foreground block truncate text-xs hover:underline"
>
{timeLeftLabel}
{episodeMeta}
</ViewTransitionLink>
))}
</div>
)}
{showMetadata && premiereBadge && (
<div className="mt-1">
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] leading-none font-semibold tracking-wide uppercase backdrop-blur-sm ${upcomingBadgeClass(
premiereBadge,
)}`}
>
{upcomingBadgeLabel(premiereBadge)}
</span>
</div>
)}
{showMetadata &&
timeLeftLabel &&
(timeLeftLabel === "\u00A0" ? (
<div className="text-muted-foreground text-xs">{timeLeftLabel}</div>
) : (
<ViewTransitionLink
to={isMangaChapter ? card.watchHref : card.itemHref}
className="text-muted-foreground block w-fit text-xs hover:underline"
>
{timeLeftLabel}
</ViewTransitionLink>
))}
</div>
) : null}
</div>
);
}
+19 -11
View File
@@ -10,6 +10,7 @@ import { overlayDataFromBrowseItem, type CardOverlayPrefs } from "@/lib/overlays
import { buildEpisodeCardLabels } from "@/lib/episodeCardLabels";
import { formatDate as formatPreferredDate } from "@/lib/datetime";
import { formatBitrate } from "@/lib/mediaFormat";
import { useUICustomization } from "@/hooks/useUICustomization";
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
@@ -190,6 +191,9 @@ export default function ItemCard({
const displayTitle = episodeLabels ? episodeLabels.seriesTitle : item.title;
const mangaCountLabel = mangaCountChipLabel(item);
const mangaStatus = mangaStatusChip(item);
const { cardPresentation } = useUICustomization();
const showCaption = cardPresentation.caption !== "artwork";
const showMetadata = cardPresentation.caption === "title_metadata";
return (
<div className="media-card group/card">
@@ -302,17 +306,21 @@ export default function ItemCard({
variant="poster"
/>
</div>
<ViewTransitionLink to={itemHref} className="block px-1 pt-3">
<div className="truncate text-[14px] font-semibold tracking-tight">{displayTitle}</div>
{episodeLabels?.episodeTitle ? (
<div className="text-muted-foreground mt-1 truncate text-[12px] font-medium">
{episodeLabels.episodeTitle}
</div>
) : null}
<div className="text-muted-foreground mt-1 text-[11px] font-medium tracking-[0.14em] uppercase">
<SortMeta item={item} sortField={sortField} />
</div>
</ViewTransitionLink>
{showCaption ? (
<ViewTransitionLink to={itemHref} className="block px-1 pt-3">
<div className="truncate text-[14px] font-semibold tracking-tight">{displayTitle}</div>
{showMetadata && episodeLabels?.episodeTitle ? (
<div className="text-muted-foreground mt-1 truncate text-[12px] font-medium">
{episodeLabels.episodeTitle}
</div>
) : null}
{showMetadata ? (
<div className="text-muted-foreground mt-1 text-[11px] font-medium tracking-[0.14em] uppercase">
<SortMeta item={item} sortField={sortField} />
</div>
) : null}
</ViewTransitionLink>
) : null}
</div>
);
}
+16 -9
View File
@@ -5,6 +5,8 @@ import ItemCard from "./ItemCard";
import { Skeleton } from "@/components/ui/skeleton";
import { useGridLayout } from "@/hooks/useGridLayout";
import { useOverlayPrefs } from "@/hooks/useOverlayPrefs";
import { useUICustomization } from "@/hooks/useUICustomization";
import { cardGridClasses, cardTextAreaHeight } from "@/lib/uiCustomization";
interface SharedItemGridProps {
loading?: boolean;
@@ -37,11 +39,6 @@ function hasStaticItems(props: ItemGridProps): props is StaticItemGridProps {
return Array.isArray((props as StaticItemGridProps).items);
}
const GRID_GAP = 12;
const TEXT_AREA_HEIGHT = 44;
const GRID_CLASSES =
"grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8 gap-3";
export default function ItemGrid(props: ItemGridProps) {
const {
loading,
@@ -52,6 +49,9 @@ export default function ItemGrid(props: ItemGridProps) {
onToggleSelect,
} = props;
const { prefs: overlayPrefs } = useOverlayPrefs();
const { cardPresentation } = useUICustomization();
const gridGap = cardPresentation.poster_size === "large" ? 16 : 12;
const gridClasses = cardGridClasses(cardPresentation.poster_size);
const totalItems = hasStaticItems(props) ? props.items.length : props.totalItems;
const pages = hasStaticItems(props)
? new Map<number, BrowseItem[]>([[0, props.items]])
@@ -59,8 +59,9 @@ export default function ItemGrid(props: ItemGridProps) {
const pageSize = hasStaticItems(props) ? Math.max(props.items.length, 1) : props.pageSize;
const onVisibleRangeChange = hasStaticItems(props) ? () => undefined : props.onVisibleRangeChange;
const { containerRef, layout } = useGridLayout({
gap: GRID_GAP,
textAreaHeight: TEXT_AREA_HEIGHT,
gap: gridGap,
textAreaHeight: cardTextAreaHeight(cardPresentation.caption),
layoutKey: cardPresentation.poster_size,
});
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>(null);
const { columnCount, rowHeight } = layout;
@@ -79,6 +80,12 @@ export default function ItemGrid(props: ItemGridProps) {
scrollMargin,
});
// The estimate function closes over rowHeight. Explicitly invalidate the
// cached measurements when a poster/caption preset changes that height.
useEffect(() => {
virtualizer.measure();
}, [rowHeight, virtualizer]);
const virtualRows = virtualizer.getVirtualItems();
// Report visible item range to parent for page fetching
@@ -103,7 +110,7 @@ export default function ItemGrid(props: ItemGridProps) {
return (
<div ref={setAnchorEl}>
{loading ? (
<div ref={containerRef} role="list" className={GRID_CLASSES}>
<div ref={containerRef} role="list" className={gridClasses}>
{Array.from({ length: 24 }).map((_, i) => (
<div key={i} role="listitem">
<Skeleton className="aspect-[2/3] rounded-lg" />
@@ -124,7 +131,7 @@ export default function ItemGrid(props: ItemGridProps) {
<div
ref={containerRef}
role="list"
className={GRID_CLASSES}
className={gridClasses}
style={{
position: "absolute",
top: 0,
@@ -1,6 +1,7 @@
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { UICustomizationContext } from "@/contexts/uiCustomizationContext";
import RecommendationGrid from "./RecommendationGrid";
const mocks = vi.hoisted(() => ({
@@ -30,4 +31,37 @@ describe("RecommendationGrid", () => {
expect(markup).toContain('href="/item/ebook%201"');
expect(mocks.useCatalogItemDetail).toHaveBeenCalledWith("ebook 1");
});
it("uses the selected density and omits artwork-only caption rows", () => {
mocks.useCatalogItemDetail.mockReturnValue({
data: {
content_id: "ebook 1",
title: "A Reader",
poster_url: "/cover.jpg",
},
});
const markup = renderToStaticMarkup(
<MemoryRouter>
<UICustomizationContext.Provider
value={{
cardPresentation: { poster_size: "compact", caption: "artwork" },
cardPresentationSource: "profile_client",
primaryMenu: null,
primaryMenuSource: "default",
shortcuts: { items: [] },
isSupported: true,
supportsAtomicShortcuts: true,
isLoading: false,
isUnavailable: false,
}}
>
<RecommendationGrid items={[{ media_item_id: "ebook 1" }]} />
</UICustomizationContext.Provider>
</MemoryRouter>,
);
expect(markup).toContain("grid-cols-3 sm:grid-cols-5");
expect(markup).not.toContain(">A Reader</p>");
});
});
+8 -2
View File
@@ -1,5 +1,7 @@
import ViewTransitionLink from "@/components/ViewTransitionLink";
import { useCatalogItemDetail } from "@/hooks/queries/catalogRead";
import { useUICustomization } from "@/hooks/useUICustomization";
import { cardGridClasses } from "@/lib/uiCustomization";
interface RecommendationGridProps {
items: Array<{ media_item_id: string }>;
@@ -12,6 +14,7 @@ interface RecommendationItemCardProps {
function RecommendationItemCard({ itemId }: RecommendationItemCardProps) {
const { data: item } = useCatalogItemDetail(itemId);
const { cardPresentation } = useUICustomization();
if (!item) {
return <div className="bg-surface aspect-[2/3] animate-pulse rounded-lg" />;
}
@@ -30,14 +33,17 @@ function RecommendationItemCard({ itemId }: RecommendationItemCardProps) {
</div>
)}
</div>
<p className="mt-1.5 truncate text-sm font-medium">{item.title}</p>
{cardPresentation.caption !== "artwork" ? (
<p className="mt-1.5 truncate text-sm font-medium">{item.title}</p>
) : null}
</ViewTransitionLink>
);
}
export default function RecommendationGrid({ items, maxItems = 12 }: RecommendationGridProps) {
const { cardPresentation } = useUICustomization();
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
<div className={cardGridClasses(cardPresentation.poster_size)}>
{items.slice(0, maxItems).map((si) => (
<RecommendationItemCard key={si.media_item_id} itemId={si.media_item_id} />
))}
+41 -35
View File
@@ -14,6 +14,7 @@ import {
upcomingBadgeLabel,
} from "@/lib/upcomingEventPresentation";
import type { SectionItem } from "@/api/types";
import { useUICustomization } from "@/hooks/useUICustomization";
interface SectionItemCardProps {
item: SectionItem;
@@ -32,6 +33,9 @@ export default function SectionItemCard({ item, libraryId }: SectionItemCardProp
const airDateLabel = upcomingEvent ? formatUpcomingDate(upcomingEvent.air_date) : "";
const airTimeLabel = upcomingEvent ? formatUpcomingTime(upcomingEvent.air_time) : null;
const episodeLabels = !upcomingEvent ? buildEpisodeCardLabels(item) : null;
const { cardPresentation } = useUICustomization();
const showCaption = cardPresentation.caption !== "artwork";
const showMetadata = cardPresentation.caption === "title_metadata";
return (
<div className="media-card group/card">
@@ -97,45 +101,47 @@ export default function SectionItemCard({ item, libraryId }: SectionItemCardProp
variant="poster"
/>
</div>
<ViewTransitionLink to={itemHref} className="block px-1 pt-3">
<div className="truncate text-[14px] font-semibold tracking-tight">
{episodeLabels ? episodeLabels.seriesTitle : item.title}
</div>
{upcomingEvent ? (
<>
{subtitle && (
<div className="text-muted-foreground mt-1 truncate text-[11px] font-medium tracking-[0.14em] uppercase">
{subtitle}
{showCaption ? (
<ViewTransitionLink to={itemHref} className="block px-1 pt-3">
<div className="truncate text-[14px] font-semibold tracking-tight">
{episodeLabels ? episodeLabels.seriesTitle : item.title}
</div>
{showMetadata && upcomingEvent ? (
<>
{subtitle && (
<div className="text-muted-foreground mt-1 truncate text-[11px] font-medium tracking-[0.14em] uppercase">
{subtitle}
</div>
)}
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] font-medium">
<span className="text-foreground">{airDateLabel}</span>
{airTimeLabel && <span className="text-muted-foreground">{airTimeLabel}</span>}
</div>
)}
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] font-medium">
<span className="text-foreground">{airDateLabel}</span>
{airTimeLabel && <span className="text-muted-foreground">{airTimeLabel}</span>}
</>
) : showMetadata && episodeLabels ? (
<>
{episodeLabels.episodeTitle ? (
<div className="text-muted-foreground mt-1 truncate text-[12px] font-medium">
{episodeLabels.episodeTitle}
</div>
) : null}
<div className="text-muted-foreground mt-1 text-[11px] font-medium tracking-[0.14em] uppercase">
{episodeLabels.episodeCode}
</div>
</>
) : showMetadata && item.item_source === "next_in_series" && item.series_title ? (
<div className="text-muted-foreground mt-1 truncate text-[11px] font-medium tracking-[0.14em] uppercase">
{[item.badges?.find((badge) => badge.startsWith("Book ")), item.series_title]
.filter(Boolean)
.join(" · ")}
</div>
</>
) : episodeLabels ? (
<>
{episodeLabels.episodeTitle ? (
<div className="text-muted-foreground mt-1 truncate text-[12px] font-medium">
{episodeLabels.episodeTitle}
</div>
) : null}
) : showMetadata ? (
<div className="text-muted-foreground mt-1 text-[11px] font-medium tracking-[0.14em] uppercase">
{episodeLabels.episodeCode}
{item.year ? `${item.year}` : ""} {item.type === "series" ? "Series" : ""}
</div>
</>
) : item.item_source === "next_in_series" && item.series_title ? (
<div className="text-muted-foreground mt-1 truncate text-[11px] font-medium tracking-[0.14em] uppercase">
{[item.badges?.find((badge) => badge.startsWith("Book ")), item.series_title]
.filter(Boolean)
.join(" · ")}
</div>
) : (
<div className="text-muted-foreground mt-1 text-[11px] font-medium tracking-[0.14em] uppercase">
{item.year ? `${item.year}` : ""} {item.type === "series" ? "Series" : ""}
</div>
)}
</ViewTransitionLink>
) : null}
</ViewTransitionLink>
) : null}
</div>
);
}
+1
View File
@@ -50,6 +50,7 @@ vi.mock("@/hooks/queries/sidebarPins", () => ({
useToggleSidebarPin: () => ({
togglePin: mockTogglePin,
isPinned: mockIsPinned,
canToggle: true,
}),
}));
+8 -6
View File
@@ -7,6 +7,8 @@ import { useOverlayPrefs } from "@/hooks/useOverlayPrefs";
import { buildSectionCatalogHref, isSectionBrowseSupported } from "@/pages/catalogSearchParams";
import type { ResolvedSection } from "@/api/types";
import { Pin, PinOff } from "lucide-react";
import { useUICustomization } from "@/hooks/useUICustomization";
import { carouselCardWidthClasses } from "@/lib/uiCustomization";
interface SectionRowProps {
section: ResolvedSection;
@@ -23,9 +25,11 @@ function SectionPinButton({
sectionTitle: string;
libraryId: number;
}) {
const { togglePin, isPinned } = useToggleSidebarPin();
const { togglePin, isPinned, canToggle } = useToggleSidebarPin();
const pinned = isPinned(libraryId, "section", sectionId);
if (!canToggle) return null;
return (
<button
onClick={() => togglePin(libraryId, { type: "section", id: sectionId, label: sectionTitle })}
@@ -45,6 +49,8 @@ export default function SectionRow({ section, libraryId }: SectionRowProps) {
const navigate = useViewTransitionNavigate();
const browseSupported = isSectionBrowseSupported(section.section_type);
const { prefs: overlayPrefs } = useOverlayPrefs();
const { cardPresentation } = useUICustomization();
const posterWidthClasses = carouselCardWidthClasses(cardPresentation.poster_size);
const handleViewAll = () => {
if (!browseSupported) {
@@ -108,11 +114,7 @@ export default function SectionRow({ section, libraryId }: SectionRowProps) {
));
})()
: section.items.map((item) => (
<div
key={item.content_id}
className="w-[140px] shrink-0 sm:w-[160px] lg:w-[185px]"
role="listitem"
>
<div key={item.content_id} className={posterWidthClasses} role="listitem">
<SectionItemCard item={item} libraryId={libraryId} />
</div>
))}
@@ -8,11 +8,15 @@ import {
upcomingBadgeLabel,
} from "@/lib/upcomingEventPresentation";
import type { CalendarEvent } from "@/hooks/queries/calendar";
import { useUICustomization } from "@/hooks/useUICustomization";
export default function CalendarEventCard({ event }: { event: CalendarEvent }) {
const [loaded, setLoaded] = useState(false);
const thumbhashUrl = event.poster_thumbhash ? decodeThumbhash(event.poster_thumbhash) : "";
const watched = event.watched === true;
const { cardPresentation } = useUICustomization();
const showCaption = cardPresentation.caption !== "artwork";
const showMetadata = cardPresentation.caption === "title_metadata";
const href =
event.type === "movie"
@@ -75,21 +79,23 @@ export default function CalendarEventCard({ event }: { event: CalendarEvent }) {
)}
</div>
</ViewTransitionLink>
<ViewTransitionLink to={href} className="block px-1 pt-3">
<div
className={`truncate text-[14px] font-semibold tracking-tight ${watched ? "text-muted-foreground" : ""}`}
>
{event.title}
</div>
{subtitle && (
<div className="text-muted-foreground mt-1 truncate text-[11px] font-medium tracking-[0.14em] uppercase">
{subtitle}
{showCaption ? (
<ViewTransitionLink to={href} className="block px-1 pt-3">
<div
className={`truncate text-[14px] font-semibold tracking-tight ${watched ? "text-muted-foreground" : ""}`}
>
{event.title}
</div>
)}
{airTime && (
<div className="text-muted-foreground mt-0.5 text-[11px] font-medium">{airTime}</div>
)}
</ViewTransitionLink>
{showMetadata && subtitle ? (
<div className="text-muted-foreground mt-1 truncate text-[11px] font-medium tracking-[0.14em] uppercase">
{subtitle}
</div>
) : null}
{showMetadata && airTime ? (
<div className="text-muted-foreground mt-0.5 text-[11px] font-medium">{airTime}</div>
) : null}
</ViewTransitionLink>
) : null}
</div>
);
}
+5 -1
View File
@@ -2,6 +2,8 @@ import MediaCarousel from "@/components/MediaCarousel";
import { formatDayHeading, isToday } from "@/lib/calendarWeek";
import CalendarEventCard from "./CalendarEventCard";
import type { CalendarDay } from "@/hooks/queries/calendar";
import { useUICustomization } from "@/hooks/useUICustomization";
import { carouselCardWidthClasses } from "@/lib/uiCustomization";
interface DayGroupProps {
day: CalendarDay;
@@ -11,6 +13,8 @@ interface DayGroupProps {
export default function DayGroup({ day, isSelected }: DayGroupProps) {
const today = isToday(day.date);
const title = formatDayHeading(day.date);
const { cardPresentation } = useUICustomization();
const posterWidthClasses = carouselCardWidthClasses(cardPresentation.poster_size);
return (
// scroll-mt offsets the sticky header so the title isn't hidden when scrolled into view
@@ -38,7 +42,7 @@ export default function DayGroup({ day, isSelected }: DayGroupProps) {
}
>
{day.items.map((event) => (
<div key={event.content_id} className="w-[140px] shrink-0 sm:w-[160px] lg:w-[185px]">
<div key={event.content_id} className={posterWidthClasses}>
<CalendarEventCard event={event} />
</div>
))}
@@ -7,13 +7,11 @@ import {
buildLibraryCollectionCatalogHref,
buildUserCollectionCatalogHref,
} from "@/pages/catalogSearchParams";
import { useUICustomization } from "@/hooks/useUICustomization";
// Shared poster card for both the per-library Collections tab and the
// aggregated "Server collections" section on the home Collections tab. Library
// (admin) collections get a sidebar-pin affordance; user collections do not.
export const COLLECTION_POSTER_GRID_CLASSES =
"grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8 gap-3";
export function CollectionPosterCard({
collection,
kind,
@@ -25,13 +23,14 @@ export function CollectionPosterCard({
}) {
const { loaded, onLoad } = useImageLoaded(collection.poster_url);
const navigate = useViewTransitionNavigate();
const { togglePin, isPinned } = useToggleSidebarPin();
const { togglePin, isPinned, canToggle } = useToggleSidebarPin();
const pinned = isPinned(libraryId, "collection", collection.id);
const { cardPresentation } = useUICustomization();
const isUserCollection = kind === "user_collections";
const href = isUserCollection
? buildUserCollectionCatalogHref(collection.id, collection.title)
: buildLibraryCollectionCatalogHref(collection.id, collection.title);
: buildLibraryCollectionCatalogHref(collection.id, collection.title, libraryId);
return (
<div className="group/card relative w-full text-left">
@@ -57,13 +56,17 @@ export function CollectionPosterCard({
{collection.item_count}
</span>
</div>
<div className="px-0.5 pt-2.5">
<div className="truncate text-[13px] font-semibold">{collection.title}</div>
{isUserCollection && <div className="text-muted-foreground text-xs">User collection</div>}
</div>
{cardPresentation.caption !== "artwork" ? (
<div className="px-0.5 pt-2.5">
<div className="truncate text-[13px] font-semibold">{collection.title}</div>
{cardPresentation.caption === "title_metadata" && isUserCollection ? (
<div className="text-muted-foreground text-xs">User collection</div>
) : null}
</div>
) : null}
</button>
{/* Pin to sidebar button — only for library collections */}
{!isUserCollection && (
{!isUserCollection && canToggle && (
<button
type="button"
onClick={(e) => {
@@ -0,0 +1,235 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useUICustomization } from "@/hooks/useUICustomization";
import { SETTING_KEYS } from "@/lib/settingsContract";
const mocks = vi.hoisted(() => ({
useEffectiveSettings: vi.fn(),
useSettingsCapabilities: vi.fn(),
}));
vi.mock("@/hooks/queries/settingValues", () => ({
useEffectiveSettings: (...args: unknown[]) => mocks.useEffectiveSettings(...args),
useSettingsCapabilities: (...args: unknown[]) => mocks.useSettingsCapabilities(...args),
settingsCapabilitiesSupportKey: (
capabilities:
| {
api_version: number;
revision: number;
supports_batched_effective?: boolean;
supports_idempotent_writes?: boolean;
}
| undefined,
) =>
capabilities?.api_version === 1 &&
capabilities.revision >= 5 &&
capabilities.supports_batched_effective === true &&
capabilities.supports_idempotent_writes === true,
settingsCapabilitiesSupportAtomicShortcuts: (
capabilities:
| {
api_version: number;
revision: number;
supports_batched_effective?: boolean;
supports_idempotent_writes?: boolean;
supports_atomic_shortcuts?: boolean;
}
| undefined,
) =>
capabilities?.api_version === 1 &&
capabilities.revision >= 5 &&
capabilities.supports_batched_effective === true &&
capabilities.supports_idempotent_writes === true &&
capabilities.supports_atomic_shortcuts === true,
}));
import { UICustomizationProvider } from "./UICustomizationProvider";
function Probe() {
const customization = useUICustomization();
return (
<output
data-supported={String(customization.isSupported)}
data-atomic={String(customization.supportsAtomicShortcuts)}
data-loading={String(customization.isLoading)}
data-unavailable={String(customization.isUnavailable)}
>
{customization.cardPresentation.poster_size}
</output>
);
}
describe("UICustomizationProvider capability gating", () => {
beforeEach(() => {
mocks.useEffectiveSettings.mockReset();
mocks.useSettingsCapabilities.mockReset();
mocks.useEffectiveSettings.mockReturnValue({
// Disabled TanStack queries can retain stale cache data. The provider
// must ignore it until the connected server advertises support.
data: {
[SETTING_KEYS.UI_CARD_PRESENTATION]: {
value: { poster_size: "large", caption: "artwork" },
source: "profile_client",
},
},
isLoading: false,
isError: false,
});
mocks.useSettingsCapabilities.mockReturnValue({
data: {
api_version: 1,
revision: 4,
contract_etag: "revision-four",
supports_batched_effective: true,
supports_idempotent_writes: true,
},
isLoading: false,
isError: false,
});
});
afterEach(cleanup);
it("does not request revision-five settings from an older server", () => {
render(
<UICustomizationProvider>
<Probe />
</UICustomizationProvider>,
);
expect(mocks.useEffectiveSettings).toHaveBeenCalledWith({
keys: [
SETTING_KEYS.NAV_PRIMARY_MENU,
SETTING_KEYS.NAV_SHORTCUTS,
SETTING_KEYS.UI_CARD_PRESENTATION,
],
enabled: false,
});
expect(screen.getByRole("status")).toHaveAttribute("data-supported", "false");
expect(screen.getByRole("status")).toHaveAttribute("data-atomic", "false");
expect(screen.getByRole("status")).toHaveTextContent("standard");
});
it("enables revision-five reads but requires an explicit atomic flag", () => {
mocks.useSettingsCapabilities.mockReturnValue({
data: {
api_version: 1,
revision: 5,
contract_etag: "revision-five",
supports_batched_effective: true,
supports_idempotent_writes: true,
},
isLoading: false,
isError: false,
});
render(
<UICustomizationProvider>
<Probe />
</UICustomizationProvider>,
);
expect(mocks.useEffectiveSettings).toHaveBeenCalledWith(
expect.objectContaining({
enabled: true,
}),
);
expect(screen.getByRole("status")).toHaveAttribute("data-supported", "true");
expect(screen.getByRole("status")).toHaveAttribute("data-atomic", "false");
});
it("fails closed without batched reads and idempotent replay", () => {
mocks.useSettingsCapabilities.mockReturnValue({
data: {
api_version: 1,
revision: 5,
contract_etag: "revision-five-incomplete",
supports_idempotent_writes: true,
supports_atomic_shortcuts: true,
},
isLoading: false,
isError: false,
});
render(
<UICustomizationProvider>
<Probe />
</UICustomizationProvider>,
);
expect(mocks.useEffectiveSettings).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
}),
);
expect(screen.getByRole("status")).toHaveAttribute("data-supported", "false");
expect(screen.getByRole("status")).toHaveAttribute("data-atomic", "false");
});
it("exposes atomic shortcut support only when advertised", () => {
mocks.useSettingsCapabilities.mockReturnValue({
data: {
api_version: 1,
revision: 5,
contract_etag: "revision-five-atomic",
supports_batched_effective: true,
supports_idempotent_writes: true,
supports_atomic_shortcuts: true,
},
isLoading: false,
isError: false,
});
mocks.useEffectiveSettings.mockReturnValue({
data: {
[SETTING_KEYS.UI_CARD_PRESENTATION]: {
value: { poster_size: "large", caption: "artwork" },
source: "profile_client",
},
},
isLoading: false,
});
render(
<UICustomizationProvider>
<Probe />
</UICustomizationProvider>,
);
expect(screen.getByRole("status")).toHaveAttribute("data-atomic", "true");
expect(screen.getByRole("status")).toHaveTextContent("large");
});
it("marks customization unavailable when effective settings fail to load", () => {
mocks.useSettingsCapabilities.mockReturnValue({
data: {
api_version: 1,
revision: 5,
contract_etag: "revision-five",
supports_batched_effective: true,
supports_idempotent_writes: true,
supports_atomic_shortcuts: true,
},
isLoading: false,
isError: false,
});
mocks.useEffectiveSettings.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
});
render(
<UICustomizationProvider>
<Probe />
</UICustomizationProvider>,
);
expect(screen.getByRole("status")).toHaveAttribute("data-supported", "true");
expect(screen.getByRole("status")).toHaveAttribute("data-loading", "false");
expect(screen.getByRole("status")).toHaveAttribute("data-unavailable", "true");
expect(screen.getByRole("status")).toHaveTextContent("standard");
});
});
@@ -0,0 +1,77 @@
import { useMemo, type ReactNode } from "react";
import {
UICustomizationContext,
type UICustomizationValue,
} from "@/contexts/uiCustomizationContext";
import {
settingsCapabilitiesSupportAtomicShortcuts,
settingsCapabilitiesSupportKey,
useEffectiveSettings,
useSettingsCapabilities,
} from "@/hooks/queries/settingValues";
import { SETTING_KEYS } from "@/lib/settingsContract";
import { parseCardPresentation, parsePrimaryMenu, parseShortcuts } from "@/lib/uiCustomization";
const UI_CUSTOMIZATION_KEYS = [
SETTING_KEYS.NAV_PRIMARY_MENU,
SETTING_KEYS.NAV_SHORTCUTS,
SETTING_KEYS.UI_CARD_PRESENTATION,
] as const;
export function UICustomizationProvider({ children }: { children: ReactNode }) {
const capabilitiesQuery = useSettingsCapabilities();
const isSupported = UI_CUSTOMIZATION_KEYS.every((key) =>
settingsCapabilitiesSupportKey(capabilitiesQuery.data, key),
);
const supportsAtomicShortcuts = settingsCapabilitiesSupportAtomicShortcuts(
capabilitiesQuery.data,
);
const query = useEffectiveSettings({ keys: UI_CUSTOMIZATION_KEYS, enabled: isSupported });
const isUnavailable =
(!capabilitiesQuery.isLoading &&
(capabilitiesQuery.isError || capabilitiesQuery.data === undefined)) ||
(isSupported && !query.isLoading && (query.isError || query.data === undefined));
// A disabled query may still expose data cached before a server downgrade or
// reconnect. Ignore it until the connected server proves support.
const primaryMenuValue = isSupported
? query.data?.[SETTING_KEYS.NAV_PRIMARY_MENU]?.value
: undefined;
const shortcutsValue = isSupported ? query.data?.[SETTING_KEYS.NAV_SHORTCUTS]?.value : undefined;
const cardPresentationValue = isSupported
? query.data?.[SETTING_KEYS.UI_CARD_PRESENTATION]?.value
: undefined;
const value = useMemo<UICustomizationValue>(
() => ({
cardPresentation: parseCardPresentation(cardPresentationValue),
cardPresentationSource: isSupported
? (query.data?.[SETTING_KEYS.UI_CARD_PRESENTATION]?.source ?? "default")
: "default",
primaryMenu: parsePrimaryMenu(primaryMenuValue),
primaryMenuSource: isSupported
? (query.data?.[SETTING_KEYS.NAV_PRIMARY_MENU]?.source ?? "default")
: "default",
shortcuts: parseShortcuts(shortcutsValue),
isSupported,
supportsAtomicShortcuts,
isLoading: capabilitiesQuery.isLoading || (isSupported && query.isLoading),
isUnavailable,
}),
[
capabilitiesQuery.isLoading,
cardPresentationValue,
isUnavailable,
isSupported,
primaryMenuValue,
query.data,
query.isLoading,
shortcutsValue,
supportsAtomicShortcuts,
],
);
return (
<UICustomizationContext.Provider value={value}>{children}</UICustomizationContext.Provider>
);
}
@@ -0,0 +1,36 @@
import { createContext } from "react";
import {
DEFAULT_CARD_PRESENTATION,
type CardPresentation,
type PrimaryMenuDocument,
type ShortcutDocument,
} from "@/lib/uiCustomization";
import type { SettingSource } from "@/hooks/queries/settingValues";
export interface UICustomizationValue {
cardPresentation: CardPresentation;
cardPresentationSource: SettingSource;
primaryMenu: PrimaryMenuDocument | null;
primaryMenuSource: SettingSource;
shortcuts: ShortcutDocument;
/** All revision-5 customization definitions are understood by this server. */
isSupported: boolean;
/** The server can merge shortcut item mutations without lost updates. */
supportsAtomicShortcuts: boolean;
isLoading: boolean;
/** Capability or effective-value loading failed, so whole-document editors must stay closed. */
isUnavailable: boolean;
}
export const UICustomizationContext = createContext<UICustomizationValue>({
cardPresentation: DEFAULT_CARD_PRESENTATION,
cardPresentationSource: "default",
primaryMenu: null,
primaryMenuSource: "default",
shortcuts: { items: [] },
isSupported: false,
supportsAtomicShortcuts: false,
isLoading: false,
isUnavailable: false,
});
@@ -7,6 +7,7 @@ import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { installPolicyStorageMocks, jsonResponse } from "@/pages/admin-policy/policyTestUtils";
import { SETTING_KEYS } from "@/lib/settingsContract";
import {
useAdminDeviceOverrides,
@@ -92,6 +93,7 @@ describe("admin canonical settings hooks", () => {
key: "playback.subtitle_mode",
scope: "profile",
profile_id: "p1",
client_family: undefined,
library_id: undefined,
series_id: undefined,
value: "always",
@@ -101,6 +103,7 @@ describe("admin canonical settings hooks", () => {
key: "playback.auto_skip_intro",
scope: "profile",
profile_id: "p1",
client_family: undefined,
library_id: undefined,
series_id: undefined,
value: "true",
@@ -225,6 +228,56 @@ describe("admin canonical settings hooks", () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
it("carries a profile-client family from listing through mutation identity", 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/ui.card_presentation?scope=profile_client&profile_id=p1&client_family=tv",
);
return jsonResponse({
key: SETTING_KEYS.UI_CARD_PRESENTATION,
scope: "profile_client",
profile_id: "p1",
client_family: "tv",
value: { poster_size: "large", caption: "artwork" },
});
}
expect(url).toBe("/api/v1/admin/users/7/settings/values");
return jsonResponse({
revision: 5,
values: [
{
key: SETTING_KEYS.UI_CARD_PRESENTATION,
scope: "profile_client",
profile_id: "p1",
client_family: "tv",
value: { poster_size: "compact", caption: "title" },
revision: 1,
},
],
});
});
vi.stubGlobal("fetch", fetchMock);
const listed = renderHook(() => useAdminUserSettings(7), { wrapper: createWrapper() });
await waitFor(() => expect(listed.result.current.data.length).toBe(1));
expect(listed.result.current.data[0]).toMatchObject({
scope: "profile_client",
profile_id: "p1",
client_family: "tv",
});
const update = renderHook(() => useUpdateAdminUserSetting(), { wrapper: createWrapper() });
update.result.current.mutate({
userId: 7,
key: SETTING_KEYS.UI_CARD_PRESENTATION,
identity: { scope: "profile_client", profileId: "p1", clientFamily: "tv" },
value: JSON.stringify({ poster_size: "large", caption: "artwork" }),
});
await waitFor(() => expect(update.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");
@@ -244,6 +297,31 @@ describe("admin canonical settings hooks", () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
it("resets navigation shortcuts with the permitted atomic empty document", async () => {
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
expect(init?.method).toBe("PUT");
expect(String(input)).toBe(
"/api/v1/admin/users/7/settings/values/nav.shortcuts?scope=profile&profile_id=p1",
);
expect(JSON.parse(String(init?.body))).toEqual({ value: { items: [] } });
return jsonResponse({
key: SETTING_KEYS.NAV_SHORTCUTS,
scope: "profile",
profile_id: "p1",
value: { items: [] },
});
});
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useDeleteAdminUserSetting(), { wrapper: createWrapper() });
result.current.mutate({
userId: 7,
key: SETTING_KEYS.NAV_SHORTCUTS,
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") {
+21 -2
View File
@@ -9,7 +9,7 @@ import type {
LoginResponse,
UpdateUserRequest,
} from "@/api/types";
import { SETTING_DEFINITIONS, type SettingKey } from "@/lib/settingsContract";
import { SETTING_DEFINITIONS, SETTING_KEYS, type SettingKey } from "@/lib/settingsContract";
import { adminKeys } from "../keys";
import { useAdminUserProfiles } from "./history";
import { toast } from "sonner";
@@ -39,15 +39,19 @@ interface AdminDevicesResponse {
export type AdminSettingScope =
| "account"
| "profile"
| "profile_client"
| "profile_device"
| "profile_library"
| "profile_series";
export type AdminSettingClientFamily = "tv" | "mobile" | "tablet" | "desktop" | "web";
/** One stored row as GET /admin/users/{id}/settings/values reports it. */
interface AdminSettingValueRow {
key: string;
scope: AdminSettingScope;
profile_id?: string;
client_family?: AdminSettingClientFamily;
device_id?: string;
library_id?: number;
series_id?: string;
@@ -65,6 +69,7 @@ interface AdminSettingValuesResponse {
export interface AdminSettingIdentity {
scope: AdminSettingScope;
profileId?: string;
clientFamily?: AdminSettingClientFamily;
deviceId?: string;
libraryId?: number;
seriesId?: string;
@@ -75,6 +80,7 @@ export interface AdminUserSettingEntry {
key: string;
scope: AdminSettingScope;
profile_id?: string;
client_family?: AdminSettingClientFamily;
library_id?: number;
series_id?: string;
value: string;
@@ -96,6 +102,7 @@ export interface AdminDeviceSetting {
function identityQuery(identity: AdminSettingIdentity): string {
const params = new URLSearchParams({ scope: identity.scope });
if (identity.profileId) params.set("profile_id", identity.profileId);
if (identity.clientFamily) params.set("client_family", identity.clientFamily);
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);
@@ -255,6 +262,7 @@ export function useAdminUserSettings(userId: number) {
key: row.key,
scope: row.scope,
profile_id: row.profile_id,
client_family: row.client_family,
library_id: row.library_id,
series_id: row.series_id,
value: settingValueToString(row.value),
@@ -304,7 +312,18 @@ export function useDeleteAdminUserSetting() {
userId: number;
key: string;
identity: AdminSettingIdentity;
}) => api(adminSettingValuePath(userId, key, identity), { method: "DELETE" }),
}) => {
const path = adminSettingValuePath(userId, key, identity);
if (key === SETTING_KEYS.NAV_SHORTCUTS) {
// Shortcut history is revisioned and may not be erased. Its reset is
// the atomic empty document accepted by the canonical admin endpoint.
return api(path, {
method: "PUT",
body: JSON.stringify({ value: { items: [] } }),
});
}
return api(path, { method: "DELETE" });
},
onSuccess: (_data, variables) => {
toast.success("User setting reset");
queryClient.invalidateQueries({ queryKey: adminKeys.userSettings(variables.userId) });
@@ -0,0 +1,265 @@
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiClientError } from "@/api/client";
import { SETTING_KEYS } from "@/lib/settingsContract";
import {
settingsCapabilitiesSupportAtomicShortcuts,
settingsCapabilitiesSupportKey,
type SettingIdentity,
useClearSettingValue,
useSetNavigationShortcutPresence,
useSetSettingValue,
} from "./settingValues";
const apiMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const apiWithProfileRequestContextMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock("@/api/client", async () => {
const actual = await vi.importActual<typeof import("@/api/client")>("@/api/client");
return {
...actual,
api: apiMock,
apiWithProfileRequestContext: apiWithProfileRequestContextMock,
};
});
function wrapper({ children }: { children: ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
describe("self-service setting identities", () => {
afterEach(() => {
cleanup();
apiMock.mockClear();
apiWithProfileRequestContextMock.mockClear();
});
it("cannot address another client family through a query parameter", async () => {
const staleIdentity = {
scope: "profile_client",
// @ts-expect-error Client family comes only from the canonical API header.
clientFamily: "tv",
} satisfies SettingIdentity;
const setHook = renderHook(() => useSetSettingValue(), { wrapper });
const clearHook = renderHook(() => useClearSettingValue(), { wrapper });
await act(async () => {
await setHook.result.current.mutateAsync({
key: SETTING_KEYS.UI_CARD_PRESENTATION,
value: { poster_size: "large", caption: "artwork" },
identity: staleIdentity,
});
await clearHook.result.current.mutateAsync({
key: SETTING_KEYS.UI_CARD_PRESENTATION,
identity: staleIdentity,
});
});
expect(apiMock.mock.calls.map(([path]) => path)).toEqual([
`/settings/values/${SETTING_KEYS.UI_CARD_PRESENTATION}?scope=profile_client`,
`/settings/values/${SETTING_KEYS.UI_CARD_PRESENTATION}?scope=profile_client`,
]);
expect(apiMock.mock.calls.map(([, options]) => options?.method)).toEqual(["PUT", "DELETE"]);
});
it("sends an atomic shortcut with its captured profile and PIN token", async () => {
const shortcutHook = renderHook(() => useSetNavigationShortcutPresence(), { wrapper });
const profileAuth = {
profileId: "profile-old",
profileToken: "fake",
accessToken: "fake",
authContextVersion: 3,
serverOrigin: "https://old.example",
};
await act(async () => {
await shortcutHook.result.current.mutateAsync({
item: { type: "library", library_id: 42, label: "Movies" },
present: true,
mutationId: "mutation-1",
profileAuth,
invalidateOnSettled: false,
});
});
expect(apiWithProfileRequestContextMock).toHaveBeenCalledWith(
"/settings/values/nav.shortcuts/item",
profileAuth,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
"X-Silo-Mutation-Id": "mutation-1",
},
body: JSON.stringify({
item: { type: "library", library_id: 42, label: "Movies" },
present: true,
}),
},
);
});
it("keeps an ordinary write pending until effective values reconcile", async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
let releaseInvalidation!: () => void;
const invalidation = new Promise<void>((resolve) => {
releaseInvalidation = resolve;
});
const invalidateQueries = vi
.spyOn(queryClient, "invalidateQueries")
.mockReturnValue(invalidation);
const localWrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const setHook = renderHook(() => useSetSettingValue(), { wrapper: localWrapper });
let settled = false;
const mutation = setHook.result.current
.mutateAsync({
key: SETTING_KEYS.UI_CARD_PRESENTATION,
value: { poster_size: "compact", caption: "title" },
identity: { scope: "profile_client" },
})
.then(() => {
settled = true;
});
await waitFor(() => expect(invalidateQueries).toHaveBeenCalled());
expect(setHook.result.current.isPending).toBe(true);
expect(settled).toBe(false);
await act(async () => {
releaseInvalidation();
await mutation;
});
await waitFor(() => expect(setHook.result.current.isPending).toBe(false));
expect(settled).toBe(true);
});
it.each([
["a successful reset", undefined],
["an already-cleared 404 reset", new ApiClientError(404, "not_found", "Already cleared")],
])("keeps %s pending until effective values reconcile", async (_label, apiError) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
let releaseInvalidation!: () => void;
const invalidation = new Promise<void>((resolve) => {
releaseInvalidation = resolve;
});
const invalidateQueries = vi
.spyOn(queryClient, "invalidateQueries")
.mockReturnValue(invalidation);
const localWrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
if (apiError) apiMock.mockRejectedValueOnce(apiError);
const clearHook = renderHook(() => useClearSettingValue(), { wrapper: localWrapper });
let settled = false;
const mutation = clearHook.result.current
.mutateAsync({
key: SETTING_KEYS.UI_CARD_PRESENTATION,
identity: { scope: "profile_client" },
})
.catch((error: unknown) => {
if (!apiError) throw error;
expect(error).toBe(apiError);
})
.then(() => {
settled = true;
});
await waitFor(() => expect(invalidateQueries).toHaveBeenCalled());
expect(clearHook.result.current.isPending).toBe(true);
expect(settled).toBe(false);
await act(async () => {
releaseInvalidation();
await mutation;
});
await waitFor(() => expect(clearHook.result.current.isPending).toBe(false));
expect(settled).toBe(true);
});
});
describe("settings capability gates", () => {
const revisionFive = {
api_version: 1,
revision: 5,
contract_etag: "revision-five",
supports_batched_effective: true,
supports_idempotent_writes: true,
};
it("requires a compatible API and the definition's introduced revision", () => {
expect(settingsCapabilitiesSupportKey(undefined, SETTING_KEYS.NAV_PRIMARY_MENU)).toBe(false);
expect(
settingsCapabilitiesSupportKey(
{ ...revisionFive, api_version: 2 },
SETTING_KEYS.NAV_PRIMARY_MENU,
),
).toBe(false);
expect(
settingsCapabilitiesSupportKey(
{ ...revisionFive, revision: 4 },
SETTING_KEYS.NAV_PRIMARY_MENU,
),
).toBe(false);
expect(settingsCapabilitiesSupportKey(revisionFive, SETTING_KEYS.NAV_PRIMARY_MENU)).toBe(true);
});
it("requires batched reads and idempotent replay semantics", () => {
expect(
settingsCapabilitiesSupportKey(
{ ...revisionFive, supports_batched_effective: false },
SETTING_KEYS.NAV_PRIMARY_MENU,
),
).toBe(false);
expect(
settingsCapabilitiesSupportKey(
{ ...revisionFive, supports_idempotent_writes: false },
SETTING_KEYS.NAV_PRIMARY_MENU,
),
).toBe(false);
const withoutBatch = {
api_version: 1,
revision: 5,
contract_etag: "without-batch",
supports_idempotent_writes: true,
};
const withoutIdempotency = {
api_version: 1,
revision: 5,
contract_etag: "without-idempotency",
supports_batched_effective: true,
};
expect(settingsCapabilitiesSupportKey(withoutBatch, SETTING_KEYS.NAV_PRIMARY_MENU)).toBe(false);
expect(settingsCapabilitiesSupportKey(withoutIdempotency, SETTING_KEYS.NAV_PRIMARY_MENU)).toBe(
false,
);
});
it("requires an explicit atomic-shortcut capability", () => {
expect(settingsCapabilitiesSupportAtomicShortcuts(revisionFive)).toBe(false);
expect(
settingsCapabilitiesSupportAtomicShortcuts({
...revisionFive,
supports_atomic_shortcuts: false,
}),
).toBe(false);
expect(
settingsCapabilitiesSupportAtomicShortcuts({
...revisionFive,
supports_atomic_shortcuts: true,
}),
).toBe(true);
});
});
+140 -21
View File
@@ -1,9 +1,21 @@
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, ApiClientError } from "@/api/client";
import {
api,
ApiClientError,
apiWithProfileRequestContext,
isProfileRequestContextCurrent,
type ProfileRequestContextSnapshot,
} from "@/api/client";
import { storage } from "@/utils/storage";
import { SETTING_DEFINITIONS, SETTINGS_REVISION, type SettingKey } from "@/lib/settingsContract";
import {
SETTING_DEFINITIONS,
SETTING_KEYS,
SETTINGS_API_VERSION,
type SettingKey,
} from "@/lib/settingsContract";
import { useEventChannel } from "@/components/realtimeEventsContext";
import type { ShortcutTarget } from "@/lib/uiCustomization";
import { deviceKeys, settingsKeys } from "./keys";
/**
@@ -16,11 +28,12 @@ import { deviceKeys, settingsKeys } from "./keys";
* cannot be expressed because SettingKey is generated from it.
*/
/** The five scopes a value can live at. */
/** The remote scopes a value can live at. */
export type SettingScope =
| "account"
| "profile"
| "profile_device"
| "profile_client"
| "profile_library"
| "profile_series";
@@ -45,6 +58,13 @@ export interface SettingIdentity {
profileId?: string;
}
/**
* Profile authorization captured as one synchronous snapshot when an intent is
* created. A queued write must not combine one profile id with another
* profile's PIN token after the active profile changes.
*/
export type ProfileAuthSnapshot = ProfileRequestContextSnapshot;
export interface EffectiveSetting<T = unknown> {
key: SettingKey;
value: T;
@@ -57,6 +77,7 @@ export interface EffectiveSetting<T = unknown> {
suggested_values?: string[];
/** The scope holding the value, so a reset can target it exactly. */
scope?: SettingScope;
client_family?: "tv" | "mobile" | "tablet" | "desktop" | "web";
library_id?: number;
series_id?: string;
}
@@ -203,13 +224,16 @@ function invalidateSettingValueQueries(
queryClient: ReturnType<typeof useQueryClient>,
identity: SettingIdentity,
) {
void queryClient.invalidateQueries({ queryKey: [...settingsKeys.all, "values"] });
const invalidations = [
queryClient.invalidateQueries({ queryKey: [...settingsKeys.all, "values"] }),
];
// A device-scoped write changes that device's "how many things differ"
// count, which the device list shows. Without this the badge stays stale
// until the list's own staleTime expires.
if (identity.scope === "profile_device") {
void queryClient.invalidateQueries({ queryKey: deviceKeys.all });
invalidations.push(queryClient.invalidateQueries({ queryKey: deviceKeys.all }));
}
return Promise.all(invalidations).then(() => undefined);
}
/** Write one value at one scope. */
@@ -228,6 +252,13 @@ export function useSetSettingValue() {
identity: SettingIdentity;
/** Optional idempotency key; a retry with the same id replays rather than re-applying. */
mutationId?: string;
/**
* Whole-document editors may serialize several optimistic writes and
* invalidate once their queue drains. Intermediate refetches would
* otherwise replace the newest optimistic document with an older server
* value. Ordinary callers should leave this enabled.
*/
invalidateOnSettled?: boolean;
}) =>
api(`/settings/values/${key}?${identityQuery(identity)}`, {
method: "PUT",
@@ -238,16 +269,76 @@ export function useSetSettingValue() {
body: JSON.stringify({ value }),
}),
onSuccess: (_data, variables) => {
invalidateSettingValueQueries(qc, variables.identity);
if (variables.invalidateOnSettled === false) return;
// Keep ordinary controls pending until their active effective-value
// reads reconcile. Otherwise a rapid follow-up edit can spread a stale
// object and silently undo the first field that was just saved.
return invalidateSettingValueQueries(qc, variables.identity);
},
onError: (error, variables) => {
if (variables.invalidateOnSettled === false) return;
if (shouldReconcileAfterMutationError(error)) {
invalidateSettingValueQueries(qc, variables.identity);
return invalidateSettingValueQueries(qc, variables.identity);
}
},
});
}
/**
* Ensure one profile-wide navigation shortcut is present or absent.
*
* This semantic endpoint is intentionally separate from useSetSettingValue:
* nav.shortcuts is shared by every client family, so replacing its whole
* document can erase a shortcut another device added from the same base.
*/
export function useSetNavigationShortcutPresence() {
const qc = useQueryClient();
const mutateAsync = useCallback(
async ({
item,
present,
mutationId,
profileAuth,
invalidateOnSettled,
}: {
item: ShortcutTarget;
present: boolean;
/** Stable across retries of this desired-state operation. */
mutationId: string;
/** Profile id and matching PIN token captured with this intent. */
profileAuth: ProfileAuthSnapshot;
/** A local serialized editor can defer refetching until its queue drains. */
invalidateOnSettled?: boolean;
}) => {
try {
return await apiWithProfileRequestContext(
`/settings/values/nav.shortcuts/item`,
profileAuth,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
"X-Silo-Mutation-Id": mutationId,
},
body: JSON.stringify({ item, present }),
},
);
} finally {
if (invalidateOnSettled !== false && isProfileRequestContextCurrent(profileAuth)) {
void qc.invalidateQueries({ queryKey: [...settingsKeys.all, "values"] });
}
}
},
[qc],
);
// This deliberately is not a TanStack mutation. Mutation variables remain
// in the mutation cache after settlement; the PIN token should live only in
// the in-memory serialized queue/request closure that needs it.
return { mutateAsync };
}
/** Clear the value at one scope, so the setting inherits again. */
export function useClearSettingValue() {
const qc = useQueryClient();
@@ -256,7 +347,7 @@ export function useClearSettingValue() {
mutationFn: ({ key, identity }: { key: SettingKey; identity: SettingIdentity }) =>
api(`/settings/values/${key}?${identityQuery(identity)}`, { method: "DELETE" }),
onSuccess: (_data, variables) => {
invalidateSettingValueQueries(qc, variables.identity);
return invalidateSettingValueQueries(qc, variables.identity);
},
onError: (error, variables) => {
// DELETE is idempotent for reset callers: a 404 means another client
@@ -265,7 +356,7 @@ export function useClearSettingValue() {
shouldReconcileAfterMutationError(error) ||
(error instanceof ApiClientError && error.status === 404)
) {
invalidateSettingValueQueries(qc, variables.identity);
return invalidateSettingValueQueries(qc, variables.identity);
}
},
});
@@ -276,24 +367,52 @@ export function useClearSettingValue() {
* client built against a newer manifest hides definitions the connected server
* does not know rather than offering a choice it will refuse.
*/
export interface SettingsCapabilities {
api_version: number;
revision: number;
contract_etag: string;
/** Effective reads can resolve the provider's key batch atomically. */
supports_batched_effective?: boolean;
/** Persisted/retried writes can replay one mutation id without re-applying. */
supports_idempotent_writes?: boolean;
/** Added alongside the semantic nav.shortcuts item endpoint. */
supports_atomic_shortcuts?: boolean;
}
/** Whether this server can safely read and write one vendored definition. */
export function settingsCapabilitiesSupportKey(
capabilities: SettingsCapabilities | undefined,
key: SettingKey,
) {
return (
capabilities?.api_version === SETTINGS_API_VERSION &&
capabilities.revision >= SETTING_DEFINITIONS[key].introducedIn &&
capabilities.supports_batched_effective === true &&
capabilities.supports_idempotent_writes === true
);
}
/**
* Atomic shortcut mutations need both the revision-5 definition and the
* semantic endpoint capability. Missing flags from older servers fail closed.
*/
export function settingsCapabilitiesSupportAtomicShortcuts(
capabilities: SettingsCapabilities | undefined,
) {
return (
settingsCapabilitiesSupportKey(capabilities, SETTING_KEYS.NAV_SHORTCUTS) &&
capabilities?.supports_atomic_shortcuts === true
);
}
export function useSettingsCapabilities() {
return useQuery({
queryKey: [...settingsKeys.all, "capabilities"] as const,
queryFn: () =>
api<{ api_version: number; revision: number; contract_etag: string }>(
"/settings/contract/capabilities",
),
queryFn: () => api<SettingsCapabilities>("/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;
@@ -0,0 +1,413 @@
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SETTING_KEYS } from "@/lib/settingsContract";
import { useSidebarPins, useToggleSidebarPin } from "./sidebarPins";
const mocks = vi.hoisted(() => ({
mutateAsync: vi.fn(),
remoteValue: { items: [] } as { items: Array<Record<string, unknown>> },
remoteLegacyValue: {} as Record<string, Array<Record<string, unknown>>>,
profileId: "profile-1",
profileToken: "fake",
accessToken: "fake",
authContextVersion: 1,
serverOrigin: "https://server-1.example",
capabilities: {
api_version: 1,
revision: 5,
contract_etag: "revision-five",
supports_batched_effective: true,
supports_idempotent_writes: true,
supports_atomic_shortcuts: true,
} as {
api_version: number;
revision: number;
contract_etag: string;
supports_batched_effective?: boolean;
supports_idempotent_writes?: boolean;
supports_atomic_shortcuts?: boolean;
},
}));
vi.mock("@/api/client", () => ({
captureProfileRequestContext: () =>
mocks.profileId && mocks.accessToken
? Object.fromEntries([
["profileId", mocks.profileId],
["profileToken", mocks.profileToken],
["accessToken", mocks.accessToken],
["authContextVersion", mocks.authContextVersion],
["serverOrigin", mocks.serverOrigin],
])
: null,
isProfileRequestContextCurrent: (snapshot: {
accessToken: string;
authContextVersion: number;
serverOrigin: string;
}) =>
snapshot.accessToken === mocks.accessToken &&
snapshot.authContextVersion === mocks.authContextVersion &&
snapshot.serverOrigin === mocks.serverOrigin,
}));
vi.mock("@/utils/storage", () => ({
storage: {
KEYS: { PROFILE_ID: "profile_id" },
get: () => mocks.profileId,
},
}));
vi.mock("./settingValues", () => ({
effectiveSettingsQueryKey: ({ keys }: { keys?: readonly string[] }) => [
"settings",
"values",
"effective",
"profile-1",
keys?.join(",") ?? "*",
],
useEffectiveSettings: ({ keys }: { keys?: readonly string[] } = {}) => ({
data: keys?.includes(SETTING_KEYS.UI_SIDEBAR_PINS)
? {
[SETTING_KEYS.UI_SIDEBAR_PINS]: {
key: SETTING_KEYS.UI_SIDEBAR_PINS,
value: mocks.remoteLegacyValue,
source: "profile",
},
}
: {
[SETTING_KEYS.NAV_SHORTCUTS]: {
key: SETTING_KEYS.NAV_SHORTCUTS,
value: mocks.remoteValue,
source: "profile",
},
},
isLoading: false,
}),
useSettingsCapabilities: () => ({ data: mocks.capabilities, isLoading: false }),
settingsCapabilitiesSupportKey: (
capabilities:
| {
api_version: number;
revision: number;
supports_batched_effective?: boolean;
supports_idempotent_writes?: boolean;
}
| undefined,
key: string,
) =>
capabilities?.api_version === 1 &&
capabilities.revision >= (key === SETTING_KEYS.NAV_SHORTCUTS ? 5 : 1) &&
capabilities.supports_batched_effective === true &&
capabilities.supports_idempotent_writes === true,
settingsCapabilitiesSupportAtomicShortcuts: (
capabilities:
| {
api_version: number;
revision: number;
supports_batched_effective?: boolean;
supports_idempotent_writes?: boolean;
supports_atomic_shortcuts?: boolean;
}
| undefined,
) =>
capabilities?.api_version === 1 &&
capabilities.revision >= 5 &&
capabilities.supports_batched_effective === true &&
capabilities.supports_idempotent_writes === true &&
capabilities.supports_atomic_shortcuts === true,
useSetNavigationShortcutPresence: () => ({ mutateAsync: mocks.mutateAsync }),
}));
function deferred() {
let resolve!: (value: unknown) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function wrapper(queryClient: QueryClient) {
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
describe("serialized sidebar pin writes", () => {
beforeEach(() => {
mocks.mutateAsync.mockReset();
mocks.remoteValue = { items: [] };
mocks.remoteLegacyValue = {};
mocks.profileId = "profile-1";
mocks.profileToken = "fake";
mocks.accessToken = "fake";
mocks.authContextVersion = 1;
mocks.serverOrigin = "https://server-1.example";
mocks.capabilities = {
api_version: 1,
revision: 5,
contract_etag: "revision-five",
supports_batched_effective: true,
supports_idempotent_writes: true,
supports_atomic_shortcuts: true,
};
});
afterEach(cleanup);
it("preserves the latest optimistic document across an intermediate refetch", async () => {
const writes = [deferred(), deferred(), deferred()];
mocks.mutateAsync.mockImplementation(
() => writes[mocks.mutateAsync.mock.calls.length - 1]!.promise,
);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const { result, rerender } = renderHook(
() => ({ pins: useSidebarPins(), toggle: useToggleSidebarPin() }),
{ wrapper: wrapper(queryClient) },
);
act(() => {
result.current.toggle.togglePin(42, {
type: "collection",
id: "a",
label: "A",
});
result.current.toggle.togglePin(42, {
type: "section",
id: "b",
label: "B",
});
});
await waitFor(() => expect(mocks.mutateAsync).toHaveBeenCalledTimes(1));
// Simulate the first write's server event/refetch returning only A while B
// remains queued. The local overlay must continue to be the edit base.
mocks.remoteValue = {
items: [{ type: "collection", library_id: 42, collection_id: "a", label: "A" }],
};
rerender();
expect(result.current.pins.pins["42"]).toHaveLength(2);
act(() => {
result.current.toggle.togglePin(42, {
type: "collection",
id: "c",
label: "C",
});
});
writes[0]!.resolve({});
await waitFor(() => expect(mocks.mutateAsync).toHaveBeenCalledTimes(2));
expect(mocks.mutateAsync.mock.calls[1]![0]).toMatchObject({
invalidateOnSettled: false,
item: { type: "section", library_id: 42, section_id: "b", label: "B" },
present: true,
mutationId: expect.any(String),
});
writes[1]!.resolve({});
await waitFor(() => expect(mocks.mutateAsync).toHaveBeenCalledTimes(3));
expect(mocks.mutateAsync.mock.calls[2]![0]).toMatchObject({
item: { type: "collection", library_id: 42, collection_id: "c", label: "C" },
present: true,
mutationId: expect.any(String),
});
writes[2]!.resolve({});
await waitFor(() => expect(result.current.pins.pins["42"]).toHaveLength(1));
});
it("serializes rapid toggles as stable desired-state operations", async () => {
mocks.mutateAsync.mockResolvedValue({});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const { result } = renderHook(() => useToggleSidebarPin(), {
wrapper: wrapper(queryClient),
});
const pin = { type: "collection" as const, id: "a", label: "A" };
act(() => {
result.current.togglePin(42, pin);
result.current.togglePin(42, pin);
});
await waitFor(() => expect(mocks.mutateAsync).toHaveBeenCalledTimes(2));
expect(mocks.mutateAsync.mock.calls[0]![0]).toMatchObject({
item: { type: "collection", library_id: 42, collection_id: "a", label: "A" },
present: true,
mutationId: expect.any(String),
});
expect(mocks.mutateAsync.mock.calls[1]![0]).toMatchObject({
item: { type: "collection", library_id: 42, collection_id: "a", label: "A" },
present: false,
mutationId: expect.any(String),
});
expect(mocks.mutateAsync.mock.calls[1]![0].mutationId).not.toBe(
mocks.mutateAsync.mock.calls[0]![0].mutationId,
);
});
it("orders the same target across separate hook instances", async () => {
const writes = [deferred(), deferred()];
mocks.mutateAsync.mockImplementation(
() => writes[mocks.mutateAsync.mock.calls.length - 1]!.promise,
);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const first = renderHook(() => useToggleSidebarPin(), { wrapper: wrapper(queryClient) });
const second = renderHook(() => useToggleSidebarPin(), { wrapper: wrapper(queryClient) });
const pin = { type: "section" as const, id: "recent", label: "Recent" };
act(() => {
first.result.current.togglePin(42, pin);
second.result.current.togglePin(42, pin);
});
await waitFor(() => expect(mocks.mutateAsync).toHaveBeenCalledTimes(1));
expect(mocks.mutateAsync.mock.calls[0]![0]).toMatchObject({ present: true });
writes[0]!.resolve({});
await waitFor(() => expect(mocks.mutateAsync).toHaveBeenCalledTimes(2));
expect(mocks.mutateAsync.mock.calls[1]![0]).toMatchObject({ present: false });
writes[1]!.resolve({});
});
it("keeps queued intent bound to the profile and PIN token captured at click time", async () => {
const writes = [deferred(), deferred()];
mocks.mutateAsync.mockImplementation(
() => writes[mocks.mutateAsync.mock.calls.length - 1]!.promise,
);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const { result } = renderHook(() => useToggleSidebarPin(), {
wrapper: wrapper(queryClient),
});
act(() => {
result.current.togglePin(42, { type: "collection", id: "a", label: "A" });
result.current.togglePin(42, { type: "collection", id: "b", label: "B" });
});
await waitFor(() => expect(mocks.mutateAsync).toHaveBeenCalledTimes(1));
mocks.profileId = "profile-2";
mocks.profileToken = "dummy";
writes[0]!.resolve({});
await waitFor(() => expect(mocks.mutateAsync).toHaveBeenCalledTimes(2));
expect(mocks.mutateAsync.mock.calls[0]![0]).toMatchObject({
profileAuth: {
profileId: "profile-1",
profileToken: "fake",
accessToken: "fake",
authContextVersion: 1,
serverOrigin: "https://server-1.example",
},
});
expect(mocks.mutateAsync.mock.calls[1]![0]).toMatchObject({
profileAuth: { profileId: "profile-1", profileToken: "fake" },
});
writes[1]!.resolve({});
});
it("cancels queued intent after the account and server context switch", async () => {
const writes = [deferred(), deferred()];
mocks.mutateAsync.mockImplementation(
() => writes[mocks.mutateAsync.mock.calls.length - 1]!.promise,
);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
const { result } = renderHook(() => useToggleSidebarPin(), {
wrapper: wrapper(queryClient),
});
act(() => {
result.current.togglePin(42, { type: "collection", id: "a", label: "A" });
result.current.togglePin(42, { type: "collection", id: "b", label: "B" });
});
await waitFor(() => expect(mocks.mutateAsync).toHaveBeenCalledTimes(1));
mocks.profileId = "profile-for-account-2";
mocks.profileToken = "dummy";
mocks.accessToken = "dummy";
mocks.authContextVersion = 2;
mocks.serverOrigin = "https://server-2.example";
await act(async () => {
writes[0]!.resolve({});
await writes[0]!.promise;
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(mocks.mutateAsync).toHaveBeenCalledTimes(1);
expect(invalidate).not.toHaveBeenCalled();
});
it("fails closed when the atomic shortcut capability is absent", () => {
mocks.capabilities = {
api_version: 1,
revision: 5,
contract_etag: "revision-five-without-atomic-shortcuts",
supports_batched_effective: true,
supports_idempotent_writes: true,
};
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const { result } = renderHook(() => useToggleSidebarPin(), {
wrapper: wrapper(queryClient),
});
expect(result.current.canToggle).toBe(false);
act(() => {
result.current.togglePin(42, { type: "collection", id: "a", label: "A" });
});
expect(mocks.mutateAsync).not.toHaveBeenCalled();
});
it("continues reading legacy sidebar pins from revision-four servers", () => {
mocks.capabilities = {
api_version: 1,
revision: 4,
contract_etag: "revision-four",
supports_batched_effective: true,
supports_idempotent_writes: true,
};
mocks.remoteLegacyValue = {
"42": [{ type: "collection", id: "legacy", label: "Legacy pin" }],
};
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const { result } = renderHook(() => useSidebarPins(), {
wrapper: wrapper(queryClient),
});
expect(result.current.pins["42"]).toEqual([
{ type: "collection", id: "legacy", label: "Legacy pin" },
]);
});
it("hides cached shortcut data when no profile is active", () => {
mocks.remoteValue = {
items: [{ type: "collection", library_id: 42, collection_id: "stale", label: "Stale" }],
};
mocks.profileId = "";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const { result } = renderHook(() => useSidebarPins(), {
wrapper: wrapper(queryClient),
});
expect(result.current.pins).toEqual({});
});
});
+91
View File
@@ -3,6 +3,9 @@ import {
createSidebarPinsOptimisticMutation,
parseSidebarPins,
rollbackSidebarPinsOptimisticMutation,
setNavigationShortcutPresence,
sidebarPinsToShortcuts,
toggleNavigationShortcut,
toggleSidebarPins,
} from "./sidebarPins";
@@ -31,6 +34,94 @@ describe("sidebar pin helpers", () => {
});
});
it("groups the canonical cross-client shortcut catalog for the web sidebar", () => {
expect(
parseSidebarPins({
items: [
{ type: "library", library_id: 42, label: "Movies" },
{ type: "section", library_id: 42, section_id: "recent", label: "Recent" },
{
type: "collection",
library_id: 42,
collection_id: "horror",
label: "Horror",
},
{ type: "collection", collection_id: "global", label: "Global" },
],
}),
).toEqual({
"42": [
{ type: "section", id: "recent", label: "Recent" },
{ type: "collection", id: "horror", label: "Horror" },
],
});
});
it("serializes grouped sidebar pins into canonical shortcuts", () => {
expect(
sidebarPinsToShortcuts({
"42": [
{ type: "section", id: "recent", label: "Recent" },
{ type: "collection", id: "horror", label: "Horror" },
],
}),
).toEqual({
items: [
{ type: "section", library_id: 42, section_id: "recent", label: "Recent" },
{
type: "collection",
library_id: 42,
collection_id: "horror",
label: "Horror",
},
],
});
});
it("toggles one sidebar target without dropping other shared shortcuts", () => {
const current = {
items: [
{ type: "library", library_id: 42, label: "Movies" },
{ type: "collection", collection_id: "global", label: "Global" },
],
};
expect(
toggleNavigationShortcut(current, 42, {
type: "section",
id: "recent",
label: "Recent",
}),
).toEqual({
items: [
{ type: "library", library_id: 42, label: "Movies" },
{ type: "collection", collection_id: "global", label: "Global" },
{ type: "section", library_id: 42, section_id: "recent", label: "Recent" },
],
});
});
it("sets desired presence and refreshes a label without reordering", () => {
const target = {
type: "section" as const,
library_id: 42,
section_id: "recent",
label: "Recently Added",
};
const current = {
items: [
{ type: "library", library_id: 42, label: "Movies" },
{ ...target, label: "Recent" },
],
};
expect(setNavigationShortcutPresence(current, target, true)).toEqual({
items: [{ type: "library", library_id: 42, label: "Movies" }, target],
});
expect(setNavigationShortcutPresence(current, target, false)).toEqual({
items: [{ type: "library", library_id: 42, label: "Movies" }],
});
});
it("adds a new pin to the target library", () => {
expect(
toggleSidebarPins({}, 42, { type: "collection", id: "col-1", label: "Pinned Horror" }),
+266 -71
View File
@@ -1,23 +1,79 @@
import { useMemo, useCallback, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useMemo, useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { SidebarPin, SidebarPins } from "@/api/types";
import { captureProfileRequestContext, isProfileRequestContextCurrent } from "@/api/client";
import { storage } from "@/utils/storage";
import { randomUUID } from "@/lib/uuid";
import { SETTING_KEYS } from "@/lib/settingsContract";
import {
effectiveSettingsQueryKey,
settingsCapabilitiesSupportAtomicShortcuts,
settingsCapabilitiesSupportKey,
useEffectiveSettings,
useSetSettingValue,
useSetNavigationShortcutPresence,
useSettingsCapabilities,
type EffectiveSettingsMap,
type SettingIdentity,
} from "./settingValues";
import {
menuItemKey,
parseShortcuts,
type ShortcutDocument,
type ShortcutTarget,
} from "@/lib/uiCustomization";
/** `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;
const PINS_KEYS = [SETTING_KEYS.NAV_SHORTCUTS] as const;
const LEGACY_PINS_KEYS = [SETTING_KEYS.UI_SIDEBAR_PINS] as const;
let nextSidebarPinsRevision = 0;
interface SidebarPinsWriteQueue {
tail: Promise<unknown>;
}
// Several sidebar/card instances can expose the same pin. Their hook-local
// callbacks still share one ordered stream per query client and active-profile
// cache key, so a later remove cannot overtake an earlier add and one instance
// cannot clear another's optimistic overlay while it still has work queued.
const sidebarPinsWriteQueues = new WeakMap<object, Map<string, SidebarPinsWriteQueue>>();
function sidebarPinsWriteQueue(queryClient: object, pinsQueryKey: readonly unknown[]) {
let queues = sidebarPinsWriteQueues.get(queryClient);
if (!queues) {
queues = new Map();
sidebarPinsWriteQueues.set(queryClient, queues);
}
const key = JSON.stringify(pinsQueryKey);
let queue = queues.get(key);
if (!queue) {
queue = { tail: Promise.resolve() };
queues.set(key, queue);
}
return { queue, queues, key };
}
function sidebarPinsOverlayQueryKey(profileId?: string) {
return [
...effectiveSettingsQueryKey({ keys: PINS_KEYS, profileId }),
"optimistic-overlay",
] as const;
}
/**
* A separate reactive overlay keeps local whole-document edits stable while
* mutation events or unrelated invalidations refetch the server value. The
* overlay is cleared only after the serialized write queue drains and a final
* refetch has completed.
*/
function useSidebarPinsOverlay() {
return useQuery<ShortcutDocument | null>({
queryKey: sidebarPinsOverlayQueryKey(),
queryFn: async () => null,
initialData: null,
enabled: false,
staleTime: Infinity,
});
}
/**
* Accepts the canonical object value, the legacy JSON-string encoding, or
* null/undefined (the contract default), and always lands on a usable map.
@@ -34,9 +90,112 @@ export function parseSidebarPins(value: unknown): SidebarPins {
}
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
// Revision 5 promotes web-only sidebar pins into the shared shortcut
// catalog. Convert the flat cross-client shape back to the grouped view the
// existing sidebar renders; library shortcuts live in the primary menu and
// therefore do not appear as children of themselves.
if ("items" in parsed && Array.isArray((parsed as { items?: unknown }).items)) {
const grouped: SidebarPins = {};
for (const item of parseShortcuts(parsed).items) {
if (item.type === "library" || item.library_id === undefined) continue;
const key = String(item.library_id);
const pin: SidebarPin =
item.type === "section"
? { type: "section", id: item.section_id, label: item.label }
: { type: "collection", id: item.collection_id, label: item.label };
grouped[key] = [...(grouped[key] ?? []), pin];
}
return grouped;
}
return parsed as SidebarPins;
}
export function sidebarPinsToShortcuts(pins: SidebarPins): ShortcutDocument {
return {
items: Object.entries(pins).flatMap(([libraryId, entries]) => {
const id = Number(libraryId);
if (!Number.isInteger(id) || id <= 0) return [];
return entries.map((pin) =>
pin.type === "section"
? ({
type: "section" as const,
library_id: id,
section_id: pin.id,
label: pin.label,
} as const)
: ({
type: "collection" as const,
library_id: id,
collection_id: pin.id,
label: pin.label,
} as const),
);
}),
};
}
function shortcutFromSidebarPin(libraryId: number, pin: SidebarPin): ShortcutTarget {
return pin.type === "section"
? { type: "section", library_id: libraryId, section_id: pin.id, label: pin.label }
: { type: "collection", library_id: libraryId, collection_id: pin.id, label: pin.label };
}
function shortcutDocumentFromSidebarValue(value: unknown): ShortcutDocument {
let parsed = value;
if (typeof value === "string") {
try {
parsed = JSON.parse(value);
} catch {
return { items: [] };
}
}
if (
typeof parsed === "object" &&
parsed !== null &&
!Array.isArray(parsed) &&
"items" in parsed
) {
return parseShortcuts(parsed);
}
return sidebarPinsToShortcuts(parseSidebarPins(parsed));
}
export function toggleNavigationShortcut(
value: unknown,
libraryId: number,
pin: SidebarPin,
): ShortcutDocument {
const document = shortcutDocumentFromSidebarValue(value);
const target = shortcutFromSidebarPin(libraryId, pin);
const targetKey = menuItemKey(target);
const exists = document.items.some((item) => menuItemKey(item) === targetKey);
return {
items: exists
? document.items.filter((item) => menuItemKey(item) !== targetKey)
: [...document.items, target],
};
}
export function setNavigationShortcutPresence(
value: unknown,
target: ShortcutTarget,
present: boolean,
): ShortcutDocument {
const document = shortcutDocumentFromSidebarValue(value);
const targetKey = menuItemKey(target);
const index = document.items.findIndex((item) => menuItemKey(item) === targetKey);
if (!present) {
return index < 0
? document
: { items: document.items.filter((item) => menuItemKey(item) !== targetKey) };
}
if (index < 0) return { items: [...document.items, target] };
const items = [...document.items];
items[index] = target;
return { items };
}
export function toggleSidebarPins(
pins: SidebarPins,
libraryId: number,
@@ -65,6 +224,10 @@ interface SidebarPinsOptimisticMutation {
previousValue: unknown;
previousRevision: number | null;
optimisticValue: SidebarPins;
optimisticDocument: ShortcutDocument;
item: ShortcutTarget;
present: boolean;
mutationId: string;
revision: number;
}
@@ -81,13 +244,20 @@ export function createSidebarPinsOptimisticMutation({
pin: SidebarPin;
revision: number;
}): SidebarPinsOptimisticMutation {
const currentPins = parseSidebarPins(currentValue);
const nextPins = toggleSidebarPins(currentPins, libraryId, pin);
const item = shortcutFromSidebarPin(libraryId, pin);
const present = !shortcutDocumentFromSidebarValue(currentValue).items.some(
(candidate) => menuItemKey(candidate) === menuItemKey(item),
);
const optimisticDocument = setNavigationShortcutPresence(currentValue, item, present);
return {
previousValue: currentValue ?? null,
previousRevision: currentRevision ?? null,
optimisticValue: nextPins,
optimisticValue: parseSidebarPins(optimisticDocument),
optimisticDocument,
item,
present,
mutationId: randomUUID(),
revision,
};
}
@@ -116,40 +286,62 @@ function useHasActiveProfile() {
}
export function useSidebarPins() {
const enabled = useHasActiveProfile();
const { data, isLoading } = useEffectiveSettings({ keys: PINS_KEYS, enabled });
const value = data?.[SETTING_KEYS.UI_SIDEBAR_PINS]?.value;
const hasActiveProfile = useHasActiveProfile();
const capabilities = useSettingsCapabilities();
const supportsShortcuts = settingsCapabilitiesSupportKey(
capabilities.data,
SETTING_KEYS.NAV_SHORTCUTS,
);
const supportsLegacyPins = settingsCapabilitiesSupportKey(
capabilities.data,
SETTING_KEYS.UI_SIDEBAR_PINS,
);
const enabled = hasActiveProfile && (supportsShortcuts || supportsLegacyPins);
const keys = supportsShortcuts ? PINS_KEYS : LEGACY_PINS_KEYS;
const { data, isLoading } = useEffectiveSettings({ keys, enabled });
const { data: optimisticDocument } = useSidebarPinsOverlay();
const value = enabled
? supportsShortcuts
? (optimisticDocument ?? data?.[SETTING_KEYS.NAV_SHORTCUTS]?.value)
: data?.[SETTING_KEYS.UI_SIDEBAR_PINS]?.value
: undefined;
const pins = useMemo(() => parseSidebarPins(value), [value]);
return { pins, isLoading };
return { pins, isLoading: capabilities.isLoading || (enabled && isLoading) };
}
export function useToggleSidebarPin() {
const enabled = useHasActiveProfile();
const hasActiveProfile = useHasActiveProfile();
const capabilities = useSettingsCapabilities();
const supportsShortcuts = settingsCapabilitiesSupportKey(
capabilities.data,
SETTING_KEYS.NAV_SHORTCUTS,
);
const canToggle =
hasActiveProfile && settingsCapabilitiesSupportAtomicShortcuts(capabilities.data);
const enabled = hasActiveProfile && supportsShortcuts;
const { data } = useEffectiveSettings({ keys: PINS_KEYS, enabled });
const renderValue = data?.[SETTING_KEYS.UI_SIDEBAR_PINS]?.value;
const renderValue = data?.[SETTING_KEYS.NAV_SHORTCUTS]?.value;
const queryClient = useQueryClient();
const setValue = useSetSettingValue();
const setShortcutPresence = useSetNavigationShortcutPresence();
const overlayQueryKey = sidebarPinsOverlayQueryKey();
/**
* 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.
* The server merges each desired-state operation atomically across clients.
* This local chain still matters for rapid toggles on one browser: it keeps
* add/remove intent in click order and gives optimistic rollback a stable
* predecessor. The overlay prevents event-driven intermediate refetches
* from replacing newer local intent while that queue is draining.
*/
const writeChain = useRef<Promise<unknown>>(Promise.resolve());
const readCachedValue = useCallback(() => {
const overlay = queryClient.getQueryData<ShortcutDocument | null>(overlayQueryKey);
if (overlay) return overlay;
const cached = queryClient.getQueryData<EffectiveSettingsMap>(
effectiveSettingsQueryKey({ keys: PINS_KEYS }),
)?.[SETTING_KEYS.UI_SIDEBAR_PINS];
)?.[SETTING_KEYS.NAV_SHORTCUTS];
return cached !== undefined ? cached.value : renderValue;
}, [queryClient, renderValue]);
}, [overlayQueryKey, queryClient, renderValue]);
const isPinned = useCallback(
(libraryId: number, pinType: SidebarPin["type"], targetId: string): boolean => {
@@ -162,45 +354,47 @@ export function useToggleSidebarPin() {
const togglePin = useCallback(
(libraryId: number, pin: SidebarPin) => {
const pinsQueryKey = effectiveSettingsQueryKey({ keys: PINS_KEYS });
if (!canToggle) return;
const profileAuth = captureProfileRequestContext();
if (!profileAuth) return;
const profileId = profileAuth.profileId;
const pinsQueryKey = effectiveSettingsQueryKey({ keys: PINS_KEYS, profileId });
const operationOverlayQueryKey = sidebarPinsOverlayQueryKey(profileId);
const revisionKey = [...pinsQueryKey, "optimistic-revision"] as const;
const { queue, queues, key: queueKey } = sidebarPinsWriteQueue(queryClient, pinsQueryKey);
const previousEntry =
queryClient.getQueryData<EffectiveSettingsMap>(pinsQueryKey)?.[
SETTING_KEYS.UI_SIDEBAR_PINS
];
queryClient.getQueryData<EffectiveSettingsMap>(pinsQueryKey)?.[SETTING_KEYS.NAV_SHORTCUTS];
const previousOverlay =
queryClient.getQueryData<ShortcutDocument | null>(operationOverlayQueryKey) ?? null;
const cachedRevision = queryClient.getQueryData<number | null>(revisionKey) ?? null;
nextSidebarPinsRevision += 1;
const mutation = createSidebarPinsOptimisticMutation({
currentValue: previousEntry !== undefined ? previousEntry.value : renderValue,
currentValue:
previousOverlay ?? (previousEntry !== undefined ? previousEntry.value : renderValue),
currentRevision: cachedRevision,
libraryId,
pin,
revision: nextSidebarPinsRevision,
});
queryClient.setQueryData<EffectiveSettingsMap>(pinsQueryKey, (current) => ({
...(current ?? {}),
[SETTING_KEYS.UI_SIDEBAR_PINS]: {
key: SETTING_KEYS.UI_SIDEBAR_PINS,
...previousEntry,
value: mutation.optimisticValue,
source: "profile",
},
}));
queryClient.setQueryData(operationOverlayQueryKey, mutation.optimisticDocument);
queryClient.setQueryData(revisionKey, mutation.revision);
writeChain.current = writeChain.current
const queuedWrite = queue.tail
.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,
}),
)
.then(() => {
if (!isProfileRequestContextCurrent(profileAuth)) return;
return setShortcutPresence.mutateAsync({
item: mutation.item,
present: mutation.present,
mutationId: mutation.mutationId,
profileAuth,
invalidateOnSettled: false,
});
})
.catch(() => {
// Account/server switches clear user-scoped query state. Never put
// this old account's optimistic predecessor back into aliased keys.
if (!isProfileRequestContextCurrent(profileAuth)) return;
const rollback = rollbackSidebarPinsOptimisticMutation({
currentRevision: queryClient.getQueryData<number | null>(revisionKey),
mutation,
@@ -210,25 +404,26 @@ export function useToggleSidebarPin() {
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(operationOverlayQueryKey, previousOverlay);
queryClient.setQueryData(revisionKey, rollback.revision);
});
const settledWrite: Promise<unknown> = queuedWrite.finally(async () => {
if (queue.tail !== settledWrite) return;
if (!isProfileRequestContextCurrent(profileAuth)) {
if (queues.get(queueKey) === queue) queues.delete(queueKey);
return;
}
await queryClient.invalidateQueries({ queryKey: pinsQueryKey, exact: true });
if (queue.tail !== settledWrite) return;
queryClient.setQueryData(operationOverlayQueryKey, null);
queryClient.setQueryData(revisionKey, null);
if (queues.get(queueKey) === queue) queues.delete(queueKey);
});
queue.tail = settledWrite;
},
[queryClient, readCachedValue, renderValue, setValue],
[canToggle, queryClient, renderValue, setShortcutPresence],
);
return { togglePin, isPinned };
return { togglePin, isPinned, canToggle };
}
+7 -2
View File
@@ -8,9 +8,11 @@ interface GridLayout {
interface UseGridLayoutOptions {
gap: number;
textAreaHeight: number;
/** Re-measure when the grid's responsive class set changes at the same width. */
layoutKey?: string;
}
export function useGridLayout({ gap, textAreaHeight }: UseGridLayoutOptions) {
export function useGridLayout({ gap, textAreaHeight, layoutKey = "" }: UseGridLayoutOptions) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [layout, setLayout] = useState<GridLayout>({
columnCount: 8,
@@ -35,11 +37,14 @@ export function useGridLayout({ gap, textAreaHeight }: UseGridLayoutOptions) {
useEffect(() => {
const el = containerRef.current;
if (!el) return;
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [measure]);
useEffect(() => {
measure();
}, [layoutKey, measure]);
return { containerRef, layout };
}
+7
View File
@@ -0,0 +1,7 @@
import { useContext } from "react";
import { UICustomizationContext } from "@/contexts/uiCustomizationContext";
export function useUICustomization() {
return useContext(UICustomizationContext);
}
+2
View File
@@ -76,6 +76,8 @@ const HIDDEN_KEYS = new Set<string>([
"ui.high_contrast",
"ui.library_page_state",
"ui.remember_library_page_state",
"nav.primary_menu",
"ui.card_presentation",
]);
/**
+1
View File
@@ -27,6 +27,7 @@ export function setAppDocumentTitle(name: string) {
const SETTINGS_TITLES: Record<string, string> = {
appearance: "Appearance Settings",
interface: "Navigation & Card Settings",
accessibility: "Accessibility Settings",
playback: "Playback Settings",
profiles: "Profile Settings",
+75 -1
View File
@@ -1,8 +1,82 @@
{
"fixture_version": 1,
"manifest_revision": 4,
"manifest_revision": 5,
"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": "profile_client_sits_between_device_and_profile",
"description": "Presentation values roam across like clients, while an exact-device override remains more specific and a profile value remains the cross-family fallback.",
"keys": ["ui.card_presentation"],
"context": { "profile_id": "p1", "client_family": "tv", "device_id": "living-room" },
"stored": [
{
"key": "ui.card_presentation",
"scope": "profile",
"profile_id": "p1",
"value": { "poster_size": "standard", "caption": "title" }
},
{
"key": "ui.card_presentation",
"scope": "profile_client",
"profile_id": "p1",
"client_family": "tv",
"value": { "poster_size": "large", "caption": "artwork" }
},
{
"key": "ui.card_presentation",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "living-room",
"value": { "poster_size": "compact", "caption": "title_metadata" }
}
],
"expected": [
{
"key": "ui.card_presentation",
"value": { "poster_size": "compact", "caption": "title_metadata" },
"source": "profile_device"
}
]
},
{
"name": "profile_client_roams_only_within_its_family",
"description": "A television menu applies to another television identity but not to mobile; the family is explicit resolution context rather than inferred from device metadata.",
"keys": ["nav.primary_menu"],
"context": { "profile_id": "p1", "client_family": "tv", "device_id": "bedroom-tv" },
"stored": [
{
"key": "nav.primary_menu",
"scope": "profile_client",
"profile_id": "p1",
"client_family": "mobile",
"value": { "items": [{ "type": "builtin", "destination": "home" }] }
},
{
"key": "nav.primary_menu",
"scope": "profile_client",
"profile_id": "p1",
"client_family": "tv",
"value": {
"items": [
{ "type": "builtin", "destination": "home" },
{ "type": "builtin", "destination": "movies" }
]
}
}
],
"expected": [
{
"key": "nav.primary_menu",
"value": {
"items": [
{ "type": "builtin", "destination": "home" },
{ "type": "builtin", "destination": "movies" }
]
},
"source": "profile_client"
}
]
},
{
"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.",
+15 -2
View File
@@ -40,6 +40,7 @@ interface ConformanceCase {
context?: {
profile_id?: string;
device_id?: string;
client_family?: string;
library_ids?: number[];
series_ids?: string[];
};
@@ -48,6 +49,7 @@ interface ConformanceCase {
scope: RemoteSettingScope;
profile_id?: string;
device_id?: string;
client_family?: string;
library_id?: number;
series_id?: string;
value: unknown;
@@ -109,7 +111,7 @@ function loadFixture(): ConformanceFixture {
if (testCase.context !== undefined) {
assertOnlyKnownFields(
testCase.context,
["profile_id", "device_id", "library_ids", "series_ids"],
["profile_id", "device_id", "client_family", "library_ids", "series_ids"],
`${casePath}.context`,
);
}
@@ -117,7 +119,16 @@ function loadFixture(): ConformanceFixture {
const rowPath = `${casePath}.stored[${rowIndex}]`;
assertOnlyKnownFields(
row,
["key", "scope", "profile_id", "device_id", "library_id", "series_id", "value"],
[
"key",
"scope",
"profile_id",
"device_id",
"client_family",
"library_id",
"series_id",
"value",
],
rowPath,
);
expect("value" in row, `${rowPath} must spell an authored null as null`).toBe(true);
@@ -167,6 +178,7 @@ describe("settings conformance fixture", () => {
const context: SettingResolutionContext = {
profileId: testCase.context?.profile_id,
deviceId: testCase.context?.device_id,
clientFamily: testCase.context?.client_family,
libraryIds: testCase.context?.library_ids,
seriesIds: testCase.context?.series_ids,
};
@@ -175,6 +187,7 @@ describe("settings conformance fixture", () => {
scope: row.scope,
profileId: row.profile_id,
deviceId: row.device_id,
clientFamily: row.client_family,
libraryId: row.library_id,
seriesId: row.series_id,
value: row.value,
+54 -1
View File
@@ -8,7 +8,8 @@
* is a manifest change plus a regeneration, never a hand-written key.
*/
export const SETTINGS_REVISION = 4;
export const SETTINGS_API_VERSION = 1;
export const SETTINGS_REVISION = 5;
export interface SettingSuggestedOption {
value: string;
@@ -162,6 +163,10 @@ export const SETTING_KEYS = {
DOWNLOADS_KEEP_WATCHED: "downloads.keep_watched",
/** Download over Wi-Fi only */
DOWNLOADS_WIFI_ONLY: "downloads.wifi_only",
/** Primary menu */
NAV_PRIMARY_MENU: "nav.primary_menu",
/** Navigation shortcuts */
NAV_SHORTCUTS: "nav.shortcuts",
/** Show audiobooks */
NAV_SHOW_AUDIOBOOKS: "nav.show_audiobooks",
/** Preferred audio language */
@@ -224,6 +229,8 @@ export const SETTING_KEYS = {
SUBTITLE_MATCHES_DEVICE: "subtitle.matches_device",
/** Poster badges */
UI_CARD_OVERLAYS: "ui.card_overlays",
/** Media cards */
UI_CARD_PRESENTATION: "ui.card_presentation",
/** Custom CSS */
UI_CUSTOM_CSS: "ui.custom_css",
/** Custom theme variables */
@@ -390,6 +397,36 @@ export const SETTING_DEFINITIONS: Record<SettingKey, SettingDefinition> = {
control: "switch",
platforms: ["ios", "android"],
},
"nav.primary_menu": {
key: "nav.primary_menu",
type: "object",
nullable: true,
persistence: "remote",
introducedIn: 5,
scopes: ["profile_client", "profile_device"],
scopeIntroducedIn: [5, 5],
resolutionOrder: ["profile_device", "profile_client", "default"],
defaultValue: null,
label: "Primary menu",
description: "The ordered visible destinations shared by like clients.",
category: "navigation",
control: "panel",
platforms: ["web", "ios", "tvos", "macos", "android", "android_tv"],
},
"nav.shortcuts": {
key: "nav.shortcuts",
type: "object",
nullable: false,
persistence: "remote",
introducedIn: 5,
scopes: ["profile"],
scopeIntroducedIn: [5],
resolutionOrder: ["profile", "default"],
defaultValue: { items: [] },
label: "Navigation shortcuts",
description: "Libraries, sections, and collections pinned for use across navigation surfaces.",
category: "navigation",
},
"nav.show_audiobooks": {
key: "nav.show_audiobooks",
type: "boolean",
@@ -936,6 +973,22 @@ export const SETTING_DEFINITIONS: Record<SettingKey, SettingDefinition> = {
category: "appearance",
platforms: ["web", "ios", "tvos", "macos", "android", "android_tv"],
},
"ui.card_presentation": {
key: "ui.card_presentation",
type: "object",
nullable: false,
persistence: "remote",
introducedIn: 5,
scopes: ["profile", "profile_client", "profile_device"],
scopeIntroducedIn: [5, 5, 5],
resolutionOrder: ["profile_device", "profile_client", "profile", "default"],
defaultValue: { poster_size: "standard", caption: "title_metadata" },
label: "Media cards",
description: "Poster size and caption detail used by media cards.",
category: "appearance",
control: "panel",
platforms: ["web", "ios", "tvos", "macos", "android", "android_tv"],
},
"ui.custom_css": {
key: "ui.custom_css",
type: "string",
+18
View File
@@ -5,6 +5,7 @@ import {
ALL_DEVICE_SETTING_KEYS,
controlKindFor,
defaultValueToString,
deviceSettingKeysForRevision,
formatSettingValue,
getSettingDefinition,
isStructuredSetting,
@@ -39,6 +40,23 @@ describe("settingsDisplay", () => {
expect(ALL_DEVICE_SETTING_KEYS).toContain(SETTING_KEYS.PLAYBACK_AUTO_PLAY_NEXT_PREVIEW);
});
it("filters device batches to keys the connected server revision understands", () => {
const revisionFour = deviceSettingKeysForRevision(4);
expect(revisionFour).not.toContain(SETTING_KEYS.NAV_PRIMARY_MENU);
expect(revisionFour).not.toContain(SETTING_KEYS.UI_CARD_PRESENTATION);
expect(
revisionFour.every((key) => {
const definition = SETTING_DEFINITIONS[key];
const scopeIndex = definition.scopes.indexOf("profile_device");
return (definition.scopeIntroducedIn[scopeIndex] ?? Number.POSITIVE_INFINITY) <= 4;
}),
).toBe(true);
const revisionFive = deviceSettingKeysForRevision(5);
expect(revisionFive).toContain(SETTING_KEYS.NAV_PRIMARY_MENU);
expect(revisionFive).toContain(SETTING_KEYS.UI_CARD_PRESENTATION);
});
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",
+13
View File
@@ -130,3 +130,16 @@ export const ALL_DEVICE_SETTING_KEYS: SettingKey[] = (
const definition = SETTING_DEFINITIONS[key];
return definition.persistence === "remote" && definition.scopes.includes("profile_device");
});
/** Device-setting keys understood by a connected server contract revision. */
export function deviceSettingKeysForRevision(revision: number | undefined): SettingKey[] {
if (revision === undefined) return [];
return ALL_DEVICE_SETTING_KEYS.filter((key) => {
const definition = SETTING_DEFINITIONS[key];
const scopeIndex = definition.scopes.indexOf("profile_device");
return (
scopeIndex >= 0 &&
(definition.scopeIntroducedIn[scopeIndex] ?? Number.POSITIVE_INFINITY) <= revision
);
});
}
+11 -1
View File
@@ -15,11 +15,12 @@ import { SETTING_DEFINITIONS, type SettingDefinition, type SettingKey } from "./
* settingsConformance.test.ts; do not change one without the fixture agreeing.
*/
/** The five storage scopes the server resolves. */
/** The remote storage scopes the server resolves. */
export type RemoteSettingScope =
| "account"
| "profile"
| "profile_device"
| "profile_client"
| "profile_library"
| "profile_series";
@@ -34,6 +35,7 @@ export interface StoredSettingRow {
scope: RemoteSettingScope;
profileId?: string;
deviceId?: string;
clientFamily?: string;
libraryId?: number;
seriesId?: string;
value: unknown;
@@ -43,6 +45,7 @@ export interface StoredSettingRow {
export interface SettingResolutionContext {
profileId?: string;
deviceId?: string;
clientFamily?: string;
libraryIds?: readonly number[];
seriesIds?: readonly string[];
}
@@ -133,6 +136,7 @@ function pickForScope(
): StoredSettingRow | undefined {
const profileId = context.profileId ?? "";
const deviceId = context.deviceId ?? "";
const clientFamily = context.clientFamily ?? "";
const matches = candidates.filter((row) => {
if (row.scope !== scope) return false;
switch (scope) {
@@ -146,6 +150,12 @@ function pickForScope(
(row.deviceId ?? "") === deviceId &&
deviceId !== ""
);
case "profile_client":
return (
(row.profileId ?? "") === profileId &&
(row.clientFamily ?? "") === clientFamily &&
clientFamily !== ""
);
case "profile_library":
return (
(row.profileId ?? "") === profileId &&
+140
View File
@@ -0,0 +1,140 @@
import { describe, expect, it } from "vitest";
import {
CARD_PRESENTATION_PRESETS,
DEFAULT_CARD_PRESENTATION,
defaultWebPrimaryMenu,
menuItemKey,
moveMenuItem,
parseCardPresentation,
parsePrimaryMenu,
parseShortcuts,
} from "./uiCustomization";
describe("UI customization contract helpers", () => {
it("falls back when card presentation is incomplete or unknown", () => {
expect(parseCardPresentation(null)).toEqual(DEFAULT_CARD_PRESENTATION);
expect(parseCardPresentation({ poster_size: "large" })).toEqual(DEFAULT_CARD_PRESENTATION);
expect(parseCardPresentation({ poster_size: "huge", caption: "artwork" })).toEqual(
DEFAULT_CARD_PRESENTATION,
);
});
it("accepts every supported card dimension", () => {
expect(parseCardPresentation({ poster_size: "large", caption: "artwork" })).toEqual({
poster_size: "large",
caption: "artwork",
});
expect(parseCardPresentation({ poster_size: "compact", caption: "title" })).toEqual({
poster_size: "compact",
caption: "title",
});
});
it("keeps the named presets aligned with the cross-client contract", () => {
expect(CARD_PRESENTATION_PRESETS.map(({ id, value }) => ({ id, value }))).toEqual([
{ id: "balanced", value: { poster_size: "standard", caption: "title_metadata" } },
{ id: "compact", value: { poster_size: "compact", caption: "title" } },
{ id: "cinema", value: { poster_size: "large", caption: "title" } },
{ id: "artwork", value: { poster_size: "large", caption: "artwork" } },
]);
});
it("rejects a custom menu without exactly one home destination", () => {
expect(parsePrimaryMenu({ items: [{ type: "builtin", destination: "calendar" }] })).toBeNull();
expect(
parsePrimaryMenu({
items: [
{ type: "builtin", destination: "home" },
{ type: "builtin", destination: "home" },
],
}),
).toEqual({ items: [{ type: "builtin", destination: "home" }] });
});
it("sanitizes unknown and duplicate menu entries", () => {
expect(
parsePrimaryMenu({
items: [
{ type: "builtin", destination: "home" },
{ type: "library", library_id: 7, label: "Movies" },
{ type: "library", library_id: 7, label: "Renamed" },
{ type: "builtin", destination: "unsupported" },
],
}),
).toEqual({
items: [
{ type: "builtin", destination: "home" },
{ type: "library", library_id: 7, label: "Movies" },
],
});
});
it("parses only shortcut targets from the profile shortcut catalog", () => {
expect(
parseShortcuts({
items: [
{ type: "builtin", destination: "home" },
{ type: "section", library_id: 7, section_id: "recent", label: "Recent" },
{ type: "collection", collection_id: "favorites", label: "Favorites" },
],
}),
).toEqual({
items: [
{ type: "section", library_id: 7, section_id: "recent", label: "Recent" },
{ type: "collection", collection_id: "favorites", label: "Favorites" },
],
});
});
it("keeps global and library-scoped collection identities unambiguous", () => {
const globalCollection = {
type: "collection" as const,
collection_id: "7:favorites",
label: "Global favorites",
};
const libraryCollection = {
type: "collection" as const,
library_id: 7,
collection_id: "favorites",
label: "Library favorites",
};
expect(menuItemKey(globalCollection)).not.toBe(menuItemKey(libraryCollection));
expect(parseShortcuts({ items: [globalCollection, libraryCollection] })).toEqual({
items: [globalCollection, libraryCollection],
});
});
it("rejects whitespace-only section and collection target IDs", () => {
expect(
parseShortcuts({
items: [
{ type: "section", library_id: 7, section_id: " ", label: "Recent" },
{ type: "collection", collection_id: "\t", label: "Favorites" },
],
}),
).toEqual({ items: [] });
});
it("builds the native web primary-menu baseline", () => {
const menu = defaultWebPrimaryMenu();
expect(menu.items.map(menuItemKey)).toEqual([
"builtin:home",
"builtin:for_you",
"builtin:calendar",
]);
});
it("moves items without mutating the source and ignores an out-of-range move", () => {
const original = defaultWebPrimaryMenu().items;
const moved = moveMenuItem(original, 1, -1);
expect(moved.map(menuItemKey)).toEqual(["builtin:for_you", "builtin:home", "builtin:calendar"]);
expect(original.map(menuItemKey)).toEqual([
"builtin:home",
"builtin:for_you",
"builtin:calendar",
]);
expect(moveMenuItem(original, 0, -1)).toEqual(original);
});
});
+283
View File
@@ -0,0 +1,283 @@
export type PosterSize = "compact" | "standard" | "large";
export type CardCaption = "title_metadata" | "title" | "artwork";
export interface CardPresentation {
poster_size: PosterSize;
caption: CardCaption;
}
export type PrimaryMenuBuiltin =
| "home"
| "movies"
| "series"
| "music"
| "audiobooks"
| "for_you"
| "calendar";
export interface BuiltinMenuItem {
type: "builtin";
destination: PrimaryMenuBuiltin;
}
export interface LibraryMenuItem {
type: "library";
library_id: number;
label: string;
}
export interface SectionMenuItem {
type: "section";
library_id: number;
section_id: string;
label: string;
}
export interface CollectionMenuItem {
type: "collection";
collection_id: string;
label: string;
library_id?: number;
}
export type ShortcutTarget = LibraryMenuItem | SectionMenuItem | CollectionMenuItem;
export type PrimaryMenuItem = BuiltinMenuItem | ShortcutTarget;
export interface PrimaryMenuDocument {
items: PrimaryMenuItem[];
}
export interface ShortcutDocument {
items: ShortcutTarget[];
}
export const DEFAULT_CARD_PRESENTATION: CardPresentation = {
poster_size: "standard",
caption: "title_metadata",
};
export const CARD_PRESENTATION_PRESETS = [
{
id: "balanced",
label: "Balanced",
description: "Standard posters with titles and metadata.",
value: DEFAULT_CARD_PRESENTATION,
},
{
id: "compact",
label: "Compact",
description: "More posters on screen with titles only.",
value: { poster_size: "compact", caption: "title" },
},
{
id: "cinema",
label: "Cinema",
description: "Large posters with titles only.",
value: { poster_size: "large", caption: "title" },
},
{
id: "artwork",
label: "Artwork only",
description: "Large artwork without caption rows.",
value: { poster_size: "large", caption: "artwork" },
},
] as const satisfies ReadonlyArray<{
id: string;
label: string;
description: string;
value: CardPresentation;
}>;
const BUILTIN_DESTINATIONS = new Set<PrimaryMenuBuiltin>([
"home",
"movies",
"series",
"music",
"audiobooks",
"for_you",
"calendar",
]);
const POSTER_SIZES = new Set<PosterSize>(["compact", "standard", "large"]);
const CARD_CAPTIONS = new Set<CardCaption>(["title_metadata", "title", "artwork"]);
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isPositiveInteger(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value) && value > 0;
}
function isLabel(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0 && [...value].length <= 256;
}
function isTargetId(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0 && [...value].length <= 128;
}
function parseMenuItem(value: unknown, allowBuiltin: boolean): PrimaryMenuItem | null {
if (!isObject(value) || typeof value.type !== "string") return null;
if (value.type === "builtin" && allowBuiltin) {
if (
typeof value.destination === "string" &&
BUILTIN_DESTINATIONS.has(value.destination as PrimaryMenuBuiltin)
) {
return { type: "builtin", destination: value.destination as PrimaryMenuBuiltin };
}
return null;
}
if (value.type === "library") {
return isPositiveInteger(value.library_id) && isLabel(value.label)
? { type: "library", library_id: value.library_id, label: value.label.trim() }
: null;
}
if (value.type === "section") {
return isPositiveInteger(value.library_id) &&
isTargetId(value.section_id) &&
isLabel(value.label)
? {
type: "section",
library_id: value.library_id,
section_id: value.section_id,
label: value.label.trim(),
}
: null;
}
if (value.type === "collection") {
if (
!isTargetId(value.collection_id) ||
!isLabel(value.label) ||
(value.library_id !== undefined && !isPositiveInteger(value.library_id))
) {
return null;
}
return {
type: "collection",
collection_id: value.collection_id,
label: value.label.trim(),
...(value.library_id === undefined ? {} : { library_id: value.library_id }),
};
}
return null;
}
export function parseCardPresentation(value: unknown): CardPresentation {
if (!isObject(value)) return DEFAULT_CARD_PRESENTATION;
const posterSize = value.poster_size;
const caption = value.caption;
if (
typeof posterSize !== "string" ||
!POSTER_SIZES.has(posterSize as PosterSize) ||
typeof caption !== "string" ||
!CARD_CAPTIONS.has(caption as CardCaption)
) {
return DEFAULT_CARD_PRESENTATION;
}
return { poster_size: posterSize as PosterSize, caption: caption as CardCaption };
}
export function menuItemKey(item: PrimaryMenuItem): string {
switch (item.type) {
case "builtin":
return `builtin:${item.destination}`;
case "library":
return `library:${item.library_id}`;
case "section":
return `section:${item.library_id}:${item.section_id}`;
case "collection":
return JSON.stringify(["collection", item.library_id ?? null, item.collection_id]);
}
}
export function parsePrimaryMenu(value: unknown): PrimaryMenuDocument | null {
if (value == null) return null;
if (!isObject(value) || !Array.isArray(value.items)) return null;
const items: PrimaryMenuItem[] = [];
const seen = new Set<string>();
for (const candidate of value.items.slice(0, 64)) {
const item = parseMenuItem(candidate, true);
if (!item) continue;
const key = menuItemKey(item);
if (seen.has(key)) continue;
seen.add(key);
items.push(item);
}
const homeCount = items.filter(
(item) => item.type === "builtin" && item.destination === "home",
).length;
return items.length > 0 && homeCount === 1 ? { items } : null;
}
export function parseShortcuts(value: unknown): ShortcutDocument {
if (!isObject(value) || !Array.isArray(value.items)) return { items: [] };
const items: ShortcutTarget[] = [];
const seen = new Set<string>();
for (const candidate of value.items.slice(0, 256)) {
const item = parseMenuItem(candidate, false);
if (!item || item.type === "builtin") continue;
const key = menuItemKey(item);
if (seen.has(key)) continue;
seen.add(key);
items.push(item);
}
return { items };
}
export function defaultWebPrimaryMenu(): PrimaryMenuDocument {
return {
items: [
{ type: "builtin", destination: "home" },
{ type: "builtin", destination: "for_you" },
{ type: "builtin", destination: "calendar" },
],
};
}
export function moveMenuItem(
items: readonly PrimaryMenuItem[],
index: number,
direction: -1 | 1,
): PrimaryMenuItem[] {
const target = index + direction;
if (index < 0 || index >= items.length || target < 0 || target >= items.length) {
return [...items];
}
const next = [...items];
[next[index], next[target]] = [next[target]!, next[index]!];
return next;
}
export function cardGridClasses(size: PosterSize): string {
switch (size) {
case "compact":
return "grid grid-cols-3 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-3";
case "large":
return "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4";
default:
return "grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8 gap-3";
}
}
export function carouselCardWidthClasses(size: PosterSize): string {
switch (size) {
case "compact":
return "w-[120px] shrink-0 sm:w-[140px] lg:w-[160px]";
case "large":
return "w-[170px] shrink-0 sm:w-[195px] lg:w-[220px]";
default:
return "w-[140px] shrink-0 sm:w-[160px] lg:w-[185px]";
}
}
export function cardTextAreaHeight(caption: CardCaption): number {
if (caption === "artwork") return 0;
return caption === "title" ? 28 : 44;
}
+58 -2
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => ({
updateUserMutate: vi.fn(),
beginImpersonation: vi.fn(),
updateSettingMutate: vi.fn(),
deleteSettingMutate: vi.fn(),
/** Rows the canonical admin settings list answers with, per test. */
userSettings: [] as unknown[],
}));
@@ -79,7 +80,7 @@ vi.mock("@/hooks/queries/admin/users", () => ({
useAdminUserDeviceSettings: () => ({ data: [], isLoading: false }),
useAdminUserSettings: () => ({ data: mocks.userSettings, isLoading: false }),
useDeleteAdminUserDeviceSetting: () => ({ mutate: vi.fn(), isPending: false }),
useDeleteAdminUserSetting: () => ({ mutate: vi.fn(), isPending: false }),
useDeleteAdminUserSetting: () => ({ mutate: mocks.deleteSettingMutate, isPending: false }),
useDeleteAllAdminUserDeviceSettingsForDevice: () => ({ mutate: vi.fn(), isPending: false }),
useUpdateAdminUserDeviceSetting: () => ({ mutate: vi.fn(), isPending: false }),
useUpdateAdminUserSetting: () => ({ mutate: mocks.updateSettingMutate, isPending: false }),
@@ -157,6 +158,7 @@ beforeEach(() => {
mocks.updateUserMutate.mockReset();
mocks.beginImpersonation.mockReset();
mocks.updateSettingMutate.mockReset();
mocks.deleteSettingMutate.mockReset();
mocks.userSettings = [];
});
@@ -258,6 +260,60 @@ describe("AdminUserDetail user settings tab", () => {
value: "true",
});
});
it("keeps client family in profile-client row display and mutation identity", async () => {
const user = userEvent.setup();
const value = JSON.stringify({ poster_size: "compact", caption: "title" });
mocks.userSettings = [
{
key: SETTING_KEYS.UI_CARD_PRESENTATION,
scope: "profile_client",
profile_id: "profile-1",
client_family: "tv",
value,
},
{
key: SETTING_KEYS.UI_CARD_PRESENTATION,
scope: "profile_client",
profile_id: "profile-1",
client_family: "web",
value,
},
];
renderUserDetail();
await user.click(screen.getByRole("tab", { name: "Settings" }));
const tvIdentity = screen.getByText(/profile profile-1 · family tv$/);
expect(screen.getByText(/profile profile-1 · family web$/)).toBeInTheDocument();
const tvRow = tvIdentity.parentElement?.parentElement;
expect(tvRow).not.toBeNull();
await user.click(within(tvRow as HTMLElement).getByRole("button", { name: "Reset" }));
expect(mocks.deleteSettingMutate).toHaveBeenCalledWith({
userId: 7,
key: SETTING_KEYS.UI_CARD_PRESENTATION,
identity: {
scope: "profile_client",
profileId: "profile-1",
clientFamily: "tv",
libraryId: undefined,
seriesId: undefined,
},
});
await user.click(within(tvRow as HTMLElement).getByRole("button", { name: "Edit JSON" }));
await user.click(screen.getByRole("button", { name: "Save value" }));
await waitFor(() => expect(mocks.updateSettingMutate).toHaveBeenCalled());
expect(mocks.updateSettingMutate.mock.calls[0]?.[0]).toMatchObject({
key: SETTING_KEYS.UI_CARD_PRESENTATION,
identity: {
scope: "profile_client",
profileId: "profile-1",
clientFamily: "tv",
},
});
});
});
describe("AdminUserDetail transcode limits", () => {
+3
View File
@@ -566,6 +566,7 @@ function UserSettingsTab({ userId }: { userId: number }) {
const identity = {
scope: entry.scope,
profileId: entry.profile_id,
clientFamily: entry.client_family,
libraryId: entry.library_id,
seriesId: entry.series_id,
};
@@ -573,11 +574,13 @@ function UserSettingsTab({ userId }: { userId: number }) {
entry.key,
entry.scope,
entry.profile_id ?? "",
entry.client_family ?? "",
entry.library_id ?? "",
entry.series_id ?? "",
].join(":");
const scopeDetail = [
entry.profile_id ? `profile ${entry.profile_id}` : null,
entry.client_family ? `family ${entry.client_family}` : null,
entry.library_id ? `library ${entry.library_id}` : null,
entry.series_id ? `series ${entry.series_id}` : null,
]
+8 -2
View File
@@ -43,6 +43,8 @@ import {
useGroupedCollectionCard,
} from "@/components/collections/GroupedCollectionsBoard";
import { slugifyGroupSlug } from "@/lib/collectionGroups";
import { useUICustomization } from "@/hooks/useUICustomization";
import { carouselCardWidthClasses } from "@/lib/uiCustomization";
import {
buildUserCollectionCatalogHref,
@@ -197,6 +199,8 @@ function CollectionList() {
// that library's full Collections tab.
function ServerCollectionsSection() {
const { data, isLoading } = useServerCollections();
const { cardPresentation } = useUICustomization();
const posterWidthClasses = carouselCardWidthClasses(cardPresentation.poster_size);
const libraries = data ?? [];
if (isLoading) {
@@ -216,7 +220,7 @@ function ServerCollectionsSection() {
<Skeleton className="h-7 w-40" />
<div className="flex gap-4 overflow-hidden lg:gap-5">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="w-[130px] shrink-0 sm:w-[150px] lg:w-[178px]">
<div key={i} className={posterWidthClasses}>
<Skeleton className="aspect-[2/3] rounded-xl" />
<Skeleton className="mt-2.5 h-4 w-3/4" />
</div>
@@ -255,6 +259,8 @@ function ServerCollectionsSection() {
// supplies horizontal padding — the row title and cards align to that column.
function ServerLibraryRow({ library }: { library: ServerCollectionsLibrary }) {
const navigate = useNavigate();
const { cardPresentation } = useUICustomization();
const posterWidthClasses = carouselCardWidthClasses(cardPresentation.poster_size);
const collectionsHref = `/library/${library.library_id}?tab=collections`;
const hasMore = library.total_count > library.collections.length;
return (
@@ -265,7 +271,7 @@ function ServerLibraryRow({ library }: { library: ServerCollectionsLibrary }) {
edgePadding={false}
>
{library.collections.map((collection) => (
<div key={collection.id} className="w-[130px] sm:w-[150px] lg:w-[178px]">
<div key={collection.id} className={posterWidthClasses}>
<CollectionPosterCard
collection={collection}
kind="regular"
@@ -0,0 +1,30 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { UICustomizationContext } from "@/contexts/uiCustomizationContext";
import { RecommendationGridSkeleton } from "./SectionSkeletons";
describe("RecommendationGridSkeleton", () => {
it("matches compact artwork-only recommendation cards", () => {
const markup = renderToStaticMarkup(
<UICustomizationContext.Provider
value={{
cardPresentation: { poster_size: "compact", caption: "artwork" },
cardPresentationSource: "profile_client",
primaryMenu: null,
primaryMenuSource: "default",
shortcuts: { items: [] },
isSupported: true,
supportsAtomicShortcuts: true,
isLoading: false,
isUnavailable: false,
}}
>
<RecommendationGridSkeleton count={2} />
</UICustomizationContext.Provider>,
);
expect(markup).toContain("grid-cols-3 sm:grid-cols-5");
expect(markup).not.toContain("mt-1.5 h-4 w-3/4");
});
});
@@ -1,4 +1,6 @@
import { Skeleton } from "@/components/ui/skeleton";
import { useUICustomization } from "@/hooks/useUICustomization";
import { cardGridClasses } from "@/lib/uiCustomization";
/** Skeleton for a horizontal cast carousel (portrait cards + name/role text) */
export function CastSkeleton({ count = 8 }: { count?: number }) {
@@ -64,14 +66,17 @@ export function SeasonCarouselSkeleton({ count = 6 }: { count?: number }) {
/** Skeleton for a poster grid (e.g. "More Like This" recommendations) */
export function RecommendationGridSkeleton({ count = 6 }: { count?: number }) {
const { cardPresentation } = useUICustomization();
return (
<div>
<Skeleton className="mb-5 h-6 w-36" />
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
<div className={cardGridClasses(cardPresentation.poster_size)}>
{Array.from({ length: count }, (_, i) => (
<div key={i}>
<Skeleton className="aspect-[2/3] w-full rounded-lg" />
<Skeleton className="mt-1.5 h-4 w-3/4" />
{cardPresentation.caption !== "artwork" ? (
<Skeleton className="mt-1.5 h-4 w-3/4" />
) : null}
</div>
))}
</div>
+26 -9
View File
@@ -2,10 +2,9 @@ import type { LibraryTabCollection, LibraryTabGroup, LibraryTabUngrouped } from
import { useLibraryCollections } from "@/hooks/queries/libraryCollections";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
COLLECTION_POSTER_GRID_CLASSES as GRID_CLASSES,
CollectionPosterCard,
} from "@/components/collections/CollectionPosterCard";
import { CollectionPosterCard } from "@/components/collections/CollectionPosterCard";
import { useUICustomization } from "@/hooks/useUICustomization";
import { cardGridClasses } from "@/lib/uiCustomization";
interface LibraryCollectionsProps {
libraryId: number;
@@ -13,11 +12,13 @@ interface LibraryCollectionsProps {
export default function LibraryCollections({ libraryId }: LibraryCollectionsProps) {
const { data, isLoading } = useLibraryCollections(libraryId);
const { cardPresentation } = useUICustomization();
const gridClasses = cardGridClasses(cardPresentation.poster_size);
if (isLoading) {
return (
<div className="page-shell py-6 sm:py-8">
<div className={GRID_CLASSES}>
<div className={gridClasses}>
{Array.from({ length: 24 }, (_, i) => (
<div key={i}>
<Skeleton className="aspect-[2/3] rounded-lg" />
@@ -65,9 +66,15 @@ export default function LibraryCollections({ libraryId }: LibraryCollectionsProp
key="ungrouped"
collections={item.collections}
libraryId={libraryId}
gridClasses={gridClasses}
/>
) : (
<GroupSection key={item.group.id} group={item.group} libraryId={libraryId} />
<GroupSection
key={item.group.id}
group={item.group}
libraryId={libraryId}
gridClasses={gridClasses}
/>
),
)}
</div>
@@ -103,13 +110,15 @@ function buildRenderOrder(
function UngroupedGroupSection({
collections,
libraryId,
gridClasses,
}: {
collections: LibraryTabCollection[];
libraryId: number;
gridClasses: string;
}) {
return (
<section>
<div className={GRID_CLASSES}>
<div className={gridClasses}>
{collections.map((c) => (
<CollectionPosterCard key={c.id} collection={c} kind="regular" libraryId={libraryId} />
))}
@@ -118,11 +127,19 @@ function UngroupedGroupSection({
);
}
function GroupSection({ group, libraryId }: { group: LibraryTabGroup; libraryId: number }) {
function GroupSection({
group,
libraryId,
gridClasses,
}: {
group: LibraryTabGroup;
libraryId: number;
gridClasses: string;
}) {
return (
<section>
<h2 className="mb-3 text-lg font-semibold">{group.name}</h2>
<div className={GRID_CLASSES}>
<div className={gridClasses}>
{group.collections.map((c) => (
<CollectionPosterCard key={c.id} collection={c} kind={group.kind} libraryId={libraryId} />
))}
+5 -1
View File
@@ -17,6 +17,8 @@ import { planNextHomeSectionBatch } from "./homeSectionQueue";
import { buildHomeSectionViewModel, type HomeSectionSlot } from "./homeSectionState";
import { collectCachedHomeSections } from "./homeSectionCache";
import { isAudiobookLibraryType } from "./libraryPageSearchParams";
import { useUICustomization } from "@/hooks/useUICustomization";
import { carouselCardWidthClasses } from "@/lib/uiCustomization";
interface LibraryRecommendedProps {
libraryId: number;
@@ -324,13 +326,15 @@ function PinnedCollectionCarousel({
}) {
const { data: items, isLoading } = useLibraryCollectionItems(libraryId, collectionId);
const { prefs: overlayPrefs } = useOverlayPrefs();
const { cardPresentation } = useUICustomization();
const posterWidthClasses = carouselCardWidthClasses(cardPresentation.poster_size);
if (!isLoading && (!items || items.length === 0)) return null;
return (
<MediaCarousel title={name} loading={isLoading}>
{(items ?? []).map((item) => (
<div key={item.content_id} className="w-[140px] shrink-0 sm:w-[160px] lg:w-[184px]">
<div key={item.content_id} className={posterWidthClasses}>
<ItemCard item={item} overlayPrefs={overlayPrefs} />
</div>
))}
+8 -8
View File
@@ -5,6 +5,8 @@ import SectionItemCard from "@/components/SectionItemCard";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { Skeleton } from "@/components/ui/skeleton";
import { Sparkles, RefreshCw } from "lucide-react";
import { useUICustomization } from "@/hooks/useUICustomization";
import { carouselCardWidthClasses } from "@/lib/uiCustomization";
function buildSectionHref(row: DiscoverRow): string | undefined {
if (!row.section_kind) return undefined;
@@ -119,7 +121,7 @@ function DiscoverErrorState({ onRetry }: { onRetry: () => void }) {
);
}
function DiscoverSkeletons() {
function DiscoverSkeletons({ posterWidthClasses }: { posterWidthClasses: string }) {
return (
<div className="space-y-10 pt-2">
{Array.from({ length: 4 }).map((_, i) => (
@@ -129,7 +131,7 @@ function DiscoverSkeletons() {
</div>
<div className="flex gap-4 overflow-hidden px-4 sm:px-6 lg:gap-5 lg:px-10 xl:px-12">
{Array.from({ length: 12 }).map((_, j) => (
<div key={j} className="w-[140px] shrink-0 sm:w-[160px] lg:w-[185px]">
<div key={j} className={posterWidthClasses}>
<Skeleton className="aspect-[2/3] w-full rounded-xl" />
<Skeleton className="mt-3 h-4 w-3/4 rounded" />
<Skeleton className="mt-1.5 h-3 w-1/2 rounded" />
@@ -147,6 +149,8 @@ export default function Recommendations() {
const tasteProfileQuery = useTasteProfile();
const { data, isLoading, isError, refetch } = useDiscover();
const { cardPresentation } = useUICustomization();
const posterWidthClasses = carouselCardWidthClasses(cardPresentation.poster_size);
const rows = data?.rows ?? [];
@@ -172,7 +176,7 @@ export default function Recommendations() {
{/* Content */}
{isLoading ? (
<DiscoverSkeletons />
<DiscoverSkeletons posterWidthClasses={posterWidthClasses} />
) : isError ? (
<DiscoverErrorState onRetry={() => refetch()} />
) : rows.length === 0 ? (
@@ -185,11 +189,7 @@ export default function Recommendations() {
titleHref={buildSectionHref(row)}
>
{row.items.map((item) => (
<div
key={item.content_id}
className="w-[140px] shrink-0 sm:w-[160px] lg:w-[185px]"
role="listitem"
>
<div key={item.content_id} className={posterWidthClasses} role="listitem">
<SectionItemCard item={item} />
</div>
))}
+6 -2
View File
@@ -6,6 +6,8 @@ import SectionItemCard from "@/components/SectionItemCard";
import { Skeleton } from "@/components/ui/skeleton";
import { useRecommendationSection } from "@/hooks/queries/recommendations";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { useUICustomization } from "@/hooks/useUICustomization";
import { cardGridClasses } from "@/lib/uiCustomization";
const KIND_FALLBACK_LABEL: Record<string, (key?: string) => string> = {
"for-you-main": () => "For You",
@@ -23,8 +25,9 @@ function fallbackTitle(kind: string, key?: string) {
}
function GridSkeleton() {
const { cardPresentation } = useUICustomization();
return (
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 lg:gap-5 xl:grid-cols-7">
<div className={cardGridClasses(cardPresentation.poster_size)}>
{Array.from({ length: 24 }).map((_, i) => (
<div key={i}>
<Skeleton className="aspect-[2/3] w-full rounded-xl" />
@@ -72,6 +75,7 @@ export default function RecommendationsSection() {
const { data, isLoading, isError, refetch } = useRecommendationSection(kind, key);
const title = data?.label || fallbackTitle(kind, key);
const { cardPresentation } = useUICustomization();
useDocumentTitle(title);
@@ -94,7 +98,7 @@ export default function RecommendationsSection() {
) : !data || data.items.length === 0 ? (
<EmptyState />
) : (
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 lg:gap-5 xl:grid-cols-7">
<div className={cardGridClasses(cardPresentation.poster_size)}>
{data.items.map((item) => (
<SectionItemCard key={item.content_id} item={item} />
))}
+5 -3
View File
@@ -143,12 +143,14 @@ describe("SettingsLayout", () => {
await userEvent.type(screen.getByRole("searchbox", { name: "Search settings" }), "pin");
// "pin" hits Profiles (where PINs are set) and Connect Apps (where the
// password#PIN format is explained).
// "pin" hits Profiles (where PINs are set), Connect Apps (where the
// password#PIN format is explained), and Navigation & Cards (where
// libraries are pinned to the primary menu).
expect(screen.getAllByRole("link", { name: /Profiles/ })).toHaveLength(1);
expect(screen.getAllByRole("link", { name: /Connect Apps/ })).toHaveLength(1);
expect(screen.getAllByRole("link", { name: /Navigation & Cards/ })).toHaveLength(1);
expect(screen.queryByRole("link", { name: /Playback/ })).not.toBeInTheDocument();
expect(screen.getByText("2 matches")).toBeInTheDocument();
expect(screen.getByText("3 matches")).toBeInTheDocument();
});
it("matches individual personal setting labels", async () => {
+30
View File
@@ -19,6 +19,7 @@ import {
Sparkles,
Bell,
MonitorSmartphone,
PanelTop,
} from "lucide-react";
// Sparkles is used by the Personalization nav entry below.
import type { LucideIcon } from "lucide-react";
@@ -154,6 +155,35 @@ const NAV_SECTIONS: NavSection[] = [
"Reset to Cinema Dark",
),
},
{
path: "interface",
label: "Navigation & Cards",
icon: PanelTop,
description: "Menus, poster size, and captions",
keywords: [
"navigation",
"menu",
"pin library",
"poster size",
"card size",
"hide title",
"hide year",
"artwork only",
"preset",
],
settings: settingIndex(
"Card preset",
"Poster size",
"Caption",
"Title & metadata",
"Title only",
"Artwork only",
"Primary menu",
"Choose destination or shortcut",
"Add to menu",
"Reset to default",
),
},
{
path: "theme-editor",
label: "Theme Editor",
@@ -2,6 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { UICustomizationContext } from "@/contexts/uiCustomizationContext";
import { RelatedRail } from "./RelatedRail";
const items = [{ content_id: "book-1", title: "Book One", poster_url: "/cover.jpg" }];
@@ -42,4 +43,40 @@ describe("RelatedRail", () => {
expect(markup).toContain('href="/item/ebook%201%2Fisbn%3A978"');
});
it("uses compact widths and hides caption rows for artwork-only cards", () => {
const markup = renderToStaticMarkup(
<MemoryRouter>
<UICustomizationContext.Provider
value={{
cardPresentation: { poster_size: "compact", caption: "artwork" },
cardPresentationSource: "profile_client",
primaryMenu: null,
primaryMenuSource: "default",
shortcuts: { items: [] },
isSupported: true,
supportsAtomicShortcuts: true,
isLoading: false,
isUnavailable: false,
}}
>
<RelatedRail
heading="Related"
items={[
{
content_id: "book-1",
title: "Book One",
poster_url: "/cover.jpg",
subtitle: "Book metadata",
},
]}
/>
</UICustomizationContext.Provider>
</MemoryRouter>,
);
expect(markup).toContain("w-[120px]");
expect(markup).not.toContain(">Book One</div>");
expect(markup).not.toContain("Book metadata");
});
});
@@ -1,4 +1,6 @@
import ViewTransitionLink from "@/components/ViewTransitionLink";
import { useUICustomization } from "@/hooks/useUICustomization";
import { carouselCardWidthClasses } from "@/lib/uiCustomization";
interface RelatedRailItem {
content_id: string;
@@ -21,8 +23,11 @@ export function RelatedRail({
items,
coverAspect = "square",
}: RelatedRailProps) {
const { cardPresentation } = useUICustomization();
if (items.length === 0) return null;
const coverAspectClass = coverAspect === "poster" ? "aspect-[2/3]" : "aspect-square";
const showCaption = cardPresentation.caption !== "artwork";
const showMetadata = cardPresentation.caption === "title_metadata";
return (
<section>
<div className="mb-4">
@@ -34,7 +39,7 @@ export function RelatedRail({
<ViewTransitionLink
key={item.content_id}
to={`/item/${encodeURIComponent(item.content_id)}`}
className="block w-[140px] shrink-0 sm:w-[160px] lg:w-[185px]"
className={`block ${carouselCardWidthClasses(cardPresentation.poster_size)}`}
>
<div
className={`bg-muted relative ${coverAspectClass} overflow-hidden rounded-lg ${
@@ -50,10 +55,12 @@ export function RelatedRail({
/>
) : null}
</div>
<div className="mt-2 truncate text-sm font-medium">{item.title}</div>
{item.subtitle && (
{showCaption ? (
<div className="mt-2 truncate text-sm font-medium">{item.title}</div>
) : null}
{showMetadata && item.subtitle ? (
<div className="text-muted-foreground truncate text-xs">{item.subtitle}</div>
)}
) : null}
</ViewTransitionLink>
))}
</div>
+51
View File
@@ -5,10 +5,12 @@ import {
buildCatalogFilterSearchParams,
buildCatalogQueryUpdateHref,
buildCatalogHref,
buildLibraryCollectionCatalogHref,
buildPersonCatalogHref,
buildPersonalCatalogHref,
catalogSourceAllowsOverlay,
parseCatalogSearchParams,
sameCatalogDestination,
} from "./catalogSearchParams";
function params(search: string) {
@@ -116,6 +118,46 @@ describe("parseCatalogSearchParams", () => {
});
});
describe("sameCatalogDestination", () => {
it("ignores section presentation params but keeps scope identity", () => {
const target = parseCatalogSearchParams(
params("source=section&scope=library&library_id=7&section_id=recent&title=Recent"),
);
const filtered = parseCatalogSearchParams(
params(
"source=section&scope=library&library_id=7&section_id=recent&sort=title&order=asc&genre=Drama",
),
);
const otherLibrary = parseCatalogSearchParams(
params("source=section&scope=library&library_id=8&section_id=recent"),
);
expect(sameCatalogDestination(target, filtered)).toBe(true);
expect(sameCatalogDestination(target, otherLibrary)).toBe(false);
});
it("ignores collection filters but keeps source and collection identity", () => {
const target = parseCatalogSearchParams(
params("source=library_collection&library_id=7&collection_id=favorites"),
);
const filtered = parseCatalogSearchParams(
params(
"source=library_collection&library_id=7&collection_id=favorites&type=movie&sort=year&order=desc",
),
);
const userCollection = parseCatalogSearchParams(
params("source=user_collection&collection_id=favorites"),
);
const filteredUserCollection = parseCatalogSearchParams(
params("source=user_collection&collection_id=favorites&library_id=9&sort=title"),
);
expect(sameCatalogDestination(target, filtered)).toBe(true);
expect(sameCatalogDestination(target, userCollection)).toBe(false);
expect(sameCatalogDestination(userCollection, filteredUserCollection)).toBe(true);
});
});
describe("catalogSourceAllowsOverlay", () => {
it("returns true only for supported overlay sources", () => {
expect(catalogSourceAllowsOverlay("query")).toBe(true);
@@ -129,6 +171,15 @@ describe("catalogSourceAllowsOverlay", () => {
});
describe("buildCatalogHref", () => {
it("keeps a library-scoped collection shortcut in its library context", () => {
const href = buildLibraryCollectionCatalogHref("collection-7", "Favorites", 42);
const built = new URL(`http://example.test${href}`);
expect(built.searchParams.get("source")).toBe("library_collection");
expect(built.searchParams.get("collection_id")).toBe("collection-7");
expect(built.searchParams.get("library_id")).toBe("42");
});
it("preserves type=all when the query filter selects All Media", () => {
const state = parseCatalogSearchParams(params("source=query&q=heat&type=video"));
state.query_definition = {
+32 -1
View File
@@ -176,6 +176,32 @@ export function parseCatalogSearchParams(searchParams: URLSearchParams): Catalog
return baseState;
}
/**
* Compares the stable destination identity represented by two catalog URLs.
* Presentation overlays such as title, sort, order, filters, and pagination
* intentionally do not participate in navigation active state.
*/
export function sameCatalogDestination(
left: CatalogSearchState,
right: CatalogSearchState,
): boolean {
if (left.source !== right.source) return false;
if (left.source === "section" && right.source === "section") {
return (
left.scope === right.scope &&
left.library_id === right.library_id &&
left.section_id === right.section_id
);
}
if (left.source === "library_collection" && right.source === "library_collection") {
return left.library_id === right.library_id && left.collection_id === right.collection_id;
}
if (left.source === "user_collection" && right.source === "user_collection") {
return left.collection_id === right.collection_id;
}
return false;
}
export function buildCatalogHref(state: CatalogSearchState): string {
const params = buildCatalogApiSearchParams(state);
return `/catalog?${params.toString()}`;
@@ -234,10 +260,15 @@ export function buildSectionCatalogHref(destination: SectionCatalogDestination):
});
}
export function buildLibraryCollectionCatalogHref(collectionId: string, title?: string): string {
export function buildLibraryCollectionCatalogHref(
collectionId: string,
title?: string,
libraryId?: number,
): string {
return buildCatalogHref({
source: "library_collection",
collection_id: collectionId,
library_id: libraryId,
title,
uses_source_order: true,
query_definition: createEmptyQueryDefinition(),
@@ -0,0 +1,153 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SettingsCapabilities } from "@/hooks/queries/settingValues";
const mocks = vi.hoisted(() => ({
refetchCapabilities: vi.fn(),
useEffectiveSettings: vi.fn(),
capabilities: {
data: undefined as SettingsCapabilities | undefined,
isLoading: false,
isError: true,
isFetching: false,
},
}));
vi.mock("@/hooks/queries/devices", () => ({
useMyDevices: () => ({
data: [
{
device_id: "living-room",
device_name: "Living Room TV",
device_platform: "tvOS",
last_seen_at: "2026-08-04T00:00:00Z",
profile_id: "profile-1",
profile_name: "Taylor",
is_current_device: true,
changed_count: 0,
},
],
isLoading: false,
}),
useClearDeviceSettings: () => ({ mutate: vi.fn(), isPending: false }),
useForgetDevice: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/hooks/queries/settingValues", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/hooks/queries/settingValues")>();
return {
...actual,
useSettingsCapabilities: () => ({
...mocks.capabilities,
refetch: mocks.refetchCapabilities,
}),
useEffectiveSettings: (...args: unknown[]) => mocks.useEffectiveSettings(...args),
useSetSettingValue: () => ({ mutate: vi.fn(), isPending: false }),
useClearSettingValue: () => ({ mutate: vi.fn(), isPending: false }),
};
});
vi.mock("@/hooks/useCurrentProfile", () => ({
useCurrentProfile: () => ({ profile: { id: "profile-1", is_primary: false } }),
}));
vi.mock("@/hooks/useIsActingAdmin", () => ({
useIsActingAdmin: () => false,
}));
vi.mock("@/components/settings/DeviceList", () => ({
DeviceList: () => <div>Device list</div>,
lastSeenLabel: () => "recently",
}));
vi.mock("@/components/settings/DeviceSettingGroups", () => ({
DeviceSettingGroups: () => <div>Editable device defaults</div>,
}));
vi.mock("@/components/settings/SubtitleAppearancePanelView", () => ({
SubtitleAppearancePanelView: () => null,
}));
import DeviceSettings from "./DeviceSettings";
describe("DeviceSettings capability discovery", () => {
const compatibleCapabilities: SettingsCapabilities = {
api_version: 1,
revision: 5,
contract_etag: "revision-five",
supports_batched_effective: true,
supports_idempotent_writes: true,
};
beforeEach(() => {
mocks.refetchCapabilities.mockReset();
mocks.useEffectiveSettings.mockReset();
mocks.useEffectiveSettings.mockReturnValue({ data: {}, isLoading: false });
mocks.capabilities.data = undefined;
mocks.capabilities.isLoading = false;
mocks.capabilities.isError = true;
mocks.capabilities.isFetching = false;
});
it("fails closed and offers a retry when capabilities cannot be loaded", async () => {
const user = userEvent.setup();
render(<DeviceSettings />);
expect(mocks.useEffectiveSettings).toHaveBeenCalledWith(
expect.objectContaining({
keys: [],
deviceId: "living-room",
enabled: false,
}),
);
expect(screen.getByRole("alert")).toHaveTextContent(
"Device controls stay unavailable until Silo confirms which settings this server supports.",
);
expect(screen.queryByText("Editable device defaults")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Retry compatibility check" }));
expect(mocks.refetchCapabilities).toHaveBeenCalledTimes(1);
});
it.each([
["API version is incompatible", { ...compatibleCapabilities, api_version: 2 }],
[
"batched effective reads are missing",
{ ...compatibleCapabilities, supports_batched_effective: undefined },
],
[
"idempotent writes are missing",
{ ...compatibleCapabilities, supports_idempotent_writes: undefined },
],
["revision is missing", { ...compatibleCapabilities, revision: undefined }],
])("does not request all settings when the %s", (_case, capabilities) => {
mocks.capabilities.data = capabilities as SettingsCapabilities;
render(<DeviceSettings />);
expect(mocks.useEffectiveSettings).toHaveBeenCalledWith(
expect.objectContaining({ keys: [], enabled: false }),
);
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.queryByText("Editable device defaults")).not.toBeInTheDocument();
});
it("enables only revision-supported keys when the full capability contract matches", () => {
mocks.capabilities.data = compatibleCapabilities;
render(<DeviceSettings />);
expect(mocks.useEffectiveSettings).toHaveBeenCalledWith(
expect.objectContaining({
keys: expect.arrayContaining(["player.hdr_enabled", "ui.card_presentation"]),
enabled: true,
}),
);
expect(screen.getByText("Editable device defaults")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
+42 -4
View File
@@ -15,13 +15,15 @@ import { DeviceSettingGroups } from "@/components/settings/DeviceSettingGroups";
import { SubtitleAppearancePanelView } from "@/components/settings/SubtitleAppearancePanelView";
import { useClearDeviceSettings, useForgetDevice, useMyDevices } from "@/hooks/queries/devices";
import {
settingsCapabilitiesSupportKey,
useSettingsCapabilities,
useClearSettingValue,
useEffectiveSettings,
useSetSettingValue,
} from "@/hooks/queries/settingValues";
import { useCurrentProfile } from "@/hooks/useCurrentProfile";
import { useIsActingAdmin } from "@/hooks/useIsActingAdmin";
import { ALL_DEVICE_SETTING_KEYS } from "@/lib/settingsDisplay";
import { deviceSettingKeysForRevision } from "@/lib/settingsDisplay";
import { SETTING_KEYS, type SettingKey } from "@/lib/settingsContract";
import { parseSubtitleAppearance, type SubtitleAppearance } from "@/lib/subtitleAppearance";
import { cn } from "@/lib/utils";
@@ -237,11 +239,26 @@ function DeviceDetail({
const ownerLabel = forSomeoneElse ? `${device.profile_name}'s` : "your";
const targetProfileId = forSomeoneElse ? device.profile_id : undefined;
const { data: settings = {}, isLoading } = useEffectiveSettings({
keys: ALL_DEVICE_SETTING_KEYS,
const capabilities = useSettingsCapabilities();
const supportedKeys = useMemo(
() =>
deviceSettingKeysForRevision(capabilities.data?.revision).filter((key) =>
settingsCapabilitiesSupportKey(capabilities.data, key),
),
[capabilities.data],
);
const canUseDeviceSettings = supportedKeys.length > 0;
const { data: settings = {}, isLoading: settingsLoading } = useEffectiveSettings({
keys: supportedKeys,
deviceId: device.device_id,
profileId: targetProfileId,
// An empty key list means "all keys" to the API, so wait until the
// connected server confirms the complete settings capability contract.
enabled: canUseDeviceSettings,
});
const isLoading = capabilities.isLoading || settingsLoading;
const capabilitiesUnavailable = !capabilities.isLoading && !canUseDeviceSettings;
const setValue = useSetSettingValue();
const clearValue = useClearSettingValue();
@@ -384,7 +401,28 @@ function DeviceDetail({
</span>
</Callout>
{isLoading ? (
{capabilitiesUnavailable ? (
<div
role="alert"
className="border-border/70 bg-surface space-y-3 rounded-[1.7rem] border p-5"
>
<div>
<p className="text-sm font-semibold">Couldn&apos;t check settings compatibility</p>
<p className="text-muted-foreground mt-1 text-[13px] leading-relaxed">
Device controls stay unavailable until Silo confirms which settings this server
supports.
</p>
</div>
<Button
type="button"
variant="outline"
disabled={capabilities.isFetching}
onClick={() => void capabilities.refetch()}
>
{capabilities.isFetching ? "Checking…" : "Retry compatibility check"}
</Button>
</div>
) : isLoading ? (
<Skeleton className="h-96 rounded-[1.7rem]" />
) : (
<DeviceSettingGroups
@@ -0,0 +1,290 @@
// @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 { defaultWebPrimaryMenu } from "@/lib/uiCustomization";
const mocks = vi.hoisted(() => ({
useUICustomization: vi.fn(),
useUserLibraries: vi.fn(),
useSetSettingValue: vi.fn(),
useClearSettingValue: vi.fn(),
}));
vi.mock("@/hooks/useUICustomization", () => ({
useUICustomization: (...args: unknown[]) => mocks.useUICustomization(...args),
}));
vi.mock("@/hooks/queries/libraries", () => ({
useUserLibraries: (...args: unknown[]) => mocks.useUserLibraries(...args),
}));
vi.mock("@/hooks/queries/settingValues", () => ({
useSetSettingValue: (...args: unknown[]) => mocks.useSetSettingValue(...args),
useClearSettingValue: (...args: unknown[]) => mocks.useClearSettingValue(...args),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
import InterfaceSettings from "./InterfaceSettings";
describe("InterfaceSettings card resets", () => {
let clearMutateAsync: ReturnType<typeof vi.fn>;
let setMutateAsync: ReturnType<typeof vi.fn>;
beforeEach(() => {
mocks.useUICustomization.mockReset();
mocks.useUserLibraries.mockReset();
mocks.useSetSettingValue.mockReset();
mocks.useClearSettingValue.mockReset();
clearMutateAsync = vi.fn().mockResolvedValue(undefined);
setMutateAsync = vi.fn().mockResolvedValue(undefined);
mocks.useUICustomization.mockReturnValue({
cardPresentation: { poster_size: "large", caption: "title" },
cardPresentationSource: "profile_client",
primaryMenu: defaultWebPrimaryMenu(),
primaryMenuSource: "profile_client",
shortcuts: { items: [] },
isSupported: true,
supportsAtomicShortcuts: true,
isLoading: false,
isUnavailable: false,
});
mocks.useUserLibraries.mockReturnValue({ data: [] });
mocks.useSetSettingValue.mockReturnValue({ isPending: false, mutateAsync: setMutateAsync });
mocks.useClearSettingValue.mockReturnValue({
isPending: false,
mutateAsync: clearMutateAsync,
});
});
afterEach(cleanup);
it("resets the web-family card preference at profile_client scope", async () => {
render(<InterfaceSettings />);
const reset = screen.getByRole("button", { name: "Reset web-family card layout" });
expect(reset).toHaveAccessibleDescription(
"Remove the layout shared by web browsers and inherit the profile or app default.",
);
fireEvent.click(reset);
await waitFor(() =>
expect(clearMutateAsync).toHaveBeenCalledWith({
key: SETTING_KEYS.UI_CARD_PRESENTATION,
identity: { scope: "profile_client" },
}),
);
});
it("keeps the device reset distinct from the web-family reset", async () => {
mocks.useUICustomization.mockReturnValue({
cardPresentation: { poster_size: "compact", caption: "artwork" },
cardPresentationSource: "profile_device",
primaryMenu: defaultWebPrimaryMenu(),
primaryMenuSource: "profile_client",
shortcuts: { items: [] },
isSupported: true,
supportsAtomicShortcuts: true,
isLoading: false,
});
render(<InterfaceSettings />);
expect(
screen.queryByRole("button", { name: "Reset web-family card layout" }),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Use web-family layout" }));
await waitFor(() =>
expect(clearMutateAsync).toHaveBeenCalledWith({
key: SETTING_KEYS.UI_CARD_PRESENTATION,
identity: { scope: "profile_device" },
}),
);
});
it("follows the effective menu after clearing a web-family override", async () => {
const effectiveMenu = {
items: [
{ type: "builtin" as const, destination: "home" as const },
{ type: "library" as const, library_id: 7, label: "Movies" },
],
};
let source = "profile_client";
mocks.useUICustomization.mockImplementation(() => ({
cardPresentation: { poster_size: "large", caption: "title" },
cardPresentationSource: "profile_client",
primaryMenu: effectiveMenu,
primaryMenuSource: source,
shortcuts: { items: [] },
isSupported: true,
supportsAtomicShortcuts: true,
isLoading: false,
}));
clearMutateAsync.mockImplementation(async () => {
source = "profile";
});
render(<InterfaceSettings />);
fireEvent.click(screen.getByRole("button", { name: "Remove Movies · Library" }));
expect(screen.queryByText("Movies · Library")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Reset to default" }));
await waitFor(() =>
expect(clearMutateAsync).toHaveBeenCalledWith({
key: SETTING_KEYS.NAV_PRIMARY_MENU,
identity: { scope: "profile_client" },
}),
);
expect(await screen.findByText("Movies · Library")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save menu" })).toBeDisabled();
});
it("keeps a rapid post-save edit when the saved baseline refetch arrives", async () => {
let effectiveMenu = {
items: [
{ type: "builtin" as const, destination: "home" as const },
{ type: "library" as const, library_id: 7, label: "Movies" },
{ type: "builtin" as const, destination: "calendar" as const },
],
};
mocks.useUICustomization.mockImplementation(() => ({
cardPresentation: { poster_size: "large", caption: "title" },
cardPresentationSource: "profile_client",
primaryMenu: effectiveMenu,
primaryMenuSource: "profile_client",
shortcuts: { items: [] },
isSupported: true,
supportsAtomicShortcuts: true,
isLoading: false,
}));
const view = render(<InterfaceSettings />);
fireEvent.click(screen.getByRole("button", { name: "Remove Movies · Library" }));
fireEvent.click(screen.getByRole("button", { name: "Save menu" }));
await waitFor(() => expect(setMutateAsync).toHaveBeenCalledTimes(1));
await waitFor(() => expect(screen.getByRole("button", { name: "Save menu" })).toBeDisabled());
fireEvent.click(screen.getByRole("button", { name: "Remove Calendar" }));
effectiveMenu = {
items: [
{ type: "builtin" as const, destination: "home" as const },
{ type: "builtin" as const, destination: "calendar" as const },
],
};
view.rerender(<InterfaceSettings />);
expect(screen.queryByText("Calendar")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save menu" })).toBeEnabled();
});
it("clears a just-saved override before its effective refetch arrives", async () => {
mocks.useUICustomization.mockReturnValue({
cardPresentation: { poster_size: "large", caption: "title" },
cardPresentationSource: "profile_client",
primaryMenu: {
items: [
{ type: "builtin" as const, destination: "home" as const },
{ type: "library" as const, library_id: 7, label: "Movies" },
],
},
primaryMenuSource: "profile",
shortcuts: { items: [] },
isSupported: true,
supportsAtomicShortcuts: true,
isLoading: false,
});
render(<InterfaceSettings />);
fireEvent.click(screen.getByRole("button", { name: "Remove Movies · Library" }));
fireEvent.click(screen.getByRole("button", { name: "Save menu" }));
await waitFor(() => expect(setMutateAsync).toHaveBeenCalledTimes(1));
await waitFor(() => expect(screen.getByRole("button", { name: "Save menu" })).toBeDisabled());
fireEvent.click(screen.getByRole("button", { name: "Reset to default" }));
await waitFor(() =>
expect(clearMutateAsync).toHaveBeenCalledWith({
key: SETTING_KEYS.NAV_PRIMARY_MENU,
identity: { scope: "profile_client" },
}),
);
});
it("discards an unsaved draft locally when no web-family override exists", async () => {
const effectiveMenu = {
items: [
{ type: "builtin" as const, destination: "home" as const },
{ type: "library" as const, library_id: 7, label: "Movies" },
],
};
mocks.useUICustomization.mockReturnValue({
cardPresentation: { poster_size: "large", caption: "title" },
cardPresentationSource: "profile_client",
primaryMenu: effectiveMenu,
primaryMenuSource: "profile",
shortcuts: { items: [] },
isSupported: true,
supportsAtomicShortcuts: true,
isLoading: false,
});
render(<InterfaceSettings />);
fireEvent.click(screen.getByRole("button", { name: "Remove Movies · Library" }));
fireEvent.click(screen.getByRole("button", { name: "Reset to default" }));
expect(clearMutateAsync).not.toHaveBeenCalled();
expect(await screen.findByText("Movies · Library")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save menu" })).toBeDisabled();
});
it("hides revision-five controls when the server does not support them", () => {
mocks.useUICustomization.mockReturnValue({
cardPresentation: { poster_size: "standard", caption: "title_metadata" },
cardPresentationSource: "default",
primaryMenu: null,
primaryMenuSource: "default",
shortcuts: { items: [] },
isSupported: false,
supportsAtomicShortcuts: false,
isLoading: false,
isUnavailable: false,
});
render(<InterfaceSettings />);
expect(screen.getByRole("alert")).toHaveTextContent("Server upgrade required");
expect(screen.queryByRole("radiogroup", { name: "Poster size" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Save menu" })).not.toBeInTheDocument();
});
it("keeps editors closed when effective customization could not be loaded", () => {
mocks.useUICustomization.mockReturnValue({
cardPresentation: { poster_size: "standard", caption: "title_metadata" },
cardPresentationSource: "default",
primaryMenu: null,
primaryMenuSource: "default",
shortcuts: { items: [] },
isSupported: true,
supportsAtomicShortcuts: true,
isLoading: false,
isUnavailable: true,
});
render(<InterfaceSettings />);
expect(screen.getByRole("alert")).toHaveTextContent("Customization unavailable");
expect(screen.getByRole("alert")).toHaveTextContent("Editing stays disabled");
expect(screen.queryByRole("radiogroup", { name: "Poster size" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Save menu" })).not.toBeInTheDocument();
expect(setMutateAsync).not.toHaveBeenCalled();
expect(clearMutateAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,575 @@
import { useMemo, useState } from "react";
import { ArrowDown, ArrowUp, Check, Monitor, RotateCcw, X } from "lucide-react";
import { toast } from "sonner";
import { SettingsGroup } from "@/components/settings/SettingsGroup";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useUICustomization } from "@/hooks/useUICustomization";
import { useUserLibraries } from "@/hooks/queries/libraries";
import { useClearSettingValue, useSetSettingValue } from "@/hooks/queries/settingValues";
import { SETTING_KEYS } from "@/lib/settingsContract";
import {
CARD_PRESENTATION_PRESETS,
defaultWebPrimaryMenu,
menuItemKey,
moveMenuItem,
type CardCaption,
type CardPresentation,
type PosterSize,
type PrimaryMenuItem,
} from "@/lib/uiCustomization";
import { cn } from "@/lib/utils";
const CLIENT_SCOPE = { scope: "profile_client" } as const;
const BUILTIN_LABELS: Record<string, string> = {
home: "Home",
movies: "Movies",
series: "TV Shows",
music: "Music",
audiobooks: "Audiobooks",
for_you: "For You",
calendar: "Calendar",
};
const ADDABLE_WEB_BUILTINS: PrimaryMenuItem[] = [
// Media-family built-ins are global by contract. Until web has global
// routes for every family, users can add the explicit library destinations
// below without giving a global item first-library semantics.
{ type: "builtin", destination: "for_you" },
{ type: "builtin", destination: "calendar" },
];
function menuItemLabel(item: PrimaryMenuItem): string {
if (item.type === "builtin") return BUILTIN_LABELS[item.destination] ?? item.destination;
if (item.type === "library") return `${item.label} · Library`;
if (item.type === "section") return `${item.label} · Section`;
return `${item.label} · Collection`;
}
function samePresentation(left: CardPresentation, right: CardPresentation) {
return left.poster_size === right.poster_size && left.caption === right.caption;
}
function CardPreview({ presentation }: { presentation: CardPresentation }) {
const widths =
presentation.poster_size === "compact"
? ["w-12", "w-12", "w-12", "w-12"]
: presentation.poster_size === "large"
? ["w-20", "w-20"]
: ["w-16", "w-16", "w-16"];
return (
<div className="bg-background/35 flex min-h-40 items-start gap-3 overflow-hidden rounded-xl border border-white/8 p-4">
{widths.map((width, index) => (
<div key={index} className={cn("shrink-0", width)}>
<div className="from-primary/45 to-accent aspect-[2/3] rounded-lg bg-gradient-to-br" />
{presentation.caption !== "artwork" ? (
<>
<div className="bg-foreground/75 mt-2 h-2 w-4/5 rounded-full" />
{presentation.caption === "title_metadata" ? (
<div className="bg-muted-foreground/45 mt-1.5 h-1.5 w-1/2 rounded-full" />
) : null}
</>
) : null}
</div>
))}
</div>
);
}
function InterfaceHeader() {
return (
<header className="space-y-2">
<h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">Navigation & cards</h2>
<p className="text-muted-foreground text-sm">
These choices sync between web browsers signed into this profile. TV, mobile, tablet, and
desktop-native apps keep their own matching-device layouts.
</p>
</header>
);
}
export default function InterfaceSettings() {
const { data: libraries = [] } = useUserLibraries();
const customization = useUICustomization();
const setValue = useSetSettingValue();
const clearValue = useClearSettingValue();
const baselineMenu = useMemo(
() => customization.primaryMenu ?? defaultWebPrimaryMenu(),
[customization.primaryMenu],
);
const baselineKey = useMemo(() => JSON.stringify(baselineMenu.items), [baselineMenu.items]);
const [menuDraft, setMenuDraft] = useState<{
baselineKey: string;
/** The just-saved baseline expected from the asynchronous query refresh. */
pendingBaselineKey?: string;
items: PrimaryMenuItem[];
dirty: boolean;
} | null>(null);
const [addItemKey, setAddItemKey] = useState("");
const menuDraftMatchesBaseline =
menuDraft?.baselineKey === baselineKey || menuDraft?.pendingBaselineKey === baselineKey;
const menuItems = menuDraftMatchesBaseline ? menuDraft.items : baselineMenu.items;
const menuDirty = menuDraftMatchesBaseline === true && menuDraft.dirty;
const cardPresentation = customization.cardPresentation;
const cardDeviceOverride = customization.cardPresentationSource === "profile_device";
const cardClientOverride = customization.cardPresentationSource === "profile_client";
const menuDeviceOverride = customization.primaryMenuSource === "profile_device";
const menuClientOverride = customization.primaryMenuSource === "profile_client";
const menuMutationPending = setValue.isPending || clearValue.isPending;
const cardMutationPending = menuMutationPending || cardDeviceOverride;
const menuAtLimit = menuItems.length >= 64;
const availableItems = useMemo(() => {
const current = new Set(menuItems.map(menuItemKey));
const visibleLibraryIds = new Set(libraries.map((library) => library.id));
const candidates: PrimaryMenuItem[] = [
...ADDABLE_WEB_BUILTINS,
...libraries.map(
(library): PrimaryMenuItem => ({
type: "library",
library_id: library.id,
label: library.name,
}),
),
...customization.shortcuts.items.filter(
(item) => item.library_id === undefined || visibleLibraryIds.has(item.library_id),
),
];
const unique = new Map<string, PrimaryMenuItem>();
for (const candidate of candidates) {
const key = menuItemKey(candidate);
if (!current.has(key)) unique.set(key, candidate);
}
return [...unique.values()];
}, [customization.shortcuts.items, libraries, menuItems]);
const selectedAddItem = availableItems.find((item) => menuItemKey(item) === addItemKey);
async function saveCardPresentation(next: CardPresentation) {
try {
await setValue.mutateAsync({
key: SETTING_KEYS.UI_CARD_PRESENTATION,
value: next,
identity: CLIENT_SCOPE,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Could not save card layout");
}
}
async function resetCardPresentation() {
try {
await clearValue.mutateAsync({
key: SETTING_KEYS.UI_CARD_PRESENTATION,
identity: CLIENT_SCOPE,
});
toast.success("Web-family card layout reset");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Could not reset card layout");
}
}
async function clearDeviceOverride(
key: typeof SETTING_KEYS.UI_CARD_PRESENTATION | typeof SETTING_KEYS.NAV_PRIMARY_MENU,
label: string,
) {
try {
await clearValue.mutateAsync({ key, identity: { scope: "profile_device" } });
toast.success(`${label} now follows the web-family preference`);
} catch (error) {
toast.error(
error instanceof Error ? error.message : `Could not clear ${label.toLowerCase()}`,
);
}
}
function updateMenu(next: PrimaryMenuItem[]) {
setMenuDraft((current) => {
if (current?.pendingBaselineKey === baselineKey) {
return { baselineKey, items: next, dirty: true };
}
if (current?.baselineKey === baselineKey) {
return { ...current, items: next, dirty: true };
}
return { baselineKey, items: next, dirty: true };
});
}
async function saveMenu() {
const savedItems = menuItems;
const savedBaselineKey = JSON.stringify(savedItems);
try {
await setValue.mutateAsync({
key: SETTING_KEYS.NAV_PRIMARY_MENU,
value: { items: savedItems },
identity: CLIENT_SCOPE,
});
setMenuDraft((current) => {
const currentItemsKey = current ? JSON.stringify(current.items) : null;
if (current?.dirty && currentItemsKey !== savedBaselineKey) {
return { ...current, pendingBaselineKey: savedBaselineKey };
}
return {
baselineKey,
pendingBaselineKey: savedBaselineKey,
items: savedItems,
dirty: false,
};
});
toast.success("Web navigation saved");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Could not save navigation");
}
}
async function resetMenu() {
const pendingClientOverride = menuDraft?.pendingBaselineKey !== undefined;
if (!menuClientOverride && !pendingClientOverride) {
setMenuDraft(null);
toast.success("Web navigation reset");
return;
}
try {
await clearValue.mutateAsync({
key: SETTING_KEYS.NAV_PRIMARY_MENU,
identity: CLIENT_SCOPE,
});
setMenuDraft(null);
toast.success("Web navigation reset");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Could not reset navigation");
}
}
if (customization.isLoading) {
return (
<div className="space-y-6">
<InterfaceHeader />
<div className="surface-panel-subtle text-muted-foreground rounded-xl border p-5 text-sm">
Checking server support…
</div>
</div>
);
}
if (customization.isUnavailable) {
return (
<div className="space-y-6">
<InterfaceHeader />
<div className="surface-panel-subtle rounded-xl border p-5" role="alert">
<p className="font-medium">Customization unavailable</p>
<p className="text-muted-foreground mt-1 text-sm">
Saved navigation and card settings could not be loaded. Editing stays disabled to
protect your existing choices.
</p>
</div>
</div>
);
}
if (!customization.isSupported) {
return (
<div className="space-y-6">
<InterfaceHeader />
<div className="surface-panel-subtle rounded-xl border p-5" role="alert">
<p className="font-medium">Server upgrade required</p>
<p className="text-muted-foreground mt-1 text-sm">
This server does not support synchronized navigation and card customization yet.
</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
<InterfaceHeader />
<SettingsGroup
title="Card preset"
description="Start with a complete layout, then adjust poster size or captions below."
>
{cardDeviceOverride ? (
<div className="border-border/70 bg-muted/25 mb-4 flex flex-col gap-3 rounded-xl border p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-sm">
This browser has a higher-priority device override. Clear it before editing the
preference shared by web browsers.
</p>
<Button
type="button"
variant="outline"
disabled={menuMutationPending}
onClick={() =>
void clearDeviceOverride(SETTING_KEYS.UI_CARD_PRESENTATION, "Card layout")
}
>
Use web-family layout
</Button>
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
{CARD_PRESENTATION_PRESETS.map((preset) => {
const active = samePresentation(cardPresentation, preset.value);
return (
<button
key={preset.id}
type="button"
onClick={() => void saveCardPresentation(preset.value)}
aria-pressed={active}
disabled={cardMutationPending}
className={cn(
"surface-panel-subtle relative rounded-xl border p-4 text-left transition-colors",
active ? "border-primary/50 bg-primary/8" : "border-border/70 hover:bg-accent/45",
"disabled:cursor-not-allowed disabled:opacity-60",
)}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold">{preset.label}</p>
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">
{preset.description}
</p>
</div>
{active ? <Check className="text-primary h-4 w-4 shrink-0" /> : null}
</div>
</button>
);
})}
</div>
</SettingsGroup>
<SettingsGroup
title="Poster cards"
description="Fine-tune density and the rows shown below artwork. Changes apply immediately."
>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(240px,0.8fr)]">
<div className="space-y-5">
<div className="space-y-2">
<p className="text-sm font-medium">Poster size</p>
<div className="flex flex-wrap gap-2" role="radiogroup" aria-label="Poster size">
{(
[
["compact", "Compact"],
["standard", "Standard"],
["large", "Large"],
] as const
).map(([value, label]) => (
<Button
key={value}
type="button"
role="radio"
aria-checked={cardPresentation.poster_size === value}
size="sm"
variant={cardPresentation.poster_size === value ? "default" : "outline"}
disabled={cardMutationPending}
onClick={() =>
void saveCardPresentation({
...cardPresentation,
poster_size: value as PosterSize,
})
}
>
{label}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Caption</p>
<div className="flex flex-wrap gap-2" role="radiogroup" aria-label="Card caption">
{(
[
["title_metadata", "Title & metadata"],
["title", "Title only"],
["artwork", "Artwork only"],
] as const
).map(([value, label]) => (
<Button
key={value}
type="button"
role="radio"
aria-checked={cardPresentation.caption === value}
size="sm"
variant={cardPresentation.caption === value ? "default" : "outline"}
disabled={cardMutationPending}
onClick={() =>
void saveCardPresentation({
...cardPresentation,
caption: value as CardCaption,
})
}
>
{label}
</Button>
))}
</div>
</div>
</div>
<CardPreview presentation={cardPresentation} />
</div>
{cardClientOverride ? (
<div className="border-border/70 mt-5 flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between">
<p id="card-layout-reset-description" className="text-muted-foreground text-sm">
Remove the layout shared by web browsers and inherit the profile or app default.
</p>
<Button
type="button"
variant="outline"
disabled={menuMutationPending}
aria-describedby="card-layout-reset-description"
onClick={() => void resetCardPresentation()}
>
<RotateCcw className="mr-1.5 h-4 w-4" />
Reset web-family card layout
</Button>
</div>
) : null}
</SettingsGroup>
<SettingsGroup
title="Primary menu"
description="Choose the ordered shortcuts at the top of the web menu. Home stays available; search and profile controls are fixed."
>
<div className="space-y-4">
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<Monitor className="h-4 w-4" />
Web browsers
</div>
{menuDeviceOverride ? (
<div className="border-border/70 bg-muted/25 flex flex-col gap-3 rounded-xl border p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-sm">
This browser has a higher-priority device menu. Clear it before editing the menu
shared by web browsers.
</p>
<Button
type="button"
variant="outline"
disabled={menuMutationPending}
onClick={() =>
void clearDeviceOverride(SETTING_KEYS.NAV_PRIMARY_MENU, "Navigation")
}
>
Use web-family menu
</Button>
</div>
) : null}
<ol className="space-y-2">
{menuItems.map((item, index) => {
const home = item.type === "builtin" && item.destination === "home";
return (
<li
key={menuItemKey(item)}
className="surface-panel-subtle border-border/65 flex items-center gap-3 rounded-xl border px-3 py-2.5"
>
<span className="text-muted-foreground w-6 shrink-0 text-center text-xs tabular-nums">
{index + 1}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{menuItemLabel(item)}
</span>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
disabled={menuMutationPending || menuDeviceOverride || index === 0}
aria-label={`Move ${menuItemLabel(item)} up`}
onClick={() => updateMenu(moveMenuItem(menuItems, index, -1))}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
disabled={
menuMutationPending || menuDeviceOverride || index === menuItems.length - 1
}
aria-label={`Move ${menuItemLabel(item)} down`}
onClick={() => updateMenu(moveMenuItem(menuItems, index, 1))}
>
<ArrowDown className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
disabled={menuMutationPending || menuDeviceOverride || home}
aria-label={home ? "Home cannot be removed" : `Remove ${menuItemLabel(item)}`}
onClick={() =>
updateMenu(menuItems.filter((_, itemIndex) => itemIndex !== index))
}
>
<X className="h-4 w-4" />
</Button>
</div>
</li>
);
})}
</ol>
{availableItems.length > 0 ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Select
value={addItemKey}
onValueChange={setAddItemKey}
disabled={menuMutationPending || menuDeviceOverride || menuAtLimit}
>
<SelectTrigger className="w-full sm:max-w-sm">
<SelectValue placeholder="Choose destination or shortcut" />
</SelectTrigger>
<SelectContent>
{availableItems.map((item) => (
<SelectItem key={menuItemKey(item)} value={menuItemKey(item)}>
{menuItemLabel(item)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="secondary"
disabled={
!selectedAddItem || menuMutationPending || menuDeviceOverride || menuAtLimit
}
onClick={() => {
if (!selectedAddItem) return;
updateMenu([...menuItems, selectedAddItem]);
setAddItemKey("");
}}
>
Add to menu
</Button>
</div>
) : null}
<div className="flex flex-wrap gap-2 pt-1">
<Button
type="button"
onClick={() => void saveMenu()}
disabled={!menuDirty || menuMutationPending || menuDeviceOverride}
>
Save menu
</Button>
<Button
type="button"
variant="outline"
onClick={() => void resetMenu()}
disabled={menuMutationPending || menuDeviceOverride}
>
<RotateCcw className="mr-1.5 h-4 w-4" />
Reset to default
</Button>
</div>
</div>
</SettingsGroup>
</div>
);
}