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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user