Files
silo-server/internal/api/handlers/profiles_settings_sync.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

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