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:
co-authored by
evulhotdog
Claude Fable 5
parent
edd919c5f7
commit
af974edfe9
@@ -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,
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
Reference in New Issue
Block a user