package api import ( "context" "encoding/json" "errors" "net/http" "slices" "strings" "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" ) 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"` } 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: 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"` } 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" } 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} } func (s *Server) currentFeaturePolicy(ctx context.Context) store.FeaturePolicy { if s.store == nil { return store.DefaultFeaturePolicy() } 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 { 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) } return featureResponse{ SchemaVersion: featureSchemaVersion, Revision: policy.Revision, SafeMode: policy.SafeMode, UpdatedAt: policy.UpdatedAt, CanRollback: policy.Previous != nil, Features: features, } } 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, _ store.Session) { writeJSON(w, http.StatusOK, featurePayload( s.currentFeaturePolicy(r.Context()), clientProtocolNumber(r), clientCapabilities(r), )) } type featurePolicyRequest struct { Action string `json:"action"` ExpectedRevision int64 `json:"expectedRevision"` Overrides map[string]bool `json:"overrides"` } 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{}, 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 } case "safe-mode": next.Overrides = current.Overrides next.SafeMode = true case "leave-safe-mode": next.Overrides = current.Overrides 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.SafeMode = current.Previous.SafeMode default: writeError(w, http.StatusBadRequest, "unknown feature policy action") return } 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 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")) }