package api import ( "encoding/json" "net/http" "sort" "strings" "time" "github.com/ponzischeme89/memby/server/internal/cache" "github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/store" ) type adminOnboardingRating struct { ItemID string `json:"itemId"` Title string `json:"title"` Rating int `json:"rating"` } type adminOnboardingPreferences struct { Completed bool `json:"completed"` Prompted bool `json:"prompted"` Updated bool `json:"updated"` Ratings []adminOnboardingRating `json:"ratings"` Genres []string `json:"genres"` Studios []string `json:"studios"` Actors []string `json:"actors"` Actresses []string `json:"actresses"` Directors []string `json:"directors"` ContentTypes []string `json:"contentTypes"` } type adminMembyAccount struct { ID string `json:"id"` Username string `json:"username"` CreatedAt time.Time `json:"createdAt"` LastSeen time.Time `json:"lastSeen"` Devices []store.MembyDevice `json:"devices"` Recommendations adminOnboardingPreferences `json:"recommendations"` // 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 // the defaults, and saying so is the difference between "chose this" and "has not // chosen anything". Settings adminAccountSettings `json:"settings"` // Themes is the ids this person may choose between, and an empty array means every // selectable theme rather than none — the same permissive reading the store and // themeAllowed take. The console renders that as every box ticked, which is what an // operator who has never touched the page should see. Themes []string `json:"themes"` } type adminAccountSettings struct { Saved bool `json:"saved"` Revision int64 `json:"revision"` Source string `json:"source,omitempty"` UpdatedAt any `json:"updatedAt,omitempty"` Preferences map[string]any `json:"preferences"` } func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) { accounts, err := s.store.MembyAccounts(r.Context()) if err != nil { s.loggerFor(r.Context()).Error("Memby account list failed", "error", err) writeError(w, http.StatusInternalServerError, "could not load Memby accounts") 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 // devices and the sign-out buttons are what this page is for. s.loggerFor(r.Context()).Warn("account settings read failed", "error", err) settings = map[string]store.UserPreferences{} } themes, err := s.store.AllUserThemes(r.Context()) if err != nil { // Same trade the settings read above makes: a colour allowlist that would not load // must not cost the operator the device list and the sign-out buttons. s.loggerFor(r.Context()).Warn("theme allowlist read failed", "error", err) themes = map[string][]string{} } 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, Preferences: decodePreferences(stored.Preferences), } if !stored.UpdatedAt.IsZero() { accountSettings.UpdatedAt = stored.UpdatedAt } result = append(result, adminMembyAccount{ ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt, LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings, Themes: nonNilStrings(themes[account.ID]), 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), }, }) } // The catalogue rides along so the console builds its editor from the server's own // vocabulary. A page that hard-coded the controls would drift from what the TV // accepts the first time a setting is added, and would do it silently. writeJSON(w, http.StatusOK, map[string]any{ "accounts": result, "catalogue": preferenceCatalogue, "schemaVersion": preferenceSchemaVersion, // The selectable themes, for the same reason the preference catalogue rides along: // a page that hard-coded the swatches would drift from what the gateway will accept // the first time a theme is added, and would do it without saying so. Seasonal ones // are absent because they are not grantable — see themes.go. "themes": selectableThemes(), }) } func (s *Server) handleAdminPromptRecommendations(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 } found := false for _, account := range accounts { if account.ID == userID { found = true break } } if !found { writeError(w, http.StatusNotFound, "Memby account not found") return } var preferences recommend.OnboardingPreferences if raw, err := s.store.RecommendationOnboarding(r.Context(), userID); err == nil { _ = json.Unmarshal(raw, &preferences) } else { writeError(w, http.StatusInternalServerError, "could not load recommendation choices") return } if preferences.Completed { writeError(w, http.StatusConflict, "recommendation setup is already complete") return } preferences.Prompted = true raw, _ := json.Marshal(preferences) if err := s.store.SetRecommendationOnboarding(r.Context(), userID, raw); err != nil { writeError(w, http.StatusInternalServerError, "could not queue recommendation prompt") return } w.WriteHeader(http.StatusNoContent) } func (s *Server) handleAdminRenameDevice(w http.ResponseWriter, r *http.Request) { userID, deviceID := strings.TrimSpace(r.PathValue("userID")), strings.TrimSpace(r.PathValue("deviceID")) var req renameDeviceRequest if userID == "" || deviceID == "" || json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req) != nil { writeError(w, http.StatusBadRequest, "user, device and name are required") return } req.DeviceName = strings.TrimSpace(req.DeviceName) if req.DeviceName == "" || len([]rune(req.DeviceName)) > 80 { writeError(w, http.StatusBadRequest, "device name must be between 1 and 80 characters") return } if err := s.store.RenameUserDevice(r.Context(), userID, deviceID, req.DeviceName); err == store.ErrNotFound { writeError(w, http.StatusNotFound, "Memby device not found") return } else if err != nil { writeError(w, http.StatusInternalServerError, "could not rename Memby device") return } w.WriteHeader(http.StatusNoContent) } func (s *Server) handleAdminDeleteDevice(w http.ResponseWriter, r *http.Request) { userID, deviceID := strings.TrimSpace(r.PathValue("userID")), strings.TrimSpace(r.PathValue("deviceID")) if userID == "" || deviceID == "" { writeError(w, http.StatusBadRequest, "user and device are required") return } hash, err := s.store.DeleteUserDevice(r.Context(), userID, deviceID) if err == store.ErrNotFound { writeError(w, http.StatusNotFound, "Memby device not found") return } if err != nil { writeError(w, http.StatusInternalServerError, "could not sign out Memby device") return } if s.cache != nil { _ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(hash))) } s.retireEmbyDevice(r.Context(), deviceID) if err := s.store.DeleteDeviceVersions(r.Context(), deviceID); err != nil { s.loggerFor(r.Context()).Warn("device version cleanup failed", "removed_device_id", deviceID, "error", err) } w.WriteHeader(http.StatusNoContent) } func (s *Server) handleAdminDeleteAccount(w http.ResponseWriter, r *http.Request) { userID := strings.TrimSpace(r.PathValue("userID")) if userID == "" { writeError(w, http.StatusBadRequest, "user is required") return } hashes, err := s.store.DeleteUserSessions(r.Context(), userID) if err == store.ErrNotFound { writeError(w, http.StatusNotFound, "Memby account not found") return } if err != nil { writeError(w, http.StatusInternalServerError, "could not revoke Memby account access") return } if s.cache != nil { keys := make([]string, 0, len(hashes)) for _, hash := range hashes { keys = append(keys, cache.SessionKey(hexHash(hash))) } _ = s.cache.Delete(r.Context(), keys...) _ = s.cache.InvalidateUser(r.Context(), userID) } w.WriteHeader(http.StatusNoContent) } type adminPreferencesRequest struct { Preferences map[string]any `json:"preferences"` } // handleAdminPushPreferences is the operator writing someone's TV settings for them. // // It writes with store.ForceRevision on purpose. Every one of that person's televisions // is also a writer of this row, and making the console lose a revision race would mean an // operator's deliberate change being quietly reverted by whichever set happened to sync // next. The push wins; the televisions notice on their next status poll and adopt it. func (s *Server) handleAdminPushPreferences(w http.ResponseWriter, r *http.Request) { userID := strings.TrimSpace(r.PathValue("userID")) if userID == "" { writeError(w, http.StatusBadRequest, "user is required") return } var req adminPreferencesRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } raw, err := json.Marshal(normalizePreferences(req.Preferences)) if err != nil { writeError(w, http.StatusBadRequest, "could not read those settings") return } stored, err := s.store.SetUserPreferences(r.Context(), userID, store.PreferenceWrite{ Preferences: raw, ExpectedRevision: store.ForceRevision, Source: "admin", }) if err != nil { s.loggerFor(r.Context()).Error("admin preferences push failed", "user", userID, "error", err) writeError(w, http.StatusInternalServerError, "could not push those settings") return } s.loggerFor(r.Context()).Info("settings pushed to viewer", "user", userID, "revision", stored.Revision, "source", "admin") writeJSON(w, http.StatusOK, preferencesPayload(stored)) } // handleAdminResetPreferences puts someone back on the defaults. It is a write rather than // a delete so it still bumps the revision — a delete would leave every television holding // the old document with nothing to tell them it had gone. func (s *Server) handleAdminResetPreferences(w http.ResponseWriter, r *http.Request) { userID := strings.TrimSpace(r.PathValue("userID")) if userID == "" { writeError(w, http.StatusBadRequest, "user is required") return } raw, err := json.Marshal(normalizePreferences(nil)) if err != nil { writeError(w, http.StatusInternalServerError, "could not build default settings") return } stored, err := s.store.SetUserPreferences(r.Context(), userID, store.PreferenceWrite{ Preferences: raw, ExpectedRevision: store.ForceRevision, Source: "admin", }) if err != nil { s.loggerFor(r.Context()).Error("admin preferences reset failed", "user", userID, "error", err) writeError(w, http.StatusInternalServerError, "could not reset those settings") return } s.loggerFor(r.Context()).Info("settings reset to defaults", "user", userID, "revision", stored.Revision, "source", "admin") writeJSON(w, http.StatusOK, preferencesPayload(stored)) } func (s *Server) handleAdminResetRecommendations(w http.ResponseWriter, r *http.Request) { userID := strings.TrimSpace(r.PathValue("userID")) if userID == "" { writeError(w, http.StatusBadRequest, "user is required") return } if err := s.store.ClearRecommendationOnboarding(r.Context(), userID); err != nil { writeError(w, http.StatusInternalServerError, "could not reset recommendation choices") return } if s.cache != nil { _ = s.cache.Delete(r.Context(), cache.RecommendationsKey(userID)) } w.WriteHeader(http.StatusNoContent) }