Files
silo-server/internal/settingsmigrate/plan.go
T
QuickandClaude Fable 5 440a75b189 refactor(jellycompat): give DisplayPreferences its own table
The Jellyfin DisplayPreferences blobs rode the legacy user_settings
key/value table under synthetic jellycompat:* keys, which forced the
legacy settings API to carry a prefix carve-out in its otherwise-closed
unknown-key gate. They are the compat subsystem's storage, not user
settings: the contract neither validates nor resolves them.

Move them to a dedicated jellycompat_displayprefs table in both
backends, keyed by (prefs id, client) per user, with the blob stored as
opaque text served back byte-for-byte (deliberately not jsonb, which
would re-serialize it). The data-copy migrations — per-user SQLite V16
and a paired SQL + Go goose migration for Postgres — are transactional
and harmless to re-run, and both drive their key parsing and row
classification from the new internal/jellycompat/displayprefs package
so the backends cannot diverge, following the internal/settingsmigrate
precedent. A jellycompat:* row that does not parse as a DisplayPrefs
key (only ever writable through the removed carve-out) is recorded in
user_setting_migration_rejects rather than silently deleted.

With the last non-settings tenant gone, the jellycompatSettingPrefix
carve-out is deleted: the legacy settings endpoints now refuse
jellycompat:* keys like any other unknown key and never surface them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:32:41 +00:00

628 lines
21 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"
"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"
)
// 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
}
// 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
AudioLanguage *string
SubtitleLanguage *string
SubtitleMode *string
ShowForcedSubtitles *bool
}
// LegacyLibraryPreference is one user_library_playback_preferences row.
type LegacyLibraryPreference struct {
ProfileID string
LibraryID int
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
}
// 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
}
// 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.
//
// quality_preference is the one that bites: the column defaults to 1080p while
// the contract defaults to auto, so migrating it unconditionally would pin
// every profile in the install to 1080p having never asked for it.
const (
defaultLanguage = "en"
defaultSubtitleMode = "auto"
defaultQualityPreference = "1080p"
)
// 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"
sourceSeriesPrefs = "series_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"
// fieldProfileID is the identity field every non-account reject carries.
const fieldProfileID = "profile_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)
return res
}
// planProfiles converts the six 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.
p.addLanguage(res, sourceUserProfiles, id("language"),
"playback.audio_language", settingscontract.ScopeProfile,
Row{ProfileID: profile.ID}, profile.Language, defaultLanguage)
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"))
}
p.addQuality(res, sourceUserProfiles, id("quality_preference"),
settingscontract.ScopeProfile, Row{ProfileID: profile.ID},
deref(profile.QualityPreference), defaultQualityPreference)
}
}
// 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,
})
}
}
}
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
}
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,
})
}
}
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, "series_id": pref.SeriesID,
})
p.addPlaybackTriple(res, sourceSeriesPrefs, 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, "library_id": pref.LibraryID,
})
p.addPlaybackTriple(res, sourceLibraryPrefs, 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, sourceTable string, identity json.RawMessage,
scope settingscontract.Scope, base Row,
audio, subtitle, mode *string, forced *bool,
) {
p.addLanguage(res, sourceTable, identity,
"playback.audio_language", scope, base, audio, "")
p.addLanguage(res, sourceTable, identity,
"playback.subtitle_language", scope, base, subtitle, "")
if m := deref(mode); m != "" {
p.addValue(res, sourceTable, 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, sourceTable, 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, "playback.max_bitrate_kbps",
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
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)
}
candidate = json.RawMessage(strconv.FormatFloat(parsed, 'g', -1, 64))
case settingscontract.TypeObject:
// Already a JSON document in the legacy store.
candidate = json.RawMessage(trimmed)
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
}
func (p *Planner) validate(def *settingscontract.Definition, value json.RawMessage) error {
return def.ValueSchema.ValidateValue(value, p.schemas)
}
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
}