feat(settings): add playback.intro_skip_mode and deprecate auto_skip_intro

Skipping intros stops being a switch and becomes a three-way choice —
never / ask / always — matching what Jellyfin offers and giving viewers a
way to turn the prompt off, which the boolean could not express.

Contract revision 6 → 7: adds playback.intro_skip_mode (enum, default
"ask", profile + profile_device scopes) and marks playback.auto_skip_intro
deprecated without removing it. Every shipped client still reads the
boolean, so for one release the server keeps the pair in step at write
time: canonical PUT/DELETE, the legacy /profiles route, and the legacy
runtime /settings/{key} route all land both rows, and a profile-scope enum
write refreshes user_profiles.auto_skip_intro so GET /profiles stays
truthful. Existing rows are carried onto the new key by a Goose migration
(Postgres) and an InitSchema twin (per-user SQLite); the settings-migrate
planner emits the companion for installs whose backfill runs later.

The spec in docs/design/2026-08-16-intro-skip-mode.md also defines the
prompt state machine every client (web, Android, Apple; browser, tablet,
mobile, TV) implements against this key. It builds on the Android TV
Skip Intro work in silo-android#210 — wall-clock timer, rebuffer-vs-pause
debounce, root-level key handling.

Co-authored-by: evulhotdog <365456+evulhotdog@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick104
2026-08-16 19:38:19 -04:00
co-authored by evulhotdog Claude Fable 5
parent edd919c5f7
commit af974edfe9
21 changed files with 1576 additions and 19 deletions
+43 -1
View File
@@ -1,6 +1,6 @@
{
"fixture_version": 1,
"manifest_revision": 6,
"manifest_revision": 7,
"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": [
{
@@ -668,6 +668,48 @@
}
]
},
{
"name": "intro_skip_mode_defaults_to_ask",
"description": "A profile that has never chosen an intro behaviour gets the contract default, which is the same prompt the old auto_skip_intro=false produced.",
"keys": ["playback.intro_skip_mode"],
"context": { "profile_id": "p1", "device_id": "d1" },
"stored": [],
"expected": [
{
"key": "playback.intro_skip_mode",
"value": "ask",
"source": "default"
}
]
},
{
"name": "intro_skip_mode_device_override_beats_profile",
"description": "A living-room television set to skip intros automatically does not change the profile-wide choice to leave them alone.",
"keys": ["playback.intro_skip_mode"],
"context": { "profile_id": "p1", "device_id": "d1" },
"stored": [
{
"key": "playback.intro_skip_mode",
"scope": "profile",
"profile_id": "p1",
"value": "never"
},
{
"key": "playback.intro_skip_mode",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "d1",
"value": "always"
}
],
"expected": [
{
"key": "playback.intro_skip_mode",
"value": "always",
"source": "profile_device"
}
]
},
{
"name": "subtitle_appearance_uses_shared_default",
"description": "With no stored profile or device value, every client receives the contract's shared Box 75% subtitle appearance.",
+25 -2
View File
@@ -1,6 +1,6 @@
{
"api_version": 1,
"revision": 6,
"revision": 7,
"option_sets": {
"playback_audio_languages": {
"type": "language_tag",
@@ -305,7 +305,30 @@
"category": "playback",
"label": "Auto-skip intros",
"description": "Jump past intros automatically when Silo can detect them.",
"recommended_control": "switch"
"recommended_control": "switch",
"deprecated": true,
"notes": "Superseded by playback.intro_skip_mode in revision 7: true is \"always\", false is \"ask\". The boolean cannot express \"never\" (no prompt at all), which is the mode this setting was missing. It stays in the manifest because every shipped client reads it and the profile DTO carries it as a NOT NULL column, and the server mirrors the two keys at write time for one release so a preference set on an old client shows up correctly on a new one. Removing it is a follow-up, once Android, Apple and web all read the enum."
},
{
"key": "playback.intro_skip_mode",
"introduced_in": 7,
"persistence": "remote",
"allowed_scopes": ["profile", "profile_device"],
"resolution_order": ["profile_device", "profile", "default"],
"value_schema": {
"type": "enum",
"values": [
{ "value": "never", "label": "Never" },
{ "value": "ask", "label": "Ask to skip" },
{ "value": "always", "label": "Skip automatically" }
]
},
"default_value": "ask",
"category": "playback",
"label": "Skip intros",
"description": "What Silo does when an intro starts: leave it alone, offer a Skip Intro button, or skip it and offer an undo.",
"recommended_control": "select",
"notes": "The replacement for playback.auto_skip_intro, which could only say \"prompt\" or \"count down then skip\" and had no way to turn the prompt off. The default is \"ask\", which is exactly what auto_skip_intro=false did, so an untouched profile behaves identically across the cutover. The schema has no segmented control, so this is a select; clients that have a segmented control should use it. See docs/design/2026-08-16-intro-skip-mode.md."
},
{
"key": "playback.auto_skip_credits",
+215
View File
@@ -0,0 +1,215 @@
# Intro Skip Mode: Never / Ask / Always
Status: Proposed (spec + cross-repo plan)
Date: 2026-08-16
Repos affected: `silo-server` (contract, migration, web), `silo-android`, `silo-apple`
## Summary
Replace the boolean `playback.auto_skip_intro` with a three-way
`playback.intro_skip_mode` setting — `never`, `ask`, `always` — and define one
intro-skip prompt state machine that every Silo client implements identically:
web (browser), Android (phone, tablet, TV) and Apple (iPhone, iPad, tvOS, macOS).
The boolean already encodes two of the three modes: `true` is "count down, then
skip" and `false` is "show a Skip Intro button". `never` (no prompt at all) is
the only new behaviour, and it matches what Jellyfin's Intro Skipper offers.
The server change is a contract addition plus a compatibility mirror; the
client change is a rewrite of the prompt behaviour to the table below.
## Motivation
- There is no way to turn the prompt off. A viewer who wants to watch intros
still gets a button over the picture on every episode.
- The `always` behaviour today is *countdown-then-skip*: the intro plays for
five seconds while a bar fills. Viewers who chose "always" wanted the intro
gone; showing it for five seconds first is the wrong default. The right shape
is *skip immediately, offer an undo*.
- The three clients drifted. Android TV (silo-android#210) got a wall-clock
fill, focus-on-appear, and D-pad/Back rules that neither web nor Apple has.
Cross-platform consistency needs a written contract, not three
re-implementations of a verbal one.
## Setting
### `playback.intro_skip_mode`
| Field | Value |
| --- | --- |
| `value_schema` | `enum``never` ("Never"), `ask` ("Ask to skip"), `always` ("Skip automatically") |
| `default_value` | `ask` — identical to today's `auto_skip_intro = false` |
| `persistence` | `remote` |
| `allowed_scopes` | `profile`, `profile_device` (same as the boolean) |
| `resolution_order` | `profile_device`, `profile`, `default` |
| `category` / `label` | `playback` / "Skip intros" |
| `recommended_control` | `select` (the schema has no segmented control; clients that have one should use it) |
| `introduced_in` | 7 |
Contract revision bumps 6 → 7. `playback.auto_skip_intro` stays in the
manifest, marked `deprecated: true`, with `notes` pointing at the new key. It
is not removed in this cut: every shipped client reads it, and the profile DTO
carries it as a `NOT NULL` column.
### Migration
A Goose SQL migration copies every stored `playback.auto_skip_intro` row (all
scopes, all backends the row can live in) to `playback.intro_skip_mode` at the
same identity:
| stored boolean | new enum |
| --- | --- |
| `true` | `"always"` |
| `false` | `"ask"` |
`ON CONFLICT DO NOTHING` so a re-run, or a client that already wrote the enum,
never has its choice overwritten by the mirror. Nobody currently has `never`,
so no data becomes unrepresentable. Down: delete the `intro_skip_mode` rows.
### Compatibility mirror (one release)
While old clients are in the field, the two keys are kept in step at write
time so a preference set on one client shows up correctly on the others:
| Write | Server also writes |
| --- | --- |
| `PUT /settings/values/playback.auto_skip_intro` (any scope) | `intro_skip_mode` at the same identity: `true → always`, `false → ask` |
| `PUT /settings/values/playback.intro_skip_mode` (any scope) | `auto_skip_intro` at the same identity: `always → true`, else `false` |
| `DELETE` of either | delete the other at the same identity |
| Legacy `PUT /profiles/{id}` with `auto_skip_intro` | both keys at `profile` scope (extends `profiles_settings_sync`) |
| Legacy `PUT /settings/{key}` (runtime-key route) with `auto_skip_intro` | both keys, wherever that route lands them |
| `intro_skip_mode` written at `profile` scope | the `user_profiles.auto_skip_intro` column, so `GET /profiles` stays truthful for old clients |
The lossy direction is deliberate: an old client sees `never` as `false`
(= ask) and shows the button. That is the least surprising degradation. An old
client that then flips the toggle overwrites `never` — acceptable for the
overlap window, and the reason the mirror is dropped once clients have moved.
Removal plan: once Android, Apple and web all read `intro_skip_mode`, a
follow-up removes the mirror, marks the boolean `deprecated` in the profile DTO
and stops writing it. The `user_profiles.auto_skip_intro` column is retired
with the other legacy profile playback columns, not on its own.
### `playback.auto_skip_credits`
Not changed here. It has the same shape and will want the same treatment
(`credits_skip_mode`); keeping it out keeps this change reviewable, and the
credits prompt has a different interaction (it competes with Next Up).
## Prompt behaviour
Terminology:
- **intro** — a `TimeRange` `[start, end)` from the server's chapter/marker
data, with a stable per-item key so "this intro" survives seeks.
- **inside** — playback position `p` with `start ≤ p < end`.
- **pill** — the single on-screen prompt. It has a **timer**, always
wall-clock, always the same length across platforms
(`INTRO_PROMPT_SECONDS = 5`; contract-visible so a future setting can drive it).
- **resolved** — the viewer has made a decision for this intro in this
playback session. A resolved intro never shows a pill again, including if
the viewer scrubs back into it.
- **Select** — click/tap on the pill, or Select/OK on a remote while the pill
is focused, or Enter/Space on web while focused.
- **Back** — remote Back, keyboard Escape, Android system back.
### `never`
Entering an intro does nothing. No pill, no skip. Chapter markers still render
on the timeline where the client already does so.
### `ask`
| Event | Outcome |
| --- | --- |
| Enter intro (not resolved) | Show pill **"Skip Intro"** with timer running. Pill takes focus on appear on focus-driven platforms (TV, keyboard). |
| Timer runs out | Pill hides. Intro keeps playing. Intro is **not** resolved — scrubbing back into it re-offers. |
| Select | Seek to `end`. Intro resolved. Pill hides. On TV, focus lands on the timeline/transport, never nowhere. |
| Back | Pill hides. Intro keeps playing. Intro resolved. Press is consumed (does not exit playback / close overlay); a second Back behaves normally. |
| D-pad / arrow / pointer move away | Focus moves as normal. **Timer keeps running.** Pill stays until timer ends. |
| Pause | Timer **freezes** at its current value; resumes from there on play. Pill stays visible. |
| Seek out of the intro | Pill hides, timer stops. Not resolved. |
| Seek back into the intro | Same as Enter (unless resolved). Timer restarts from full. |
| Playback stall / rebuffer < 1.5 s | Ignored: timer keeps running (see *Timing*). |
| Controls overlay hides/shows | Pill stays; it may reposition (e.g. drop toward the corner when transport hides). Never hidden by the overlay timeout. |
### `always`
| Event | Outcome |
| --- | --- |
| Enter intro (not resolved) | **Seek to `end` immediately.** Show pill **"Intro Skipped · Play Intro"** with timer running. Focus as in `ask`. |
| Timer runs out | Pill hides. Playback continues past the intro. Intro resolved. |
| Select | Seek to `start`. Intro resolved (so re-entering the range does not skip again). Pill hides. Focus lands on transport. |
| Back | Pill hides. Playback continues. Intro resolved. Press consumed. |
| Move focus away | Timer keeps running. |
| Pause | Timer freezes; resumes on play. |
| Seek by the viewer into a resolved intro | Nothing. The viewer asked for it. |
| Seek by the viewer into an unresolved intro | Same as Enter — skipped again, undo offered. This only happens on a fresh session or an item with several marked intros. |
The pill in `always` is the *undo* affordance, so unlike `ask` its timeout does
resolve the intro: the viewer was told it was skipped and let it go.
### Timing
- The timer is **wall-clock**. Compose scales `AnimationSpec` by the system
animator duration scale, `prefers-reduced-motion` can shorten CSS
transitions, and UIKit honours accessibility motion settings; a countdown to
an action must ignore all of those or the fill lies about when the action
fires. Decorative motion (fade in/out, reposition) may still honour them.
- Timer state and its visual (fill / ring / number) derive from the same
clock. Never run two timers.
- The timer starts when playback is actually running, not when the player is
still coming up, so the pill and the fill start together.
- A pause is a pause only after `PLAYBACK_PAUSE_GRACE_MS = 1500`; a shorter
`isPlaying == false` is a rebuffer and does not touch the timer. (This is
the `settlingFalseEdges` behaviour from silo-android#210; port it, don't
reinvent it.)
### Input handling
- Select / Back for the pill are handled at the **player root**, not on the
pill widget, so they work whether or not the pill is in the focus tree. TV
and keyboard both need this.
- While the pill is focused, Select acts on it with a single press — no
navigate-then-press.
- The pill must never steal focus during a scrub or from an open menu. If the
viewer is mid-interaction when the intro starts, the pill appears unfocused
and the timer runs anyway.
- Pointer platforms (web, phone, tablet): the pill is a normal button; hover
and tap behave as Select. Tap outside the pill is not Back.
### Presentation
- One pill, lower-right of the video, above the transport cluster while
controls are visible; drops toward the corner when they hide.
- Fill creeps left → right and reaches full exactly when the timer ends
(`ask`: pill hides; `always`: pill hides). Same colour language everywhere:
dimmed when unfocused, lit when focused, solid on press.
- Copy: **"Skip Intro"** / **"Intro Skipped · Play Intro"**. Localised via
each client's normal string tables; the semantics are fixed.
- Disappears instantly on Select/Back; fades on timeout.
## Client rollout
Each client reads `playback.intro_skip_mode` from the effective settings
endpoint, falling back to `auto_skip_intro` only if the server contract
revision is < 7. Settings UI shows a three-way control (segmented where the platform has one) labelled
Never / Ask to skip / Skip automatically; the old switch goes away.
| Repo | Work |
| --- | --- |
| `silo-server` (this change) | Manifest rev 7, migration, write mirror, bindings regen, web settings control reads/writes the enum. Web player behaviour to the tables above is a follow-up in the same repo. |
| `silo-android` | `IntroAutoSkipController` (shared KMP, drives phone + TV) gains the three modes and the `Skipped/OfferingUndo` state; `TvIntroAutoSkipBanner` / `IntroAutoSkipBanner` render both copies; settings screens swap the switch for a segmented control; `SettingKeys.kt` regenerated by `make settings-bindings`. |
| `silo-apple` | Same state machine in the shared player; tvOS focus rules; iOS/macOS pointer rules; `SettingKeys.generated.swift` regenerated. |
Conformance: the tables above are the test oracle. Each client's state-machine
tests should assert them case-for-case (silo-android's
`IntroAutoSkipControllerTest` is the template), so a divergence is a failing
test rather than a bug report.
## Open questions
- Should `ask`'s timeout be configurable (a `playback.intro_prompt_seconds`
key like `next_up_prompt_seconds`)? Not in this cut; the constant is named
so it can become one.
- Whether the same tri-state should reach `auto_skip_credits` in the same
release train, or wait for the Next Up interaction to be specified.
+8
View File
@@ -1,5 +1,13 @@
# Feature Changelog
## 2026-08-16
### Let viewers turn the intro prompt off
Skipping intros stops being a switch and becomes a choice of three: leave intros alone, offer a Skip Intro button, or skip automatically and offer an undo.
- Adds `playback.intro_skip_mode` (`never` / `ask` / `always`, default `ask`) at contract revision 7 and deprecates `playback.auto_skip_intro`, which could not express "never".
- Migrates every stored auto-skip-intro preference onto the new key, on both the PostgreSQL and per-user SQLite backends, so nobody's existing choice changes.
- Mirrors the two keys at write time for one release, so a preference set on an older phone, TV, or browser still shows up correctly on an updated one.
## 2026-04-09
Covers commits from 2026-04-08 22:32 EDT through 2026-04-09 20:02 EDT.
+36
View File
@@ -131,6 +131,42 @@ Content-Type: application/json
Stored-value responses include `client_family` when the source or explicit row
is at `profile_client`.
### Superseded keys and the write mirror
A definition may be marked `deprecated: true` in the manifest. It is still
served, still readable and still writable — old clients depend on it — but new
clients should read and write its replacement.
Revision 7 deprecates `playback.auto_skip_intro` in favor of
`playback.intro_skip_mode`, whose three members say what the boolean could not:
`never` (no prompt at all), `ask` (offer a Skip Intro button, what the boolean's
`false` always did) and `always` (skip it and offer an undo). The default is
`ask`, so an untouched profile behaves identically across the cutover.
For one release the server keeps the pair in step, so a preference set on any
client shows up correctly on the others:
| Request | Server also does |
| -------------------------------------- | ------------------------------------------------------------- |
| `PUT` of `playback.auto_skip_intro` | writes `playback.intro_skip_mode` at the same identity: `true → "always"`, `false → "ask"` |
| `PUT` of `playback.intro_skip_mode` | writes `playback.auto_skip_intro` at the same identity: `"always" → true`, otherwise `false` |
| `DELETE` of either | removes the other at the same identity |
| `PUT`/`POST /profiles` with `auto_skip_intro` | writes both keys at `profile` scope |
| `PUT`/`DELETE` of the legacy `/settings/{key}` or `/settings/device/{key}` for `playback.auto_skip_intro` | writes or clears both keys at the scope that route owns |
| `playback.intro_skip_mode` at `profile` scope | updates `user_profiles.auto_skip_intro`, so the profile DTO stays truthful for clients that read it |
Both rows commit together on the idempotent write path, and a replayed
mutation id re-serves its receipt without writing either again. The response is
always the stored value of the key the request addressed; the companion row is
not reported. Only the addressed key raises a `user_settings.changed` event and
an audit record.
The boolean direction is lossy on purpose: a client that only understands the
switch sees `never` as `false` and shows the button. Such a client that then
flips the switch overwrites `never`, which is accepted for the overlap window
and is why the mirror is temporary. Once every client reads the enum, a
follow-up removes the mirror. Design: `docs/design/2026-08-16-intro-skip-mode.md`.
### Atomic navigation shortcuts
`nav.shortcuts` is a profile-wide catalog shared by TV, mobile, desktop, and
@@ -124,10 +124,21 @@ func planProfileSettingsSync(
if field.raw == nil {
continue
}
out = append(out, profileSettingSync{
key: field.key,
value: json.RawMessage(strconv.FormatBool(*field.raw)),
})
value := json.RawMessage(strconv.FormatBool(*field.raw))
out = append(out, profileSettingSync{key: field.key, value: value})
// A key that has a replacement carries it along, so the legacy profile
// route lands the same pair of rows the canonical route does. Without
// this, a client that still sets auto_skip_intro through PUT /profiles
// would leave intro_skip_mode resolving to the contract default and a
// new client would read a preference nobody chose.
mirror, ok, err := settingscontract.MirrorWrite(field.key, value)
if err != nil {
return nil, fmt.Errorf("%s: %w", field.key, err)
}
if ok {
out = append(out, profileSettingSync{key: mirror.Key, value: mirror.Value})
}
}
return out, nil
}
@@ -170,6 +170,10 @@ func TestUpdateProfileSyncsSkipPreferences(t *testing.T) {
settingskeys.PlaybackAutoSkipCredits: `true`,
settingskeys.PlaybackAutoSkipRecap: `true`,
settingskeys.PlaybackAutoPlayNextPreview: `false`,
// auto_skip_intro carries its revision-7 replacement with it, so a
// client still using the legacy route does not leave a current client
// resolving the contract default.
settingskeys.PlaybackIntroSkipMode: `"always"`,
} {
value := storedProfileSetting(t, store, key, "profile-1")
if value == nil {
@@ -196,6 +200,40 @@ func TestUpdateProfileSyncsSkipPreferences(t *testing.T) {
})
}
// TestUpdateProfileSyncsIntroSkipMode covers both directions of the legacy
// switch, including the one an untouched-looking false has to produce: "ask" is
// the mode that reproduces what auto_skip_intro=false always did, and a profile
// route that wrote only the boolean would leave the enum saying something else.
func TestUpdateProfileSyncsIntroSkipMode(t *testing.T) {
for _, tc := range []struct{ body, wantMode, wantBool string }{
{`{"auto_skip_intro":true}`, `"always"`, `true`},
{`{"auto_skip_intro":false}`, `"ask"`, `false`},
} {
t.Run(tc.body, func(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
if rr := updateProfileVia(t, handler, "profile-1", tc.body); rr.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
}
for key, want := range map[string]string{
settingskeys.PlaybackIntroSkipMode: tc.wantMode,
settingskeys.PlaybackAutoSkipIntro: tc.wantBool,
} {
value := storedProfileSetting(t, store, key, "profile-1")
if value == nil {
t.Errorf("no canonical %s row after the profile update", key)
continue
}
if string(value.Value) != want {
t.Errorf("canonical %s = %s, want %s", key, value.Value, want)
}
}
})
}
}
// TestUpdateProfileRejectsInvalidLanguageBeforeWriting: a value the canonical
// endpoint would refuse must fail the request as a no-op instead of leaving
// the column and the canonical store disagreeing.
@@ -1070,3 +1070,66 @@ func withRouteParams(req *http.Request, params map[string]string) *http.Request
}
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
}
// TestLegacyDeviceSettingCarriesIntroSkipMode closes the third write path onto
// the intro-skip pair. The shipped apps save this switch through the legacy
// generic route, not through /settings/values, so without the mirror in the
// runtime plan an updated client would resolve the contract default while the
// household's own device said otherwise.
func TestLegacyDeviceSettingCarriesIntroSkipMode(t *testing.T) {
store := newProfileTestStore(t)
handler := NewSettingsHandler(testUserStoreProvider{store: store})
send := func(method string, body []byte) *httptest.ResponseRecorder {
key := "playback.auto_skip_intro"
req := httptest.NewRequest(method, "/settings/device/"+key, bytes.NewReader(body))
req = withRouteParams(req, map[string]string{"key": key})
req.Header.Set(deviceIDHeader, "living-room")
req = req.WithContext(apimw.SetProfileID(
apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-1"))
rec := httptest.NewRecorder()
if method == http.MethodPut {
handler.HandleSetDeviceSetting(rec, req)
} else {
handler.HandleDeleteDeviceSetting(rec, req)
}
return rec
}
canonical := func(key string) *userstore.SettingValue {
t.Helper()
value, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: key, Scope: settingscontract.ScopeProfileDevice,
ProfileID: "profile-1", DeviceID: "living-room",
})
if err != nil {
t.Fatalf("GetSettingValue(%s): %v", key, err)
}
return value
}
for _, tc := range []struct{ body, wantBool, wantMode string }{
{`{"value":"true"}`, `true`, `"always"`},
{`{"value":"false"}`, `false`, `"ask"`},
} {
if rec := send(http.MethodPut, []byte(tc.body)); rec.Code != http.StatusNoContent {
t.Fatalf("PUT %s = %d: %s", tc.body, rec.Code, rec.Body.String())
}
if value := canonical("playback.auto_skip_intro"); value == nil || string(value.Value) != tc.wantBool {
t.Fatalf("canonical auto_skip_intro after %s = %+v, want %s", tc.body, value, tc.wantBool)
}
if value := canonical("playback.intro_skip_mode"); value == nil || string(value.Value) != tc.wantMode {
t.Fatalf("canonical intro_skip_mode after %s = %+v, want %s", tc.body, value, tc.wantMode)
}
}
// Clearing through the same route has to reach both rows, or the companion
// would go on overriding at a scope the device just gave up.
if rec := send(http.MethodDelete, nil); rec.Code != http.StatusNoContent {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String())
}
for _, key := range []string{"playback.auto_skip_intro", "playback.intro_skip_mode"} {
if value := canonical(key); value != nil {
t.Errorf("%s survived the legacy delete: %+v", key, value)
}
}
}
+103 -1
View File
@@ -813,6 +813,21 @@ func (h *SettingValuesHandler) setValueAt(
return
}
// A deprecated key and its replacement are one preference stored twice
// while old clients are in the field, so a write to either lands on both.
// The value is already normalized, so a conversion failure here is a defect
// in the pairing rather than bad input — refuse the request rather than
// store half of it.
mirror, hasMirror, err := settingscontract.MirrorWrite(identity.Key, normalized)
if err != nil {
slog.ErrorContext(r.Context(), "settings mirror could not convert a normalized value",
"component", "api", "key", identity.Key, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store the setting")
return
}
mirrorIdentity := identity
mirrorIdentity.Key = mirror.Key
// Idempotency: a client that retries a write after a dropped response must
// not double-apply it, and must be able to tell "already done" from "that
// id means something else".
@@ -821,12 +836,30 @@ func (h *SettingValuesHandler) setValueAt(
var idempotentResult json.RawMessage
if mutationID == "" {
stored, err = store.UpsertSettingValue(r.Context(), identity, normalized)
if err == nil && hasMirror {
// No transaction on this path — the store's plain write API has
// none — so the companion write is a second statement. Its failure
// is the caller's failure: a request that reported success while
// leaving the pair disagreeing is the exact drift the mirror
// exists to prevent, and both writes are upserts, so the retry a
// 500 provokes is safe.
_, err = store.UpsertSettingValue(r.Context(), mirrorIdentity, mirror.Value)
}
} 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 err != nil || !hasMirror {
return value, true, err
}
// Same transaction as the primary write and the replay
// receipt, so a replay re-serves the receipt without
// re-applying either row.
if _, err := writer.UpsertSettingValue(r.Context(), mirrorIdentity, mirror.Value); err != nil {
return nil, false, err
}
return value, true, nil
},
)
if errors.Is(mutationErr, errMutationIDConflict) {
@@ -855,6 +888,13 @@ func (h *SettingValuesHandler) setValueAt(
return
}
// GET /profiles still serves auto_skip_intro from the legacy column, so a
// profile-scope choice made through the new key has to reach it or the
// profile DTO keeps reporting the preference the household abandoned.
if !h.syncLegacyIntroSkipColumn(r, w, store, identity, mirror, hasMirror) {
return
}
response := settingValueToResponse(*stored)
acting := actingProfileID(r.Context())
if identity.Scope == settingscontract.ScopeProfileDevice {
@@ -891,6 +931,52 @@ func (h *SettingValuesHandler) setValueAt(
}
}
// syncLegacyIntroSkipColumn mirrors a profile-scope playback.intro_skip_mode
// write into user_profiles.auto_skip_intro, and reports whether the request may
// continue.
//
// It is deliberately one-directional. The canonical write path does not
// otherwise touch the legacy preference columns — the cutover direction is that
// the profile DTO stops reading them, which it already has for the language and
// subtitle fields — so this is not a general dual-write, only the narrow repair
// that keeps the one DTO field still served from its column truthful while the
// key that replaced it is the one clients write. A write of the deprecated
// boolean itself is left alone, exactly as it is today.
//
// A failure fails the request for the same reason the companion row write does:
// the caller asked for one preference change, and reporting success while half
// of it landed is what makes the two halves drift.
func (h *SettingValuesHandler) syncLegacyIntroSkipColumn(
r *http.Request,
w http.ResponseWriter,
store userstore.UserStore,
identity userstore.SettingIdentity,
mirror settingscontract.MirroredWrite,
hasMirror bool,
) bool {
if !hasMirror ||
identity.Key != settingskeys.PlaybackIntroSkipMode ||
identity.Scope != settingscontract.ScopeProfile {
return true
}
var enabled bool
if err := json.Unmarshal(mirror.Value, &enabled); err != nil {
slog.ErrorContext(r.Context(), "settings mirror produced a non-boolean auto_skip_intro",
"component", "api", "profile_id", identity.ProfileID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store the setting")
return false
}
if err := store.UpdateProfile(r.Context(), identity.ProfileID,
userstore.UpdateProfileInput{AutoSkipIntro: &enabled}); err != nil {
slog.ErrorContext(r.Context(), "failed to mirror intro skip mode into the profile column",
"component", "api", "profile_id", identity.ProfileID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store the setting")
return false
}
return true
}
// registerWritingDevice refreshes the device registry from the request's
// device headers after a successful profile_device write. Best effort and
// throttled: the value write already succeeded, and the registry entry is
@@ -965,6 +1051,22 @@ func (h *SettingValuesHandler) deleteValueAt(
writeError(w, http.StatusNotFound, "not_found", "No value is set at this scope")
return
}
// Clearing one half of a mirrored pair clears both: a surviving companion
// row would go on resolving as an explicit choice at a scope the caller
// just said it wanted to inherit at. Only attempted once the primary row
// really went away, so a 404 for "nothing set here" still means nothing was
// touched. A companion that was already absent removes nothing, which is
// success.
if mirrorKey, ok := settingscontract.MirrorKey(identity.Key); ok {
mirrorIdentity := identity
mirrorIdentity.Key = mirrorKey
if _, err := store.DeleteSettingValue(r.Context(), mirrorIdentity); err != nil {
slog.ErrorContext(r.Context(), "failed to clear the mirrored setting",
"component", "api", "key", identity.Key, "mirror_key", mirrorKey, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear the setting")
return
}
}
auditSettingsForOther(r.Context(), settingsAuditRecord{
Action: "clear",
ActorProfileID: actingProfileID(r.Context()),
@@ -0,0 +1,298 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// The intro-skip pair is one preference stored under two keys for one release:
// revision 7 replaced the boolean playback.auto_skip_intro with the three-way
// playback.intro_skip_mode, and every shipped client still reads the boolean.
// These tests hold the canonical write path to keeping the two in step, because
// a household whose two keys disagree gets a different intro behavior on every
// device it owns.
// storedValueAt reads one explicit row, whatever scope it lives at.
func storedValueAt(
t *testing.T, store userstore.UserStore, identity userstore.SettingIdentity,
) *userstore.SettingValue {
t.Helper()
value, err := store.GetSettingValue(context.Background(), identity)
if err != nil {
t.Fatalf("reading %s at %s: %v", identity.Key, identity.Scope, err)
}
return value
}
func profileIdentity(key string) userstore.SettingIdentity {
return userstore.SettingIdentity{
Key: key, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
}
}
func deviceIdentity(key string) userstore.SettingIdentity {
return userstore.SettingIdentity{
Key: key, Scope: settingscontract.ScopeProfileDevice,
ProfileID: "profile-1", DeviceID: "device-1",
}
}
// requireStored asserts a row exists and holds want.
func requireStored(
t *testing.T, store userstore.UserStore, identity userstore.SettingIdentity, want string,
) {
t.Helper()
value := storedValueAt(t, store, identity)
if value == nil {
t.Fatalf("no %s row at %s", identity.Key, identity.Scope)
}
if string(value.Value) != want {
t.Errorf("%s at %s = %s, want %s", identity.Key, identity.Scope, value.Value, want)
}
}
// TestWritingTheDeprecatedBooleanMirrorsTheEnum: an unmigrated client saves the
// switch and a current client must read the mode the household actually chose,
// not the contract default.
func TestWritingTheDeprecatedBooleanMirrorsTheEnum(t *testing.T) {
for name, tc := range map[string]struct{ body, wantMode string }{
"on": {`{"value":true}`, `"always"`},
"off": {`{"value":false}`, `"ask"`},
} {
t.Run(name, func(t *testing.T) {
handler, store := newValuesTestHandler(t)
rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackAutoSkipIntro, "scope=profile", []byte(tc.body))
if rec.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
requireStored(t, store, profileIdentity(settingskeys.PlaybackIntroSkipMode), tc.wantMode)
// The response is the key the caller addressed, unchanged: the
// mirror is a side effect, not a redirect.
var response settingValueResponse
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("decoding response: %v; body=%s", err, rec.Body.String())
}
if response.Key != settingskeys.PlaybackAutoSkipIntro {
t.Errorf("response key = %s, want the key that was written", response.Key)
}
})
}
}
// TestWritingTheEnumMirrorsTheDeprecatedBoolean covers all three modes,
// including the one the boolean cannot express.
func TestWritingTheEnumMirrorsTheDeprecatedBoolean(t *testing.T) {
for _, tc := range []struct{ mode, wantBool string }{
{"never", `false`},
{"ask", `false`},
{"always", `true`},
} {
t.Run(tc.mode, func(t *testing.T) {
handler, store := newValuesTestHandler(t)
rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackIntroSkipMode, "scope=profile",
[]byte(`{"value":"`+tc.mode+`"}`))
if rec.Code != http.StatusOK {
t.Fatalf("PUT %q = %d: %s", tc.mode, rec.Code, rec.Body.String())
}
requireStored(t, store, profileIdentity(settingskeys.PlaybackIntroSkipMode),
`"`+tc.mode+`"`)
requireStored(t, store, profileIdentity(settingskeys.PlaybackAutoSkipIntro), tc.wantBool)
})
}
}
// TestTheMirrorFollowsTheIdentity, not just the key: a device override must not
// silently become a profile-wide one.
func TestTheMirrorFollowsTheIdentity(t *testing.T) {
handler, store := newValuesTestHandler(t)
rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackIntroSkipMode, "scope=profile_device", []byte(`{"value":"always"}`))
if rec.Code != http.StatusOK {
t.Fatalf("device PUT = %d: %s", rec.Code, rec.Body.String())
}
requireStored(t, store, deviceIdentity(settingskeys.PlaybackAutoSkipIntro), `true`)
if value := storedValueAt(t, store, profileIdentity(settingskeys.PlaybackAutoSkipIntro)); value != nil {
t.Errorf("a device write left a profile-scope mirror row: %s", value.Value)
}
}
// TestDeletingEitherHalfClearsBoth. A row left behind would go on resolving as
// an explicit choice at the very scope the caller asked to inherit at.
func TestDeletingEitherHalfClearsBoth(t *testing.T) {
for _, deleted := range []string{
settingskeys.PlaybackIntroSkipMode,
settingskeys.PlaybackAutoSkipIntro,
} {
t.Run(deleted, func(t *testing.T) {
handler, store := newValuesTestHandler(t)
if rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackIntroSkipMode, "scope=profile",
[]byte(`{"value":"always"}`)); rec.Code != http.StatusOK {
t.Fatalf("seed PUT = %d: %s", rec.Code, rec.Body.String())
}
rec := routeValues(t, handler, http.MethodDelete, deleted, "scope=profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE %s = %d: %s", deleted, rec.Code, rec.Body.String())
}
for _, key := range []string{
settingskeys.PlaybackIntroSkipMode,
settingskeys.PlaybackAutoSkipIntro,
} {
if value := storedValueAt(t, store, profileIdentity(key)); value != nil {
t.Errorf("%s survived a DELETE of %s: %s", key, deleted, value.Value)
}
}
})
}
}
// TestDeletingAnAbsentValueTouchesNeitherHalf: the 404 for "nothing set here"
// still has to mean nothing was written, mirror included.
func TestDeletingAnAbsentValueTouchesNeitherHalf(t *testing.T) {
handler, store := newValuesTestHandler(t)
if rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackAutoSkipIntro, "scope=profile",
[]byte(`{"value":true}`)); rec.Code != http.StatusOK {
t.Fatalf("seed PUT = %d: %s", rec.Code, rec.Body.String())
}
// Nothing was ever written at device scope, so this clears nothing —
// including the profile-scope pair, which belongs to another identity.
rec := routeValues(t, handler, http.MethodDelete,
settingskeys.PlaybackIntroSkipMode, "scope=profile_device", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("DELETE of an unset device value = %d, want 404: %s", rec.Code, rec.Body.String())
}
requireStored(t, store, profileIdentity(settingskeys.PlaybackAutoSkipIntro), `true`)
requireStored(t, store, profileIdentity(settingskeys.PlaybackIntroSkipMode), `"always"`)
}
// TestMirroredWritesReplayInsteadOfDoubleApplying. The mirror rides inside the
// mutation's transaction, so a retried write must re-serve the receipt and
// leave both rows at the revision the first write produced.
func TestMirroredWritesReplayInsteadOfDoubleApplying(t *testing.T) {
handler, store := newValuesTestHandler(t)
send := func(mutationID, value string) *httptest.ResponseRecorder {
req := valuesRequest(http.MethodPut,
"/settings/values/"+settingskeys.PlaybackIntroSkipMode+"?scope=profile",
[]byte(`{"value":`+value+`}`))
req.Header.Set(mutationIDHeader, mutationID)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("key", settingskeys.PlaybackIntroSkipMode)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rec := httptest.NewRecorder()
handler.HandleSetValue(rec, req)
return rec
}
if rec := send("mut-intro", `"always"`); rec.Code != http.StatusOK {
t.Fatalf("first write = %d: %s", rec.Code, rec.Body.String())
}
mirrored := storedValueAt(t, store, profileIdentity(settingskeys.PlaybackAutoSkipIntro))
if mirrored == nil {
t.Fatal("the first write stored no mirror row")
}
replay := send("mut-intro", `"always"`)
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())
}
after := storedValueAt(t, store, profileIdentity(settingskeys.PlaybackAutoSkipIntro))
if after == nil {
t.Fatal("the replay removed the mirror row")
}
if after.Revision != mirrored.Revision {
t.Errorf("mirror revision moved from %d to %d on replay; the replay wrote again",
mirrored.Revision, after.Revision)
}
if string(after.Value) != `true` {
t.Errorf("mirror value = %s after replay, want true", after.Value)
}
}
// TestProfileScopeEnumWriteUpdatesTheLegacyColumn. GET /profiles still serves
// auto_skip_intro from user_profiles, so a household that switches intros off
// through the new key must not keep seeing the old answer in the profile DTO.
func TestProfileScopeEnumWriteUpdatesTheLegacyColumn(t *testing.T) {
handler, store := newValuesTestHandler(t)
if err := store.UpdateProfile(context.Background(), "profile-1",
userstore.UpdateProfileInput{AutoSkipIntro: boolPtr(true)}); err != nil {
t.Fatalf("seeding the column: %v", err)
}
rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackIntroSkipMode, "scope=profile", []byte(`{"value":"never"}`))
if rec.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
profile, err := store.GetProfile(context.Background(), "profile-1")
if err != nil || profile == nil {
t.Fatalf("reading profile: %v", err)
}
if profile.AutoSkipIntro {
t.Error("user_profiles.auto_skip_intro still true after intro_skip_mode was set to never")
}
// And back the other way, so the column tracks the mode rather than only
// ever being cleared.
if rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackIntroSkipMode, "scope=profile",
[]byte(`{"value":"always"}`)); rec.Code != http.StatusOK {
t.Fatalf("second PUT = %d: %s", rec.Code, rec.Body.String())
}
profile, err = store.GetProfile(context.Background(), "profile-1")
if err != nil || profile == nil {
t.Fatalf("re-reading profile: %v", err)
}
if !profile.AutoSkipIntro {
t.Error("user_profiles.auto_skip_intro still false after intro_skip_mode was set to always")
}
}
// TestDeviceScopeEnumWriteLeavesTheProfileColumnAlone: the column is
// profile-wide, and one television's override is not the household's choice.
func TestDeviceScopeEnumWriteLeavesTheProfileColumnAlone(t *testing.T) {
handler, store := newValuesTestHandler(t)
rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackIntroSkipMode, "scope=profile_device", []byte(`{"value":"always"}`))
if rec.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
profile, err := store.GetProfile(context.Background(), "profile-1")
if err != nil || profile == nil {
t.Fatalf("reading profile: %v", err)
}
if profile.AutoSkipIntro {
t.Error("a device-scope write moved the profile-wide column")
}
}
@@ -111,6 +111,20 @@ SELECT COUNT(*) FROM user_setting_values
}
})
t.Run("auto_skip_intro carries its revision-7 replacement", func(t *testing.T) {
var value string
err := pool.QueryRow(ctx, `
SELECT value::text FROM user_setting_values
WHERE key = 'playback.intro_skip_mode' AND scope = 'profile' AND profile_id = 'mp1'`).
Scan(&value)
if err != nil {
t.Fatalf("reading migrated intro_skip_mode: %v", err)
}
if value != `"always"` {
t.Errorf("intro_skip_mode = %s, want \"always\"", value)
}
})
t.Run("metadata language migrates from the postgres-only column", func(t *testing.T) {
var value string
err := pool.QueryRow(ctx, `
+111
View File
@@ -0,0 +1,111 @@
package settingscontract
import (
"encoding/json"
"fmt"
"github.com/Silo-Server/silo-server/internal/settingskeys"
)
// Key relationships: settings that are two spellings of one preference.
//
// A deprecated key is not free to leave behind. Every shipped client reads
// playback.auto_skip_intro, and revision 7 replaced it with the three-way
// playback.intro_skip_mode, so for one release the server keeps the pair in
// step at write time: a preference set on an old client shows up on a new one
// and the other way round. See docs/design/2026-08-16-intro-skip-mode.md.
//
// The pairing lives here, next to the contract that declares both keys, rather
// than in the handlers. Four write paths need it — the canonical mutation
// route, its delete, the legacy profile route, and the one-time migration
// planner — and a mapping that disagreed between any two of them would be a
// preference that changes meaning depending on which client last touched it.
// Intro-skip modes. These are the enum members playback.intro_skip_mode
// declares; the manifest is the source of truth and Validate proves a default
// or stored value is one of them, but the mirror has to name them to convert.
const (
IntroSkipModeNever = "never"
IntroSkipModeAsk = "ask"
IntroSkipModeAlways = "always"
)
// MirroredWrite is the companion row implied by writing another key.
type MirroredWrite struct {
Key string
Value json.RawMessage
}
// MirrorKey names the key whose row travels with this one, and reports whether
// there is one. Used by the delete path, which has no value to convert:
// clearing either half of a mirrored pair clears both, or the surviving row
// would resolve as an explicit choice nobody made.
func MirrorKey(key string) (string, bool) {
switch key {
case settingskeys.PlaybackAutoSkipIntro:
return settingskeys.PlaybackIntroSkipMode, true
case settingskeys.PlaybackIntroSkipMode:
return settingskeys.PlaybackAutoSkipIntro, true
default:
return "", false
}
}
// MirrorWrite converts a value written at key into the companion row that must
// be written alongside it.
//
// The second result is false when key has no mirror at all, which is the
// common case and not an error. An error means the key does have a mirror but
// the value is not one the pairing can express — impossible for a value that
// came through NormalizeValue, and therefore a defect rather than bad input,
// so callers must surface it rather than skip the companion write.
//
// The boolean direction is lossy on purpose: "never" and "ask" both mean
// "don't skip it for me" to a client that only understands the switch, so both
// map to false. An old client that then flips that switch overwrites "never" —
// accepted for the overlap window, and the reason the mirror is temporary.
func MirrorWrite(key string, value json.RawMessage) (MirroredWrite, bool, error) {
mirror, ok := MirrorKey(key)
if !ok {
return MirroredWrite{}, false, nil
}
switch key {
case settingskeys.PlaybackAutoSkipIntro:
var enabled bool
if err := json.Unmarshal(value, &enabled); err != nil {
return MirroredWrite{}, false, fmt.Errorf(
"%s: mirroring to %s needs a boolean, got %s", key, mirror, value)
}
mode := IntroSkipModeAsk
if enabled {
mode = IntroSkipModeAlways
}
encoded, err := json.Marshal(mode)
if err != nil {
return MirroredWrite{}, false, fmt.Errorf("%s: encoding %s: %w", key, mirror, err)
}
return MirroredWrite{Key: mirror, Value: encoded}, true, nil
case settingskeys.PlaybackIntroSkipMode:
var mode string
if err := json.Unmarshal(value, &mode); err != nil {
return MirroredWrite{}, false, fmt.Errorf(
"%s: mirroring to %s needs a string, got %s", key, mirror, value)
}
switch mode {
case IntroSkipModeNever, IntroSkipModeAsk, IntroSkipModeAlways:
default:
return MirroredWrite{}, false, fmt.Errorf(
"%s: %q is not an intro skip mode", key, mode)
}
encoded := json.RawMessage("false")
if mode == IntroSkipModeAlways {
encoded = json.RawMessage("true")
}
return MirroredWrite{Key: mirror, Value: encoded}, true, nil
default:
return MirroredWrite{}, false, nil
}
}
+158
View File
@@ -0,0 +1,158 @@
package settingscontract
import (
"encoding/json"
"slices"
"testing"
"github.com/Silo-Server/silo-server/internal/settingskeys"
)
func TestMirrorWriteConvertsBothDirections(t *testing.T) {
for name, tc := range map[string]struct {
key string
value string
wantKey string
wantValue string
}{
"switch on means always": {
settingskeys.PlaybackAutoSkipIntro, `true`,
settingskeys.PlaybackIntroSkipMode, `"always"`,
},
"switch off means ask": {
settingskeys.PlaybackAutoSkipIntro, `false`,
settingskeys.PlaybackIntroSkipMode, `"ask"`,
},
"always means switch on": {
settingskeys.PlaybackIntroSkipMode, `"always"`,
settingskeys.PlaybackAutoSkipIntro, `true`,
},
"ask means switch off": {
settingskeys.PlaybackIntroSkipMode, `"ask"`,
settingskeys.PlaybackAutoSkipIntro, `false`,
},
// The lossy edge the design accepts: a client that only knows the
// switch cannot express "leave intros alone", and false is the closer
// of the two answers it can give.
"never also means switch off": {
settingskeys.PlaybackIntroSkipMode, `"never"`,
settingskeys.PlaybackAutoSkipIntro, `false`,
},
} {
t.Run(name, func(t *testing.T) {
mirror, ok, err := MirrorWrite(tc.key, json.RawMessage(tc.value))
if err != nil {
t.Fatalf("MirrorWrite(%s, %s): %v", tc.key, tc.value, err)
}
if !ok {
t.Fatalf("MirrorWrite(%s) reported no mirror", tc.key)
}
if mirror.Key != tc.wantKey {
t.Errorf("mirror key = %s, want %s", mirror.Key, tc.wantKey)
}
if string(mirror.Value) != tc.wantValue {
t.Errorf("mirror value = %s, want %s", mirror.Value, tc.wantValue)
}
})
}
}
// TestMirrorWriteRoundTripsThroughTheBoolean pins the one-way loss: every mode
// survives a round trip except "never", which the boolean cannot hold.
func TestMirrorWriteRoundTripsThroughTheBoolean(t *testing.T) {
for _, mode := range []string{IntroSkipModeNever, IntroSkipModeAsk, IntroSkipModeAlways} {
encoded, err := json.Marshal(mode)
if err != nil {
t.Fatalf("encoding %q: %v", mode, err)
}
boolean, _, err := MirrorWrite(settingskeys.PlaybackIntroSkipMode, encoded)
if err != nil {
t.Fatalf("MirrorWrite(%q): %v", mode, err)
}
back, _, err := MirrorWrite(settingskeys.PlaybackAutoSkipIntro, boolean.Value)
if err != nil {
t.Fatalf("MirrorWrite back from %s: %v", boolean.Value, err)
}
want := mode
if mode == IntroSkipModeNever {
want = IntroSkipModeAsk
}
if string(back.Value) != `"`+want+`"` {
t.Errorf("%q round-tripped to %s, want %q", mode, back.Value, want)
}
}
}
func TestMirrorWriteIgnoresUnpairedKeys(t *testing.T) {
for _, key := range []string{
settingskeys.PlaybackAutoSkipCredits,
settingskeys.PlaybackSubtitleMode,
"totally.invented.key",
} {
if _, ok, err := MirrorWrite(key, json.RawMessage(`true`)); ok || err != nil {
t.Errorf("MirrorWrite(%s) = ok %v err %v, want no mirror and no error", key, ok, err)
}
if _, ok := MirrorKey(key); ok {
t.Errorf("MirrorKey(%s) reported a mirror", key)
}
}
}
// TestMirrorWriteRefusesValuesItCannotConvert: the callers write the companion
// row on the strength of this result, so a value the pairing does not
// understand has to be an error rather than a silently skipped write.
func TestMirrorWriteRefusesValuesItCannotConvert(t *testing.T) {
for name, tc := range map[string]struct{ key, value string }{
"boolean key given a string": {settingskeys.PlaybackAutoSkipIntro, `"yes"`},
"enum key given a boolean": {settingskeys.PlaybackIntroSkipMode, `true`},
"enum key given a non-member": {
settingskeys.PlaybackIntroSkipMode, `"sideways"`,
},
} {
t.Run(name, func(t *testing.T) {
if _, ok, err := MirrorWrite(tc.key, json.RawMessage(tc.value)); err == nil || ok {
t.Errorf("MirrorWrite(%s, %s) = ok %v err %v, want an error", tc.key, tc.value, ok, err)
}
})
}
}
// TestMirroredKeysAgreeInTheContract keeps the pairing honest against the
// manifest. The write path lands the companion row at the identity the caller
// addressed without re-checking it, so the two definitions have to accept the
// same scopes — a pair that drifted apart would let a legal write produce a
// companion the contract forbids.
func TestMirroredKeysAgreeInTheContract(t *testing.T) {
manifest, err := Load()
if err != nil {
t.Fatalf("loading manifest: %v", err)
}
for _, key := range []string{
settingskeys.PlaybackAutoSkipIntro,
settingskeys.PlaybackIntroSkipMode,
} {
mirrorKey, ok := MirrorKey(key)
if !ok {
t.Fatalf("%s has no mirror", key)
}
if back, _ := MirrorKey(mirrorKey); back != key {
t.Errorf("MirrorKey(%s) = %s, want the pairing to be symmetric", mirrorKey, back)
}
def, found := manifest.Lookup(key)
if !found {
t.Fatalf("%s is mirrored but has no definition", key)
}
mirrorDef, found := manifest.Lookup(mirrorKey)
if !found {
t.Fatalf("%s is mirrored but has no definition", mirrorKey)
}
if !def.IsRemote() || !mirrorDef.IsRemote() {
t.Errorf("%s/%s are mirrored but not both server-stored", key, mirrorKey)
}
if !slices.Equal(def.AllowedScopes, mirrorDef.AllowedScopes) {
t.Errorf("%s allows %v but %s allows %v; a write to one could not be mirrored onto the other",
key, def.AllowedScopes, mirrorKey, mirrorDef.AllowedScopes)
}
}
}
+4 -1
View File
@@ -9,7 +9,7 @@
package settingskeys
// Revision is the manifest revision these bindings were generated from.
const Revision = 6
const Revision = 7
// Setting keys, one constant per definition.
const (
@@ -41,6 +41,8 @@ const (
PlaybackAutoSkipIntro = "playback.auto_skip_intro"
// Auto-skip recaps
PlaybackAutoSkipRecap = "playback.auto_skip_recap"
// Skip intros
PlaybackIntroSkipMode = "playback.intro_skip_mode"
// Maximum bitrate
PlaybackMaxBitrateKbps = "playback.max_bitrate_kbps"
// Next up prompt
@@ -133,6 +135,7 @@ var Remote = []string{
PlaybackAutoSkipCredits,
PlaybackAutoSkipIntro,
PlaybackAutoSkipRecap,
PlaybackIntroSkipMode,
PlaybackMaxBitrateKbps,
PlaybackNextUpPromptSeconds,
PlaybackPreferredQuality,
+92 -8
View File
@@ -193,10 +193,10 @@ func (p *Planner) PlanRuntimeValue(legacyKey, raw string) ([]RuntimeValue, error
for _, row := range result.Rows {
planned[row.Key] = row.Value
}
return []RuntimeValue{
return withRuntimeMirrors([]RuntimeValue{
{Key: keyPreferredQuality, Value: planned[keyPreferredQuality]},
{Key: settingskeys.PlaybackMaxBitrateKbps, Value: planned[settingskeys.PlaybackMaxBitrateKbps]},
}, nil
})
}
def, ok := p.contract.Lookup(key)
@@ -207,20 +207,68 @@ func (p *Planner) PlanRuntimeValue(legacyKey, raw string) ([]RuntimeValue, error
if err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
return []RuntimeValue{{Key: key, Value: value}}, nil
return withRuntimeMirrors([]RuntimeValue{{Key: key, Value: value}})
}
// withRuntimeMirrors appends the companion mutation for every planned value
// whose key has a replacement.
//
// It sits here rather than in the handler because three legacy write paths
// share PlanRuntimeValue — the account-wide fan-out, the per-device setting,
// and the inheritance a newly created profile picks up — and a mirror applied
// at only some of them would make a preference's meaning depend on which
// legacy route last touched it.
//
// A nil Value means "clear this row", and the companion is cleared with it for
// the same reason DELETE clears both: a surviving half would go on resolving as
// an explicit choice nobody made.
func withRuntimeMirrors(planned []RuntimeValue) ([]RuntimeValue, error) {
out := make([]RuntimeValue, 0, len(planned)+1)
out = append(out, planned...)
for _, mutation := range planned {
if mutation.Value == nil {
if mirrorKey, ok := settingscontract.MirrorKey(mutation.Key); ok {
out = append(out, RuntimeValue{Key: mirrorKey})
}
continue
}
mirror, ok, err := settingscontract.MirrorWrite(mutation.Key, mutation.Value)
if err != nil {
return nil, err
}
if ok {
out = append(out, RuntimeValue{Key: mirror.Key, Value: mirror.Value})
}
}
return out, nil
}
// RuntimeKeys returns every canonical row owned by a legacy generic key. It is
// used by DELETE, where there is no value to run through PlanRuntimeValue.
func (p *Planner) RuntimeKeys(legacyKey string) []string {
key := CanonicalKey(legacyKey)
if key == keyPreferredQuality {
return []string{keyPreferredQuality, settingskeys.PlaybackMaxBitrateKbps}
var keys []string
switch key {
case keyPreferredQuality:
keys = []string{keyPreferredQuality, settingskeys.PlaybackMaxBitrateKbps}
default:
if def, ok := p.contract.Lookup(key); ok && def.IsRemote() {
keys = []string{key}
}
}
if def, ok := p.contract.Lookup(key); ok && def.IsRemote() {
return []string{key}
if len(keys) == 0 {
return nil // the caller reads this as "no canonical target"
}
return nil
// Clearing through the legacy route has to reach the same rows a write
// through it created, mirror included.
out := make([]string, 0, len(keys)+1)
out = append(out, keys...)
for _, owned := range keys {
if mirrorKey, ok := settingscontract.MirrorKey(owned); ok {
out = append(out, mirrorKey)
}
}
return out
}
// profileColumnDefaults are the values the legacy schema wrote when nobody
@@ -346,6 +394,7 @@ func (p *Planner) Plan(in Input) Result {
p.planDeviceSettings(in.DeviceSettings, &res)
p.planSeriesPrefs(in.SeriesPrefs, &res)
p.planLibraryPrefs(in.LibraryPrefs, &res)
p.planMirroredRows(&res)
res.Rows = dedupeRows(res.Rows)
res.Rows = dropOrphanProfileRows(res.Rows, in.Profiles, &res)
@@ -353,6 +402,41 @@ func (p *Planner) Plan(in Input) Result {
return res
}
// planMirroredRows carries every planned row whose key has a replacement onto
// that replacement, at the same identity.
//
// The SQL migration that introduced playback.intro_skip_mode copies the rows
// that were already canonical when it ran. This is the other half: the backfill
// converts a user's legacy storage the first time their store opens, which for
// an install upgrading later is after that migration, so a legacy
// auto_skip_intro would otherwise arrive with no companion and a current client
// would read the contract default instead of the household's choice.
//
// It runs over the planned rows rather than inside planProfiles so it covers
// every source a mirrored key can come from — the profile column, the account
// table's fan-out, and per-device rows alike — and cannot fall behind when one
// of them changes. Companions are appended, so dedupeRows keeps an explicitly
// stored value ahead of a derived one.
//
// A conversion failure is dropped rather than rejected: the source row was
// planned, which means it already validated against the contract, so the only
// way to fail here is a defect in the pairing, and losing the companion is
// strictly better than losing the row that produced it.
func (p *Planner) planMirroredRows(res *Result) {
planned := len(res.Rows)
for i := 0; i < planned; i++ {
row := res.Rows[i]
mirror, ok, err := settingscontract.MirrorWrite(row.Key, row.Value)
if err != nil || !ok {
continue
}
companion := row
companion.Key = mirror.Key
companion.Value = mirror.Value
res.Rows = append(res.Rows, companion)
}
}
// dropOrphanProfileRows removes rows whose profile no longer exists.
//
// The legacy per-profile tables carry an ON DELETE CASCADE on (user_id,
+107
View File
@@ -2,6 +2,7 @@ package settingsmigrate
import (
"encoding/json"
"slices"
"strings"
"testing"
@@ -532,6 +533,61 @@ func TestAutoSkipColumnsMigrateOnlyWhenTrue(t *testing.T) {
}
}
// TestAutoSkipIntroCarriesItsReplacement: the backfill runs the first time a
// user's store opens, which for a deployment upgrading later is after the SQL
// migration that copied the already-canonical rows. A legacy auto_skip_intro
// arriving after that would have no companion, and a current client would read
// the contract default instead of the household's choice.
func TestAutoSkipIntroCarriesItsReplacement(t *testing.T) {
res := planner(t).Plan(Input{
Profiles: []LegacyProfile{{ID: "p1", AutoSkipIntro: boolp(true)}},
DeviceSettings: []LegacyDeviceSetting{
{ProfileID: "p1", DeviceID: "d1", Key: "playback.auto_skip_intro", Value: "false"},
},
})
if len(res.Rejects) != 0 {
t.Fatalf("unexpected rejects: %+v", res.Rejects)
}
profileRow := find(t, res, "playback.intro_skip_mode", func(row Row) bool {
return row.Scope == settingscontract.ScopeProfile
})
if string(profileRow.Value) != `"always"` {
t.Errorf("profile intro_skip_mode = %s, want \"always\"", profileRow.Value)
}
deviceRow := find(t, res, "playback.intro_skip_mode", func(row Row) bool {
return row.Scope == settingscontract.ScopeProfileDevice
})
if string(deviceRow.Value) != `"ask"` || deviceRow.DeviceID != "d1" {
t.Errorf("device intro_skip_mode = %s on %q, want \"ask\" on d1",
deviceRow.Value, deviceRow.DeviceID)
}
// A false column is still not a decision, so it produces neither key.
bare := planner(t).Plan(Input{Profiles: []LegacyProfile{{ID: "p1", AutoSkipIntro: boolp(false)}}})
if hasKey(bare, "playback.intro_skip_mode") {
t.Error("an untouched false column became a stored intro_skip_mode choice")
}
}
// TestAnExplicitlyStoredModeOutranksTheDerivedOne. Once a client writes the
// enum through the legacy device route, the boolean beside it is the older
// answer, and the backfill must not overwrite the newer one with it.
func TestAnExplicitlyStoredModeOutranksTheDerivedOne(t *testing.T) {
res := planner(t).Plan(Input{
Profiles: []LegacyProfile{{ID: "p1"}},
DeviceSettings: []LegacyDeviceSetting{
{ProfileID: "p1", DeviceID: "d1", Key: "playback.intro_skip_mode", Value: "never"},
{ProfileID: "p1", DeviceID: "d1", Key: "playback.auto_skip_intro", Value: "true"},
},
})
row := find(t, res, "playback.intro_skip_mode", nil)
if string(row.Value) != `"never"` {
t.Errorf("intro_skip_mode = %s, want the explicitly stored \"never\"", row.Value)
}
}
// TestCardOverlaysV1DocumentsUpgrade: old web clients stored a flat
// Record<overlayId, config> the contract schema does not accept. The planner
// has to upgrade it the way web/src/lib/overlays/schema.ts does at read time,
@@ -730,6 +786,57 @@ func TestPlanRuntimeValueUsesMigrationAliasesAndQualityDecomposition(t *testing.
}
}
// TestRuntimePlansCarryMirroredKeys covers the legacy generic settings routes.
// They write canonically through this planner, so without the mirror here a
// client that saves the intro switch through /settings/{key} — the route the
// shipped apps still use for device preferences — would leave intro_skip_mode
// resolving to the contract default. The mirror lives in the plan rather than
// in one handler because the account fan-out, the per-device write, and new
// profile inheritance all share it.
func TestRuntimePlansCarryMirroredKeys(t *testing.T) {
p := planner(t)
for _, tc := range []struct{ raw, wantMode string }{
{"true", `"always"`},
{"false", `"ask"`},
} {
planned, err := p.PlanRuntimeValue("playback.auto_skip_intro", tc.raw)
if err != nil {
t.Fatalf("PlanRuntimeValue(%s): %v", tc.raw, err)
}
if len(planned) != 2 {
t.Fatalf("plan for %s = %+v, want the boolean and its replacement", tc.raw, planned)
}
if planned[0].Key != "playback.auto_skip_intro" || string(planned[0].Value) != tc.raw {
t.Errorf("primary mutation = %+v, want the key that was written", planned[0])
}
if planned[1].Key != "playback.intro_skip_mode" || string(planned[1].Value) != tc.wantMode {
t.Errorf("companion mutation = %+v, want intro_skip_mode %s", planned[1], tc.wantMode)
}
}
// DELETE has no value to convert, so it works off the owned-key list. It
// has to reach every row a write through the same route created.
keys := p.RuntimeKeys("playback.auto_skip_intro")
if !slices.Equal(keys, []string{"playback.auto_skip_intro", "playback.intro_skip_mode"}) {
t.Errorf("RuntimeKeys = %v, want both halves of the pair", keys)
}
// An unpaired key is unchanged, and quality still decomposes into exactly
// its two axes rather than gaining a phantom third row.
if keys := p.RuntimeKeys("playback.auto_skip_credits"); !slices.Equal(
keys, []string{"playback.auto_skip_credits"}) {
t.Errorf("RuntimeKeys for an unpaired key = %v", keys)
}
if keys := p.RuntimeKeys("playback.preferred_quality"); !slices.Equal(
keys, []string{"playback.preferred_quality", "playback.max_bitrate_kbps"}) {
t.Errorf("RuntimeKeys for quality = %v", keys)
}
if keys := p.RuntimeKeys("totally.invented.key"); keys != nil {
t.Errorf("RuntimeKeys for an unknown key = %v, want nil", keys)
}
}
func TestOrphanRejectsPreserveSourceTableAndContentIdentity(t *testing.T) {
res := planner(t).Plan(Input{
Profiles: []LegacyProfile{{ID: "live"}},
+53
View File
@@ -415,9 +415,62 @@ func InitSchema(db *sql.DB) error {
if err := migratePlaybackSettingsToDeviceScope(db); err != nil {
return err
}
if err := migrateAutoSkipIntroToIntroSkipMode(db); err != nil {
return err
}
return backfillUserDevices(db)
}
// migrateAutoSkipIntroToIntroSkipMode is this backend's half of the revision-7
// intro-skip cutover, matching the Goose migration that does the same for
// PostgreSQL's user_setting_values.
//
// Only already-canonical rows are its business. A store whose legacy tables
// have not been converted yet gets its companion rows from
// settingsmigrate.Planner when migrateSettingsToCanonical runs, which is where
// both backends share the conversion rules; this covers the rows a previous
// open already wrote, which that pass will never look at again.
//
// The NOT EXISTS guard, not just INSERT OR IGNORE, is what makes a re-run cheap
// and — more importantly — keeps it from ever contradicting an enum a client
// wrote itself. Nobody can hold "never" yet, so no choice is lost on the way in.
func migrateAutoSkipIntroToIntroSkipMode(db *sql.DB) error {
_, err := db.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
)
SELECT
'playback.intro_skip_mode',
legacy.scope,
legacy.profile_id,
legacy.client_family,
legacy.device_id,
legacy.library_id,
legacy.series_id,
CASE WHEN json(legacy.value) = json('true') THEN '"always"' ELSE '"ask"' END,
1,
legacy.created_at,
legacy.updated_at
FROM user_setting_values AS legacy
WHERE legacy.key = 'playback.auto_skip_intro'
AND NOT EXISTS (
SELECT 1
FROM user_setting_values AS existing
WHERE existing.key = 'playback.intro_skip_mode'
AND existing.scope = legacy.scope
AND existing.profile_id IS legacy.profile_id
AND existing.client_family IS legacy.client_family
AND existing.device_id IS legacy.device_id
AND existing.library_id IS legacy.library_id
AND existing.series_id IS legacy.series_id
)`)
if err != nil {
return fmt.Errorf("backfilling playback.intro_skip_mode from playback.auto_skip_intro: %w", err)
}
return nil
}
// 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
@@ -420,3 +420,79 @@ func TestMigrateToV16IsIdempotentUnderReRun(t *testing.T) {
t.Error("a second migration run was accepted; values would be duplicated")
}
}
// TestInitSchemaCarriesAutoSkipIntroOntoIntroSkipMode covers this backend's half
// of the revision-7 cutover: rows that were already canonical when the release
// landed, which the legacy backfill will never look at again because it has
// already run.
//
// The PostgreSQL side of the same copy is a Goose migration; both have to reach
// the same rows or a household's intro behavior would depend on which backend
// its account happens to live in.
func TestInitSchemaCarriesAutoSkipIntroOntoIntroSkipMode(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("InitSchema: %v", err)
}
// Three identities a real store can hold the boolean at, plus an enum a
// client already chose for itself.
for _, row := range []struct{ scope, profile, device, key, value string }{
{"profile", "p1", "", "playback.auto_skip_intro", "true"},
{"profile", "p2", "", "playback.auto_skip_intro", "false"},
{"profile_device", "p1", "d1", "playback.auto_skip_intro", "true"},
{"profile_device", "p1", "d2", "playback.auto_skip_intro", "true"},
{"profile_device", "p1", "d2", "playback.intro_skip_mode", `"never"`},
} {
if _, err := db.Exec(`
INSERT INTO user_setting_values
(key, scope, profile_id, device_id, value, revision, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 1, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`,
row.key, row.scope, nullableText(row.profile), nullableText(row.device),
row.value); err != nil {
t.Fatalf("seeding %s at %s: %v", row.key, row.scope, err)
}
}
// The copy runs on open, so re-running InitSchema is how a deployment
// upgrading into this release reaches it.
if err := InitSchema(db); err != nil {
t.Fatalf("second InitSchema: %v", err)
}
for _, want := range []struct {
scope, where, value string
args []any
}{
{"profile", "profile_id = ?", `"always"`, []any{"p1"}},
{"profile", "profile_id = ?", `"ask"`, []any{"p2"}},
{"profile_device", "profile_id = ? AND device_id = ?", `"always"`, []any{"p1", "d1"}},
// The client's own choice survives; the boolean beside it does not
// overwrite the mode that cannot be spelled as a boolean.
{"profile_device", "profile_id = ? AND device_id = ?", `"never"`, []any{"p1", "d2"}},
} {
got, ok := canonicalValue(t, db, "playback.intro_skip_mode", want.scope, want.where, want.args...)
if !ok || got != want.value {
t.Errorf("intro_skip_mode at %s %v = %q (found=%v), want %s",
want.scope, want.args, got, ok, want.value)
}
}
// Idempotent: opening the store again must not duplicate or disturb a row.
if err := InitSchema(db); err != nil {
t.Fatalf("third InitSchema: %v", err)
}
var count int
if err := db.QueryRow(
`SELECT COUNT(*) FROM user_setting_values WHERE key = 'playback.intro_skip_mode'`,
).Scan(&count); err != nil {
t.Fatalf("counting intro_skip_mode rows: %v", err)
}
if count != 4 {
t.Errorf("intro_skip_mode rows = %d, want 4 after repeated opens", count)
}
}
@@ -0,0 +1,50 @@
-- Carry every stored playback.auto_skip_intro onto playback.intro_skip_mode.
--
-- Contract revision 7 replaces the boolean with a three-way enum: the boolean
-- could only say "prompt" or "count down then skip", and had no way to turn the
-- prompt off. Both spellings stay live for one release — every shipped client
-- reads the boolean — so each existing choice needs its enum row or a current
-- client would resolve the contract default and quietly discard a preference
-- the household already made.
--
-- Nobody can hold "never" yet, so nothing becomes unrepresentable here.
-- ON CONFLICT DO NOTHING covers the partial unique index on each scope, which
-- makes a re-run a no-op and, more importantly, never overwrites an enum a
-- client wrote itself. See docs/design/2026-08-16-intro-skip-mode.md.
-- +goose Up
-- +goose StatementBegin
INSERT INTO public.user_setting_values (
user_id,
key,
scope,
profile_id,
client_family,
device_id,
library_id,
series_id,
value
)
SELECT
legacy.user_id,
'playback.intro_skip_mode',
legacy.scope,
legacy.profile_id,
legacy.client_family,
legacy.device_id,
legacy.library_id,
legacy.series_id,
CASE WHEN legacy.value = 'true'::jsonb
THEN '"always"'::jsonb
ELSE '"ask"'::jsonb
END
FROM public.user_setting_values AS legacy
WHERE legacy.key = 'playback.auto_skip_intro'
ON CONFLICT DO NOTHING;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DELETE FROM public.user_setting_values
WHERE key = 'playback.intro_skip_mode';
-- +goose StatementEnd
+43 -1
View File
@@ -1,6 +1,6 @@
{
"fixture_version": 1,
"manifest_revision": 6,
"manifest_revision": 7,
"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": [
{
@@ -668,6 +668,48 @@
}
]
},
{
"name": "intro_skip_mode_defaults_to_ask",
"description": "A profile that has never chosen an intro behaviour gets the contract default, which is the same prompt the old auto_skip_intro=false produced.",
"keys": ["playback.intro_skip_mode"],
"context": { "profile_id": "p1", "device_id": "d1" },
"stored": [],
"expected": [
{
"key": "playback.intro_skip_mode",
"value": "ask",
"source": "default"
}
]
},
{
"name": "intro_skip_mode_device_override_beats_profile",
"description": "A living-room television set to skip intros automatically does not change the profile-wide choice to leave them alone.",
"keys": ["playback.intro_skip_mode"],
"context": { "profile_id": "p1", "device_id": "d1" },
"stored": [
{
"key": "playback.intro_skip_mode",
"scope": "profile",
"profile_id": "p1",
"value": "never"
},
{
"key": "playback.intro_skip_mode",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "d1",
"value": "always"
}
],
"expected": [
{
"key": "playback.intro_skip_mode",
"value": "always",
"source": "profile_device"
}
]
},
{
"name": "subtitle_appearance_uses_shared_default",
"description": "With no stored profile or device value, every client receives the contract's shared Box 75% subtitle appearance.",
+24 -1
View File
@@ -9,7 +9,7 @@
*/
export const SETTINGS_API_VERSION = 1;
export const SETTINGS_REVISION = 6;
export const SETTINGS_REVISION = 7;
export interface SettingSuggestedOption {
value: string;
@@ -181,6 +181,8 @@ export const SETTING_KEYS = {
PLAYBACK_AUTO_SKIP_INTRO: "playback.auto_skip_intro",
/** Auto-skip recaps */
PLAYBACK_AUTO_SKIP_RECAP: "playback.auto_skip_recap",
/** Skip intros */
PLAYBACK_INTRO_SKIP_MODE: "playback.intro_skip_mode",
/** Maximum bitrate */
PLAYBACK_MAX_BITRATE_KBPS: "playback.max_bitrate_kbps",
/** Next up prompt */
@@ -535,6 +537,27 @@ export const SETTING_DEFINITIONS: Record<SettingKey, SettingDefinition> = {
category: "playback",
control: "switch",
},
"playback.intro_skip_mode": {
key: "playback.intro_skip_mode",
type: "enum",
nullable: false,
persistence: "remote",
introducedIn: 7,
scopes: ["profile", "profile_device"],
scopeIntroducedIn: [7, 7],
resolutionOrder: ["profile_device", "profile", "default"],
defaultValue: "ask",
label: "Skip intros",
description:
"What Silo does when an intro starts: leave it alone, offer a Skip Intro button, or skip it and offer an undo.",
category: "playback",
control: "select",
values: [
{ value: "never", label: "Never", introducedIn: 7 },
{ value: "ask", label: "Ask to skip", introducedIn: 7 },
{ value: "always", label: "Skip automatically", introducedIn: 7 },
],
},
"playback.max_bitrate_kbps": {
key: "playback.max_bitrate_kbps",
type: "integer",