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>
112 lines
4.1 KiB
Go
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
|
|
}
|
|
}
|