0.2.82
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user