Files
memby/server/internal/store/viewers.go
T
2026-08-20 15:06:00 +12:00

239 lines
8.6 KiB
Go

package store
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// ViewerKind separates the one viewer whose state is Emby's from the ones whose state is
// Memby's. It is stated on the row rather than derived from whether an id looks like an
// Emby GUID: the id shape is a safety property, not a source of truth, and a household
// that arrived at an odd id must not silently change which viewer publishes.
const (
ViewerMain = "main"
ViewerShadow = "shadow"
)
// ErrViewerNotFound is returned when an id names no viewer of the account that asked.
var ErrViewerNotFound = errors.New("store: viewer not found")
// MaxShadowViewers bounds an account's list. A picker is a row of cards on a television
// and the D-pad has to reach the end of it; this is a limit on the UI, not on the schema.
const MaxShadowViewers = 7
type Viewer struct {
ID string `json:"id"`
Name string `json:"name"`
ShortName string `json:"shortName,omitempty"`
Colour string `json:"colour,omitempty"`
Kind string `json:"kind"`
HasPIN bool `json:"hasPin"`
CreatedAt time.Time `json:"createdAt"`
}
// IsMain reports whether this viewer's state is published to Emby.
func (v Viewer) IsMain() bool { return v.Kind == ViewerMain }
// NewShadowViewerID mints an id that cannot be mistaken for an Emby user id.
//
// Emby's are 32 hex characters. This is a "v" followed by 32 more, so the two are
// distinguishable by inspection anywhere one is read out of a log line or a cache key —
// which matters because a viewer id is substituted for an emby_user_id in twenty tables
// that cannot tell the difference themselves.
func NewShadowViewerID() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("store: viewer id: %w", err)
}
return "v" + hex.EncodeToString(buf), nil
}
// IsShadowViewerID reports whether an id belongs to the shadow namespace. Callers holding
// no viewer record use it to answer "is this Emby's user or Memby's" cheaply.
func IsShadowViewerID(id string) bool {
return strings.HasPrefix(id, "v") && len(id) == 33
}
// Viewers lists an account's viewers, main first and the rest in the order they were
// added. The main viewer is created on demand: an account that predates this feature has
// no row, and its first request must still resolve to something rather than to an error.
func (s *Store) Viewers(ctx context.Context, embyUserID, username string) ([]Viewer, error) {
if err := s.ensureMainViewer(ctx, embyUserID, username); err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `
SELECT id, name, short_name, colour, kind, pin_hash IS NOT NULL, created_at
FROM viewers WHERE emby_user_id = $1
ORDER BY kind = 'main' DESC, created_at, id`, embyUserID)
if err != nil {
return nil, fmt.Errorf("store: list viewers: %w", err)
}
defer rows.Close()
viewers := []Viewer{}
for rows.Next() {
var v Viewer
if err := rows.Scan(
&v.ID, &v.Name, &v.ShortName, &v.Colour, &v.Kind, &v.HasPIN, &v.CreatedAt,
); err != nil {
return nil, fmt.Errorf("store: scan viewer: %w", err)
}
viewers = append(viewers, v)
}
return viewers, rows.Err()
}
// ensureMainViewer records the account's own viewer if it has none.
//
// The insert is ON CONFLICT DO NOTHING on the primary key, so two televisions signing in
// at once cannot both create it, and the name is only ever set on the way in: the viewer
// may have been renamed since, and an Emby username arriving on every request must not
// overwrite that.
func (s *Store) ensureMainViewer(ctx context.Context, embyUserID, username string) error {
if strings.TrimSpace(embyUserID) == "" {
return fmt.Errorf("store: main viewer: no account")
}
name := strings.TrimSpace(username)
if name == "" {
name = "Me"
}
_, err := s.pool.Exec(ctx, `
INSERT INTO viewers (id, emby_user_id, name, kind)
VALUES ($1, $1, $2, 'main')
ON CONFLICT (id) DO NOTHING`, embyUserID, name)
if err != nil {
return fmt.Errorf("store: ensure main viewer: %w", err)
}
return nil
}
// ViewerFor resolves one viewer *of this account*.
//
// The account is part of the query rather than checked afterwards: the id arrives in a
// request header, so this is the boundary at which one household's television is stopped
// from naming another household's viewer.
func (s *Store) ViewerFor(ctx context.Context, embyUserID, viewerID string) (Viewer, error) {
var v Viewer
err := s.pool.QueryRow(ctx, `
SELECT id, name, short_name, colour, kind, pin_hash IS NOT NULL, created_at
FROM viewers WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID,
).Scan(&v.ID, &v.Name, &v.ShortName, &v.Colour, &v.Kind, &v.HasPIN, &v.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Viewer{}, ErrViewerNotFound
}
if err != nil {
return Viewer{}, fmt.Errorf("store: viewer: %w", err)
}
return v, nil
}
// CreateShadowViewer adds a person to an account.
//
// The count is taken inside the transaction, because the limit is the only thing standing
// between a held D-pad on the add button and an unbounded picker.
func (s *Store) CreateShadowViewer(
ctx context.Context, embyUserID, name, shortName, colour string,
) (Viewer, error) {
name = strings.TrimSpace(name)
if name == "" {
return Viewer{}, fmt.Errorf("store: viewer name is required")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return Viewer{}, fmt.Errorf("store: begin create viewer: %w", err)
}
defer tx.Rollback(ctx)
var shadows int
if err := tx.QueryRow(ctx, `
SELECT count(*) FROM viewers WHERE emby_user_id = $1 AND kind = 'shadow'`,
embyUserID,
).Scan(&shadows); err != nil {
return Viewer{}, fmt.Errorf("store: count viewers: %w", err)
}
if shadows >= MaxShadowViewers {
return Viewer{}, fmt.Errorf("store: %d viewers is the limit", MaxShadowViewers)
}
id, err := NewShadowViewerID()
if err != nil {
return Viewer{}, err
}
var v Viewer
if err := tx.QueryRow(ctx, `
INSERT INTO viewers (id, emby_user_id, name, short_name, colour, kind)
VALUES ($1, $2, $3, $4, $5, 'shadow')
RETURNING id, name, short_name, colour, kind, pin_hash IS NOT NULL, created_at`,
id, embyUserID, name, strings.TrimSpace(shortName), strings.TrimSpace(colour),
).Scan(&v.ID, &v.Name, &v.ShortName, &v.Colour, &v.Kind, &v.HasPIN, &v.CreatedAt); err != nil {
return Viewer{}, fmt.Errorf("store: create viewer: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return Viewer{}, fmt.Errorf("store: commit create viewer: %w", err)
}
return v, nil
}
// UpdateShadowViewer renames or re-colours a viewer. The main viewer is deliberately not
// updatable here: its name is the Emby account's and belongs to Emby.
func (s *Store) UpdateShadowViewer(
ctx context.Context, embyUserID, viewerID, name, shortName, colour string,
) (Viewer, error) {
name = strings.TrimSpace(name)
if name == "" {
return Viewer{}, fmt.Errorf("store: viewer name is required")
}
var v Viewer
err := s.pool.QueryRow(ctx, `
UPDATE viewers SET name = $3, short_name = $4, colour = $5, updated_at = now()
WHERE emby_user_id = $1 AND id = $2 AND kind = 'shadow'
RETURNING id, name, short_name, colour, kind, pin_hash IS NOT NULL, created_at`,
embyUserID, viewerID, name, strings.TrimSpace(shortName), strings.TrimSpace(colour),
).Scan(&v.ID, &v.Name, &v.ShortName, &v.Colour, &v.Kind, &v.HasPIN, &v.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Viewer{}, ErrViewerNotFound
}
if err != nil {
return Viewer{}, fmt.Errorf("store: update viewer: %w", err)
}
return v, nil
}
// DeleteShadowViewer removes a viewer and everything Memby held on their behalf.
//
// A main viewer can never be deleted through this route: it is the account's own, and an
// account with no main viewer would have nothing to fall back to. The playback state goes
// with the row rather than being left to a housekeeping task, because the whole of what it
// describes is a person who no longer exists.
func (s *Store) DeleteShadowViewer(ctx context.Context, embyUserID, viewerID string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("store: begin delete viewer: %w", err)
}
defer tx.Rollback(ctx)
tag, err := tx.Exec(ctx, `
DELETE FROM viewers WHERE emby_user_id = $1 AND id = $2 AND kind = 'shadow'`,
embyUserID, viewerID)
if err != nil {
return fmt.Errorf("store: delete viewer: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrViewerNotFound
}
if _, err := tx.Exec(ctx,
`DELETE FROM viewer_playback_state WHERE viewer_id = $1`, viewerID); err != nil {
return fmt.Errorf("store: delete viewer state: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("store: commit delete viewer: %w", err)
}
return nil
}