package api import ( "context" "encoding/json" "errors" "net/http" "slices" "strings" "github.com/ponzischeme89/memby/server/internal/config" "github.com/ponzischeme89/memby/server/internal/store" ) const ( featureSchemaVersion = 1 featureSonarrPreroll = "sonarr_preroll" featureAutomaticMyShows = "automatic_my_shows" featureMyShowsNotification = "my_shows_notifications" featureHEVCDirectPlay = "hevc_direct_play" featureInstallPermission = "install_permission_prompt" featureSubtitleDownload = "subtitle_download" featureTrickplay = "trickplay" featureSkipIntro = "skip_intro" featureEndCredits = "end_credits" featureSeasonalThemes = "seasonal_themes" featureSeasonalDecorations = "seasonal_decorations" featureGenreBrowser = "genre_browser" featureTVCalendar = "tv_calendar" featureContinueWatching = "continue_watching" featureWatchTimeDigest = "watch_time_digest" featureViewers = "viewers" ) type featureDefinition struct { Key string `json:"key"` Name string `json:"name"` Description string `json:"description"` Area string `json:"area"` DefaultEnabled bool `json:"defaultEnabled"` MinimumProtocol int `json:"minimumProtocol"` Capability string `json:"capability"` 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: "detailExperience", Name: "Detail page experience", Description: "Which detail-page layout a viewer sees.", Type: "enum", Scopes: []string{"global", "user", "device"}, Default: "v1", Options: []string{"v1", "v2"}}, {Key: "presentation.fontFamily", Name: "App font family", Description: "Choose the bundled font used in Memby’s typography trial areas.", Type: "enum", Scopes: []string{"global"}, Default: "system", Options: []string{"system", "inter"}}, {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", Description: "Show the fan-art calendar before a fresh episode starts.", DefaultEnabled: true, MinimumProtocol: 1, Capability: "sonarr_preroll_v1", Recovery: "Takes effect the next time a title is opened.", }, { Key: featureAutomaticMyShows, Name: "Automatic My Shows", Area: "My Shows", Description: "Follow a continuing Sonarr show after half an episode is watched.", DefaultEnabled: true, MinimumProtocol: 1, Capability: "auto_my_shows_v1", Recovery: "Server-enforced; takes effect on the next playback report.", }, { Key: featureMyShowsNotification, Name: "Automatic follow notification", Area: "Notifications", Description: "Notify a viewer when a continuing show is automatically followed.", DefaultEnabled: true, MinimumProtocol: 1, Capability: "auto_my_shows_v1", Recovery: "Server-enforced; disabling it never removes a saved show.", }, { Key: featureHEVCDirectPlay, Name: "HEVC direct play", Area: "Playback", Description: "Allow capable TVs to direct-play H.265/HEVC instead of requesting H.264.", DefaultEnabled: true, MinimumProtocol: 1, Capability: "video_hevc_decode", Recovery: "Server-enforced; takes effect the next time playback starts or refreshes.", }, { Key: featureSubtitleDownload, Name: "Download missing subtitles", Area: "Playback", Description: "Let a viewer fetch a subtitle through Bazarr from the player, for a " + "title the library has none for.", DefaultEnabled: true, MinimumProtocol: 1, Capability: "subtitle_download_v1", Recovery: "Takes effect the next time playback starts; the option simply stops being offered.", }, { Key: featureTrickplay, Name: "Seek preview thumbnails", Area: "Playback", Description: "Show the frame a skip will land on, from the preview images Emby " + "generates. Turn it off to stop the gateway reading them.", DefaultEnabled: true, MinimumProtocol: 1, Capability: "trickplay_v1", Recovery: "Takes effect the next time playback starts; the preview simply stops appearing.", }, { Key: featureSkipIntro, Name: "Skip the title sequence", Area: "Playback", Description: "Offer to jump past an episode's opening titles, from the intro " + "markers Emby writes. Turn it off to stop the gateway reading them.", DefaultEnabled: true, MinimumProtocol: 1, Capability: "skip_intro_v1", Recovery: "Takes effect the next time playback starts; the button simply stops appearing.", }, { Key: featureEndCredits, Name: "Speed through the credits", Area: "Playback", Description: "Shrink the picture and run the closing credits at double speed with " + "the next episode beside them. Emby's own marker is preferred where it has one, " + "and where it has none the position discovered from episodes viewers are about to watch " + "in your library is used instead. It is read from the same chapter list as the " + "title sequence, so turning this off saves no request unless that is off too.", DefaultEnabled: true, MinimumProtocol: 1, Capability: "end_credits_v1", Recovery: "Takes effect the next time playback starts; the credits simply play out full size.", }, { // The only switch there is for seasonal themes, and it is deliberately the // operator's rather than the viewer's: a per-person opt-out is a thing somebody // turns off in October and never reconsiders, which is the same as the feature not // existing. Off here means every television falls back to its viewer's own choice // on the next status poll. Key: featureSeasonalThemes, Name: "Seasonal themes", Area: "Presentation", Description: "Put every television into the Halloween, Christmas or Easter palette " + "for its dates. Viewers cannot decline one; turning this off is the only way to " + "stop them.", DefaultEnabled: true, MinimumProtocol: 1, Capability: "themes_v1", Recovery: "Takes effect on the next status poll, within ten seconds on an open TV.", }, { // A second switch rather than a consequence of the one above, because the palette // and the animation have quite different costs. Snow drifting over the launcher is // the only thing in the app that animates continuously while somebody is browsing, // and these are weak boxes; an operator who finds it costs frames should be able to // keep December looking like December without it. Key: featureSeasonalDecorations, Name: "Seasonal decorations", Area: "Presentation", Description: "Drift snow, bats or blossom over the launcher while a seasonal theme " + "is on. Turning it off keeps the seasonal colours and stops the animation.", DefaultEnabled: true, MinimumProtocol: 1, Capability: "seasonal_decorations_v1", Recovery: "Takes effect on the next status poll; the launcher simply stops drawing them.", }, { Key: featureGenreBrowser, Name: "Genre browser", Area: "Presentation", Description: "Show the full genre browser at the top of Movies and TV Shows.", DefaultEnabled: false, MinimumProtocol: 1, Capability: "genre_browser_v1", Recovery: "Takes effect on the next status poll; the browser is hidden when off.", }, { Key: featureTVCalendar, Name: "TV calendar", Area: "Presentation", Description: "Show the month-by-month Sonarr calendar on the navigation rail. " + "Turning it off hides the destination and stops the gateway reading months.", DefaultEnabled: true, MinimumProtocol: 1, Capability: "tv_calendar_v1", Recovery: "Takes effect on the next status poll; the rail entry simply disappears.", }, { Key: featureContinueWatching, Name: "Continue Watching", Area: "Home", Description: "Show resumable films and episodes on Home. Turning it off hides the " + "row and stops the gateway loading its resume and Next Up feeds.", DefaultEnabled: true, MinimumProtocol: 1, Recovery: "Takes effect on the next status poll, within ten seconds on an open TV.", }, { // No capability, because nothing on the television has to understand this: the // summary is an ordinary entry in My Alerts, which every build that has that page // already renders. This switch is the household's — a viewer's own is the // watch-time toggle on their account. Key: featureWatchTimeDigest, Name: "Weekly watch-time summary", Area: "Notifications", Description: "Tell each viewer how long they watched this week and this month, on " + "Sunday evening, with a summary of the month just gone once it ends. Read from " + "Tracearr; a server running none never sends one.", DefaultEnabled: true, MinimumProtocol: 1, Recovery: "Server-enforced; takes effect before the next summary is due.", }, { // Default **off**, the stance the genre browser takes. This is the switch that // decides where a household's watched state is written, and a feature that // arrives already on is one every server running this build starts using before // anybody has decided to — so it is opted into rather than out of. // // Switching it on or off never deletes a viewer or their history: the rows stay // in Postgres and come back intact. Off, the gateway routes nobody's state // anywhere but Emby, which is the state a household was in before the feature // existed; on, a shadow viewer's watching goes to Memby and is picked up exactly // where they left it. Key: featureViewers, Name: "Viewers", Area: "Accounts", Description: "Let one Emby account hold several people, each with their own " + "Continue Watching, watched history and favourites. Off by default; turning " + "it off again returns every television to watching as the account itself, " + "without losing what anybody has watched.", DefaultEnabled: false, MinimumProtocol: 1, Capability: "viewers_v1", Recovery: "Takes effect on the next request; nothing a viewer has watched is lost.", }, { Key: featureInstallPermission, Name: "Ask TVs for install permission", Area: "Setup", Description: "Ask a signed-in TV that cannot install its own updates to grant the " + "permission, so a mandatory update is not the first time it comes up.", DefaultEnabled: true, MinimumProtocol: 1, Capability: "install_permission_v1", Recovery: "Appears on the next status poll, and only on a TV missing the permission.", }, } type evaluatedFeature struct { featureDefinition Enabled bool `json:"enabled"` Source string `json:"source"` Compatible bool `json:"compatible"` } 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"` 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" } // detailExperienceFor resolves the "detailExperience" configuration value for a session, // validating the stored value rather than trusting its type: Values/UserValues/DeviceValues // are opaque JSON, and a value that is not exactly "v1" or "v2" must never reach the client // as something it has to guess how to handle. func detailExperienceFor(policy store.FeaturePolicy, sess store.Session) string { definition, ok := configurationDefinitionFor("detailExperience") if !ok { return "v1" } value, _ := configurationValue(policy, definition, sess) if text, ok := value.(string); ok && (text == "v1" || text == "v2") { return text } return "v1" } 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) { for _, definition := range featureCatalogue { if definition.Key == key { return definition, true } } return featureDefinition{}, false } func evaluateFeature(policy store.FeaturePolicy, definition featureDefinition, protocol int) evaluatedFeature { enabled, source := definition.DefaultEnabled, "default" 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" } compatible := protocol >= definition.MinimumProtocol if !compatible { enabled, source = false, "incompatible_client" } return evaluatedFeature{featureDefinition: definition, Enabled: enabled, Source: source, Compatible: compatible} } // currentFeaturePolicy is read on the request path from sixteen places and by the status // poll every open television makes, so it is cached rather than queried — see // featurePolicyCache for how long and why that is safe. func (s *Server) currentFeaturePolicy(ctx context.Context) store.FeaturePolicy { if s.store == nil { return store.DefaultFeaturePolicy() } return s.featurePolicy.read(ctx, func(ctx context.Context) store.FeaturePolicy { policy, err := s.store.FeaturePolicy(ctx) if err != nil { s.loggerFor(ctx).Warn("feature policy unavailable; using safe defaults", "error", err) return store.DefaultFeaturePolicy() } return policy }) } func (s *Server) featureEnabled(ctx context.Context, key string) bool { definition, ok := knownFeature(key) if !ok { return false } return evaluateFeature(s.currentFeaturePolicy(ctx), definition, ProtocolVersion).Enabled } 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) if len(capabilities) > 0 && definition.Capability != "" && !slices.Contains(capabilities[0], definition.Capability) { evaluated.Enabled = false evaluated.Compatible = false evaluated.Source = "missing_capability" } features = append(features, evaluated) } 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 { result := make(map[string]bool, len(featureCatalogue)) for _, feature := range featurePayload(policy, protocol, capabilities).Features { result[feature.Key] = feature.Enabled } return result } 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"` 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) { var req featurePolicyRequest decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)) decoder.DisallowUnknownFields() if err := decoder.Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } current := s.currentFeaturePolicy(r.Context()) if req.ExpectedRevision != current.Revision { writeError(w, http.StatusConflict, "feature flags changed in another admin session; refresh before saving") return } 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 { if _, ok := knownFeature(key); !ok { writeError(w, http.StatusBadRequest, "unknown feature flag: "+key) return } 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 case "rollback": if current.Previous == nil { writeError(w, http.StatusConflict, "there is no previous feature revision to restore") 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") return } s.featurePolicy.invalidate() stored, err := s.store.SetFeaturePolicy(r.Context(), next, req.ExpectedRevision) if err != nil { if errors.Is(err, store.ErrFeaturePolicyConflict) { writeError(w, http.StatusConflict, "feature flags changed in another admin session; refresh before saving") return } s.loggerFor(r.Context()).Error("feature policy write failed", "error", err) writeError(w, http.StatusInternalServerError, "could not save feature flags") return } s.loggerFor(r.Context()).Info("feature policy changed", "action", req.Action, "revision", stored.Revision, "safe_mode", stored.SafeMode, "overrides", len(stored.Overrides)) 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{} for _, value := range strings.Split(raw, ",") { value = strings.ToLower(strings.TrimSpace(value)) if value == "" || len(value) > 64 || seen[value] { continue } seen[value] = true values = append(values, value) } slices.Sort(values) return values } func clientCapabilities(r *http.Request) []string { return parseCapabilities(r.Header.Get("X-Memby-Capabilities")) }