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:
ponzischeme89
2026-08-06 22:33:56 +12:00
co-authored by Claude Opus 5
parent 2675e6d82b
commit 4a4df7a73c
257 changed files with 24868 additions and 3108 deletions
+389
View File
@@ -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(&current)
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
}