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
+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 == "" {