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:
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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},
|
||||
¬ifications.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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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§ion_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");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ vi.mock("@/hooks/queries/sidebarPins", () => ({
|
||||
useToggleSidebarPin: () => ({
|
||||
togglePin: mockTogglePin,
|
||||
isPinned: mockIsPinned,
|
||||
canToggle: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useContext } from "react";
|
||||
|
||||
import { UICustomizationContext } from "@/contexts/uiCustomizationContext";
|
||||
|
||||
export function useUICustomization() {
|
||||
return useContext(UICustomizationContext);
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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,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} />
|
||||
))}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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§ion_id=recent&title=Recent"),
|
||||
);
|
||||
const filtered = parseCatalogSearchParams(
|
||||
params(
|
||||
"source=section&scope=library&library_id=7§ion_id=recent&sort=title&order=asc&genre=Drama",
|
||||
),
|
||||
);
|
||||
const otherLibrary = parseCatalogSearchParams(
|
||||
params("source=section&scope=library&library_id=8§ion_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 = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user