One answer to "what is this setting, for this profile, on this device, for this content". Before this, each caller carried its own ladder: catalog/detail.go resolved subtitles across four levels by hand and audio across three, 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. Resolution is one batched read regardless of how many keys, libraries, or series are in play — ranking happens in Go against each definition's declared resolution_order. Five sequential index lookups per key per item is the implementation the design rejects, and a season view is exactly where it would have shown up. An absent identity drops its scope rather than erroring, so one code path serves an identified client, an anonymous jellycompat seed, and a batch spanning many series. Rows for a foreign profile, device, library or series are ignored even though the batched read returns them. Constraints narrow without destroying: a capped 4K preference resolves to the cap, reports itself constrained, and keeps the authored value so it takes effect the day the cap lifts. Two cases needed care — null on a nullable numeric means unbounded, so a ceiling must cap it rather than rank it equal and let the value that most needs capping slip past; and an allowlist falls back to a permitted member rather than the definition's default, which may itself be outside the list. Adds ValueSchema.CompareValues to the contract package, since ordering values is what makes a ceiling or floor mean anything and value semantics belong with the schema that declares them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
354 lines
11 KiB
Go
354 lines
11 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
|
|
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"`
|
|
|
|
// 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 := 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)
|
|
}
|
|
if len(known) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
stored, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
|
|
Keys: known,
|
|
ProfileID: rc.ProfileID,
|
|
DeviceID: rc.DeviceID,
|
|
LibraryIDs: rc.LibraryIDs,
|
|
SeriesIDs: rc.SeriesIDs,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("settingsresolve: reading candidates: %w", err)
|
|
}
|
|
|
|
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, nil
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
|
|
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
|
|
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 — so the caller's context decides which is
|
|
// the relevant one. Ties are broken by the most specific id in the request
|
|
// order, which is why a batch must resolve per item rather than expecting one
|
|
// answer to cover a whole season.
|
|
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.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
|
|
}
|
|
|
|
narrow, changed := narrowValue(def, eff.Value, limit)
|
|
if !changed {
|
|
return eff
|
|
}
|
|
eff.StoredValue = eff.Value
|
|
eff.Value = narrow
|
|
eff.Constrained = true
|
|
eff.ConstraintKind = def.ConstrainedBy.Constraint
|
|
return eff
|
|
}
|
|
|
|
// 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
|
|
}
|