Files
silo-server/internal/settingsresolve/resolve.go
QuickandGitHub 3bdfc58512 feat(settings): sync navigation and card customization by client family (#538)
* test(web): use safe auth placeholders

* feat(settings): sync navigation and card customization

* fix(settings): address customization review feedback

* fix(settings): address customization review feedback

* fix(settings): harden customization capability handling
2026-08-04 08:20:41 -04:00

555 lines
18 KiB
Go

// Package settingsresolve turns stored setting values into effective ones.
//
// It is the single answer to "what is this setting, for this profile, on this
// device, for this content" — the mutation endpoint, the effective-values
// endpoint, playback, catalog, and the jellycompat DisplayPreferences seed all
// resolve through here. Before this package each of those carried its own
// ladder: internal/catalog/detail.go resolved subtitles across four levels by
// hand and audio across three, internal/api/handlers/settings.go had a
// two-level device/user resolution with a lazy write-back inside a GET, and
// jellycompat read profile columns directly. Those disagreed about precedence,
// which is the drift the contract exists to remove.
//
// The package deliberately holds no storage of its own. It takes candidate rows
// and a contract, and returns decisions.
package settingsresolve
import (
"bytes"
"context"
"encoding/json"
"fmt"
"sort"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// Context is the identity a resolution happens against.
//
// Every field is optional and an absent one simply drops the scopes that need
// it: no DeviceID means no profile_device candidates, no SeriesIDs means no
// profile_series. That is what lets one code path serve an identified client,
// an anonymous jellycompat seed, and a batch spanning many series.
type Context struct {
ProfileID string
ClientFamily settingscontract.ClientFamily
DeviceID string
// LibraryIDs and SeriesIDs are the content contexts in play. A batch
// resolving a season passes every id once rather than resolving per item.
LibraryIDs []int
SeriesIDs []string
}
// Constraints carries the policy inputs a definition's constrained_by may
// reference, keyed by policy_input name. A missing entry means the policy does
// not constrain that setting for this viewer.
//
// Values are compared through the definition's own value schema, so a ceiling
// on an ordered enum ranks by member order and a ceiling on a number compares
// numerically.
type Constraints map[string]json.RawMessage
// Source names where an effective value came from. It is the resolved scope, or
// ScopeDefault when nothing was stored.
type Source = settingscontract.Scope
// Effective is one resolved setting.
type Effective struct {
Key string `json:"key"`
Value json.RawMessage `json:"value"`
// Source is the scope Value came from, or "default".
Source Source `json:"source"`
// StoredValue is what the user actually authored, present only when a
// constraint changed the answer. A capped 4K preference must survive the
// cap so it takes effect the day the cap lifts, so the stored value is
// reported rather than overwritten.
StoredValue json.RawMessage `json:"stored_value,omitempty"`
// Constrained is set when policy narrowed Value away from StoredValue.
Constrained bool `json:"constrained,omitempty"`
// ConstraintKind names how it was narrowed, for client copy.
ConstraintKind settingscontract.ConstraintKind `json:"constraint_kind,omitempty"`
// RequestedValue, ConstrainedBy and PermittedValues are the public
// constraint contract. RequestedValue is present only when policy changed
// the answer; the other two describe an active policy input even when the
// authored value already falls inside it.
RequestedValue json.RawMessage `json:"requested_value,omitempty"`
ConstrainedBy *settingscontract.Constraint `json:"constrained_by,omitempty"`
PermittedValues []json.RawMessage `json:"permitted_values,omitempty"`
// DefinitionRevision lets clients relate an answer to the manifest member
// that defined it. UpdatedAt belongs to the winning stored row; defaults
// have no update timestamp.
DefinitionRevision int `json:"definition_revision"`
UpdatedAt string `json:"updated_at,omitempty"`
// Identity locates the row Value came from, so a client can offer "reset
// this device's override" against the exact scope that holds it. Empty for
// a default.
Identity *userstore.SettingIdentity `json:"-"`
}
// Store is the read surface this package needs. It is satisfied by
// userstore.UserStore and by a fake in tests.
type Store interface {
ListSettingValuesForResolution(
ctx context.Context, query userstore.SettingResolutionQuery,
) ([]userstore.SettingValue, error)
}
// Resolver resolves against one contract.
type Resolver struct {
contract *settingscontract.Manifest
}
// New returns a Resolver over the given contract.
func New(contract *settingscontract.Manifest) *Resolver {
return &Resolver{contract: contract}
}
// Resolve returns the effective value for each requested key.
//
// One batched store read regardless of how many keys, libraries, or series are
// in play; ranking happens here in Go. Unknown keys are omitted rather than
// erroring, so a newer client asking for a setting this server does not have
// gets a short answer instead of a failed request.
func (r *Resolver) Resolve(
ctx context.Context,
store Store,
rc Context,
keys []string,
constraints Constraints,
) ([]Effective, error) {
if r == nil || r.contract == nil {
return nil, fmt.Errorf("settingsresolve: no contract")
}
known, defs := r.knownKeys(keys)
if len(known) == 0 {
return nil, nil
}
stored, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: known,
ProfileIDs: nonEmpty(rc.ProfileID),
ClientFamily: rc.ClientFamily,
DeviceID: rc.DeviceID,
LibraryIDs: rc.LibraryIDs,
SeriesIDs: rc.SeriesIDs,
})
if err != nil {
return nil, fmt.Errorf("settingsresolve: reading candidates: %w", err)
}
return r.rank(known, defs, stored, rc, constraints), nil
}
// ResolveContexts resolves the same keys for several content contexts using
// one candidate read. The contexts may vary by profile, library, or series,
// but they must use the same device because SettingResolutionQuery represents
// one requesting client device.
func (r *Resolver) ResolveContexts(
ctx context.Context,
store Store,
contexts []Context,
keys []string,
constraints Constraints,
) ([][]Effective, error) {
if r == nil || r.contract == nil {
return nil, fmt.Errorf("settingsresolve: no contract")
}
if len(contexts) == 0 {
return nil, nil
}
known, defs := r.knownKeys(keys)
if len(known) == 0 {
return make([][]Effective, len(contexts)), nil
}
deviceID := contexts[0].DeviceID
clientFamily := contexts[0].ClientFamily
query := userstore.SettingResolutionQuery{Keys: known, ClientFamily: clientFamily, DeviceID: deviceID}
for _, rc := range contexts {
if rc.DeviceID != deviceID {
return nil, fmt.Errorf("settingsresolve: batched contexts use different devices")
}
if rc.ClientFamily != clientFamily {
return nil, fmt.Errorf("settingsresolve: batched contexts use different client families")
}
if rc.ProfileID != "" {
query.ProfileIDs = append(query.ProfileIDs, rc.ProfileID)
}
query.LibraryIDs = append(query.LibraryIDs, rc.LibraryIDs...)
query.SeriesIDs = append(query.SeriesIDs, rc.SeriesIDs...)
}
stored, err := store.ListSettingValuesForResolution(ctx, query)
if err != nil {
return nil, fmt.Errorf("settingsresolve: reading candidates: %w", err)
}
out := make([][]Effective, len(contexts))
for i, rc := range contexts {
out[i] = r.rank(known, defs, stored, rc, constraints)
}
return out, nil
}
// ResolveProfiles resolves the same keys for several profiles in one store
// read, returning each profile's effective values keyed by profile id.
//
// It exists for the household shape Resolve cannot serve without a read per
// profile: GET /profiles serves a preference block for every profile on the
// account, and looping Resolve would turn one list request into n round trips.
// Ranking is per profile against the shared candidate set, so the answer for
// each id is identical to what a single-profile Resolve would return.
//
// Content contexts are deliberately absent: a profile list has no library or
// series in play, so only the account, profile, and profile_device scopes can
// contribute.
func (r *Resolver) ResolveProfiles(
ctx context.Context,
store Store,
profileIDs []string,
keys []string,
constraints Constraints,
) (map[string][]Effective, error) {
if r == nil || r.contract == nil {
return nil, fmt.Errorf("settingsresolve: no contract")
}
if len(profileIDs) == 0 {
return nil, nil
}
known, defs := r.knownKeys(keys)
if len(known) == 0 {
return nil, nil
}
stored, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
Keys: known,
ProfileIDs: profileIDs,
})
if err != nil {
return nil, fmt.Errorf("settingsresolve: reading candidates: %w", err)
}
out := make(map[string][]Effective, len(profileIDs))
for _, profileID := range profileIDs {
if _, seen := out[profileID]; seen {
continue
}
out[profileID] = r.rank(known, defs, stored, Context{ProfileID: profileID}, constraints)
}
return out, nil
}
// knownKeys filters requested keys down to the remotely-stored definitions this
// contract declares, preserving request order and dropping duplicates.
func (r *Resolver) knownKeys(keys []string) ([]string, map[string]*settingscontract.Definition) {
known := make([]string, 0, len(keys))
defs := make(map[string]*settingscontract.Definition, len(keys))
for _, key := range keys {
def, ok := r.contract.Lookup(key)
if !ok || def.Persistence != settingscontract.PersistenceRemote {
// client_local settings never have server rows; asking for one is
// not an error, it simply has no server answer.
continue
}
if _, seen := defs[key]; seen {
continue
}
defs[key] = def
known = append(known, key)
}
return known, defs
}
// rank turns one candidate set into one context's effective values. Candidates
// that belong to another profile are filtered by pickForScope, which is what
// lets a batched read serve several profiles from the same rows.
func (r *Resolver) rank(
known []string,
defs map[string]*settingscontract.Definition,
stored []userstore.SettingValue,
rc Context,
constraints Constraints,
) []Effective {
byKey := make(map[string][]userstore.SettingValue, len(known))
for _, row := range stored {
byKey[row.Key] = append(byKey[row.Key], row)
}
out := make([]Effective, 0, len(known))
for _, key := range known {
out = append(out, r.resolveOne(defs[key], byKey[key], rc, constraints))
}
return out
}
func nonEmpty(value string) []string {
if value == "" {
return nil
}
return []string{value}
}
// resolveOne ranks one key's candidates by its declared resolution order.
func (r *Resolver) resolveOne(
def *settingscontract.Definition,
candidates []userstore.SettingValue,
rc Context,
constraints Constraints,
) Effective {
eff := Effective{
Key: def.Key,
Value: append(json.RawMessage(nil), def.DefaultValue...),
Source: settingscontract.ScopeDefault,
// Definitions can change additively after their introduced_in marker.
// The response therefore names the manifest revision that produced the
// effective definition, not merely the revision that first added its key.
DefinitionRevision: r.contract.Revision,
}
for _, scope := range def.ResolutionOrder {
if scope == settingscontract.ScopeDefault {
break
}
row, ok := pickForScope(scope, candidates, rc)
if !ok {
continue
}
eff.Value = append(json.RawMessage(nil), row.Value...)
eff.Source = scope
eff.UpdatedAt = row.UpdatedAt
identity := row.SettingIdentity
eff.Identity = &identity
break
}
return applyConstraint(def, eff, constraints)
}
// pickForScope returns the candidate row for one scope.
//
// Library and series scopes can return several rows in a batch — one per
// library or series in the request. A batch spanning several libraries or
// series has no single right answer, so a caller wanting per-item values must
// resolve per item; when several rows match anyway, the tie is broken by
// ascending library id then series id, purely so two identical requests
// cannot disagree.
func pickForScope(
scope settingscontract.Scope,
candidates []userstore.SettingValue,
rc Context,
) (userstore.SettingValue, bool) {
matches := make([]userstore.SettingValue, 0, 2)
for _, row := range candidates {
if row.Scope != scope {
continue
}
switch scope {
case settingscontract.ScopeAccount:
matches = append(matches, row)
case settingscontract.ScopeProfile:
if row.ProfileID == rc.ProfileID {
matches = append(matches, row)
}
case settingscontract.ScopeProfileClient:
if row.ProfileID == rc.ProfileID && row.ClientFamily == rc.ClientFamily && rc.ClientFamily.Valid() {
matches = append(matches, row)
}
case settingscontract.ScopeProfileDevice:
if row.ProfileID == rc.ProfileID && row.DeviceID == rc.DeviceID && rc.DeviceID != "" {
matches = append(matches, row)
}
case settingscontract.ScopeProfileLibrary:
if row.ProfileID == rc.ProfileID && containsInt(rc.LibraryIDs, row.LibraryID) {
matches = append(matches, row)
}
case settingscontract.ScopeProfileSeries:
if row.ProfileID == rc.ProfileID && containsString(rc.SeriesIDs, row.SeriesID) {
matches = append(matches, row)
}
}
}
if len(matches) == 0 {
return userstore.SettingValue{}, false
}
if len(matches) > 1 {
// Deterministic rather than arbitrary: a batch spanning several
// libraries or series has no single right answer, and the caller is
// expected to resolve per item. Sorting means it at least cannot differ
// between two identical requests.
sort.Slice(matches, func(i, j int) bool {
if matches[i].LibraryID != matches[j].LibraryID {
return matches[i].LibraryID < matches[j].LibraryID
}
return matches[i].SeriesID < matches[j].SeriesID
})
}
return matches[0], true
}
// applyConstraint narrows an effective value to what policy permits.
//
// The stored value is never destroyed: a preference capped today must take
// effect the day the cap lifts, so the cap is reported alongside the authored
// value rather than replacing it.
func applyConstraint(
def *settingscontract.Definition,
eff Effective,
constraints Constraints,
) Effective {
if def.ConstrainedBy == nil || len(constraints) == 0 {
return eff
}
limit, ok := constraints[def.ConstrainedBy.PolicyInput]
if !ok || len(limit) == 0 {
return eff
}
eff.ConstrainedBy = &settingscontract.Constraint{
PolicyInput: def.ConstrainedBy.PolicyInput,
Constraint: def.ConstrainedBy.Constraint,
}
eff.PermittedValues = permittedValues(def, limit)
narrow, changed := narrowValue(def, eff.Value, limit)
if !changed {
return eff
}
eff.StoredValue = eff.Value
eff.RequestedValue = eff.Value
eff.Value = narrow
eff.Constrained = true
eff.ConstraintKind = def.ConstrainedBy.Constraint
return eff
}
// permittedValues returns the finite choices allowed by an active policy
// input. Numeric ceilings and floors do not have a finite option list, so they
// leave this absent and clients continue rendering their numeric control.
func permittedValues(def *settingscontract.Definition, limit json.RawMessage) []json.RawMessage {
switch def.ConstrainedBy.Constraint {
case settingscontract.ConstraintAllowlist:
var allowed []json.RawMessage
if err := json.Unmarshal(limit, &allowed); err != nil || len(allowed) == 0 {
return nil
}
out := make([]json.RawMessage, 0, len(allowed))
for _, value := range allowed {
out = append(out, append(json.RawMessage(nil), value...))
}
return out
case settingscontract.ConstraintLocked:
return []json.RawMessage{append(json.RawMessage(nil), limit...)}
case settingscontract.ConstraintCeiling, settingscontract.ConstraintFloor:
if def.ValueSchema.Type != settingscontract.TypeEnum {
return nil
}
out := make([]json.RawMessage, 0, len(def.ValueSchema.Values))
for _, member := range def.ValueSchema.Values {
raw, err := json.Marshal(member.Value)
if err != nil {
continue
}
comparison := def.ValueSchema.CompareValues(raw, limit)
if def.ConstrainedBy.Constraint == settingscontract.ConstraintCeiling && comparison <= 0 ||
def.ConstrainedBy.Constraint == settingscontract.ConstraintFloor && comparison >= 0 {
out = append(out, raw)
}
}
return out
}
return nil
}
// narrowValue applies one constraint kind, returning the permitted value and
// whether it differs from the stored one.
func narrowValue(
def *settingscontract.Definition,
value, limit json.RawMessage,
) (json.RawMessage, bool) {
switch def.ConstrainedBy.Constraint {
case settingscontract.ConstraintLocked:
// The policy value replaces the user's outright.
if bytes.Equal(bytes.TrimSpace(value), bytes.TrimSpace(limit)) {
return value, false
}
return append(json.RawMessage(nil), limit...), true
case settingscontract.ConstraintCeiling:
// null on a nullable numeric means "no cap of my own", which is
// unbounded above — exactly what a ceiling exists to bring down. It has
// no rank, so CompareValues reports 0 and the value would otherwise
// slip past the cap it most needs to obey.
if isNull(value) && isNumeric(def) {
return append(json.RawMessage(nil), limit...), true
}
if def.ValueSchema.CompareValues(value, limit) <= 0 {
return value, false
}
return append(json.RawMessage(nil), limit...), true
case settingscontract.ConstraintFloor:
// The mirror of the above: unbounded above already satisfies any floor.
if isNull(value) && isNumeric(def) {
return value, false
}
if def.ValueSchema.CompareValues(value, limit) >= 0 {
return value, false
}
return append(json.RawMessage(nil), limit...), true
case settingscontract.ConstraintAllowlist:
var allowed []json.RawMessage
if err := json.Unmarshal(limit, &allowed); err != nil || len(allowed) == 0 {
return value, false
}
trimmed := bytes.TrimSpace(value)
for _, entry := range allowed {
if bytes.Equal(bytes.TrimSpace(entry), trimmed) {
return value, false
}
}
// Falling back to the first allowed member rather than the default:
// the default may itself be outside the allowlist, and an effective
// value the policy forbids is the one thing this must never return.
return append(json.RawMessage(nil), allowed[0]...), true
}
return value, false
}
func isNull(raw json.RawMessage) bool {
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
}
func isNumeric(def *settingscontract.Definition) bool {
return def.ValueSchema.Type == settingscontract.TypeInteger ||
def.ValueSchema.Type == settingscontract.TypeNumber
}
func containsInt(haystack []int, needle int) bool {
for _, v := range haystack {
if v == needle {
return true
}
}
return false
}
func containsString(haystack []string, needle string) bool {
for _, v := range haystack {
if v == needle {
return true
}
}
return false
}