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