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