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,389 @@
|
||||
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.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM user_preference_revisions
|
||||
WHERE emby_user_id = $1 AND revision <= $2 - $3`,
|
||||
userID, next.Revision, preferenceHistoryLimit,
|
||||
); 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 - $3
|
||||
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, next.Revision, preferenceHistoryLimit,
|
||||
); 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
|
||||
}
|
||||
Reference in New Issue
Block a user