Files
memby/server/internal/api/admin_preferences.go
ponzischeme89andClaude Opus 5 4a4df7a73c 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>
2026-08-06 22:33:56 +12:00

301 lines
12 KiB
Go

package api
import (
"encoding/json"
"errors"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The settings history: every revision of one person's synced settings, what each one
// changed, which televisions have taken it, and how to put an old one back.
//
// It exists because the revision is a delivery mechanism with no receipt. /v1/status hands
// every open television a number and the set fetches the document when it differs — which
// works, and tells the operator nothing. "I changed their card size an hour ago and the
// bedroom TV still looks wrong" has three possible answers (it never fetched, it fetched
// and something else has since overwritten it, or it is fine and the complaint is about
// something else) and no way to tell them apart. Acks are what separate them.
//
// Nothing here rewinds a revision. A restore is a *new* revision carrying an old document,
// for the same reason the counter only ever goes up: a television compares numbers, so one
// that went backwards would leave every set in the house believing it was already up to
// date and holding settings the operator had just replaced.
// adminHistoryLimit is how many revisions the page asks for. The store caps it too; this
// is the page saying what it can usefully draw.
const adminHistoryLimit = 60
type adminPreferenceRevision struct {
Revision int64 `json:"revision"`
Source string `json:"source"`
// Author is who made the change, in the console's words: a television's name, or the
// admin console itself. Recorded at write time, so a set signed out since still has
// a name here.
Author string `json:"author"`
DeviceID string `json:"deviceId,omitempty"`
ClientVersion string `json:"clientVersion,omitempty"`
RestoredFrom int64 `json:"restoredFrom,omitempty"`
CreatedAt time.Time `json:"createdAt"`
Current bool `json:"current"`
Changes []preferenceChange `json:"changes"`
// Initial marks the first revision this history holds. Its "changes" are measured
// against the defaults, and calling that a change would be a claim about a decision
// nobody made — so the page labels it rather than listing it.
Initial bool `json:"initial"`
Preferences map[string]any `json:"preferences"`
Acks []store.PreferenceAck `json:"acks"`
}
// adminPreferenceDevice is one television's position: which revision it holds, and how far
// that is behind the current one.
type adminPreferenceDevice struct {
DeviceID string `json:"deviceId"`
Name string `json:"name"`
ClientVersion string `json:"clientVersion,omitempty"`
LastSeen time.Time `json:"lastSeen"`
SignedOut bool `json:"signedOut"`
Revision int64 `json:"revision"`
AckedAt any `json:"ackedAt,omitempty"`
// Behind is how many revisions this set has yet to take. Zero is up to date; the
// field is what the page sorts and colours by.
Behind int64 `json:"behind"`
// Never is a set that has never fetched the document at all — a fresh sign-in, or a
// build that predates synced settings. Distinct from being behind, because there is
// nothing to roll back to for it.
Never bool `json:"never"`
}
// handleAdminPreferenceHistory answers the whole page in one request: the revisions, what
// each changed, and where every television has got to.
func (s *Server) handleAdminPreferenceHistory(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("userID"))
if userID == "" {
writeError(w, http.StatusBadRequest, "user is required")
return
}
history, err := s.store.UserPreferenceHistory(r.Context(), userID, adminHistoryLimit)
if err != nil {
s.loggerFor(r.Context()).Error("preference history read failed", "user", userID, "error", err)
writeError(w, http.StatusInternalServerError, "could not read the settings history")
return
}
current, err := s.store.UserPreferences(r.Context(), userID)
if err != nil {
s.loggerFor(r.Context()).Error("preference read failed", "user", userID, "error", err)
writeError(w, http.StatusInternalServerError, "could not read these settings")
return
}
// One account read for both the person's name and their televisions. A failure costs
// the page its device names, never the history itself — which is the half that cannot
// be reconstructed from anywhere else.
account, err := s.membyAccount(r, userID)
if err != nil {
s.loggerFor(r.Context()).Warn("account unavailable for settings history", "error", err)
}
writeJSON(w, http.StatusOK, map[string]any{
"userId": userID,
"username": account.Username,
"currentRevision": current.Revision,
"currentSource": current.Source,
"updatedAt": optionalTime(current.UpdatedAt),
"saved": current.Revision > 0,
"revisions": adminRevisions(history, current.Revision),
"devices": s.adminPreferenceDevices(r, userID, account.Devices, current.Revision),
"catalogue": preferenceCatalogue,
"schemaVersion": preferenceSchemaVersion,
})
}
// adminRevisions turns stored revisions into what the table draws. It is a pure function
// of the history so it can be tested without a database, which is the only way to pin the
// two rules that are easy to get wrong: the diff is against the revision *below* this one,
// and the oldest entry held has nothing below it to be a diff of.
func adminRevisions(history []store.PreferenceRevision, currentRevision int64) []adminPreferenceRevision {
result := make([]adminPreferenceRevision, 0, len(history))
for index, entry := range history {
document := decodePreferences(entry.Preferences)
// history is newest first, so the predecessor is the next element along.
initial := index == len(history)-1
changes := []preferenceChange{}
if !initial {
changes = preferenceChanges(decodePreferences(history[index+1].Preferences), document)
}
acks := entry.Acks
if acks == nil {
acks = []store.PreferenceAck{}
}
result = append(result, adminPreferenceRevision{
Revision: entry.Revision, Source: entry.Source,
Author: revisionAuthor(entry), DeviceID: entry.DeviceID,
ClientVersion: entry.ClientVersion, RestoredFrom: entry.RestoredFrom,
CreatedAt: entry.CreatedAt, Current: entry.Revision == currentRevision,
Changes: changes, Initial: initial, Preferences: document, Acks: acks,
})
}
return result
}
// revisionAuthor names who made a change in the console's own vocabulary. A device row
// with no name is an older write, from before the history recorded one — "a television" is
// honest where inventing a name would not be.
func revisionAuthor(entry store.PreferenceRevision) string {
switch {
case entry.Source == "admin":
return "Admin console"
case entry.DeviceName != "":
return entry.DeviceName
case entry.Source == "device":
return "A television"
default:
return entry.Source
}
}
// adminPreferenceDevices lists every television signed in to this account beside the
// revision it holds. Signed-in sets come first because they are the ones an operator can
// still expect to catch up; a set that has acked but is no longer signed in is kept so its
// last known position is not silently dropped.
func (s *Server) adminPreferenceDevices(
r *http.Request, userID string, known []store.MembyDevice, currentRevision int64,
) []adminPreferenceDevice {
states, err := s.store.PreferenceDeviceStates(r.Context(), userID)
if err != nil {
s.loggerFor(r.Context()).Warn("preference device states unavailable", "error", err)
states = map[string]store.PreferenceDeviceState{}
}
devices := []adminPreferenceDevice{}
seen := map[string]bool{}
for _, device := range known {
state, acked := states[device.ID]
seen[device.ID] = true
devices = append(devices, adminPreferenceDevice{
DeviceID: device.ID, Name: device.Name, ClientVersion: device.Version,
LastSeen: device.LastSeen, Revision: state.Revision,
AckedAt: optionalTime(state.AckedAt), Never: !acked,
Behind: behindBy(currentRevision, state.Revision),
})
}
for deviceID, state := range states {
if seen[deviceID] {
continue
}
devices = append(devices, adminPreferenceDevice{
DeviceID: deviceID, Name: "Signed-out television", SignedOut: true,
Revision: state.Revision, AckedAt: optionalTime(state.AckedAt),
Behind: behindBy(currentRevision, state.Revision),
})
}
sort.SliceStable(devices, func(i, j int) bool {
if devices[i].SignedOut != devices[j].SignedOut {
return devices[j].SignedOut
}
if devices[i].Behind != devices[j].Behind {
return devices[i].Behind > devices[j].Behind
}
return strings.ToLower(devices[i].Name) < strings.ToLower(devices[j].Name)
})
return devices
}
// behindBy never goes negative. A television holding a revision above the current one is
// impossible in ordinary operation, but the counter is the whole delivery mechanism and a
// negative "behind" on the page would read as nonsense rather than as the anomaly it is.
func behindBy(current, held int64) int64 {
if held >= current {
return 0
}
return current - held
}
func optionalTime(value time.Time) any {
if value.IsZero() {
return nil
}
return value
}
// membyAccount is one person out of the same list the console's other pages read. Somebody
// with no signed-in television is not an error: their settings history outlives their
// devices, and is one of the more useful times to be able to read it.
func (s *Server) membyAccount(r *http.Request, userID string) (store.MembyAccount, error) {
accounts, err := s.store.MembyAccounts(r.Context())
if err != nil {
return store.MembyAccount{}, err
}
for _, account := range accounts {
if account.ID == userID {
return account, nil
}
}
return store.MembyAccount{}, nil
}
// handleAdminRestorePreferences puts an earlier document back.
//
// It is a forward write, never a rewind: the restored document goes out as the next
// revision, attributed to the console and recording what it was taken from. That is what
// makes it visible to televisions at all — they compare revision numbers — and it is also
// what makes a restore itself undoable, since the revision it replaced is still sitting in
// the history one row down.
//
// The document is re-normalised on the way out. A revision written before a setting
// existed has nothing to say about it, and the catalogue's default is the right answer
// there; a revision written before a setting's options changed may hold one that is no
// longer legal, and restoring it verbatim would put a value on a television that the
// server itself would reject.
func (s *Server) handleAdminRestorePreferences(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("userID"))
revision, err := strconv.ParseInt(strings.TrimSpace(r.PathValue("revision")), 10, 64)
if userID == "" || err != nil || revision <= 0 {
writeError(w, http.StatusBadRequest, "user and revision are required")
return
}
document, err := s.store.PreferenceRevisionDocument(r.Context(), userID, revision)
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "that revision is no longer held")
return
}
if err != nil {
s.loggerFor(r.Context()).Error("preference revision read failed",
"user", userID, "revision", revision, "error", err)
writeError(w, http.StatusInternalServerError, "could not read that revision")
return
}
values := map[string]any{}
_ = json.Unmarshal(document, &values)
raw, err := json.Marshal(normalizePreferences(values))
if err != nil {
writeError(w, http.StatusInternalServerError, "could not rebuild that revision")
return
}
// ForceRevision for the same reason an ordinary push takes it: every one of this
// person's televisions is also a writer of this row, and an operator deliberately
// restoring a version must not lose a race to whichever set syncs next.
stored, err := s.store.SetUserPreferences(r.Context(), userID, store.PreferenceWrite{
Preferences: raw, ExpectedRevision: store.ForceRevision,
Source: "admin", RestoredFrom: revision,
})
if err != nil {
s.loggerFor(r.Context()).Error("preference restore failed",
"user", userID, "revision", revision, "error", err)
writeError(w, http.StatusInternalServerError, "could not restore that revision")
return
}
s.loggerFor(r.Context()).Info("settings restored",
"user", userID, "revision", stored.Revision, "restoredFrom", revision, "source", "admin")
writeJSON(w, http.StatusOK, preferencesPayload(stored))
}