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>
1098 lines
40 KiB
Go
1098 lines
40 KiB
Go
// Package settingsmigrate turns legacy settings storage into canonical setting
|
|
// values.
|
|
//
|
|
// The rules are subtle enough — and the cost of getting them wrong high enough,
|
|
// since this runs once against every user's data — that they live here as
|
|
// ordinary Go rather than twice in two SQL dialects. Both backends read their
|
|
// own rows, hand them to Plan, and write what comes back. The interesting
|
|
// decisions are then testable without a database, and SQLite and Postgres
|
|
// cannot drift apart in what they decide.
|
|
//
|
|
// Nothing here touches storage. Plan is a pure function from legacy rows to
|
|
// canonical rows plus rejects.
|
|
package settingsmigrate
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/santhosh-tekuri/jsonschema/v6"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/jellycompat/displayprefs"
|
|
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
|
"github.com/Silo-Server/silo-server/internal/settingskeys"
|
|
)
|
|
|
|
// LegacySetting is one row of the account-wide user_settings key/value table.
|
|
type LegacySetting struct {
|
|
Key string
|
|
Value string
|
|
}
|
|
|
|
// LegacyDeviceSetting is one row of user_device_settings.
|
|
type LegacyDeviceSetting struct {
|
|
ProfileID string
|
|
DeviceID string
|
|
Key string
|
|
Value string
|
|
}
|
|
|
|
// LegacyProfile carries the preference columns on a profile row.
|
|
//
|
|
// Every field is a pointer or a "set" flag because the two backends disagree
|
|
// about nullability: Postgres declares these NOT NULL with defaults while
|
|
// SQLite leaves them nullable, so "the user chose the default" and "nobody ever
|
|
// wrote here" are spelled differently per backend. The caller resolves that
|
|
// when it reads; Plan sees one shape.
|
|
type LegacyProfile struct {
|
|
ID string
|
|
|
|
Language *string
|
|
SubtitleLanguage *string
|
|
SubtitleMode *string
|
|
ShowForcedSubtitles *bool
|
|
QualityPreference *string
|
|
PreferredMetadataLanguage *string
|
|
AutoSkipIntro *bool
|
|
AutoSkipCredits *bool
|
|
AutoSkipRecap *bool
|
|
AutoPlayNextPreview *bool
|
|
}
|
|
|
|
// LegacySeriesPreference is one user_subtitle_preferences or
|
|
// user_audio_preferences row. Track indexes and signatures are deliberately
|
|
// absent: they identify a concrete track rather than expressing a preference,
|
|
// and they stay in their specialized tables.
|
|
type LegacySeriesPreference struct {
|
|
ProfileID string
|
|
SeriesID string
|
|
// AudioSourceTable and SubtitleSourceTable preserve which physical legacy
|
|
// table supplied each half of this merged record. PostgreSQL and per-user
|
|
// SQLite use different table names, and rejected rows must point operators
|
|
// back to the table that actually owns the value.
|
|
AudioSourceTable string
|
|
SubtitleSourceTable string
|
|
|
|
AudioLanguage *string
|
|
SubtitleLanguage *string
|
|
SubtitleMode *string
|
|
ShowForcedSubtitles *bool
|
|
}
|
|
|
|
// LegacyLibraryPreference is one user_library_playback_preferences row.
|
|
type LegacyLibraryPreference struct {
|
|
ProfileID string
|
|
LibraryID int
|
|
// SourceTable is backend-specific: user_library_playback_preferences in
|
|
// PostgreSQL and library_playback_preferences in per-user SQLite.
|
|
SourceTable string
|
|
|
|
AudioLanguage *string
|
|
SubtitleLanguage *string
|
|
SubtitleMode *string
|
|
ShowForcedSubtitles *bool
|
|
}
|
|
|
|
// Input is everything one user's migration reads.
|
|
type Input struct {
|
|
Settings []LegacySetting
|
|
DeviceSettings []LegacyDeviceSetting
|
|
Profiles []LegacyProfile
|
|
SeriesPrefs []LegacySeriesPreference
|
|
LibraryPrefs []LegacyLibraryPreference
|
|
}
|
|
|
|
// Row is one canonical value to write.
|
|
type Row struct {
|
|
Key string
|
|
Scope settingscontract.Scope
|
|
ProfileID string
|
|
DeviceID string
|
|
LibraryID int
|
|
SeriesID string
|
|
Value json.RawMessage
|
|
|
|
// Source provenance is carried until the orphan-profile pass. A planned
|
|
// row can no longer be reconstructed from its canonical scope alone: a
|
|
// profile_series row may have come from either the audio or subtitle table.
|
|
sourceTable string
|
|
sourceKey string
|
|
sourceIdentity json.RawMessage
|
|
|
|
// fromRenamedKey records that this row's source was stored under a legacy
|
|
// spelling in legacyKeyRenames. It exists only for the dedup pass: when a
|
|
// renamed row and a canonically-keyed row land on the same identity, this is
|
|
// how the pass knows which one the user wrote last.
|
|
fromRenamedKey bool
|
|
}
|
|
|
|
// Reject records a legacy value that could not be converted. It is written to
|
|
// user_setting_migration_rejects so an operator can see exactly what did not
|
|
// survive, rather than discovering it from a support ticket.
|
|
//
|
|
// Identity is JSON because both backends store it as one: Postgres declares the
|
|
// column jsonb NOT NULL and SQLite guards it with a json_valid CHECK. A
|
|
// free-form description would violate the schema on write, and a structured one
|
|
// is queryable — "every reject for profile p1" is a jsonb predicate rather than
|
|
// a LIKE over prose.
|
|
type Reject struct {
|
|
SourceTable string
|
|
SourceKey string
|
|
Identity json.RawMessage
|
|
Value string
|
|
Reason string
|
|
}
|
|
|
|
// identityJSON builds the reject identity document. Only the fields that locate
|
|
// the row are emitted, so a profile-column reject does not carry an empty
|
|
// device id.
|
|
func identityJSON(fields map[string]any) json.RawMessage {
|
|
encoded, err := json.Marshal(fields)
|
|
if err != nil {
|
|
// Only strings and ints reach here, so this cannot fire; an empty
|
|
// object still satisfies both backends' JSON constraint.
|
|
return json.RawMessage(`{}`)
|
|
}
|
|
return encoded
|
|
}
|
|
|
|
// Result is what one user's migration produced.
|
|
type Result struct {
|
|
Rows []Row
|
|
Rejects []Reject
|
|
}
|
|
|
|
// RuntimeValue is one canonical mutation implied by a write through a shipped
|
|
// legacy generic-settings endpoint. Nil Value means the canonical row must be
|
|
// cleared. The generic handlers use this live counterpart of the backfill so
|
|
// aliases, typed coercion, object upgrades, and compound quality values cannot
|
|
// drift between upgrade-time and runtime conversion.
|
|
type RuntimeValue struct {
|
|
Key string
|
|
Value json.RawMessage
|
|
}
|
|
|
|
// PlanRuntimeValue converts one legacy string setting into its canonical
|
|
// mutations. Quality always returns both axes: values without a bitrate clear
|
|
// an older max_bitrate_kbps row rather than leaving half of the previous
|
|
// compound preference behind.
|
|
func (p *Planner) PlanRuntimeValue(legacyKey, raw string) ([]RuntimeValue, error) {
|
|
key := CanonicalKey(legacyKey)
|
|
if key == keyPreferredQuality {
|
|
var result Result
|
|
p.addQuality(&result, sourceUserDeviceSettings, identityJSON(map[string]any{}),
|
|
settingscontract.ScopeProfileDevice, Row{}, raw, "")
|
|
if len(result.Rejects) > 0 {
|
|
return nil, fmt.Errorf("%s: %s", key, result.Rejects[0].Reason)
|
|
}
|
|
planned := map[string]json.RawMessage{}
|
|
for _, row := range result.Rows {
|
|
planned[row.Key] = row.Value
|
|
}
|
|
return withRuntimeMirrors([]RuntimeValue{
|
|
{Key: keyPreferredQuality, Value: planned[keyPreferredQuality]},
|
|
{Key: settingskeys.PlaybackMaxBitrateKbps, Value: planned[settingskeys.PlaybackMaxBitrateKbps]},
|
|
})
|
|
}
|
|
|
|
def, ok := p.contract.Lookup(key)
|
|
if !ok || !def.IsRemote() {
|
|
return nil, fmt.Errorf("%s has no remote contract definition", key)
|
|
}
|
|
value, err := p.coerce(def, raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", key, err)
|
|
}
|
|
return withRuntimeMirrors([]RuntimeValue{{Key: key, Value: value}})
|
|
}
|
|
|
|
// withRuntimeMirrors appends the companion mutation for every planned value
|
|
// whose key has a replacement.
|
|
//
|
|
// It sits here rather than in the handler because three legacy write paths
|
|
// share PlanRuntimeValue — the account-wide fan-out, the per-device setting,
|
|
// and the inheritance a newly created profile picks up — and a mirror applied
|
|
// at only some of them would make a preference's meaning depend on which
|
|
// legacy route last touched it.
|
|
//
|
|
// A nil Value means "clear this row", and the companion is cleared with it for
|
|
// the same reason DELETE clears both: a surviving half would go on resolving as
|
|
// an explicit choice nobody made.
|
|
func withRuntimeMirrors(planned []RuntimeValue) ([]RuntimeValue, error) {
|
|
out := make([]RuntimeValue, 0, len(planned)+1)
|
|
out = append(out, planned...)
|
|
for _, mutation := range planned {
|
|
if mutation.Value == nil {
|
|
if mirrorKey, ok := settingscontract.MirrorKey(mutation.Key); ok {
|
|
out = append(out, RuntimeValue{Key: mirrorKey})
|
|
}
|
|
continue
|
|
}
|
|
mirror, ok, err := settingscontract.MirrorWrite(mutation.Key, mutation.Value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ok {
|
|
out = append(out, RuntimeValue{Key: mirror.Key, Value: mirror.Value})
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// RuntimeKeys returns every canonical row owned by a legacy generic key. It is
|
|
// used by DELETE, where there is no value to run through PlanRuntimeValue.
|
|
func (p *Planner) RuntimeKeys(legacyKey string) []string {
|
|
key := CanonicalKey(legacyKey)
|
|
var keys []string
|
|
switch key {
|
|
case keyPreferredQuality:
|
|
keys = []string{keyPreferredQuality, settingskeys.PlaybackMaxBitrateKbps}
|
|
default:
|
|
if def, ok := p.contract.Lookup(key); ok && def.IsRemote() {
|
|
keys = []string{key}
|
|
}
|
|
}
|
|
if len(keys) == 0 {
|
|
return nil // the caller reads this as "no canonical target"
|
|
}
|
|
// Clearing through the legacy route has to reach the same rows a write
|
|
// through it created, mirror included.
|
|
out := make([]string, 0, len(keys)+1)
|
|
out = append(out, keys...)
|
|
for _, owned := range keys {
|
|
if mirrorKey, ok := settingscontract.MirrorKey(owned); ok {
|
|
out = append(out, mirrorKey)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// profileColumnDefaults are the values the legacy schema wrote when nobody
|
|
// chose anything. A column still holding its default is not a preference, and
|
|
// migrating it would turn "never decided" into an explicit choice that outranks
|
|
// the contract default forever.
|
|
//
|
|
// The language column's 'en' default is deliberately absent: see planProfiles.
|
|
const defaultSubtitleMode = "auto"
|
|
|
|
// Source table names, recorded on every reject so an operator can find the row
|
|
// the migration could not convert.
|
|
const (
|
|
sourceUserSettings = "user_settings"
|
|
sourceUserDeviceSettings = "user_device_settings"
|
|
sourceUserProfiles = "user_profiles"
|
|
sourceAudioPrefs = "audio_preferences"
|
|
sourceSubtitlePrefs = "subtitle_preferences"
|
|
sourceLibraryPrefs = "library_playback_preferences"
|
|
)
|
|
|
|
// keyPreferredQuality is the one key with bespoke handling: it decomposes into
|
|
// two canonical keys rather than converting to one.
|
|
const keyPreferredQuality = "playback.preferred_quality"
|
|
|
|
// keyCardOverlays needs a format upgrade rather than a plain conversion: old
|
|
// web clients stored a v1 document the contract schema does not accept.
|
|
const keyCardOverlays = "ui.card_overlays"
|
|
|
|
// keyAudioLanguage has a device-scope quarantine: see planDeviceSettings.
|
|
const keyAudioLanguage = "playback.audio_language"
|
|
|
|
// These identity fields make migration rejects directly queryable by the
|
|
// content context that owned the legacy value.
|
|
const (
|
|
fieldProfileID = "profile_id"
|
|
fieldSeriesID = "series_id"
|
|
fieldLibraryID = "library_id"
|
|
)
|
|
|
|
// accountIdentity locates a reject from the account-wide key/value table, which
|
|
// has no profile, device or content to name.
|
|
func accountIdentity() json.RawMessage {
|
|
return identityJSON(map[string]any{"scope": "account"})
|
|
}
|
|
|
|
// legacyQualityDecomposition maps each legacy compound quality value to the two
|
|
// axes that replaced it.
|
|
//
|
|
// The suffixes were never a third dimension — web/src/player/hooks/
|
|
// useTranscodeQuality.ts already treats 1080p-high as resolution 1080p at
|
|
// 10000 kbps and sends the two separately, so this table is that file's ladder
|
|
// read back out. Bitrates come from there, not invented here.
|
|
//
|
|
// A lookup table reads as a table, so the resolutions stay inline: naming
|
|
// "1080p" would hide the mapping this exists to make obvious.
|
|
//
|
|
//nolint:goconst // declarative table; see above
|
|
var legacyQualityDecomposition = map[string]struct {
|
|
Resolution string
|
|
KBPS int
|
|
}{
|
|
"1080p-high": {"1080p", 10000},
|
|
"1080p": {"1080p", 6000},
|
|
"1080p-medium": {"1080p", 4500},
|
|
"1080p-8": {"1080p", 6000},
|
|
"720p-high": {"720p", 4000},
|
|
"720p-medium": {"720p", 3000},
|
|
"720p": {"720p", 2000},
|
|
"480p": {"480p", 1500},
|
|
"420p": {"480p", 720},
|
|
"328p": {"480p", 720},
|
|
}
|
|
|
|
// legacyKeyRenames maps a legacy key to its canonical name. A key absent here
|
|
// keeps its name.
|
|
//
|
|
// As above: the left column is the legacy spelling and the right is canonical,
|
|
// so both sides belong inline.
|
|
//
|
|
//nolint:goconst // declarative table; see above
|
|
var legacyKeyRenames = map[string]string{
|
|
"subtitle_appearance": "playback.subtitle_appearance",
|
|
"player.next_up_prompt_seconds": "playback.next_up_prompt_seconds",
|
|
"ui_theme": "ui.theme",
|
|
"ui_text_scale": "ui.text_scale",
|
|
"ui_text_weight": "ui.text_weight",
|
|
"ui_high_contrast": "ui.high_contrast",
|
|
"ui_custom_theme_vars": "ui.custom_theme_vars",
|
|
"ui_custom_css": "ui.custom_css",
|
|
"card_overlays": "ui.card_overlays",
|
|
"next_up_mode": "ui.next_up_mode",
|
|
"sidebar_pins": "ui.sidebar_pins",
|
|
"disabled_library_ids": "ui.disabled_library_ids",
|
|
"library_order": "ui.library_order",
|
|
}
|
|
|
|
// Planner converts legacy rows using one contract.
|
|
type Planner struct {
|
|
contract *settingscontract.Manifest
|
|
schemas map[string]*jsonschema.Schema
|
|
}
|
|
|
|
// New returns a Planner over the given contract. objectSchemas is the compiled
|
|
// schema set from settingscontract.ObjectSchemas, used to validate every value
|
|
// before it is planned — nothing reaches storage that the mutation endpoint
|
|
// would refuse.
|
|
func New(contract *settingscontract.Manifest, objectSchemas map[string]*jsonschema.Schema) *Planner {
|
|
return &Planner{contract: contract, schemas: objectSchemas}
|
|
}
|
|
|
|
// Plan converts one user's legacy rows.
|
|
//
|
|
// Profiles are needed even when nothing else is: an account-scoped legacy row
|
|
// fans out to every profile, because the contract moved appearance and search
|
|
// scope from the account to the profile and there is no account-scope reader
|
|
// left for them.
|
|
func (p *Planner) Plan(in Input) Result {
|
|
var res Result
|
|
|
|
p.planProfiles(in.Profiles, &res)
|
|
p.planAccountSettings(in.Settings, in.Profiles, &res)
|
|
p.planDeviceSettings(in.DeviceSettings, &res)
|
|
p.planSeriesPrefs(in.SeriesPrefs, &res)
|
|
p.planLibraryPrefs(in.LibraryPrefs, &res)
|
|
p.planMirroredRows(&res)
|
|
|
|
res.Rows = dedupeRows(res.Rows)
|
|
res.Rows = dropOrphanProfileRows(res.Rows, in.Profiles, &res)
|
|
|
|
return res
|
|
}
|
|
|
|
// planMirroredRows carries every planned row whose key has a replacement onto
|
|
// that replacement, at the same identity.
|
|
//
|
|
// The SQL migration that introduced playback.intro_skip_mode copies the rows
|
|
// that were already canonical when it ran. This is the other half: the backfill
|
|
// converts a user's legacy storage the first time their store opens, which for
|
|
// an install upgrading later is after that migration, so a legacy
|
|
// auto_skip_intro would otherwise arrive with no companion and a current client
|
|
// would read the contract default instead of the household's choice.
|
|
//
|
|
// It runs over the planned rows rather than inside planProfiles so it covers
|
|
// every source a mirrored key can come from — the profile column, the account
|
|
// table's fan-out, and per-device rows alike — and cannot fall behind when one
|
|
// of them changes. Companions are appended, so dedupeRows keeps an explicitly
|
|
// stored value ahead of a derived one.
|
|
//
|
|
// A conversion failure is dropped rather than rejected: the source row was
|
|
// planned, which means it already validated against the contract, so the only
|
|
// way to fail here is a defect in the pairing, and losing the companion is
|
|
// strictly better than losing the row that produced it.
|
|
func (p *Planner) planMirroredRows(res *Result) {
|
|
planned := len(res.Rows)
|
|
for i := 0; i < planned; i++ {
|
|
row := res.Rows[i]
|
|
mirror, ok, err := settingscontract.MirrorWrite(row.Key, row.Value)
|
|
if err != nil || !ok {
|
|
continue
|
|
}
|
|
companion := row
|
|
companion.Key = mirror.Key
|
|
companion.Value = mirror.Value
|
|
res.Rows = append(res.Rows, companion)
|
|
}
|
|
}
|
|
|
|
// dropOrphanProfileRows removes rows whose profile no longer exists.
|
|
//
|
|
// The legacy per-profile tables carry an ON DELETE CASCADE on (user_id,
|
|
// profile_id) today, but rows predating that constraint survived their
|
|
// profile's deletion — a real install had 46 such rows across 14 deleted
|
|
// profiles. The canonical table declares the same foreign key, so copying one
|
|
// aborts the whole migration transaction and the server cannot start.
|
|
//
|
|
// Dropping rather than repairing: a device override belonging to a profile
|
|
// nobody can select is not a preference anyone can be shown or reset. They are
|
|
// recorded as rejects rather than discarded silently, so an operator inspecting
|
|
// user_setting_migration_rejects sees what was left behind and why.
|
|
func dropOrphanProfileRows(rows []Row, profiles []LegacyProfile, res *Result) []Row {
|
|
// Nil means the caller did not load profiles and cannot safely validate
|
|
// ownership. A loaded-but-empty slice means the account has no profiles, so
|
|
// every profile-anchored legacy row is necessarily orphaned.
|
|
if profiles == nil {
|
|
return rows
|
|
}
|
|
known := make(map[string]struct{}, len(profiles))
|
|
for _, profile := range profiles {
|
|
known[profile.ID] = struct{}{}
|
|
}
|
|
|
|
kept := rows[:0]
|
|
for _, row := range rows {
|
|
if row.ProfileID == "" {
|
|
kept = append(kept, row) // account scope: no profile to orphan
|
|
continue
|
|
}
|
|
if _, ok := known[row.ProfileID]; ok {
|
|
kept = append(kept, row)
|
|
continue
|
|
}
|
|
res.Rejects = append(res.Rejects, Reject{
|
|
SourceTable: row.sourceTable,
|
|
SourceKey: row.sourceKey,
|
|
Identity: row.sourceIdentity,
|
|
Value: string(row.Value),
|
|
Reason: "profile no longer exists; the legacy row outlived the profile it belonged to",
|
|
})
|
|
}
|
|
return kept
|
|
}
|
|
|
|
// dedupeRows collapses rows that landed on the same canonical identity. The
|
|
// legacy tables key on the stored spelling, so one device can legitimately hold
|
|
// both player.next_up_prompt_seconds and playback.next_up_prompt_seconds; after
|
|
// the rename both become one identity, and writing both would trip the unique
|
|
// index — on SQLite that aborts NewUserDB and the user's store never opens
|
|
// again.
|
|
//
|
|
// The tiebreak mirrors how the rows got there: the runtime handler writes the
|
|
// canonical key first and only best-effort-deletes the alias, so when the alias
|
|
// survived alongside a canonical row, the canonical row is the newer write and
|
|
// wins. Between two rows of equal provenance the first in input order is kept,
|
|
// which keeps the pass deterministic.
|
|
func dedupeRows(rows []Row) []Row {
|
|
type identity struct {
|
|
key string
|
|
scope settingscontract.Scope
|
|
profileID string
|
|
deviceID string
|
|
libraryID int
|
|
seriesID string
|
|
}
|
|
|
|
kept := make(map[identity]int, len(rows))
|
|
out := rows[:0]
|
|
for _, row := range rows {
|
|
id := identity{
|
|
key: row.Key, scope: row.Scope,
|
|
profileID: row.ProfileID, deviceID: row.DeviceID,
|
|
libraryID: row.LibraryID, seriesID: row.SeriesID,
|
|
}
|
|
at, seen := kept[id]
|
|
if !seen {
|
|
kept[id] = len(out)
|
|
out = append(out, row)
|
|
continue
|
|
}
|
|
if out[at].fromRenamedKey && !row.fromRenamedKey {
|
|
out[at] = row
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// planProfiles converts the preference columns on user_profiles.
|
|
func (p *Planner) planProfiles(profiles []LegacyProfile, res *Result) {
|
|
for _, profile := range profiles {
|
|
id := func(field string) json.RawMessage {
|
|
return identityJSON(map[string]any{fieldProfileID: profile.ID, "column": field})
|
|
}
|
|
|
|
// Language columns: the empty string is the legacy spelling of "no
|
|
// preference", and the contract spells that as the absence of a row.
|
|
//
|
|
// The audio language deliberately gets no columnDefault, unlike quality
|
|
// below: the column's 'en' default was the effective behavior — playback
|
|
// preferred English tracks for it — while the contract default is null,
|
|
// under which audio_select skips language matching entirely. Suppressing
|
|
// 'en' as "never decided" would silently change what plays for every
|
|
// profile that was getting English selection, chosen or not. The other
|
|
// language columns default to empty, which already means unset on both
|
|
// sides, so only this one carries behavior worth preserving.
|
|
p.addLanguage(res, sourceUserProfiles, id("language"),
|
|
"playback.audio_language", settingscontract.ScopeProfile,
|
|
Row{ProfileID: profile.ID}, profile.Language, "")
|
|
p.addLanguage(res, sourceUserProfiles, id("subtitle_language"),
|
|
"playback.subtitle_language", settingscontract.ScopeProfile,
|
|
Row{ProfileID: profile.ID}, profile.SubtitleLanguage, "")
|
|
p.addLanguage(res, sourceUserProfiles, id("preferred_metadata_language"),
|
|
"catalog.metadata_language", settingscontract.ScopeProfile,
|
|
Row{ProfileID: profile.ID}, profile.PreferredMetadataLanguage, "")
|
|
|
|
if mode := deref(profile.SubtitleMode); mode != "" && mode != defaultSubtitleMode {
|
|
p.addValue(res, sourceUserProfiles, id("subtitle_mode"),
|
|
"playback.subtitle_mode", settingscontract.ScopeProfile,
|
|
Row{ProfileID: profile.ID}, jsonString(mode))
|
|
}
|
|
|
|
// show_forced_subtitles is NOT NULL DEFAULT true, so only false is a
|
|
// decision worth carrying: true is indistinguishable from untouched.
|
|
if profile.ShowForcedSubtitles != nil && !*profile.ShowForcedSubtitles {
|
|
p.addValue(res, sourceUserProfiles, id("show_forced_subtitles"),
|
|
"playback.show_forced_subtitles", settingscontract.ScopeProfile,
|
|
Row{ProfileID: profile.ID}, json.RawMessage("false"))
|
|
}
|
|
|
|
// The legacy schema's 1080p default was also the effective playback
|
|
// behavior. The contract default is auto, so even an untouched legacy
|
|
// profile needs an explicit canonical row or the cutover silently lifts
|
|
// its resolution and bitrate cap.
|
|
p.addQuality(res, sourceUserProfiles, id("quality_preference"),
|
|
settingscontract.ScopeProfile, Row{ProfileID: profile.ID},
|
|
deref(profile.QualityPreference), "")
|
|
|
|
// The auto-skip switches default to false in both the columns and the
|
|
// contract, so only an explicit true is a decision: migrating a false
|
|
// would turn "never touched the switch" into a stored choice that
|
|
// outranks the contract default forever, the show_forced_subtitles
|
|
// problem mirrored.
|
|
//
|
|
//nolint:goconst // declarative table mapping columns to keys
|
|
for _, flag := range []struct {
|
|
column string
|
|
key string
|
|
value *bool
|
|
}{
|
|
{"auto_skip_intro", "playback.auto_skip_intro", profile.AutoSkipIntro},
|
|
{"auto_skip_credits", "playback.auto_skip_credits", profile.AutoSkipCredits},
|
|
{"auto_skip_recap", "playback.auto_skip_recap", profile.AutoSkipRecap},
|
|
{"auto_play_next_preview", "playback.auto_play_next_preview", profile.AutoPlayNextPreview},
|
|
} {
|
|
if flag.value != nil && *flag.value {
|
|
p.addValue(res, sourceUserProfiles, id(flag.column),
|
|
flag.key, settingscontract.ScopeProfile,
|
|
Row{ProfileID: profile.ID}, json.RawMessage("true"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// planAccountSettings converts the account-wide key/value table.
|
|
//
|
|
// Every surviving definition for these keys is profile-scoped, so each value
|
|
// fans out to every profile on the account. That is the account-to-profile move
|
|
// the contract makes for appearance and search scope: one row becomes n, and a
|
|
// household that shared a theme now each own theirs.
|
|
func (p *Planner) planAccountSettings(
|
|
settings []LegacySetting, profiles []LegacyProfile, res *Result,
|
|
) {
|
|
for _, setting := range settings {
|
|
key := CanonicalKey(setting.Key)
|
|
|
|
// jellycompat stashed its DisplayPreferences blobs in this table under
|
|
// synthetic keys. They are that subsystem's storage, not user settings:
|
|
// the migration step that follows this backfill moves them to the
|
|
// dedicated jellycompat_displayprefs table, so this planner must
|
|
// neither convert nor reject them.
|
|
if strings.HasPrefix(setting.Key, displayprefs.NamespacePrefix) {
|
|
continue
|
|
}
|
|
|
|
def, ok := p.contract.Lookup(key)
|
|
if !ok {
|
|
res.Rejects = append(res.Rejects, Reject{
|
|
SourceTable: sourceUserSettings, SourceKey: setting.Key,
|
|
Identity: accountIdentity(),
|
|
Value: setting.Value,
|
|
Reason: "no contract definition; the key was only ever accepted by the unknown-key extension bag",
|
|
})
|
|
continue
|
|
}
|
|
|
|
value, err := p.coerce(def, setting.Value)
|
|
if err != nil {
|
|
res.Rejects = append(res.Rejects, Reject{
|
|
SourceTable: sourceUserSettings, SourceKey: setting.Key,
|
|
Identity: accountIdentity(),
|
|
Value: setting.Value, Reason: err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
if value == nil {
|
|
continue // legacy spelling of unset
|
|
}
|
|
|
|
scope := settingscontract.ScopeProfile
|
|
if !def.AllowsScope(scope) {
|
|
res.Rejects = append(res.Rejects, Reject{
|
|
SourceTable: sourceUserSettings, SourceKey: setting.Key,
|
|
Identity: accountIdentity(),
|
|
Value: setting.Value,
|
|
Reason: fmt.Sprintf("%s does not allow profile scope", key),
|
|
})
|
|
continue
|
|
}
|
|
for _, profile := range profiles {
|
|
res.Rows = append(res.Rows, Row{
|
|
Key: key, Scope: scope, ProfileID: profile.ID, Value: value,
|
|
fromRenamedKey: key != setting.Key,
|
|
sourceTable: sourceUserSettings, sourceKey: setting.Key,
|
|
sourceIdentity: accountIdentity(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *Planner) planDeviceSettings(devices []LegacyDeviceSetting, res *Result) {
|
|
for _, row := range devices {
|
|
key := CanonicalKey(row.Key)
|
|
identity := identityJSON(map[string]any{
|
|
fieldProfileID: row.ProfileID, "device_id": row.DeviceID,
|
|
})
|
|
|
|
if key == keyPreferredQuality {
|
|
p.addQuality(res, sourceUserDeviceSettings, identity,
|
|
settingscontract.ScopeProfileDevice,
|
|
Row{ProfileID: row.ProfileID, DeviceID: row.DeviceID}, row.Value, "")
|
|
continue
|
|
}
|
|
|
|
// Device-scope audio language rows are stranded, not preferences: no
|
|
// pre-contract playback path ever read them (track selection used
|
|
// profile.Language), and migration 098 machine-fanned the account
|
|
// value onto every known device besides. Promoting one to a real
|
|
// profile_device override would make a value that never influenced
|
|
// playback suddenly outrank the profile language after upgrade.
|
|
// Quarantined rather than dropped so an operator can restore any row
|
|
// a user insists was a choice.
|
|
if key == keyAudioLanguage {
|
|
// An empty value is the legacy spelling of unset, which produces
|
|
// no row anywhere and so has nothing to quarantine.
|
|
if strings.TrimSpace(row.Value) != "" {
|
|
res.Rejects = append(res.Rejects, Reject{
|
|
SourceTable: sourceUserDeviceSettings, SourceKey: row.Key,
|
|
Identity: identity, Value: row.Value,
|
|
Reason: "stranded device-scope audio language: never read by playback before the contract, so promoting it would change track selection",
|
|
})
|
|
}
|
|
continue
|
|
}
|
|
|
|
def, ok := p.contract.Lookup(key)
|
|
if !ok {
|
|
res.Rejects = append(res.Rejects, Reject{
|
|
SourceTable: sourceUserDeviceSettings, SourceKey: row.Key,
|
|
Identity: identity, Value: row.Value,
|
|
Reason: "no contract definition",
|
|
})
|
|
continue
|
|
}
|
|
value, err := p.coerce(def, row.Value)
|
|
if err != nil {
|
|
res.Rejects = append(res.Rejects, Reject{
|
|
SourceTable: sourceUserDeviceSettings, SourceKey: row.Key,
|
|
Identity: identity, Value: row.Value, Reason: err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
if value == nil {
|
|
continue
|
|
}
|
|
if !def.AllowsScope(settingscontract.ScopeProfileDevice) {
|
|
res.Rejects = append(res.Rejects, Reject{
|
|
SourceTable: sourceUserDeviceSettings, SourceKey: row.Key,
|
|
Identity: identity, Value: row.Value,
|
|
Reason: fmt.Sprintf("%s does not allow profile_device scope", key),
|
|
})
|
|
continue
|
|
}
|
|
res.Rows = append(res.Rows, Row{
|
|
Key: key, Scope: settingscontract.ScopeProfileDevice,
|
|
ProfileID: row.ProfileID, DeviceID: row.DeviceID, Value: value,
|
|
fromRenamedKey: key != row.Key,
|
|
sourceTable: sourceUserDeviceSettings, sourceKey: row.Key,
|
|
sourceIdentity: identity,
|
|
})
|
|
}
|
|
}
|
|
|
|
func (p *Planner) planSeriesPrefs(prefs []LegacySeriesPreference, res *Result) {
|
|
for _, pref := range prefs {
|
|
base := Row{ProfileID: pref.ProfileID, SeriesID: pref.SeriesID}
|
|
identity := identityJSON(map[string]any{
|
|
fieldProfileID: pref.ProfileID, fieldSeriesID: pref.SeriesID,
|
|
})
|
|
audioSource := pref.AudioSourceTable
|
|
if audioSource == "" {
|
|
audioSource = sourceAudioPrefs
|
|
}
|
|
subtitleSource := pref.SubtitleSourceTable
|
|
if subtitleSource == "" {
|
|
subtitleSource = sourceSubtitlePrefs
|
|
}
|
|
p.addPlaybackTriple(res, audioSource, subtitleSource, identity,
|
|
settingscontract.ScopeProfileSeries, base,
|
|
pref.AudioLanguage, pref.SubtitleLanguage, pref.SubtitleMode, pref.ShowForcedSubtitles)
|
|
}
|
|
}
|
|
|
|
func (p *Planner) planLibraryPrefs(prefs []LegacyLibraryPreference, res *Result) {
|
|
for _, pref := range prefs {
|
|
base := Row{ProfileID: pref.ProfileID, LibraryID: pref.LibraryID}
|
|
identity := identityJSON(map[string]any{
|
|
fieldProfileID: pref.ProfileID, fieldLibraryID: pref.LibraryID,
|
|
})
|
|
source := pref.SourceTable
|
|
if source == "" {
|
|
source = sourceLibraryPrefs
|
|
}
|
|
p.addPlaybackTriple(res, source, source, identity,
|
|
settingscontract.ScopeProfileLibrary, base,
|
|
pref.AudioLanguage, pref.SubtitleLanguage, pref.SubtitleMode, pref.ShowForcedSubtitles)
|
|
}
|
|
}
|
|
|
|
// addPlaybackTriple converts the four playback preferences that series and
|
|
// library rows share. Both tables carry the same columns with the same
|
|
// semantics, so they convert identically at different scopes.
|
|
func (p *Planner) addPlaybackTriple(
|
|
res *Result, audioSourceTable, subtitleSourceTable string, identity json.RawMessage,
|
|
scope settingscontract.Scope, base Row,
|
|
audio, subtitle, mode *string, forced *bool,
|
|
) {
|
|
p.addLanguage(res, audioSourceTable, identity,
|
|
"playback.audio_language", scope, base, audio, "")
|
|
p.addLanguage(res, subtitleSourceTable, identity,
|
|
"playback.subtitle_language", scope, base, subtitle, "")
|
|
|
|
if m := deref(mode); m != "" {
|
|
p.addValue(res, subtitleSourceTable, identity,
|
|
"playback.subtitle_mode", scope, base, jsonString(m))
|
|
}
|
|
// Nullable at these scopes, so a non-null value is a real override in
|
|
// either direction — unlike the profile column, where true is the default.
|
|
if forced != nil {
|
|
p.addValue(res, subtitleSourceTable, identity,
|
|
"playback.show_forced_subtitles", scope, base,
|
|
json.RawMessage(strconv.FormatBool(*forced)))
|
|
}
|
|
}
|
|
|
|
// addLanguage converts a legacy language column. The empty string is the legacy
|
|
// spelling of "no preference" and produces no row; so does a value still equal
|
|
// to the column default.
|
|
func (p *Planner) addLanguage(
|
|
res *Result, sourceTable string, identity json.RawMessage, key string,
|
|
scope settingscontract.Scope, base Row, raw *string, columnDefault string,
|
|
) {
|
|
value := strings.TrimSpace(deref(raw))
|
|
if value == "" || (columnDefault != "" && value == columnDefault) {
|
|
return
|
|
}
|
|
normalized, ok := settingscontract.NormalizeLanguageTag(value)
|
|
if !ok {
|
|
res.Rejects = append(res.Rejects, Reject{
|
|
SourceTable: sourceTable, SourceKey: key, Identity: identity,
|
|
Value: value,
|
|
Reason: "not a well-formed BCP 47 language tag",
|
|
})
|
|
return
|
|
}
|
|
p.addValue(res, sourceTable, identity, key, scope, base, jsonString(normalized))
|
|
}
|
|
|
|
// addQuality decomposes a legacy quality value into the two axes that replaced
|
|
// it, writing up to two rows.
|
|
func (p *Planner) addQuality(
|
|
res *Result, sourceTable string, identity json.RawMessage,
|
|
scope settingscontract.Scope, base Row, raw, columnDefault string,
|
|
) {
|
|
value := strings.TrimSpace(raw)
|
|
if value == "" || value == columnDefault {
|
|
return
|
|
}
|
|
|
|
// auto and original are resolution answers with no bitrate implication.
|
|
if value == "auto" || value == "original" || value == "2160p" {
|
|
p.addValue(res, sourceTable, identity, "playback.preferred_quality",
|
|
scope, base, jsonString(value))
|
|
return
|
|
}
|
|
|
|
decomposed, ok := legacyQualityDecomposition[value]
|
|
if !ok {
|
|
res.Rejects = append(res.Rejects, Reject{
|
|
SourceTable: sourceTable, SourceKey: keyPreferredQuality,
|
|
Identity: identity, Value: value,
|
|
Reason: "not a quality the server ever resolved; NormalizeQualityV3 folded it to auto",
|
|
})
|
|
return
|
|
}
|
|
p.addValue(res, sourceTable, identity, keyPreferredQuality,
|
|
scope, base, jsonString(decomposed.Resolution))
|
|
p.addValue(res, sourceTable, identity, settingskeys.PlaybackMaxBitrateKbps,
|
|
scope, base, json.RawMessage(strconv.Itoa(decomposed.KBPS)))
|
|
}
|
|
|
|
// addValue appends a row after checking the value against its own definition,
|
|
// so nothing reaches storage that the mutation endpoint would refuse.
|
|
func (p *Planner) addValue(
|
|
res *Result, sourceTable string, identity json.RawMessage, key string,
|
|
scope settingscontract.Scope, base Row, value json.RawMessage,
|
|
) {
|
|
def, ok := p.contract.Lookup(key)
|
|
if !ok {
|
|
res.Rejects = append(res.Rejects, Reject{
|
|
SourceTable: sourceTable, SourceKey: key, Identity: identity,
|
|
Value: string(value), Reason: "no contract definition",
|
|
})
|
|
return
|
|
}
|
|
if err := p.validate(def, value); err != nil {
|
|
res.Rejects = append(res.Rejects, Reject{
|
|
SourceTable: sourceTable, SourceKey: key, Identity: identity,
|
|
Value: string(value), Reason: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
row := base
|
|
row.Key = key
|
|
row.Scope = scope
|
|
row.Value = value
|
|
row.sourceTable = sourceTable
|
|
row.sourceKey = key
|
|
row.sourceIdentity = append(json.RawMessage(nil), identity...)
|
|
res.Rows = append(res.Rows, row)
|
|
}
|
|
|
|
// coerce turns a legacy string value into the JSON its definition declares.
|
|
//
|
|
// The legacy store held everything as a string, including booleans, numbers and
|
|
// whole JSON documents, so this is where "true" becomes true and "30" becomes
|
|
// 30. A nil return with no error means the value was the legacy spelling of
|
|
// unset and should produce no row at all.
|
|
func (p *Planner) coerce(def *settingscontract.Definition, raw string) (json.RawMessage, error) {
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
var candidate json.RawMessage
|
|
switch def.ValueSchema.Type {
|
|
case settingscontract.TypeBoolean:
|
|
parsed, err := strconv.ParseBool(trimmed)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%q is not a boolean", raw)
|
|
}
|
|
candidate = json.RawMessage(strconv.FormatBool(parsed))
|
|
|
|
case settingscontract.TypeInteger:
|
|
parsed, err := strconv.ParseInt(trimmed, 10, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%q is not an integer", raw)
|
|
}
|
|
candidate = json.RawMessage(strconv.FormatInt(parsed, 10))
|
|
|
|
case settingscontract.TypeNumber:
|
|
parsed, err := strconv.ParseFloat(trimmed, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%q is not a number", raw)
|
|
}
|
|
parsed = snapToStep(def, parsed)
|
|
candidate = json.RawMessage(strconv.FormatFloat(parsed, 'g', -1, 64))
|
|
|
|
case settingscontract.TypeObject:
|
|
// Already a JSON document in the legacy store.
|
|
candidate = json.RawMessage(trimmed)
|
|
if def.Key == keyCardOverlays {
|
|
candidate = upgradeCardOverlaysV1(candidate)
|
|
}
|
|
|
|
case settingscontract.TypeLanguageTag:
|
|
normalized, ok := settingscontract.NormalizeLanguageTag(trimmed)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%q is not a well-formed BCP 47 language tag", raw)
|
|
}
|
|
candidate = jsonString(normalized)
|
|
|
|
default:
|
|
// Strings and enums were stored as themselves.
|
|
candidate = jsonString(trimmed)
|
|
}
|
|
|
|
if err := p.validate(def, candidate); err != nil {
|
|
return nil, err
|
|
}
|
|
return candidate, nil
|
|
}
|
|
|
|
// snapToStep rounds a legacy number onto its definition's step grid. The
|
|
// legacy endpoint never enforced the declared step, so stored values like a
|
|
// playback speed of 0.26 are real user preferences; rejecting them at
|
|
// migration would destroy the preference over a rounding artifact, and every
|
|
// client's stepper was going to snap it on the next write anyway.
|
|
// Only the grid is corrected, never the range: a value outside the declared
|
|
// bounds was never storable through any endpoint, so clamping it would invent
|
|
// a preference rather than preserve one. Those still fall to validation and
|
|
// land in the rejects for an operator to see.
|
|
func snapToStep(def *settingscontract.Definition, value float64) float64 {
|
|
if def.ValueSchema.Step == nil || *def.ValueSchema.Step <= 0 {
|
|
return value
|
|
}
|
|
step := *def.ValueSchema.Step
|
|
base := 0.0
|
|
if minimum, ok := def.ValueSchema.Minimum.Current(); ok {
|
|
base = minimum
|
|
}
|
|
return base + math.Round((value-base)/step)*step
|
|
}
|
|
|
|
// cardOverlayIDs mirrors the overlayId enum in
|
|
// contracts/settings/v1/schemas/card-overlays.json. The v1 upgrade needs it to
|
|
// drop ids the schema no longer knows: validation is all-or-nothing, so one
|
|
// stale id left in place would quarantine the user's whole badge config.
|
|
var cardOverlayIDs = map[string]bool{
|
|
"resolution": true, "hdr": true, "resolution_hdr": true,
|
|
"audio": true, "audio_channels": true, "video_codec": true,
|
|
"container": true, "aspect_ratio": true, "release_type": true,
|
|
"edition": true, "multi_audio": true, "multi_sub": true,
|
|
"rating_imdb": true, "rating_tmdb": true, "rating_rt": true,
|
|
"rating_rt_audience": true, "content_rating": true,
|
|
"year": true, "runtime": true, "original_language": true,
|
|
"studio": true, "network": true, "show_status": true,
|
|
"imdb_top_250": true, "rt_certified_fresh": true,
|
|
}
|
|
|
|
// cardOverlayPositions and cardOverlayAccent mirror the per-item constraints in
|
|
// the same schema.
|
|
var (
|
|
cardOverlayPositions = map[string]bool{
|
|
"top-left": true, "top-right": true, "bottom-left": true, "bottom-right": true,
|
|
}
|
|
cardOverlayAccent = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
|
)
|
|
|
|
// looksLikeCardOverlaysV2 is web/src/lib/overlays/schema.ts's looksLikeV2
|
|
// heuristic: v1 documents are flat overlay-id records, v2 documents carry a
|
|
// version field or at minimum a preset string and an items object.
|
|
func looksLikeCardOverlaysV2(doc map[string]any) bool {
|
|
if version, ok := doc["version"].(float64); ok && version == 2 {
|
|
return true
|
|
}
|
|
_, presetIsString := doc["preset"].(string)
|
|
_, itemsIsObject := doc["items"].(map[string]any)
|
|
return presetIsString && itemsIsObject
|
|
}
|
|
|
|
// upgradeCardOverlaysV1 rewrites a v1 card_overlays document into the v2 shape
|
|
// the contract schema requires, mirroring the web client's migrateFromV1.
|
|
//
|
|
// Old web clients stored a flat Record<overlayId, {enabled, position}>; the web
|
|
// parser upgrades that at read time, but the contract schema only accepts v2,
|
|
// so without this every v1 row would be quarantined and the user's badge
|
|
// config silently lost. The upgrade keeps what migrateFromV1 keeps — known ids,
|
|
// the fields the v2 schema accepts, the "classic" default preset and an empty
|
|
// order — and drops the rest. Items missing a valid enabled or position are
|
|
// dropped whole rather than half-written: the v2 schema requires both, the
|
|
// registry defaults that would fill them live only in the web bundle, and an
|
|
// absent item already resolves to those same defaults at read time.
|
|
//
|
|
// Anything that is not an object, or already looks like v2, passes through
|
|
// unchanged for validation to judge.
|
|
func upgradeCardOverlaysV1(raw json.RawMessage) json.RawMessage {
|
|
var doc map[string]any
|
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
|
return raw
|
|
}
|
|
if looksLikeCardOverlaysV2(doc) {
|
|
return raw
|
|
}
|
|
|
|
items := map[string]any{}
|
|
for id, entry := range doc {
|
|
if !cardOverlayIDs[id] {
|
|
continue
|
|
}
|
|
fields, ok := entry.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
enabled, hasEnabled := fields["enabled"].(bool)
|
|
position, hasPosition := fields["position"].(string)
|
|
if !hasEnabled || !hasPosition || !cardOverlayPositions[position] {
|
|
continue
|
|
}
|
|
item := map[string]any{"enabled": enabled, "position": position}
|
|
if accent, ok := fields["accentColor"].(string); ok && cardOverlayAccent.MatchString(accent) {
|
|
item["accentColor"] = accent
|
|
}
|
|
if showIcon, ok := fields["showIcon"].(bool); ok {
|
|
item["showIcon"] = showIcon
|
|
}
|
|
items[id] = item
|
|
}
|
|
|
|
upgraded, err := json.Marshal(map[string]any{
|
|
"version": 2, "preset": "classic", "order": []string{}, "items": items,
|
|
})
|
|
if err != nil {
|
|
// Maps of plain JSON values always marshal; this cannot fire.
|
|
return raw
|
|
}
|
|
return upgraded
|
|
}
|
|
|
|
func (p *Planner) validate(def *settingscontract.Definition, value json.RawMessage) error {
|
|
return def.ValueSchema.ValidateValue(value, p.schemas)
|
|
}
|
|
|
|
// CanonicalKey returns the contract spelling for a legacy settings key. Live
|
|
// compatibility paths and admin projections use the same rename table as the
|
|
// migration so newly added aliases cannot drift between them.
|
|
func CanonicalKey(legacy string) string {
|
|
if canonical, ok := legacyKeyRenames[legacy]; ok {
|
|
return canonical
|
|
}
|
|
return legacy
|
|
}
|
|
|
|
func jsonString(value string) json.RawMessage {
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
// A Go string always marshals; this cannot fire.
|
|
return json.RawMessage(`""`)
|
|
}
|
|
return encoded
|
|
}
|
|
|
|
func deref(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return *value
|
|
}
|