This commit is contained in:
ponzischeme89
2026-08-24 22:56:46 +12:00
parent 4f95767e2f
commit 396d35e2f5
48 changed files with 1541 additions and 672 deletions
+11 -3
View File
@@ -40,6 +40,8 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("PUT /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminPushPreferences))
mux.Handle("DELETE /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminResetPreferences))
mux.Handle("PUT /admin/api/accounts/{userID}/notifications", s.adminAuth(s.handleAdminNotificationPreferences))
mux.Handle("POST /admin/api/accounts/{userID}/force-update", s.adminAuth(s.handleAdminForceUpdate))
mux.Handle("PUT /admin/api/accounts/{userID}/enabled", s.adminAuth(s.handleAdminUserEnabled))
mux.Handle("GET /admin/api/accounts/{userID}/preferences/history", s.adminAuth(s.handleAdminPreferenceHistory))
mux.Handle("POST /admin/api/accounts/{userID}/preferences/revisions/{revision}/restore",
s.adminAuth(s.handleAdminRestorePreferences))
@@ -857,16 +859,22 @@ func (s *Server) handleAdminJourneys(w http.ResponseWriter, r *http.Request) {
}
stats, statsErr := s.store.JourneyStats(r.Context(), userID, since)
features, featureErr := s.store.UserFeatureStats(r.Context(), userID, since)
featureUsage, usageErr := s.store.JourneyFeatureUsage(r.Context(), userID, since)
featureBreakdown, breakdownErr := s.store.JourneyFeatureBreakdown(r.Context(), userID, since)
paths, pathErr := s.store.UserPaths(r.Context(), userID, since)
actions, actionErr := s.store.JourneyActionStats(r.Context(), userID, since)
if statsErr != nil || featureErr != nil || pathErr != nil || actionErr != nil {
s.loggerFor(r.Context()).Error("journey analytics read failed", "user_id", userID)
if statsErr != nil || featureErr != nil || usageErr != nil || breakdownErr != nil || pathErr != nil || actionErr != nil {
s.loggerFor(r.Context()).Error("journey analytics read failed", "user_id", userID,
"stats_error", statsErr, "feature_error", featureErr,
"feature_usage_error", usageErr, "feature_breakdown_error", breakdownErr,
"paths_error", pathErr, "actions_error", actionErr)
writeError(w, http.StatusInternalServerError, "could not read journeys")
return
}
payload := map[string]any{
"days": days, "retentionDays": int(s.cfg.AnalyticsRetention / (24 * time.Hour)),
"users": users, "stats": stats, "features": features, "paths": paths, "actions": actions,
"users": users, "stats": stats, "features": features, "featureUsage": featureUsage,
"featureBreakdown": featureBreakdown, "paths": paths, "actions": actions,
}
if userID != "" {
events, eventErr := s.store.UserJourneyEvents(r.Context(), userID, since, 1000)
+58 -46
View File
@@ -3,7 +3,6 @@ package api
import (
"encoding/json"
"net/http"
"sort"
"strings"
"time"
@@ -38,11 +37,12 @@ type adminMembyAccount struct {
// ShortName is the friendly name the launcher greets this person by, and is blank far
// more often than not — the directory reads it as "their account name" rather than as
// something missing.
ShortName string `json:"shortName"`
CreatedAt time.Time `json:"createdAt"`
LastSeen time.Time `json:"lastSeen"`
Devices []store.MembyDevice `json:"devices"`
Recommendations adminOnboardingPreferences `json:"recommendations"`
ShortName string `json:"shortName"`
CreatedAt time.Time `json:"createdAt"`
LastSeen time.Time `json:"lastSeen"`
Devices []store.MembyDevice `json:"devices"`
Enabled bool `json:"enabled"`
LastIP string `json:"lastIp"`
// Settings is the same document the television reads, normalised the same way, so
// the console is editing what the TV will actually receive rather than a projection
// of it. Saved is false for someone who has never synced — the values shown are then
@@ -77,23 +77,6 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
return
}
allRatingIDs := []string{}
preferences := make(map[string]recommend.OnboardingPreferences, len(accounts))
for _, account := range accounts {
var pref recommend.OnboardingPreferences
_ = json.Unmarshal(account.RecommendationPreferences, &pref)
preferences[account.ID] = pref
for id := range pref.Ratings {
allRatingIDs = append(allRatingIDs, id)
}
}
titles := map[string]string{}
if raws, loadErr := s.store.LibraryItemsByID(r.Context(), allRatingIDs); loadErr == nil {
for _, item := range recommend.Decode(raws) {
titles[item.ID] = item.Name
}
}
settings, err := s.store.AllUserPreferences(r.Context())
if err != nil {
// A settings read failure must not cost the operator the account list; the
@@ -126,21 +109,6 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
result := make([]adminMembyAccount, 0, len(accounts))
for _, account := range accounts {
pref := preferences[account.ID]
ratings := make([]adminOnboardingRating, 0, len(pref.Ratings))
for itemID, rating := range pref.Ratings {
title := titles[itemID]
if title == "" {
title = itemID
}
ratings = append(ratings, adminOnboardingRating{ItemID: itemID, Title: title, Rating: rating})
}
sort.Slice(ratings, func(i, j int) bool {
if ratings[i].Rating != ratings[j].Rating {
return ratings[i].Rating > ratings[j].Rating
}
return strings.ToLower(ratings[i].Title) < strings.ToLower(ratings[j].Title)
})
stored, saved := settings[account.ID]
accountSettings := adminAccountSettings{
Saved: saved, Revision: stored.Revision, Source: stored.Source,
@@ -162,14 +130,7 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
Themes: nonNilStrings(themes[account.ID]),
Notifications: notificationPrefs,
Recommendations: adminOnboardingPreferences{
Completed: pref.Completed, Prompted: pref.Prompted,
Updated: len(account.RecommendationPreferences) > 2,
Ratings: ratings, Genres: nonNilStrings(pref.Genres),
Studios: nonNilStrings(pref.Studios), Actors: nonNilStrings(pref.Actors),
Actresses: nonNilStrings(pref.Actresses),
Directors: nonNilStrings(pref.Directors), ContentTypes: nonNilStrings(pref.ContentTypes),
},
Enabled: account.Enabled, LastIP: account.LastIP,
})
}
// The catalogue rides along so the console builds its editor from the server's own
@@ -218,6 +179,57 @@ func (s *Server) handleAdminNotificationPreferences(w http.ResponseWriter, r *ht
writeJSON(w, http.StatusOK, prefs)
}
func (s *Server) handleAdminUserEnabled(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("userID"))
if userID == "" {
writeError(w, http.StatusBadRequest, "user is required")
return
}
var req struct {
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req); err != nil || req.Enabled == nil {
writeError(w, http.StatusBadRequest, "enabled is required")
return
}
if err := s.store.SetUserEnabled(r.Context(), userID, *req.Enabled); err != nil {
writeError(w, http.StatusInternalServerError, "could not change user access")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"enabled": *req.Enabled})
}
// The gateway's update check is policy-driven. This is the operator-facing queue point;
// the device sees the request on its next status poll.
func (s *Server) handleAdminForceUpdate(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("userID"))
if userID == "" {
writeError(w, http.StatusBadRequest, "user is required")
return
}
accounts, err := s.store.MembyAccounts(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load Memby account")
return
}
for _, account := range accounts {
if account.ID == userID {
version := strings.TrimSpace(s.updatePolicy.get().LatestVersion)
if version == "" {
writeError(w, http.StatusConflict, "no current release is configured")
return
}
if err := s.store.SetForcedUpdate(r.Context(), userID, version); err != nil {
writeError(w, http.StatusInternalServerError, "could not queue update")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "queued"})
return
}
}
writeError(w, http.StatusNotFound, "Memby account not found")
}
func (s *Server) handleAdminPromptRecommendations(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("userID"))
if userID == "" {
+3
View File
@@ -701,6 +701,9 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
if raw, err := s.cache.Get(ctx, key); err == nil {
var cs cachedSession
if json.Unmarshal(raw, &cs) == nil {
if enabled, err := s.store.UserEnabled(ctx, cs.EmbyUserID); err != nil || !enabled {
return store.Session{}, store.ErrNotFound
}
return store.Session{
TokenHash: hash,
EmbyUserID: cs.EmbyUserID,
+17
View File
@@ -62,6 +62,7 @@ var configurationCatalogue = []configurationDefinition{
{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 Membys 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},
@@ -281,6 +282,22 @@ func configurationValue(policy store.FeaturePolicy, definition configurationDefi
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 {
+37
View File
@@ -89,6 +89,43 @@ func TestAppFontFamilyIsAValidatedGlobalConfiguration(t *testing.T) {
}
}
func TestDetailExperienceResolvesDeviceThenUserThenGlobalThenDefault(t *testing.T) {
sess := store.Session{EmbyUserID: "user-1", DeviceID: "device-1"}
if got := detailExperienceFor(store.DefaultFeaturePolicy(), sess); got != "v1" {
t.Fatalf("default detail experience = %q, want v1", got)
}
global := store.FeaturePolicy{Values: map[string]json.RawMessage{"detailExperience": json.RawMessage(`"v2"`)}}
if got := detailExperienceFor(global, sess); got != "v2" {
t.Fatalf("global detail experience = %q, want v2", got)
}
perUser := store.FeaturePolicy{
Values: map[string]json.RawMessage{"detailExperience": json.RawMessage(`"v2"`)},
UserValues: map[string]map[string]json.RawMessage{"user-1": {"detailExperience": json.RawMessage(`"v1"`)}},
}
if got := detailExperienceFor(perUser, sess); got != "v1" {
t.Fatalf("per-user detail experience = %q, want v1 to outrank the global v2", got)
}
perDevice := store.FeaturePolicy{
UserValues: map[string]map[string]json.RawMessage{"user-1": {"detailExperience": json.RawMessage(`"v1"`)}},
DeviceValues: map[string]map[string]json.RawMessage{"device-1": {"detailExperience": json.RawMessage(`"v2"`)}},
}
if got := detailExperienceFor(perDevice, sess); got != "v2" {
t.Fatalf("per-device detail experience = %q, want v2 to outrank the per-user v1", got)
}
}
func TestDetailExperienceFallsBackToV1OnAnUnrecognisedStoredValue(t *testing.T) {
sess := store.Session{EmbyUserID: "user-1", DeviceID: "device-1"}
garbage := store.FeaturePolicy{Values: map[string]json.RawMessage{"detailExperience": json.RawMessage(`"v3"`)}}
if got := detailExperienceFor(garbage, sess); got != "v1" {
t.Fatalf("garbage detail experience = %q, want v1", got)
}
notAString := store.FeaturePolicy{Values: map[string]json.RawMessage{"detailExperience": json.RawMessage(`42`)}}
if got := detailExperienceFor(notAString, sess); got != "v1" {
t.Fatalf("non-string detail experience = %q, want v1", got)
}
}
func TestClientCapabilitiesAreNormalizedAndBounded(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
req.Header.Set("X-Memby-Capabilities", " Sonarr_Preroll_V1,server_features_v1,sonarr_preroll_v1,"+
+6
View File
@@ -195,5 +195,11 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
// else — the handlers refuse regardless, but a menu item that only ever produces a
// refusal is worse than no menu item.
"requests": map[string]any{"allowed": s.requestAllowed(r, sess)},
// The v2 detail-page experiment, as a plain string rather than a boolean feature
// flag: it needs three legal values room to grow into (a later variant), and per-
// user/per-device scope the way the theme does. It rides this poll rather than
// /v1/config because that document is fetched unauthenticated, before sign-in, and
// cannot resolve a session to scope by user or device.
"detailExperience": detailExperienceFor(featurePolicy, sess),
})
}
+11 -1
View File
@@ -119,7 +119,17 @@ func effectiveUpdatePolicy(policy appupdate.Policy) appupdate.Policy {
}
func (s *Server) updateDecision(r *http.Request) appupdate.Decision {
return appupdate.Decide(effectiveUpdatePolicy(s.updatePolicy.get()), clientVersion(r))
policy := effectiveUpdatePolicy(s.updatePolicy.get())
if identity := identityFrom(r.Context()); identity != nil && identity.userID != "" {
if forced, err := s.store.ForcedUpdate(r.Context(), identity.userID); err == nil && forced != "" {
if appupdate.CompareVersions(clientVersion(r), forced) >= 0 {
_ = s.store.ClearForcedUpdate(r.Context(), identity.userID)
} else if policy.Enabled && policy.DownloadURL != "" && (policy.MinimumVersion == "" || appupdate.CompareVersions(policy.MinimumVersion, forced) < 0) {
policy.MinimumVersion = forced
}
}
}
return appupdate.Decide(policy, clientVersion(r))
}
// mustRetireForUpdate is narrower than "mandatory": an operator may temporarily force a