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>
447 lines
16 KiB
Go
447 lines
16 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strconv"
|
|
"strings"
|
|
|
|
evt "github.com/Silo-Server/silo-server/internal/events"
|
|
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
|
"github.com/Silo-Server/silo-server/internal/settingskeys"
|
|
"github.com/Silo-Server/silo-server/internal/settingsresolve"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
// The legacy profile endpoints are still the write path shipped clients use
|
|
// for the preference columns, but every server-side reader of those
|
|
// preferences now resolves them canonically from user_setting_values:
|
|
// access.Resolver and policy.ViewerResolver for catalog.metadata_language,
|
|
// playback start and catalog detail for the playback.* preferences. The
|
|
// settings backfill runs once, so a column write that never reaches the
|
|
// canonical store simply never takes effect — the stale backfilled row, or
|
|
// the contract default, wins forever.
|
|
//
|
|
// Until the clients move to /settings/values, every profile create or update
|
|
// therefore mirrors its preference fields into the profile-scope canonical
|
|
// rows the readers consult. The mapping is the live-write counterpart of
|
|
// settingsmigrate.planProfiles with one deliberate difference: the migration
|
|
// skips a column still holding its schema default because it cannot tell
|
|
// "never decided" from "chose the default", while a live request names the
|
|
// field explicitly, so its value — default or not — is a real choice and is
|
|
// stored.
|
|
//
|
|
// quality_preference is deliberately not mirrored: the server never resolves
|
|
// the legacy column (playback requests carry the quality preference
|
|
// per-request), and the two-axis quality picker already writes
|
|
// playback.preferred_quality and playback.max_bitrate_kbps through
|
|
// /settings/values directly.
|
|
|
|
// profileSettingSync is one canonical write implied by a legacy profile
|
|
// mutation. A nil value clears the profile-scope row so resolution falls
|
|
// back to the contract default, which is how the legacy empty string spells
|
|
// "no preference".
|
|
type profileSettingSync struct {
|
|
key string
|
|
value json.RawMessage
|
|
}
|
|
|
|
// planCreateProfileSettingsSync plans the canonical writes for POST
|
|
// /profiles. Create requests carry plain strings, so an absent field arrives
|
|
// as "" and plans a no-op delete against the freshly created profile.
|
|
func planCreateProfileSettingsSync(req createProfileRequest) ([]profileSettingSync, error) {
|
|
return planProfileSettingsSync(
|
|
&req.Language, &req.SubtitleLanguage, &req.PreferredMetadataLanguage,
|
|
&req.SubtitleMode, req.ShowForcedSubtitles,
|
|
profileSkipFields{
|
|
autoSkipIntro: &req.AutoSkipIntro,
|
|
autoSkipCredits: &req.AutoSkipCredits,
|
|
autoSkipRecap: &req.AutoSkipRecap,
|
|
autoPlayNextPreview: &req.AutoPlayNextPreview,
|
|
})
|
|
}
|
|
|
|
// planUpdateProfileSettingsSync plans the canonical writes for PUT
|
|
// /profiles/{id}. A nil field was not part of the request and must not touch
|
|
// the canonical row; the shipped clients send single-field deltas.
|
|
func planUpdateProfileSettingsSync(req updateProfileRequest) ([]profileSettingSync, error) {
|
|
return planProfileSettingsSync(
|
|
req.Language, req.SubtitleLanguage, req.PreferredMetadataLanguage,
|
|
req.SubtitleMode, req.ShowForcedSubtitles,
|
|
profileSkipFields{
|
|
autoSkipIntro: req.AutoSkipIntro,
|
|
autoSkipCredits: req.AutoSkipCredits,
|
|
autoSkipRecap: req.AutoSkipRecap,
|
|
autoPlayNextPreview: req.AutoPlayNextPreview,
|
|
})
|
|
}
|
|
|
|
// profileSkipFields groups the four boolean playback toggles the profile DTO
|
|
// carries. They travel together because they behave identically: a nil field
|
|
// was not in the request, and a present one mirrors verbatim.
|
|
type profileSkipFields struct {
|
|
autoSkipIntro *bool
|
|
autoSkipCredits *bool
|
|
autoSkipRecap *bool
|
|
autoPlayNextPreview *bool
|
|
}
|
|
|
|
func planProfileSettingsSync(
|
|
audioLang, subtitleLang, metadataLang, subtitleMode *string,
|
|
showForced *bool,
|
|
skips profileSkipFields,
|
|
) ([]profileSettingSync, error) {
|
|
var out []profileSettingSync
|
|
var err error
|
|
|
|
for _, field := range []struct {
|
|
key string
|
|
raw *string
|
|
}{
|
|
{settingskeys.PlaybackAudioLanguage, audioLang},
|
|
{settingskeys.PlaybackSubtitleLanguage, subtitleLang},
|
|
{settingskeys.CatalogMetadataLanguage, metadataLang},
|
|
{settingskeys.PlaybackSubtitleMode, subtitleMode},
|
|
} {
|
|
if out, err = appendStringSync(out, field.key, field.raw); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
// The booleans have no "unset" spelling on the wire — the legacy columns
|
|
// are NOT NULL — so a present field always writes an explicit value.
|
|
for _, field := range []struct {
|
|
key string
|
|
raw *bool
|
|
}{
|
|
{settingskeys.PlaybackShowForcedSubtitles, showForced},
|
|
{settingskeys.PlaybackAutoSkipIntro, skips.autoSkipIntro},
|
|
{settingskeys.PlaybackAutoSkipCredits, skips.autoSkipCredits},
|
|
{settingskeys.PlaybackAutoSkipRecap, skips.autoSkipRecap},
|
|
{settingskeys.PlaybackAutoPlayNextPreview, skips.autoPlayNextPreview},
|
|
} {
|
|
if field.raw == nil {
|
|
continue
|
|
}
|
|
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
|
|
}
|
|
|
|
// appendStringSync plans one string-valued column. The empty string is the
|
|
// legacy spelling of "unset" for both the language columns and subtitle_mode,
|
|
// so it clears the canonical row; anything else must normalize under the
|
|
// contract — the same check /settings/values applies — so nothing reaches
|
|
// storage that the canonical endpoint would refuse, and an invalid value is
|
|
// reported instead of silently never taking effect.
|
|
func appendStringSync(out []profileSettingSync, key string, raw *string) ([]profileSettingSync, error) {
|
|
if raw == nil {
|
|
return out, nil
|
|
}
|
|
trimmed := strings.TrimSpace(*raw)
|
|
if trimmed == "" {
|
|
return append(out, profileSettingSync{key: key}), nil
|
|
}
|
|
|
|
encoded, err := json.Marshal(trimmed)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", key, err)
|
|
}
|
|
normalized, err := normalizeCanonicalSettingValue(key, encoded)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return append(out, profileSettingSync{key: key, value: normalized}), nil
|
|
}
|
|
|
|
// normalizeCanonicalSettingValue runs a planned value through the same
|
|
// contract validation the canonical mutation endpoint uses.
|
|
func normalizeCanonicalSettingValue(key string, raw json.RawMessage) (json.RawMessage, error) {
|
|
contract, err := settingscontract.Load()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading the settings contract: %w", err)
|
|
}
|
|
def, ok := contract.Lookup(key)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s has no contract definition", key)
|
|
}
|
|
normalized, err := def.ValueSchema.NormalizeValue(raw, settingscontract.ObjectSchemas())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", key, err)
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
// createProfileWithSettingsSync creates the profile, snapshots surviving
|
|
// account-wide legacy settings, and writes every canonical row in one store
|
|
// transaction. PostgreSQL's transaction wrapper also holds a per-user
|
|
// advisory lock shared with legacy account-setting fan-out, closing the
|
|
// cross-replica create/write race.
|
|
func (h *ProfileHandler) createProfileWithSettingsSync(
|
|
ctx context.Context,
|
|
store userstore.UserStore,
|
|
userID int,
|
|
profile userstore.Profile,
|
|
writes []profileSettingSync,
|
|
) error {
|
|
transactioner, ok := store.(userstore.PreferenceSettingsTransactioner)
|
|
if !ok {
|
|
return fmt.Errorf("user store does not support atomic preference settings synchronization")
|
|
}
|
|
var changedKeys []string
|
|
err := transactioner.WithPreferenceSettingsTransaction(ctx, func(tx userstore.PreferenceSettingsWriter) error {
|
|
if err := tx.CreateProfile(ctx, profile); err != nil {
|
|
return err
|
|
}
|
|
inherited, err := planInheritedLegacyUserSettings(ctx, tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
changedKeys, err = writeCanonicalSettingsSync(ctx, tx, userstore.SettingIdentity{
|
|
Scope: settingscontract.ScopeProfile, ProfileID: profile.ID,
|
|
}, append(writes, inherited...))
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, key := range changedKeys {
|
|
publishUserSettingsEvent(ctx, h.EventsHub, userID, profile.ID, key, string(settingscontract.ScopeProfile))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *ProfileHandler) applyProfileUpdateSettingsSync(
|
|
ctx context.Context,
|
|
store userstore.UserStore,
|
|
userID int,
|
|
profileID string,
|
|
input userstore.UpdateProfileInput,
|
|
writes []profileSettingSync,
|
|
) error {
|
|
return applyLegacyPreferenceSettingsSync(ctx, store, h.EventsHub, userID, userstore.SettingIdentity{
|
|
Scope: settingscontract.ScopeProfile, ProfileID: profileID,
|
|
}, writes, func(tx userstore.PreferenceSettingsWriter) error {
|
|
return tx.UpdateProfile(ctx, profileID, input)
|
|
})
|
|
}
|
|
|
|
// applyLegacyPreferenceSettingsSync is the live-write counterpart of the
|
|
// migration planner for legacy preference endpoints. The legacy mutation and
|
|
// every canonical row commit in one store transaction; events are deliberately
|
|
// published afterwards so subscribers can never observe uncommitted state.
|
|
func applyLegacyPreferenceSettingsSync(
|
|
ctx context.Context,
|
|
store userstore.UserStore,
|
|
events *evt.Hub,
|
|
userID int,
|
|
base userstore.SettingIdentity,
|
|
writes []profileSettingSync,
|
|
legacyMutation func(userstore.PreferenceSettingsWriter) error,
|
|
) error {
|
|
var changedKeys []string
|
|
transactioner, ok := store.(userstore.PreferenceSettingsTransactioner)
|
|
if !ok {
|
|
return fmt.Errorf("user store does not support atomic preference settings synchronization")
|
|
}
|
|
err := transactioner.WithPreferenceSettingsTransaction(ctx, func(tx userstore.PreferenceSettingsWriter) error {
|
|
if err := legacyMutation(tx); err != nil {
|
|
return err
|
|
}
|
|
var err error
|
|
changedKeys, err = writeCanonicalSettingsSync(ctx, tx, base, writes)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, key := range changedKeys {
|
|
publishUserSettingsEvent(ctx, events, userID, base.ProfileID, key, string(base.Scope))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeCanonicalSettingsSync(
|
|
ctx context.Context,
|
|
store userstore.PreferenceSettingsWriter,
|
|
base userstore.SettingIdentity,
|
|
writes []profileSettingSync,
|
|
) ([]string, error) {
|
|
changedKeys := make([]string, 0, len(writes))
|
|
for _, write := range writes {
|
|
identity := base
|
|
identity.Key = write.key
|
|
if write.value == nil {
|
|
removed, err := store.DeleteSettingValue(ctx, identity)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("clearing %s: %w", write.key, err)
|
|
}
|
|
if !removed {
|
|
continue // nothing was stored, so nothing changed
|
|
}
|
|
} else if _, err := store.UpsertSettingValue(ctx, identity, write.value); err != nil {
|
|
return nil, fmt.Errorf("storing %s: %w", write.key, err)
|
|
}
|
|
changedKeys = append(changedKeys, write.key)
|
|
}
|
|
return changedKeys, nil
|
|
}
|
|
|
|
// --- Read side ---
|
|
//
|
|
// The profile DTO's preference fields are served from the same canonical rows
|
|
// the sync above writes, not from the legacy columns. Without this, a
|
|
// preference saved through PUT /settings/values lands in user_setting_values
|
|
// and is invisible in every profile DTO reader on every platform: the columns
|
|
// only move when a client goes through POST/PUT /profiles, and the cutover
|
|
// direction is that they stop being read rather than start being dual-written.
|
|
//
|
|
// The fallback is the contract default, never the column. A column holding a
|
|
// pre-cutover value that the one-time backfill already converted would
|
|
// otherwise resurface the moment its canonical row is unset — the "clear this
|
|
// preference" path would read as "restore the value from before the cutover".
|
|
|
|
// profilePreferences is the resolved form of the DTO's preference block. Each
|
|
// field is the effective value for one profile, already defaulted, so the
|
|
// serializer copies rather than decides.
|
|
type profilePreferences struct {
|
|
AudioLanguage string
|
|
MetadataLanguage string
|
|
SubtitleLanguage string
|
|
SubtitleMode string
|
|
ShowForcedSubtitles bool
|
|
}
|
|
|
|
// profilePreferenceKeys are the canonical keys behind the DTO's preference
|
|
// fields, in DTO field order.
|
|
//
|
|
// quality_preference has no entry: the legacy column is a single compound
|
|
// value while the contract splits it across playback.preferred_quality and
|
|
// playback.max_bitrate_kbps, so there is no lossless read and the field stays
|
|
// column-backed. The auto_skip_* and auto_play_next_preview fields do sync on
|
|
// write, but this list drives the DTO's read block, whose shape the clients
|
|
// pin; they keep reading their columns, which the sync now keeps current.
|
|
var profilePreferenceKeys = []string{
|
|
settingskeys.PlaybackAudioLanguage,
|
|
settingskeys.CatalogMetadataLanguage,
|
|
settingskeys.PlaybackSubtitleLanguage,
|
|
settingskeys.PlaybackSubtitleMode,
|
|
settingskeys.PlaybackShowForcedSubtitles,
|
|
}
|
|
|
|
// resolveProfilePreferences resolves the preference block for every listed
|
|
// profile in one store read.
|
|
//
|
|
// One read for the whole household rather than one per profile: GET /profiles
|
|
// serves several profiles and this is on its hot path. A resolution failure
|
|
// degrades to contract defaults rather than failing the request — these are
|
|
// presentation preferences, not an access boundary — but it is logged, because
|
|
// a store outage that silently hands every profile the defaults is otherwise
|
|
// indistinguishable from a household that never set anything.
|
|
func resolveProfilePreferences(
|
|
ctx context.Context,
|
|
store userstore.UserStore,
|
|
profileIDs []string,
|
|
) map[string]profilePreferences {
|
|
defaults := contractProfilePreferences()
|
|
out := make(map[string]profilePreferences, len(profileIDs))
|
|
for _, id := range profileIDs {
|
|
out[id] = defaults
|
|
}
|
|
if store == nil || len(profileIDs) == 0 {
|
|
return out
|
|
}
|
|
|
|
contract, err := settingscontract.Load()
|
|
if err != nil {
|
|
slog.WarnContext(ctx, "profile preferences degraded to contract defaults: loading settings contract failed",
|
|
"component", "api", "error", err)
|
|
return out
|
|
}
|
|
resolved, err := settingsresolve.New(contract).ResolveProfiles(
|
|
ctx, store, profileIDs, profilePreferenceKeys, nil)
|
|
if err != nil {
|
|
slog.WarnContext(ctx, "profile preferences degraded to contract defaults: reading setting values failed",
|
|
"component", "api", "profiles", len(profileIDs), "error", err)
|
|
return out
|
|
}
|
|
|
|
for profileID, effective := range resolved {
|
|
prefs := defaults
|
|
for _, eff := range effective {
|
|
applyProfilePreference(&prefs, eff.Key, eff.Value)
|
|
}
|
|
out[profileID] = prefs
|
|
}
|
|
return out
|
|
}
|
|
|
|
// contractProfilePreferences is the block every profile starts from: the
|
|
// contract's own defaults, decoded once per request.
|
|
//
|
|
// It is derived from the manifest rather than hard-coded so a default that
|
|
// changes there changes here too. A contract that fails to load leaves the Go
|
|
// zero values, which is the same "no preference" the empty string and false
|
|
// have always spelled in this DTO.
|
|
func contractProfilePreferences() profilePreferences {
|
|
var prefs profilePreferences
|
|
contract, err := settingscontract.Load()
|
|
if err != nil {
|
|
return prefs
|
|
}
|
|
for _, key := range profilePreferenceKeys {
|
|
def, ok := contract.Lookup(key)
|
|
if !ok {
|
|
continue
|
|
}
|
|
applyProfilePreference(&prefs, key, def.DefaultValue)
|
|
}
|
|
return prefs
|
|
}
|
|
|
|
// applyProfilePreference decodes one canonical value into its DTO field.
|
|
//
|
|
// A value that fails to decode leaves the field as it was, so a single
|
|
// malformed row degrades one field to its default instead of the whole block.
|
|
// The language keys default to JSON null, which unmarshals into "" — the same
|
|
// spelling of "no preference" the legacy columns used.
|
|
func applyProfilePreference(prefs *profilePreferences, key string, value json.RawMessage) {
|
|
switch key {
|
|
case settingskeys.PlaybackAudioLanguage:
|
|
decodeSettingString(value, &prefs.AudioLanguage)
|
|
case settingskeys.CatalogMetadataLanguage:
|
|
decodeSettingString(value, &prefs.MetadataLanguage)
|
|
case settingskeys.PlaybackSubtitleLanguage:
|
|
decodeSettingString(value, &prefs.SubtitleLanguage)
|
|
case settingskeys.PlaybackSubtitleMode:
|
|
decodeSettingString(value, &prefs.SubtitleMode)
|
|
case settingskeys.PlaybackShowForcedSubtitles:
|
|
var forced bool
|
|
if json.Unmarshal(value, &forced) == nil {
|
|
prefs.ShowForcedSubtitles = forced
|
|
}
|
|
}
|
|
}
|
|
|
|
func decodeSettingString(value json.RawMessage, dst *string) {
|
|
var decoded string
|
|
if json.Unmarshal(value, &decoded) == nil {
|
|
*dst = strings.TrimSpace(decoded)
|
|
}
|
|
}
|