package store import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/jackc/pgx/v5" ) // UserPreferences is one viewer's TV settings, held server-side so they follow the person // rather than the television. // // The document is stored opaquely on purpose. The vocabulary — which keys exist, what // values are legal — lives in internal/api, next to the client contract it belongs to, so // adding a setting is one file and never a database migration. What the store owns is the // part the API cannot: the revision, and the guarantee that two writers cannot interleave. type UserPreferences struct { Preferences json.RawMessage `json:"preferences"` // Revision increases by one on every accepted write. It is what lets a television // notice, from the status poll alone, that something changed it elsewhere. Revision int64 `json:"revision"` UpdatedAt time.Time `json:"updatedAt"` // Source records who wrote it last: "device" or "admin". The TV shows nothing with // this, but an operator asking "why did their layout change" needs the answer. Source string `json:"source"` } // ErrPreferencesConflict means the caller's revision was not the current one. The client // resolves it by reading the server's copy — never by retrying its own write, which is // how an admin push would get silently undone by whichever TV was slowest to notice. var ErrPreferencesConflict = errors.New("store: user preferences revision conflict") // ForceRevision skips the revision check. Only the admin console passes it: an operator // pushing settings is deliberately overriding whatever the devices last agreed on. const ForceRevision int64 = -1 func emptyPreferences() UserPreferences { return UserPreferences{Preferences: json.RawMessage(`{}`), Source: "default"} } func (s *Store) UserPreferences(ctx context.Context, userID string) (UserPreferences, error) { var result UserPreferences var raw []byte err := s.pool.QueryRow(ctx, ` SELECT preferences, revision, updated_at, source FROM user_preferences WHERE emby_user_id = $1`, userID). Scan(&raw, &result.Revision, &result.UpdatedAt, &result.Source) if errors.Is(err, pgx.ErrNoRows) { return emptyPreferences(), nil } if err != nil { return emptyPreferences(), fmt.Errorf("store: read user preferences: %w", err) } result.Preferences = json.RawMessage(raw) return result, nil } // AllUserPreferences is the admin console's read. Absent users are absent rather than // defaulted, so the page can distinguish "never saved anything" from "saved the defaults". func (s *Store) AllUserPreferences(ctx context.Context) (map[string]UserPreferences, error) { rows, err := s.pool.Query(ctx, ` SELECT emby_user_id, preferences, revision, updated_at, source FROM user_preferences`) if err != nil { return nil, fmt.Errorf("store: list user preferences: %w", err) } defer rows.Close() all := map[string]UserPreferences{} for rows.Next() { var userID string var value UserPreferences var raw []byte if err := rows.Scan(&userID, &raw, &value.Revision, &value.UpdatedAt, &value.Source); err != nil { return nil, fmt.Errorf("store: scan user preferences: %w", err) } value.Preferences = json.RawMessage(raw) all[userID] = value } return all, rows.Err() } // PreferenceWrite is one attempt to change somebody's settings, and everything the // history needs to describe it afterwards. // // The attribution fields are on the write rather than looked up later because the answer // changes: a television is renamed, signed out, replaced. What the history has to be able // to say is what was true when the change was made. type PreferenceWrite struct { Preferences json.RawMessage // ExpectedRevision is the revision the writer believes it is editing, or // ForceRevision for a deliberate override. ExpectedRevision int64 // Source is "device" or "admin". Source string DeviceID, DeviceName, ClientVersion string // RestoredFrom is the revision this document was taken from, when the write is a // restore. Zero otherwise. RestoredFrom int64 } // preferenceHistoryLimit is how many revisions are kept per person. This is a household: // a hundred changes is years of them, and the operator question history answers — "what // did I just do to their launcher, and can I undo it" — is asked about the recent end. const preferenceHistoryLimit = 100 // SetUserPreferences writes the document if write.ExpectedRevision still matches, and // returns what is now stored. // // The advisory lock and the re-read inside the transaction are the point: every // television in the house writes this row, and an admin push lands in the middle of them. // Without the lock, two TVs that both read revision 4 would both write revision 5 and one // person's choice would vanish with no error anywhere. // // The history row is written in the same transaction as the document, so there is no // state in which a revision exists and nothing records where it came from. func (s *Store) SetUserPreferences( ctx context.Context, userID string, write PreferenceWrite, ) (UserPreferences, error) { preferences, expectedRevision, source := write.Preferences, write.ExpectedRevision, write.Source tx, err := s.pool.Begin(ctx) if err != nil { return UserPreferences{}, fmt.Errorf("store: begin user preferences write: %w", err) } defer func() { _ = tx.Rollback(ctx) }() if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, "prefs:"+userID); err != nil { return UserPreferences{}, fmt.Errorf("store: lock user preferences: %w", err) } var current int64 err = tx.QueryRow(ctx, `SELECT revision FROM user_preferences WHERE emby_user_id = $1`, userID).Scan(¤t) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return UserPreferences{}, fmt.Errorf("store: read current user preferences: %w", err) } if expectedRevision != ForceRevision && current != expectedRevision { return UserPreferences{}, ErrPreferencesConflict } next := UserPreferences{ Preferences: preferences, Revision: current + 1, UpdatedAt: time.Now().UTC(), Source: source, } if _, err := tx.Exec(ctx, ` INSERT INTO user_preferences (emby_user_id, preferences, revision, updated_at, source) VALUES ($1, $2::jsonb, $3, $4, $5) ON CONFLICT (emby_user_id) DO UPDATE SET preferences = EXCLUDED.preferences, revision = EXCLUDED.revision, updated_at = EXCLUDED.updated_at, source = EXCLUDED.source`, userID, string(preferences), next.Revision, next.UpdatedAt, source); err != nil { return UserPreferences{}, fmt.Errorf("store: write user preferences: %w", err) } var restoredFrom any if write.RestoredFrom > 0 { restoredFrom = write.RestoredFrom } if _, err := tx.Exec(ctx, ` INSERT INTO user_preference_revisions ( emby_user_id, revision, preferences, source, device_id, device_name, client_version, restored_from, created_at) VALUES ($1, $2, $3::jsonb, $4, $5, $6, $7, $8, $9) ON CONFLICT (emby_user_id, revision) DO NOTHING`, userID, next.Revision, string(preferences), source, write.DeviceID, write.DeviceName, write.ClientVersion, restoredFrom, next.UpdatedAt, ); err != nil { return UserPreferences{}, fmt.Errorf("store: write preference history: %w", err) } // Pruned here rather than on a schedule: this is the only writer, so it is the only // place the table can grow, and the acks of a revision nobody can see any more are // dead weight with it. // // The cutoff is worked out in Go rather than as `$2 - $3` in the statement. Postgres // has to infer a type for every placeholder, and two of them either side of an // operator give it nothing to infer from — it answers `operator is not unique: // unknown - unknown`, which failed the whole transaction and returned 500 to every // television trying to save a setting. Arithmetic on two parameters belongs on this // side of the wire. pruneBelow := next.Revision - preferenceHistoryLimit if _, err := tx.Exec(ctx, ` DELETE FROM user_preference_revisions WHERE emby_user_id = $1 AND revision <= $2`, userID, pruneBelow, ); err != nil { return UserPreferences{}, fmt.Errorf("store: prune preference history: %w", err) } // A device's most recent ack is never pruned, however far behind it has fallen. A // television switched off for a year is exactly the one an operator wants to see // described as "on revision 12" rather than as one that has never checked in. if _, err := tx.Exec(ctx, ` DELETE FROM user_preference_acks stale WHERE stale.emby_user_id = $1 AND stale.revision <= $2 AND EXISTS ( SELECT 1 FROM user_preference_acks newer WHERE newer.emby_user_id = stale.emby_user_id AND newer.device_id = stale.device_id AND newer.revision > stale.revision)`, userID, pruneBelow, ); err != nil { return UserPreferences{}, fmt.Errorf("store: prune preference acks: %w", err) } if err := tx.Commit(ctx); err != nil { return UserPreferences{}, fmt.Errorf("store: commit user preferences: %w", err) } return next, nil } // PreferenceRevision is one entry in the history: what was stored, who stored it, and // which televisions have since taken it. type PreferenceRevision struct { Revision int64 `json:"revision"` Preferences json.RawMessage `json:"-"` Source string `json:"source"` DeviceID string `json:"deviceId,omitempty"` DeviceName string `json:"deviceName,omitempty"` ClientVersion string `json:"clientVersion,omitempty"` RestoredFrom int64 `json:"restoredFrom,omitempty"` CreatedAt time.Time `json:"createdAt"` Acks []PreferenceAck `json:"acks"` } // PreferenceAck is one television taking one revision. type PreferenceAck struct { DeviceID string `json:"deviceId"` DeviceName string `json:"deviceName"` ClientVersion string `json:"clientVersion,omitempty"` Revision int64 `json:"revision"` AckedAt time.Time `json:"ackedAt"` } // UserPreferenceHistory returns the newest revisions first, each carrying the televisions // that fetched it. // // Two queries rather than a join: the history is small and bounded, and a join would // return the document — the largest column here — once per acknowledging device. func (s *Store) UserPreferenceHistory( ctx context.Context, userID string, limit int, ) ([]PreferenceRevision, error) { if limit <= 0 || limit > preferenceHistoryLimit { limit = preferenceHistoryLimit } rows, err := s.pool.Query(ctx, ` SELECT revision, preferences, source, device_id, device_name, client_version, COALESCE(restored_from, 0), created_at FROM user_preference_revisions WHERE emby_user_id = $1 ORDER BY revision DESC LIMIT $2`, userID, limit) if err != nil { return nil, fmt.Errorf("store: read preference history: %w", err) } defer rows.Close() history := []PreferenceRevision{} byRevision := map[int64]int{} for rows.Next() { var entry PreferenceRevision var raw []byte if err := rows.Scan(&entry.Revision, &raw, &entry.Source, &entry.DeviceID, &entry.DeviceName, &entry.ClientVersion, &entry.RestoredFrom, &entry.CreatedAt, ); err != nil { return nil, fmt.Errorf("store: scan preference history: %w", err) } entry.Preferences = json.RawMessage(raw) entry.Acks = []PreferenceAck{} byRevision[entry.Revision] = len(history) history = append(history, entry) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("store: read preference history: %w", err) } if len(history) == 0 { return history, nil } oldest := history[len(history)-1].Revision ackRows, err := s.pool.Query(ctx, ` SELECT device_id, device_name, client_version, revision, acked_at FROM user_preference_acks WHERE emby_user_id = $1 AND revision >= $2 ORDER BY acked_at`, userID, oldest) if err != nil { return nil, fmt.Errorf("store: read preference acks: %w", err) } defer ackRows.Close() for ackRows.Next() { var ack PreferenceAck if err := ackRows.Scan(&ack.DeviceID, &ack.DeviceName, &ack.ClientVersion, &ack.Revision, &ack.AckedAt); err != nil { return nil, fmt.Errorf("store: scan preference ack: %w", err) } if index, ok := byRevision[ack.Revision]; ok { history[index].Acks = append(history[index].Acks, ack) } } return history, ackRows.Err() } // PreferenceRevisionDocument is the stored document for one revision, which is what a // restore reads. Absent means pruned or never written, and the caller must say so rather // than silently restoring something else. func (s *Store) PreferenceRevisionDocument( ctx context.Context, userID string, revision int64, ) (json.RawMessage, error) { var raw []byte err := s.pool.QueryRow(ctx, ` SELECT preferences FROM user_preference_revisions WHERE emby_user_id = $1 AND revision = $2`, userID, revision).Scan(&raw) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, fmt.Errorf("store: read preference revision: %w", err) } return json.RawMessage(raw), nil } // PreferenceDeviceState is where one television has got to: the newest revision it has // fetched. A set that has never fetched one is absent, which the caller reports as such — // "has not taken anything yet" and "is on revision 3" are different situations. type PreferenceDeviceState struct { DeviceID string `json:"deviceId"` Revision int64 `json:"revision"` AckedAt time.Time `json:"ackedAt"` } func (s *Store) PreferenceDeviceStates( ctx context.Context, userID string, ) (map[string]PreferenceDeviceState, error) { rows, err := s.pool.Query(ctx, ` SELECT DISTINCT ON (device_id) device_id, revision, acked_at FROM user_preference_acks WHERE emby_user_id = $1 ORDER BY device_id, revision DESC`, userID) if err != nil { return nil, fmt.Errorf("store: read preference device states: %w", err) } defer rows.Close() states := map[string]PreferenceDeviceState{} for rows.Next() { var state PreferenceDeviceState if err := rows.Scan(&state.DeviceID, &state.Revision, &state.AckedAt); err != nil { return nil, fmt.Errorf("store: scan preference device state: %w", err) } states[state.DeviceID] = state } return states, rows.Err() } // RecordPreferenceAck notes that one television now holds one revision. // // It is written when a set *fetches* the document, which is the only proof there is that // it took it: the status poll tells every open TV the revision, but being told is not the // same as having adopted, and a set that is switched off or cannot reach the gateway is // exactly the one an operator is asking about. // // A device with no id is not recorded rather than recorded as "": the empty id would // collect every legacy client into one imaginary television. func (s *Store) RecordPreferenceAck( ctx context.Context, userID, deviceID, deviceName, clientVersion string, revision int64, ) error { if userID == "" || deviceID == "" || revision <= 0 { return nil } _, err := s.pool.Exec(ctx, ` INSERT INTO user_preference_acks ( emby_user_id, device_id, revision, device_name, client_version, acked_at) VALUES ($1, $2, $3, $4, $5, now()) ON CONFLICT (emby_user_id, device_id, revision) DO UPDATE SET device_name = EXCLUDED.device_name, client_version = EXCLUDED.client_version`, userID, deviceID, revision, deviceName, clientVersion) if err != nil { return fmt.Errorf("store: record preference ack: %w", err) } return nil } // UserPreferenceRevision is what the status poll consults: the revision alone, never the // document. /v1/status runs every ten seconds for every open television, so this has to // stay a primary-key lookup returning one number — the TV only needs to know whether to // go and fetch the rest. func (s *Store) UserPreferenceRevision(ctx context.Context, userID string) (int64, error) { var revision int64 err := s.pool.QueryRow(ctx, `SELECT revision FROM user_preferences WHERE emby_user_id = $1`, userID).Scan(&revision) if errors.Is(err, pgx.ErrNoRows) { return 0, nil } if err != nil { return 0, fmt.Errorf("store: read preference revision: %w", err) } return revision, nil }