* feat(activity): report exact client app version, build, and channel
The admin Activity page could not name the build a session was streaming
from. Android already sent X-Silo-Client-Version and the server already
stored it intact, but playbackClientDisplayName routed it through
shortPlaybackClientVersion, which strips non-numeric runes, truncates to
two components, and drops a trailing ".0" — so a client reporting "1.0.0"
rendered as "Silo Android TV 1". The Apple clients sent no client name or
version at all and fell back to user-agent sniffing.
Adds two additive, opaque wire fields alongside the existing client
headers — X-Silo-Client-Build (<=64) and X-Silo-Client-Channel (<=32) —
with client_playback_context.app_build/app_channel as the v3 fallback,
which is also where the previously discarded app_version now gets used.
The server never parses, compares, or enum-validates either value: Apple
uses a per-platform TestFlight sequence and Android a per-marketing-
version counter, and keeping them opaque lets both coexist without a
shared scheme. Any future minimum-version gating belongs on
client_version, which is semver.
Only the named-client branch of playbackClientDisplayName stops
truncating; the user-agent branch keeps shortPlaybackClientVersion, so
browser labels stay "Chrome 120" rather than a full UA version string.
The compact session row is unchanged in width — it is shared with
AdminDashboard, AdminStats, and HouseholdStreamsPanel — and the exact
string lands in the row tooltip and a new Client card in the expanded
panel.
Diagnostic logs carry client_name/version/build/channel on both
"playback plan decided" lines and on session expiry. opslog stores an
open attrs JSONB, so this needs no migration. activity_log is
deliberately untouched: it is the highest-volume table and the value is
constant per device.
Jellyfin compat sessions keep an empty build — the MediaBrowser auth
header vocabulary has no build concept, and synthesizing one from a user
agent would be a guess.
Part of the client-version-visibility work spanning silo-android and
silo-apple.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(activity): resolve client identity without polluting client_version
Review follow-up on the client build/channel work. Fourteen findings; the
substantive ones:
The v3 body fallback took client_playback_context.app_version whenever the
header was absent. The web player sends the literal "web" there and sends no
X-Silo-Client, so every browser session would have stamped client_version="web"
— the one field the contract promises is semver and the field a future
minimum-version gate has to key on. client_playback_context carries no app name,
so the body can never identify a nameless client anyway; the fallback now
applies only to a client that sent X-Silo-Client, and a test pins the "web"
case.
An over-long app_build or app_channel in the start body failed the whole request
with 400 while the same value in a header was silently clamped — an opaque
diagnostic label could refuse playback. validateCapabilitiesV3 now clamps both
with the same helper the header path uses, which is what the docs already
claimed.
Route events posted out of band resolved identity from headers only, so a client
reporting its build in the start body attributed plan_selected to a build and
every later event of the same attempt to none. They now fill empty fields from
the session, as the replan path already did.
playbackClientFullDisplayName discarded build and channel whenever the client
reported no name, so the new Client card could never show a build for a
user-agent-labelled session. It now qualifies whatever label the compact
formatter resolved, which also drops its duplicated name+version assembly.
normalizeClientMetadataValue truncated by bytes; a multi-byte header value cut
mid-rune yields invalid UTF-8, which Postgres rejects — and the per-node session
upserts share one transaction, so one malformed client string would fail that
whole node's sync. It now clamps on a rune boundary.
replan-request.schema.json never got app_build/app_channel even though
ReplanRequestV3 reuses ClientPlaybackContextV3 and validates the same bounds. A
new contract test asserts every $def the two request schemas share is identical,
so the copies cannot drift again.
Also: the four client log attrs move to ClientInfo.LogAttrs(), which is now
their single definition and omits fields the client did not report rather than
persisting empty keys into opslog; startPlannedPlaybackV3 takes the resolved
identity instead of re-parsing the headers; client_label_full is omitted when it
would repeat client_label; getSessionClientLabelFull delegates to
getSessionClientLabel instead of re-implementing it; the Activity search matches
the exact label so a build number is findable; and the web ClientPlaybackContextV3
type mirrors the two new optional fields.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(activity): clamp client identity at the request boundary, by runes
Follow-up to bot review on the previous commit.
The 64/32 clamp for X-Silo-Client-Build / -Channel only ran where newSession
stamped its fields, but the resolved ClientInfo is written straight to the
plan-decision log and to playback_route_events. A client sending a header-sized
build reached both despite the published bound. ClientInfo.Normalized() is now
the single definition of those limits and runs at the request boundary —
playbackClientInfoFromRequest and playbackClientInfoForStartV3 — with newSession
still normalizing because identities also arrive from the Jellyfin and
Audiobookshelf compat surfaces.
normalizeClientMetadataValue now clamps by runes rather than bytes. The bounds
are published to clients as JSON Schema maxLength, which counts characters, so a
byte clamp cut values the contract calls valid — a 32-character emoji channel
was 128 bytes. It also scrubs invalid UTF-8 outright rather than only after a
mid-rune cut, since a header may carry bytes that were never valid UTF-8 and a
text column refuses them.
Two tests cover it: oversized headers clamp at the boundary, and a 40-rune
multi-byte channel lands on the 32-character bound as valid UTF-8.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(activity): strip control characters from client identity
A JSON NUL escape in a v3 start body's app_version, app_build or app_channel
decodes to a real NUL. That is valid UTF-8, so the UTF-8 repair leaves it and
TrimSpace does not treat it as whitespace — but Postgres refuses NUL in a text
column. The per-node session upserts share one transaction, so a single such
start would stop every live session on that node from reconciling until the
offending session went away. Headers cannot carry it (net/http rejects bytes
below 0x20), which is why only the body path this PR added is exposed.
normalizeClientMetadataValue now strips control characters outright rather than
NUL alone: none of them belong in an identity label rendered in the admin UI and
written to structured logs.
Reported by Codex review on b43b7ef06.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(activity): satisfy goconst and misspell on the changed log lines
CI's `golangci-lint --new-from-merge-base` failed on the previous commits: the
two decision-log calls were reformatted into slice literals, which brought their
"component" key inside the changed-lines window where goconst flags it against
the existing logComponentKey constant, and a doc comment used the British
"labelled". Both lines now use the constant, and the spelling is corrected here
and in docs/settings-api.md.
The file's other 16 "component" literals are left alone: CI only requires the
lines a branch touches to be clean, and rewriting them would bury this change in
unrelated churn.
Verified with the same command and version CI runs (golangci-lint v2.12.2,
--new-from-merge-base=origin/main): 0 issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(playback): require context-aware session starts
startPlannedPlaybackV3 probed for StartSessionWithFilesContext with a type
assertion and fell back to the context-free StartSessionWithFiles. The context
is how the reporting client's identity reaches the new session, so any
implementation missing the method would start sessions carrying no client name,
version, build or channel — silently, and now that build and channel ride the
same path, silently losing more.
SessionManagerInterface requires the method instead, so a non-conforming
implementation fails to compile rather than dropping the identity at run time.
The one test double gains a three-line method; production already implemented it.
Raised as a nitpick by CodeRabbit review; pre-existing, but it is this PR's data
that the fallback drops.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
231 lines
13 KiB
Markdown
231 lines
13 KiB
Markdown
# 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` |
|
|
|
|
### App identity headers
|
|
|
|
Separately from the family, every first-party client should send its own app
|
|
identity on playback requests. These are server-wide contextual headers, not
|
|
settings-specific: the playback session stores them, the admin Activity page
|
|
renders them ("Silo Android TV 1.0.0 (build 5)"), and playback decision logs
|
|
carry them so a report can be tied to an exact build.
|
|
|
|
| Header | Clamp | Meaning |
|
|
| ----------------------- | ----- | ------------------------------------------------------------------------------------------------------------- |
|
|
| `X-Silo-Client` | 128 | Product name, e.g. `Silo Android TV`, `Silo iOS`. |
|
|
| `X-Silo-Client-Version` | 64 | Marketing version, e.g. `1.0.0`. Sent verbatim and displayed verbatim — do not pre-shorten it. |
|
|
| `X-Silo-Client-Build` | 64 | Opaque per-platform build identifier (Android `versionCode`, Apple `CFBundleVersion`). Never parsed or compared. |
|
|
| `X-Silo-Client-Channel` | 32 | Opaque distribution channel: `release`, `beta`, `sideload`, `dev`. Stored verbatim; `release` is not displayed. |
|
|
|
|
Values are trimmed and truncated to the clamp above — never rejected, on either
|
|
route, because an identity label must not be able to fail a playback start. The
|
|
clamp counts characters, not bytes, matching `maxLength` in the v3 request
|
|
schemas, and is applied where the request is read rather than where the session
|
|
is created so the decision logs and `playback_route_events` observe it too.
|
|
Nothing is validated against an enum either, so a client may introduce a new
|
|
channel without a server change.
|
|
|
|
Protocol-v3 `POST /playback/start` accepts `client_playback_context.app_version`,
|
|
`.app_build`, and `.app_channel` as a body-level fallback for clients that cannot
|
|
set the headers on every request. The headers win field by field when both are
|
|
present, and the fallback applies **only to a client that sent `X-Silo-Client`**:
|
|
`client_playback_context` carries no app name, so nothing in the body can
|
|
identify a client that did not name itself — such a session is labeled from its
|
|
user agent, and its `app_version` is a free-form platform string rather than the
|
|
marketing version `client_version` promises.
|
|
|
|
## 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.
|