Files
silo-server/internal/settingscontract/mirror.go
T
af974edfe9 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>
2026-08-16 19:38:19 -04:00

112 lines
4.1 KiB
Go

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