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
@@ -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)
}
}