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