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
@@ -0,0 +1,300 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user