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
@@ -124,10 +124,21 @@ func planProfileSettingsSync(
if field.raw == nil {
continue
}
out = append(out, profileSettingSync{
key: field.key,
value: json.RawMessage(strconv.FormatBool(*field.raw)),
})
value := json.RawMessage(strconv.FormatBool(*field.raw))
out = append(out, profileSettingSync{key: field.key, value: value})
// A key that has a replacement carries it along, so the legacy profile
// route lands the same pair of rows the canonical route does. Without
// this, a client that still sets auto_skip_intro through PUT /profiles
// would leave intro_skip_mode resolving to the contract default and a
// new client would read a preference nobody chose.
mirror, ok, err := settingscontract.MirrorWrite(field.key, value)
if err != nil {
return nil, fmt.Errorf("%s: %w", field.key, err)
}
if ok {
out = append(out, profileSettingSync{key: mirror.Key, value: mirror.Value})
}
}
return out, nil
}
@@ -170,6 +170,10 @@ func TestUpdateProfileSyncsSkipPreferences(t *testing.T) {
settingskeys.PlaybackAutoSkipCredits: `true`,
settingskeys.PlaybackAutoSkipRecap: `true`,
settingskeys.PlaybackAutoPlayNextPreview: `false`,
// auto_skip_intro carries its revision-7 replacement with it, so a
// client still using the legacy route does not leave a current client
// resolving the contract default.
settingskeys.PlaybackIntroSkipMode: `"always"`,
} {
value := storedProfileSetting(t, store, key, "profile-1")
if value == nil {
@@ -196,6 +200,40 @@ func TestUpdateProfileSyncsSkipPreferences(t *testing.T) {
})
}
// TestUpdateProfileSyncsIntroSkipMode covers both directions of the legacy
// switch, including the one an untouched-looking false has to produce: "ask" is
// the mode that reproduces what auto_skip_intro=false always did, and a profile
// route that wrote only the boolean would leave the enum saying something else.
func TestUpdateProfileSyncsIntroSkipMode(t *testing.T) {
for _, tc := range []struct{ body, wantMode, wantBool string }{
{`{"auto_skip_intro":true}`, `"always"`, `true`},
{`{"auto_skip_intro":false}`, `"ask"`, `false`},
} {
t.Run(tc.body, func(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
if rr := updateProfileVia(t, handler, "profile-1", tc.body); rr.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
}
for key, want := range map[string]string{
settingskeys.PlaybackIntroSkipMode: tc.wantMode,
settingskeys.PlaybackAutoSkipIntro: tc.wantBool,
} {
value := storedProfileSetting(t, store, key, "profile-1")
if value == nil {
t.Errorf("no canonical %s row after the profile update", key)
continue
}
if string(value.Value) != want {
t.Errorf("canonical %s = %s, want %s", key, value.Value, want)
}
}
})
}
}
// TestUpdateProfileRejectsInvalidLanguageBeforeWriting: a value the canonical
// endpoint would refuse must fail the request as a no-op instead of leaving
// the column and the canonical store disagreeing.
@@ -1070,3 +1070,66 @@ func withRouteParams(req *http.Request, params map[string]string) *http.Request
}
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
}
// TestLegacyDeviceSettingCarriesIntroSkipMode closes the third write path onto
// the intro-skip pair. The shipped apps save this switch through the legacy
// generic route, not through /settings/values, so without the mirror in the
// runtime plan an updated client would resolve the contract default while the
// household's own device said otherwise.
func TestLegacyDeviceSettingCarriesIntroSkipMode(t *testing.T) {
store := newProfileTestStore(t)
handler := NewSettingsHandler(testUserStoreProvider{store: store})
send := func(method string, body []byte) *httptest.ResponseRecorder {
key := "playback.auto_skip_intro"
req := httptest.NewRequest(method, "/settings/device/"+key, bytes.NewReader(body))
req = withRouteParams(req, map[string]string{"key": key})
req.Header.Set(deviceIDHeader, "living-room")
req = req.WithContext(apimw.SetProfileID(
apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-1"))
rec := httptest.NewRecorder()
if method == http.MethodPut {
handler.HandleSetDeviceSetting(rec, req)
} else {
handler.HandleDeleteDeviceSetting(rec, req)
}
return rec
}
canonical := func(key string) *userstore.SettingValue {
t.Helper()
value, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: key, Scope: settingscontract.ScopeProfileDevice,
ProfileID: "profile-1", DeviceID: "living-room",
})
if err != nil {
t.Fatalf("GetSettingValue(%s): %v", key, err)
}
return value
}
for _, tc := range []struct{ body, wantBool, wantMode string }{
{`{"value":"true"}`, `true`, `"always"`},
{`{"value":"false"}`, `false`, `"ask"`},
} {
if rec := send(http.MethodPut, []byte(tc.body)); rec.Code != http.StatusNoContent {
t.Fatalf("PUT %s = %d: %s", tc.body, rec.Code, rec.Body.String())
}
if value := canonical("playback.auto_skip_intro"); value == nil || string(value.Value) != tc.wantBool {
t.Fatalf("canonical auto_skip_intro after %s = %+v, want %s", tc.body, value, tc.wantBool)
}
if value := canonical("playback.intro_skip_mode"); value == nil || string(value.Value) != tc.wantMode {
t.Fatalf("canonical intro_skip_mode after %s = %+v, want %s", tc.body, value, tc.wantMode)
}
}
// Clearing through the same route has to reach both rows, or the companion
// would go on overriding at a scope the device just gave up.
if rec := send(http.MethodDelete, nil); rec.Code != http.StatusNoContent {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String())
}
for _, key := range []string{"playback.auto_skip_intro", "playback.intro_skip_mode"} {
if value := canonical(key); value != nil {
t.Errorf("%s survived the legacy delete: %+v", key, value)
}
}
}
+103 -1
View File
@@ -813,6 +813,21 @@ func (h *SettingValuesHandler) setValueAt(
return
}
// A deprecated key and its replacement are one preference stored twice
// while old clients are in the field, so a write to either lands on both.
// The value is already normalized, so a conversion failure here is a defect
// in the pairing rather than bad input — refuse the request rather than
// store half of it.
mirror, hasMirror, err := settingscontract.MirrorWrite(identity.Key, normalized)
if err != nil {
slog.ErrorContext(r.Context(), "settings mirror could not convert a normalized value",
"component", "api", "key", identity.Key, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store the setting")
return
}
mirrorIdentity := identity
mirrorIdentity.Key = mirror.Key
// Idempotency: a client that retries a write after a dropped response must
// not double-apply it, and must be able to tell "already done" from "that
// id means something else".
@@ -821,12 +836,30 @@ func (h *SettingValuesHandler) setValueAt(
var idempotentResult json.RawMessage
if mutationID == "" {
stored, err = store.UpsertSettingValue(r.Context(), identity, normalized)
if err == nil && hasMirror {
// No transaction on this path — the store's plain write API has
// none — so the companion write is a second statement. Its failure
// is the caller's failure: a request that reported success while
// leaving the pair disagreeing is the exact drift the mirror
// exists to prevent, and both writes are upserts, so the retry a
// 500 provokes is safe.
_, err = store.UpsertSettingValue(r.Context(), mirrorIdentity, mirror.Value)
}
} else {
outcome, mutationErr := runIdempotentSettingMutation(
r.Context(), store, mutationID, hashMutationRequest(identity, normalized),
func(writer userstore.SettingMutationWriter) (*userstore.SettingValue, bool, error) {
value, err := writer.UpsertSettingValue(r.Context(), identity, normalized)
return value, true, err
if err != nil || !hasMirror {
return value, true, err
}
// Same transaction as the primary write and the replay
// receipt, so a replay re-serves the receipt without
// re-applying either row.
if _, err := writer.UpsertSettingValue(r.Context(), mirrorIdentity, mirror.Value); err != nil {
return nil, false, err
}
return value, true, nil
},
)
if errors.Is(mutationErr, errMutationIDConflict) {
@@ -855,6 +888,13 @@ func (h *SettingValuesHandler) setValueAt(
return
}
// GET /profiles still serves auto_skip_intro from the legacy column, so a
// profile-scope choice made through the new key has to reach it or the
// profile DTO keeps reporting the preference the household abandoned.
if !h.syncLegacyIntroSkipColumn(r, w, store, identity, mirror, hasMirror) {
return
}
response := settingValueToResponse(*stored)
acting := actingProfileID(r.Context())
if identity.Scope == settingscontract.ScopeProfileDevice {
@@ -891,6 +931,52 @@ func (h *SettingValuesHandler) setValueAt(
}
}
// syncLegacyIntroSkipColumn mirrors a profile-scope playback.intro_skip_mode
// write into user_profiles.auto_skip_intro, and reports whether the request may
// continue.
//
// It is deliberately one-directional. The canonical write path does not
// otherwise touch the legacy preference columns — the cutover direction is that
// the profile DTO stops reading them, which it already has for the language and
// subtitle fields — so this is not a general dual-write, only the narrow repair
// that keeps the one DTO field still served from its column truthful while the
// key that replaced it is the one clients write. A write of the deprecated
// boolean itself is left alone, exactly as it is today.
//
// A failure fails the request for the same reason the companion row write does:
// the caller asked for one preference change, and reporting success while half
// of it landed is what makes the two halves drift.
func (h *SettingValuesHandler) syncLegacyIntroSkipColumn(
r *http.Request,
w http.ResponseWriter,
store userstore.UserStore,
identity userstore.SettingIdentity,
mirror settingscontract.MirroredWrite,
hasMirror bool,
) bool {
if !hasMirror ||
identity.Key != settingskeys.PlaybackIntroSkipMode ||
identity.Scope != settingscontract.ScopeProfile {
return true
}
var enabled bool
if err := json.Unmarshal(mirror.Value, &enabled); err != nil {
slog.ErrorContext(r.Context(), "settings mirror produced a non-boolean auto_skip_intro",
"component", "api", "profile_id", identity.ProfileID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store the setting")
return false
}
if err := store.UpdateProfile(r.Context(), identity.ProfileID,
userstore.UpdateProfileInput{AutoSkipIntro: &enabled}); err != nil {
slog.ErrorContext(r.Context(), "failed to mirror intro skip mode into the profile column",
"component", "api", "profile_id", identity.ProfileID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store the setting")
return false
}
return true
}
// registerWritingDevice refreshes the device registry from the request's
// device headers after a successful profile_device write. Best effort and
// throttled: the value write already succeeded, and the registry entry is
@@ -965,6 +1051,22 @@ func (h *SettingValuesHandler) deleteValueAt(
writeError(w, http.StatusNotFound, "not_found", "No value is set at this scope")
return
}
// Clearing one half of a mirrored pair clears both: a surviving companion
// row would go on resolving as an explicit choice at a scope the caller
// just said it wanted to inherit at. Only attempted once the primary row
// really went away, so a 404 for "nothing set here" still means nothing was
// touched. A companion that was already absent removes nothing, which is
// success.
if mirrorKey, ok := settingscontract.MirrorKey(identity.Key); ok {
mirrorIdentity := identity
mirrorIdentity.Key = mirrorKey
if _, err := store.DeleteSettingValue(r.Context(), mirrorIdentity); err != nil {
slog.ErrorContext(r.Context(), "failed to clear the mirrored setting",
"component", "api", "key", identity.Key, "mirror_key", mirrorKey, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear the setting")
return
}
}
auditSettingsForOther(r.Context(), settingsAuditRecord{
Action: "clear",
ActorProfileID: actingProfileID(r.Context()),
@@ -0,0 +1,298 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// The intro-skip pair is one preference stored under two keys for one release:
// revision 7 replaced the boolean playback.auto_skip_intro with the three-way
// playback.intro_skip_mode, and every shipped client still reads the boolean.
// These tests hold the canonical write path to keeping the two in step, because
// a household whose two keys disagree gets a different intro behavior on every
// device it owns.
// storedValueAt reads one explicit row, whatever scope it lives at.
func storedValueAt(
t *testing.T, store userstore.UserStore, identity userstore.SettingIdentity,
) *userstore.SettingValue {
t.Helper()
value, err := store.GetSettingValue(context.Background(), identity)
if err != nil {
t.Fatalf("reading %s at %s: %v", identity.Key, identity.Scope, err)
}
return value
}
func profileIdentity(key string) userstore.SettingIdentity {
return userstore.SettingIdentity{
Key: key, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
}
}
func deviceIdentity(key string) userstore.SettingIdentity {
return userstore.SettingIdentity{
Key: key, Scope: settingscontract.ScopeProfileDevice,
ProfileID: "profile-1", DeviceID: "device-1",
}
}
// requireStored asserts a row exists and holds want.
func requireStored(
t *testing.T, store userstore.UserStore, identity userstore.SettingIdentity, want string,
) {
t.Helper()
value := storedValueAt(t, store, identity)
if value == nil {
t.Fatalf("no %s row at %s", identity.Key, identity.Scope)
}
if string(value.Value) != want {
t.Errorf("%s at %s = %s, want %s", identity.Key, identity.Scope, value.Value, want)
}
}
// TestWritingTheDeprecatedBooleanMirrorsTheEnum: an unmigrated client saves the
// switch and a current client must read the mode the household actually chose,
// not the contract default.
func TestWritingTheDeprecatedBooleanMirrorsTheEnum(t *testing.T) {
for name, tc := range map[string]struct{ body, wantMode string }{
"on": {`{"value":true}`, `"always"`},
"off": {`{"value":false}`, `"ask"`},
} {
t.Run(name, func(t *testing.T) {
handler, store := newValuesTestHandler(t)
rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackAutoSkipIntro, "scope=profile", []byte(tc.body))
if rec.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
requireStored(t, store, profileIdentity(settingskeys.PlaybackIntroSkipMode), tc.wantMode)
// The response is the key the caller addressed, unchanged: the
// mirror is a side effect, not a redirect.
var response settingValueResponse
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("decoding response: %v; body=%s", err, rec.Body.String())
}
if response.Key != settingskeys.PlaybackAutoSkipIntro {
t.Errorf("response key = %s, want the key that was written", response.Key)
}
})
}
}
// TestWritingTheEnumMirrorsTheDeprecatedBoolean covers all three modes,
// including the one the boolean cannot express.
func TestWritingTheEnumMirrorsTheDeprecatedBoolean(t *testing.T) {
for _, tc := range []struct{ mode, wantBool string }{
{"never", `false`},
{"ask", `false`},
{"always", `true`},
} {
t.Run(tc.mode, func(t *testing.T) {
handler, store := newValuesTestHandler(t)
rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackIntroSkipMode, "scope=profile",
[]byte(`{"value":"`+tc.mode+`"}`))
if rec.Code != http.StatusOK {
t.Fatalf("PUT %q = %d: %s", tc.mode, rec.Code, rec.Body.String())
}
requireStored(t, store, profileIdentity(settingskeys.PlaybackIntroSkipMode),
`"`+tc.mode+`"`)
requireStored(t, store, profileIdentity(settingskeys.PlaybackAutoSkipIntro), tc.wantBool)
})
}
}
// TestTheMirrorFollowsTheIdentity, not just the key: a device override must not
// silently become a profile-wide one.
func TestTheMirrorFollowsTheIdentity(t *testing.T) {
handler, store := newValuesTestHandler(t)
rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackIntroSkipMode, "scope=profile_device", []byte(`{"value":"always"}`))
if rec.Code != http.StatusOK {
t.Fatalf("device PUT = %d: %s", rec.Code, rec.Body.String())
}
requireStored(t, store, deviceIdentity(settingskeys.PlaybackAutoSkipIntro), `true`)
if value := storedValueAt(t, store, profileIdentity(settingskeys.PlaybackAutoSkipIntro)); value != nil {
t.Errorf("a device write left a profile-scope mirror row: %s", value.Value)
}
}
// TestDeletingEitherHalfClearsBoth. A row left behind would go on resolving as
// an explicit choice at the very scope the caller asked to inherit at.
func TestDeletingEitherHalfClearsBoth(t *testing.T) {
for _, deleted := range []string{
settingskeys.PlaybackIntroSkipMode,
settingskeys.PlaybackAutoSkipIntro,
} {
t.Run(deleted, func(t *testing.T) {
handler, store := newValuesTestHandler(t)
if rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackIntroSkipMode, "scope=profile",
[]byte(`{"value":"always"}`)); rec.Code != http.StatusOK {
t.Fatalf("seed PUT = %d: %s", rec.Code, rec.Body.String())
}
rec := routeValues(t, handler, http.MethodDelete, deleted, "scope=profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE %s = %d: %s", deleted, rec.Code, rec.Body.String())
}
for _, key := range []string{
settingskeys.PlaybackIntroSkipMode,
settingskeys.PlaybackAutoSkipIntro,
} {
if value := storedValueAt(t, store, profileIdentity(key)); value != nil {
t.Errorf("%s survived a DELETE of %s: %s", key, deleted, value.Value)
}
}
})
}
}
// TestDeletingAnAbsentValueTouchesNeitherHalf: the 404 for "nothing set here"
// still has to mean nothing was written, mirror included.
func TestDeletingAnAbsentValueTouchesNeitherHalf(t *testing.T) {
handler, store := newValuesTestHandler(t)
if rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackAutoSkipIntro, "scope=profile",
[]byte(`{"value":true}`)); rec.Code != http.StatusOK {
t.Fatalf("seed PUT = %d: %s", rec.Code, rec.Body.String())
}
// Nothing was ever written at device scope, so this clears nothing —
// including the profile-scope pair, which belongs to another identity.
rec := routeValues(t, handler, http.MethodDelete,
settingskeys.PlaybackIntroSkipMode, "scope=profile_device", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("DELETE of an unset device value = %d, want 404: %s", rec.Code, rec.Body.String())
}
requireStored(t, store, profileIdentity(settingskeys.PlaybackAutoSkipIntro), `true`)
requireStored(t, store, profileIdentity(settingskeys.PlaybackIntroSkipMode), `"always"`)
}
// TestMirroredWritesReplayInsteadOfDoubleApplying. The mirror rides inside the
// mutation's transaction, so a retried write must re-serve the receipt and
// leave both rows at the revision the first write produced.
func TestMirroredWritesReplayInsteadOfDoubleApplying(t *testing.T) {
handler, store := newValuesTestHandler(t)
send := func(mutationID, value string) *httptest.ResponseRecorder {
req := valuesRequest(http.MethodPut,
"/settings/values/"+settingskeys.PlaybackIntroSkipMode+"?scope=profile",
[]byte(`{"value":`+value+`}`))
req.Header.Set(mutationIDHeader, mutationID)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("key", settingskeys.PlaybackIntroSkipMode)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rec := httptest.NewRecorder()
handler.HandleSetValue(rec, req)
return rec
}
if rec := send("mut-intro", `"always"`); rec.Code != http.StatusOK {
t.Fatalf("first write = %d: %s", rec.Code, rec.Body.String())
}
mirrored := storedValueAt(t, store, profileIdentity(settingskeys.PlaybackAutoSkipIntro))
if mirrored == nil {
t.Fatal("the first write stored no mirror row")
}
replay := send("mut-intro", `"always"`)
if replay.Code != http.StatusOK || replay.Header().Get("X-Silo-Idempotent-Replay") != "true" {
t.Fatalf("replay = %d header %q: %s",
replay.Code, replay.Header().Get("X-Silo-Idempotent-Replay"), replay.Body.String())
}
after := storedValueAt(t, store, profileIdentity(settingskeys.PlaybackAutoSkipIntro))
if after == nil {
t.Fatal("the replay removed the mirror row")
}
if after.Revision != mirrored.Revision {
t.Errorf("mirror revision moved from %d to %d on replay; the replay wrote again",
mirrored.Revision, after.Revision)
}
if string(after.Value) != `true` {
t.Errorf("mirror value = %s after replay, want true", after.Value)
}
}
// TestProfileScopeEnumWriteUpdatesTheLegacyColumn. GET /profiles still serves
// auto_skip_intro from user_profiles, so a household that switches intros off
// through the new key must not keep seeing the old answer in the profile DTO.
func TestProfileScopeEnumWriteUpdatesTheLegacyColumn(t *testing.T) {
handler, store := newValuesTestHandler(t)
if err := store.UpdateProfile(context.Background(), "profile-1",
userstore.UpdateProfileInput{AutoSkipIntro: boolPtr(true)}); err != nil {
t.Fatalf("seeding the column: %v", err)
}
rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackIntroSkipMode, "scope=profile", []byte(`{"value":"never"}`))
if rec.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
profile, err := store.GetProfile(context.Background(), "profile-1")
if err != nil || profile == nil {
t.Fatalf("reading profile: %v", err)
}
if profile.AutoSkipIntro {
t.Error("user_profiles.auto_skip_intro still true after intro_skip_mode was set to never")
}
// And back the other way, so the column tracks the mode rather than only
// ever being cleared.
if rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackIntroSkipMode, "scope=profile",
[]byte(`{"value":"always"}`)); rec.Code != http.StatusOK {
t.Fatalf("second PUT = %d: %s", rec.Code, rec.Body.String())
}
profile, err = store.GetProfile(context.Background(), "profile-1")
if err != nil || profile == nil {
t.Fatalf("re-reading profile: %v", err)
}
if !profile.AutoSkipIntro {
t.Error("user_profiles.auto_skip_intro still false after intro_skip_mode was set to always")
}
}
// TestDeviceScopeEnumWriteLeavesTheProfileColumnAlone: the column is
// profile-wide, and one television's override is not the household's choice.
func TestDeviceScopeEnumWriteLeavesTheProfileColumnAlone(t *testing.T) {
handler, store := newValuesTestHandler(t)
rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackIntroSkipMode, "scope=profile_device", []byte(`{"value":"always"}`))
if rec.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
profile, err := store.GetProfile(context.Background(), "profile-1")
if err != nil || profile == nil {
t.Fatalf("reading profile: %v", err)
}
if profile.AutoSkipIntro {
t.Error("a device-scope write moved the profile-wide column")
}
}
@@ -111,6 +111,20 @@ SELECT COUNT(*) FROM user_setting_values
}
})
t.Run("auto_skip_intro carries its revision-7 replacement", func(t *testing.T) {
var value string
err := pool.QueryRow(ctx, `
SELECT value::text FROM user_setting_values
WHERE key = 'playback.intro_skip_mode' AND scope = 'profile' AND profile_id = 'mp1'`).
Scan(&value)
if err != nil {
t.Fatalf("reading migrated intro_skip_mode: %v", err)
}
if value != `"always"` {
t.Errorf("intro_skip_mode = %s, want \"always\"", value)
}
})
t.Run("metadata language migrates from the postgres-only column", func(t *testing.T) {
var value string
err := pool.QueryRow(ctx, `
+111
View File
@@ -0,0 +1,111 @@
package settingscontract
import (
"encoding/json"
"fmt"
"github.com/Silo-Server/silo-server/internal/settingskeys"
)
// Key relationships: settings that are two spellings of one preference.
//
// A deprecated key is not free to leave behind. Every shipped client reads
// playback.auto_skip_intro, and revision 7 replaced it with the three-way
// playback.intro_skip_mode, so for one release the server keeps the pair in
// step at write time: a preference set on an old client shows up on a new one
// and the other way round. See docs/design/2026-08-16-intro-skip-mode.md.
//
// The pairing lives here, next to the contract that declares both keys, rather
// than in the handlers. Four write paths need it — the canonical mutation
// route, its delete, the legacy profile route, and the one-time migration
// planner — and a mapping that disagreed between any two of them would be a
// preference that changes meaning depending on which client last touched it.
// Intro-skip modes. These are the enum members playback.intro_skip_mode
// declares; the manifest is the source of truth and Validate proves a default
// or stored value is one of them, but the mirror has to name them to convert.
const (
IntroSkipModeNever = "never"
IntroSkipModeAsk = "ask"
IntroSkipModeAlways = "always"
)
// MirroredWrite is the companion row implied by writing another key.
type MirroredWrite struct {
Key string
Value json.RawMessage
}
// MirrorKey names the key whose row travels with this one, and reports whether
// there is one. Used by the delete path, which has no value to convert:
// clearing either half of a mirrored pair clears both, or the surviving row
// would resolve as an explicit choice nobody made.
func MirrorKey(key string) (string, bool) {
switch key {
case settingskeys.PlaybackAutoSkipIntro:
return settingskeys.PlaybackIntroSkipMode, true
case settingskeys.PlaybackIntroSkipMode:
return settingskeys.PlaybackAutoSkipIntro, true
default:
return "", false
}
}
// MirrorWrite converts a value written at key into the companion row that must
// be written alongside it.
//
// The second result is false when key has no mirror at all, which is the
// common case and not an error. An error means the key does have a mirror but
// the value is not one the pairing can express — impossible for a value that
// came through NormalizeValue, and therefore a defect rather than bad input,
// so callers must surface it rather than skip the companion write.
//
// The boolean direction is lossy on purpose: "never" and "ask" both mean
// "don't skip it for me" to a client that only understands the switch, so both
// map to false. An old client that then flips that switch overwrites "never" —
// accepted for the overlap window, and the reason the mirror is temporary.
func MirrorWrite(key string, value json.RawMessage) (MirroredWrite, bool, error) {
mirror, ok := MirrorKey(key)
if !ok {
return MirroredWrite{}, false, nil
}
switch key {
case settingskeys.PlaybackAutoSkipIntro:
var enabled bool
if err := json.Unmarshal(value, &enabled); err != nil {
return MirroredWrite{}, false, fmt.Errorf(
"%s: mirroring to %s needs a boolean, got %s", key, mirror, value)
}
mode := IntroSkipModeAsk
if enabled {
mode = IntroSkipModeAlways
}
encoded, err := json.Marshal(mode)
if err != nil {
return MirroredWrite{}, false, fmt.Errorf("%s: encoding %s: %w", key, mirror, err)
}
return MirroredWrite{Key: mirror, Value: encoded}, true, nil
case settingskeys.PlaybackIntroSkipMode:
var mode string
if err := json.Unmarshal(value, &mode); err != nil {
return MirroredWrite{}, false, fmt.Errorf(
"%s: mirroring to %s needs a string, got %s", key, mirror, value)
}
switch mode {
case IntroSkipModeNever, IntroSkipModeAsk, IntroSkipModeAlways:
default:
return MirroredWrite{}, false, fmt.Errorf(
"%s: %q is not an intro skip mode", key, mode)
}
encoded := json.RawMessage("false")
if mode == IntroSkipModeAlways {
encoded = json.RawMessage("true")
}
return MirroredWrite{Key: mirror, Value: encoded}, true, nil
default:
return MirroredWrite{}, false, nil
}
}
+158
View File
@@ -0,0 +1,158 @@
package settingscontract
import (
"encoding/json"
"slices"
"testing"
"github.com/Silo-Server/silo-server/internal/settingskeys"
)
func TestMirrorWriteConvertsBothDirections(t *testing.T) {
for name, tc := range map[string]struct {
key string
value string
wantKey string
wantValue string
}{
"switch on means always": {
settingskeys.PlaybackAutoSkipIntro, `true`,
settingskeys.PlaybackIntroSkipMode, `"always"`,
},
"switch off means ask": {
settingskeys.PlaybackAutoSkipIntro, `false`,
settingskeys.PlaybackIntroSkipMode, `"ask"`,
},
"always means switch on": {
settingskeys.PlaybackIntroSkipMode, `"always"`,
settingskeys.PlaybackAutoSkipIntro, `true`,
},
"ask means switch off": {
settingskeys.PlaybackIntroSkipMode, `"ask"`,
settingskeys.PlaybackAutoSkipIntro, `false`,
},
// The lossy edge the design accepts: a client that only knows the
// switch cannot express "leave intros alone", and false is the closer
// of the two answers it can give.
"never also means switch off": {
settingskeys.PlaybackIntroSkipMode, `"never"`,
settingskeys.PlaybackAutoSkipIntro, `false`,
},
} {
t.Run(name, func(t *testing.T) {
mirror, ok, err := MirrorWrite(tc.key, json.RawMessage(tc.value))
if err != nil {
t.Fatalf("MirrorWrite(%s, %s): %v", tc.key, tc.value, err)
}
if !ok {
t.Fatalf("MirrorWrite(%s) reported no mirror", tc.key)
}
if mirror.Key != tc.wantKey {
t.Errorf("mirror key = %s, want %s", mirror.Key, tc.wantKey)
}
if string(mirror.Value) != tc.wantValue {
t.Errorf("mirror value = %s, want %s", mirror.Value, tc.wantValue)
}
})
}
}
// TestMirrorWriteRoundTripsThroughTheBoolean pins the one-way loss: every mode
// survives a round trip except "never", which the boolean cannot hold.
func TestMirrorWriteRoundTripsThroughTheBoolean(t *testing.T) {
for _, mode := range []string{IntroSkipModeNever, IntroSkipModeAsk, IntroSkipModeAlways} {
encoded, err := json.Marshal(mode)
if err != nil {
t.Fatalf("encoding %q: %v", mode, err)
}
boolean, _, err := MirrorWrite(settingskeys.PlaybackIntroSkipMode, encoded)
if err != nil {
t.Fatalf("MirrorWrite(%q): %v", mode, err)
}
back, _, err := MirrorWrite(settingskeys.PlaybackAutoSkipIntro, boolean.Value)
if err != nil {
t.Fatalf("MirrorWrite back from %s: %v", boolean.Value, err)
}
want := mode
if mode == IntroSkipModeNever {
want = IntroSkipModeAsk
}
if string(back.Value) != `"`+want+`"` {
t.Errorf("%q round-tripped to %s, want %q", mode, back.Value, want)
}
}
}
func TestMirrorWriteIgnoresUnpairedKeys(t *testing.T) {
for _, key := range []string{
settingskeys.PlaybackAutoSkipCredits,
settingskeys.PlaybackSubtitleMode,
"totally.invented.key",
} {
if _, ok, err := MirrorWrite(key, json.RawMessage(`true`)); ok || err != nil {
t.Errorf("MirrorWrite(%s) = ok %v err %v, want no mirror and no error", key, ok, err)
}
if _, ok := MirrorKey(key); ok {
t.Errorf("MirrorKey(%s) reported a mirror", key)
}
}
}
// TestMirrorWriteRefusesValuesItCannotConvert: the callers write the companion
// row on the strength of this result, so a value the pairing does not
// understand has to be an error rather than a silently skipped write.
func TestMirrorWriteRefusesValuesItCannotConvert(t *testing.T) {
for name, tc := range map[string]struct{ key, value string }{
"boolean key given a string": {settingskeys.PlaybackAutoSkipIntro, `"yes"`},
"enum key given a boolean": {settingskeys.PlaybackIntroSkipMode, `true`},
"enum key given a non-member": {
settingskeys.PlaybackIntroSkipMode, `"sideways"`,
},
} {
t.Run(name, func(t *testing.T) {
if _, ok, err := MirrorWrite(tc.key, json.RawMessage(tc.value)); err == nil || ok {
t.Errorf("MirrorWrite(%s, %s) = ok %v err %v, want an error", tc.key, tc.value, ok, err)
}
})
}
}
// TestMirroredKeysAgreeInTheContract keeps the pairing honest against the
// manifest. The write path lands the companion row at the identity the caller
// addressed without re-checking it, so the two definitions have to accept the
// same scopes — a pair that drifted apart would let a legal write produce a
// companion the contract forbids.
func TestMirroredKeysAgreeInTheContract(t *testing.T) {
manifest, err := Load()
if err != nil {
t.Fatalf("loading manifest: %v", err)
}
for _, key := range []string{
settingskeys.PlaybackAutoSkipIntro,
settingskeys.PlaybackIntroSkipMode,
} {
mirrorKey, ok := MirrorKey(key)
if !ok {
t.Fatalf("%s has no mirror", key)
}
if back, _ := MirrorKey(mirrorKey); back != key {
t.Errorf("MirrorKey(%s) = %s, want the pairing to be symmetric", mirrorKey, back)
}
def, found := manifest.Lookup(key)
if !found {
t.Fatalf("%s is mirrored but has no definition", key)
}
mirrorDef, found := manifest.Lookup(mirrorKey)
if !found {
t.Fatalf("%s is mirrored but has no definition", mirrorKey)
}
if !def.IsRemote() || !mirrorDef.IsRemote() {
t.Errorf("%s/%s are mirrored but not both server-stored", key, mirrorKey)
}
if !slices.Equal(def.AllowedScopes, mirrorDef.AllowedScopes) {
t.Errorf("%s allows %v but %s allows %v; a write to one could not be mirrored onto the other",
key, def.AllowedScopes, mirrorKey, mirrorDef.AllowedScopes)
}
}
}
+4 -1
View File
@@ -9,7 +9,7 @@
package settingskeys
// Revision is the manifest revision these bindings were generated from.
const Revision = 6
const Revision = 7
// Setting keys, one constant per definition.
const (
@@ -41,6 +41,8 @@ const (
PlaybackAutoSkipIntro = "playback.auto_skip_intro"
// Auto-skip recaps
PlaybackAutoSkipRecap = "playback.auto_skip_recap"
// Skip intros
PlaybackIntroSkipMode = "playback.intro_skip_mode"
// Maximum bitrate
PlaybackMaxBitrateKbps = "playback.max_bitrate_kbps"
// Next up prompt
@@ -133,6 +135,7 @@ var Remote = []string{
PlaybackAutoSkipCredits,
PlaybackAutoSkipIntro,
PlaybackAutoSkipRecap,
PlaybackIntroSkipMode,
PlaybackMaxBitrateKbps,
PlaybackNextUpPromptSeconds,
PlaybackPreferredQuality,
+92 -8
View File
@@ -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,
+107
View File
@@ -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"}},
+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)
}
}