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
+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"}},