Publish current app and server
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
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"
|
||||
)
|
||||
|
||||
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.",
|
||||
},
|
||||
}
|
||||
|
||||
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.log.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, membyProtocolVersion).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.log.Error("feature policy write failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save feature flags")
|
||||
return
|
||||
}
|
||||
s.log.Info("feature policy changed", "action", req.Action, "revision", stored.Revision,
|
||||
"safe_mode", stored.SafeMode, "overrides", len(stored.Overrides))
|
||||
writeJSON(w, http.StatusOK, featurePayload(stored, membyProtocolVersion))
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
Reference in New Issue
Block a user