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 }