App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
@@ -20,11 +20,13 @@ type adminOnboardingRating struct {
|
||||
|
||||
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"`
|
||||
}
|
||||
@@ -36,12 +38,26 @@ type adminMembyAccount struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
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.log.Error("Memby account list failed", "error", err)
|
||||
s.loggerFor(r.Context()).Error("Memby account list failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not load Memby accounts")
|
||||
return
|
||||
}
|
||||
@@ -63,6 +79,14 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
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{}
|
||||
}
|
||||
|
||||
result := make([]adminMembyAccount, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
pref := preferences[account.ID]
|
||||
@@ -80,18 +104,76 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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,
|
||||
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
|
||||
Recommendations: adminOnboardingPreferences{
|
||||
Completed: pref.Completed, Updated: len(account.RecommendationPreferences) > 2,
|
||||
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),
|
||||
},
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"accounts": result})
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -134,6 +216,11 @@ func (s *Server) handleAdminDeleteDevice(w http.ResponseWriter, r *http.Request)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -163,6 +250,72 @@ func (s *Server) handleAdminDeleteAccount(w http.ResponseWriter, r *http.Request
|
||||
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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user