This commit is contained in:
ponzischeme89
2026-08-22 12:38:26 +12:00
parent f982d391b9
commit b3e3f62c54
26 changed files with 1279 additions and 108 deletions
+214 -14
View File
@@ -8,6 +8,7 @@ import (
"slices"
"strings"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -42,6 +43,41 @@ type featureDefinition struct {
Recovery string `json:"recovery"`
}
// configurationDefinition is the shared catalogue for booleans and behavioural
// values. Scope is part of the contract so new features do not grow bespoke settings.
type configurationDefinition struct {
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
Scopes []string `json:"scopes"`
Default any `json:"default"`
Options []string `json:"options,omitempty"`
Min *int `json:"min,omitempty"`
Max *int `json:"max,omitempty"`
}
var configurationCatalogue = []configurationDefinition{
{Key: "forYou.enabled", Name: "For You", Description: "Show personalised recommendations on Home.", Type: "boolean", Scopes: []string{"global", "user", "device", "experimental"}, Default: true},
{Key: "continueWatching.enabled", Name: "Continue Watching", Description: "Show the Continue Watching row.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
{Key: "continueWatching.showNextUp", Name: "Continue Watching: Next Up", Description: "Include an unstarted next episode in Continue Watching.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
{Key: "continueWatching.progressColour", Name: "Progress bar colour", Description: "Choose the progress bar treatment.", Type: "enum", Scopes: []string{"global", "user", "device"}, Default: "emby", Options: []string{"emby", "white"}},
{Key: "ratings.enabled", Name: "Ratings", Description: "Show ratings throughout the catalogue.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
{Key: "genres.enabled", Name: "Genres", Description: "Show genre browsing controls.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
{Key: "trailers.enabled", Name: "Trailers", Description: "Offer trailers where available.", Type: "boolean", Scopes: []string{"global", "device", "experimental"}, Default: true},
{Key: "requests.enabled", Name: "Requests", Description: "Allow title requests from viewers.", Type: "boolean", Scopes: []string{"global", "user"}, Default: true},
{Key: "hero.enabled", Name: "Hero", Description: "Show the Home hero presentation.", Type: "boolean", Scopes: []string{"global", "device", "experimental"}, Default: true},
{Key: "home.heroRefreshSeconds", Name: "Hero refresh interval", Description: "Seconds between hero refreshes.", Type: "integer", Scopes: []string{"global", "device"}, Default: 60, Min: intPtr(15), Max: intPtr(3600)},
{Key: "home.maxItemsPerRow", Name: "Maximum items per row", Description: "Maximum number of cards shown in a row.", Type: "integer", Scopes: []string{"global", "device"}, Default: 20, Min: intPtr(1), Max: intPtr(100)},
{Key: "home.sectionDefinitions", Name: "Home page sections", Description: "JSON section definitions controlling Home composition and order.", Type: "json", Scopes: []string{"global", "device"}, Default: config.DefaultRemoteConfig().Home.SectionDefinitions},
{Key: "movies.sectionDefinitions", Name: "Movies page sections", Description: "JSON section definitions controlling Movies composition and order.", Type: "json", Scopes: []string{"global", "device"}, Default: config.DefaultRemoteConfig().Movies.SectionDefinitions},
{Key: "tv.sectionDefinitions", Name: "TV page sections", Description: "JSON section definitions controlling TV composition and order.", Type: "json", Scopes: []string{"global", "device"}, Default: config.DefaultRemoteConfig().TV.SectionDefinitions},
{Key: "branding.markUrl", Name: "Memby mark URL", Description: "HTTPS image used for the TV rail mark; the bundled mark remains the fallback.", Type: "string", Scopes: []string{"global", "device"}, Default: ""},
{Key: "branding.markVersion", Name: "Memby mark version", Description: "Cache-busting version for the configured mark.", Type: "string", Scopes: []string{"global", "device"}, Default: ""},
}
func intPtr(v int) *int { return &v }
var featureCatalogue = []featureDefinition{
{
Key: featureSonarrPreroll, Name: "Sonarr upcoming preroll", Area: "Playback",
@@ -191,12 +227,66 @@ type evaluatedFeature struct {
}
type featureResponse struct {
SchemaVersion int `json:"schemaVersion"`
Revision int64 `json:"revision"`
SafeMode bool `json:"safeMode"`
UpdatedAt any `json:"updatedAt,omitempty"`
CanRollback bool `json:"canRollback"`
Features []evaluatedFeature `json:"features"`
SchemaVersion int `json:"schemaVersion"`
Revision int64 `json:"revision"`
SafeMode bool `json:"safeMode"`
UpdatedAt any `json:"updatedAt,omitempty"`
CanRollback bool `json:"canRollback"`
Features []evaluatedFeature `json:"features"`
Configuration []evaluatedConfiguration `json:"configuration"`
}
type evaluatedConfiguration struct {
configurationDefinition
Value any `json:"value"`
Source string `json:"source"`
}
func configurationDefinitionFor(key string) (configurationDefinition, bool) {
for _, definition := range configurationCatalogue {
if definition.Key == key {
return definition, true
}
}
return configurationDefinition{}, false
}
func configurationValue(policy store.FeaturePolicy, definition configurationDefinition, sessions ...store.Session) (any, string) {
if len(sessions) > 0 {
session := sessions[0]
if values, ok := policy.DeviceValues[session.DeviceID]; ok {
if raw, ok := values[definition.Key]; ok {
var value any
if json.Unmarshal(raw, &value) == nil {
return value, "device"
}
}
}
if values, ok := policy.UserValues[session.EmbyUserID]; ok {
if raw, ok := values[definition.Key]; ok {
var value any
if json.Unmarshal(raw, &value) == nil {
return value, "user"
}
}
}
}
if raw, ok := policy.Values[definition.Key]; ok {
var value any
if json.Unmarshal(raw, &value) == nil {
return value, "global"
}
}
return definition.Default, "default"
}
func configurationPayload(policy store.FeaturePolicy, sessions ...store.Session) []evaluatedConfiguration {
result := make([]evaluatedConfiguration, 0, len(configurationCatalogue))
for _, definition := range configurationCatalogue {
value, source := configurationValue(policy, definition, sessions...)
result = append(result, evaluatedConfiguration{configurationDefinition: definition, Value: value, Source: source})
}
return result
}
func knownFeature(key string) (featureDefinition, bool) {
@@ -213,6 +303,20 @@ func evaluateFeature(policy store.FeaturePolicy, definition featureDefinition, p
if override, ok := policy.Overrides[definition.Key]; ok {
enabled, source = override, "override"
}
// Legacy server call-sites continue to use their stable snake_case keys while
// operators edit the canonical typed catalogue.
canonical := map[string]string{
featureContinueWatching: "continueWatching.enabled",
featureGenreBrowser: "genres.enabled",
}[definition.Key]
if canonical != "" {
if raw, ok := policy.Values[canonical]; ok {
var value bool
if json.Unmarshal(raw, &value) == nil {
enabled, source = value, "configuration"
}
}
}
if policy.SafeMode {
enabled, source = false, "safe_mode"
}
@@ -249,6 +353,10 @@ func (s *Server) featureEnabled(ctx context.Context, key string) bool {
}
func featurePayload(policy store.FeaturePolicy, protocol int, capabilities ...[]string) featureResponse {
return featurePayloadForSession(policy, protocol, nil, capabilities...)
}
func featurePayloadForSession(policy store.FeaturePolicy, protocol int, session *store.Session, capabilities ...[]string) featureResponse {
features := make([]evaluatedFeature, 0, len(featureCatalogue))
for _, definition := range featureCatalogue {
evaluated := evaluateFeature(policy, definition, protocol)
@@ -260,11 +368,17 @@ func featurePayload(policy store.FeaturePolicy, protocol int, capabilities ...[]
}
features = append(features, evaluated)
}
return featureResponse{
response := featureResponse{
SchemaVersion: featureSchemaVersion, Revision: policy.Revision,
SafeMode: policy.SafeMode, UpdatedAt: policy.UpdatedAt,
CanRollback: policy.Previous != nil, Features: features,
}
if session != nil {
response.Configuration = configurationPayload(policy, *session)
} else {
response.Configuration = configurationPayload(policy)
}
return response
}
func featureMap(policy store.FeaturePolicy, protocol int, capabilities []string) map[string]bool {
@@ -275,16 +389,20 @@ func featureMap(policy store.FeaturePolicy, protocol int, capabilities []string)
return result
}
func (s *Server) handleFeatures(w http.ResponseWriter, r *http.Request, _ store.Session) {
writeJSON(w, http.StatusOK, featurePayload(
s.currentFeaturePolicy(r.Context()), clientProtocolNumber(r), clientCapabilities(r),
func (s *Server) handleFeatures(w http.ResponseWriter, r *http.Request, session store.Session) {
writeJSON(w, http.StatusOK, featurePayloadForSession(
s.currentFeaturePolicy(r.Context()), clientProtocolNumber(r), &session, clientCapabilities(r),
))
}
type featurePolicyRequest struct {
Action string `json:"action"`
ExpectedRevision int64 `json:"expectedRevision"`
Overrides map[string]bool `json:"overrides"`
Action string `json:"action"`
ExpectedRevision int64 `json:"expectedRevision"`
Overrides map[string]bool `json:"overrides"`
Values map[string]json.RawMessage `json:"values"`
UserValues map[string]map[string]json.RawMessage `json:"userValues"`
DeviceValues map[string]map[string]json.RawMessage `json:"deviceValues"`
Experimental map[string]json.RawMessage `json:"experimental"`
}
func (s *Server) handleAdminFeaturePolicy(w http.ResponseWriter, r *http.Request) {
@@ -300,7 +418,7 @@ func (s *Server) handleAdminFeaturePolicy(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusConflict, "feature flags changed in another admin session; refresh before saving")
return
}
next := store.FeaturePolicy{Overrides: map[string]bool{}, SafeMode: current.SafeMode}
next := store.FeaturePolicy{Overrides: map[string]bool{}, Values: req.Values, UserValues: req.UserValues, DeviceValues: req.DeviceValues, Experimental: req.Experimental, SafeMode: current.SafeMode}
switch strings.TrimSpace(req.Action) {
case "save":
for key, enabled := range req.Overrides {
@@ -310,11 +428,17 @@ func (s *Server) handleAdminFeaturePolicy(w http.ResponseWriter, r *http.Request
}
next.Overrides[key] = enabled
}
if err := validateConfigurationValues(next); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
case "safe-mode":
next.Overrides = current.Overrides
next.Values, next.UserValues, next.DeviceValues, next.Experimental = current.Values, current.UserValues, current.DeviceValues, current.Experimental
next.SafeMode = true
case "leave-safe-mode":
next.Overrides = current.Overrides
next.Values, next.UserValues, next.DeviceValues, next.Experimental = current.Values, current.UserValues, current.DeviceValues, current.Experimental
next.SafeMode = false
case "reset":
next.SafeMode = false
@@ -324,6 +448,7 @@ func (s *Server) handleAdminFeaturePolicy(w http.ResponseWriter, r *http.Request
return
}
next.Overrides = current.Previous.Overrides
next.Values, next.UserValues, next.DeviceValues, next.Experimental = current.Previous.Values, current.Previous.UserValues, current.Previous.DeviceValues, current.Previous.Experimental
next.SafeMode = current.Previous.SafeMode
default:
writeError(w, http.StatusBadRequest, "unknown feature policy action")
@@ -345,6 +470,81 @@ func (s *Server) handleAdminFeaturePolicy(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusOK, featurePayload(stored, ProtocolVersion))
}
func validateConfigurationValues(policy store.FeaturePolicy) error {
for key, raw := range policy.Values {
definition, ok := configurationDefinitionFor(key)
if !ok {
return errors.New("unknown configuration value: " + key)
}
if err := validateConfigurationValue(definition, raw); err != nil {
return err
}
}
for scope, values := range map[string]map[string]json.RawMessage{"experimental": policy.Experimental} {
for key, raw := range values {
definition, ok := configurationDefinitionFor(key)
if !ok {
return errors.New("unknown configuration value: " + key)
}
if !slices.Contains(definition.Scopes, scope) {
return errors.New("configuration value does not support scope " + scope + ": " + key)
}
if err := validateConfigurationValue(definition, raw); err != nil {
return err
}
}
}
for scope, groups := range map[string]map[string]map[string]json.RawMessage{"user": policy.UserValues, "device": policy.DeviceValues} {
for _, values := range groups {
for key, raw := range values {
definition, ok := configurationDefinitionFor(key)
if !ok {
return errors.New("unknown configuration value: " + key)
}
if !slices.Contains(definition.Scopes, scope) {
return errors.New("configuration value does not support scope " + scope + ": " + key)
}
if err := validateConfigurationValue(definition, raw); err != nil {
return err
}
}
}
}
return nil
}
func validateConfigurationValue(definition configurationDefinition, raw json.RawMessage) error {
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return errors.New("invalid configuration value: " + definition.Key)
}
switch definition.Type {
case "boolean":
if _, ok := value.(bool); !ok {
return errors.New("configuration value must be boolean: " + definition.Key)
}
case "integer":
n, ok := value.(float64)
if !ok || n != float64(int(n)) || (definition.Min != nil && int(n) < *definition.Min) || (definition.Max != nil && int(n) > *definition.Max) {
return errors.New("configuration value is outside its allowed range: " + definition.Key)
}
case "enum":
text, ok := value.(string)
if !ok || !slices.Contains(definition.Options, text) {
return errors.New("configuration value is not an allowed option: " + definition.Key)
}
case "json":
if _, ok := value.([]any); !ok {
return errors.New("configuration value must be a JSON array: " + definition.Key)
}
case "string":
if _, ok := value.(string); !ok {
return errors.New("configuration value must be text: " + definition.Key)
}
}
return nil
}
func parseCapabilities(raw string) []string {
seen := map[string]bool{}
values := []string{}