2026-08-20 15:06:00 +12:00
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"`
}
2026-08-25 11:39:55 +12:00
type DeviceRecoveryProfile struct {
DeviceID string
DeviceName string
UserID string
Username string
ServerID string
Viewer Viewer
EmbyToken string
}
var ErrPINLocked = errors . New ( "store: pin temporarily locked" )
var ErrPINInvalid = errors . New ( "store: invalid pin" )
2026-08-20 15:06:00 +12:00
// 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 ()
}
2026-08-25 11:39:55 +12:00
// DeviceRecoveryProfiles is deliberately based on the surviving gateway session, not
// on IP address or device name. A reinstall loses local credentials but not this record.
func ( s * Store ) DeviceRecoveryProfiles ( ctx context . Context , deviceID string ) ([] DeviceRecoveryProfile , error ) {
rows , err := s . pool . Query ( ctx , `
SELECT s.device_id, s.device_name, s.emby_user_id, s.username, s.server_id, s.emby_token
FROM sessions s WHERE s.device_id = $1 ORDER BY s.last_seen_at DESC LIMIT 1` , deviceID )
if err != nil {
return nil , fmt . Errorf ( "store: device recovery profiles: %w" , err )
}
defer rows . Close ()
profiles := [] DeviceRecoveryProfile {}
for rows . Next () {
var p DeviceRecoveryProfile
if err := rows . Scan ( & p . DeviceID , & p . DeviceName , & p . UserID , & p . Username , & p . ServerID , & p . EmbyToken ); err != nil {
return nil , fmt . Errorf ( "store: scan recovery profile: %w" , err )
}
viewers , err := s . Viewers ( ctx , p . UserID , p . Username )
if err != nil {
return nil , err
}
for _ , viewer := range viewers {
copy := p
copy . Viewer = viewer
profiles = append ( profiles , copy )
}
}
return profiles , rows . Err ()
}
func ( s * Store ) SetViewerPIN ( ctx context . Context , embyUserID , viewerID string , hash [] byte ) error {
result , err := s . pool . Exec ( ctx , `UPDATE viewers SET pin_hash = $3, pin_failed_attempts = 0,
pin_locked_until = NULL, updated_at = now() WHERE emby_user_id = $1 AND id = $2` , embyUserID , viewerID , hash )
if err != nil {
return fmt . Errorf ( "store: set viewer pin: %w" , err )
}
if result . RowsAffected () == 0 {
return ErrViewerNotFound
}
return nil
}
func ( s * Store ) SetViewerPINValue ( ctx context . Context , embyUserID , viewerID , pin string ) error {
_ , err := s . pool . Exec ( ctx , `UPDATE viewers SET pin_value = $3 WHERE emby_user_id = $1 AND id = $2` , embyUserID , viewerID , nilIfEmpty ( pin ))
return err
}
func nilIfEmpty ( value string ) any {
if value == "" {
return nil
}
return value
}
func ( s * Store ) ClearViewerPIN ( ctx context . Context , embyUserID , viewerID string ) error {
return s . SetViewerPIN ( ctx , embyUserID , viewerID , nil )
}
// ViewerPIN is intentionally not part of Viewer: only the admin handler may ask for it.
func ( s * Store ) ViewerPIN ( ctx context . Context , embyUserID , viewerID string ) ( string , error ) {
var pin * string
err := s . pool . QueryRow ( ctx , `SELECT pin_value FROM viewers WHERE emby_user_id = $1 AND id = $2` , embyUserID , viewerID ). Scan ( & pin )
if errors . Is ( err , pgx . ErrNoRows ) {
return "" , ErrViewerNotFound
}
if err != nil {
return "" , err
}
if pin == nil {
return "" , nil
}
return * pin , nil
}
// CheckViewerPIN applies the failed-attempt budget. Ten failures are not
// permanent revocation; they are a short server-side pause which survives a reinstall.
func ( s * Store ) CheckViewerPIN ( ctx context . Context , embyUserID , viewerID string , valid func ([] byte ) bool ) ( bool , error ) {
var hash [] byte
var failures int
var lockedUntil * time . Time
err := s . pool . QueryRow ( ctx , `SELECT pin_hash, pin_failed_attempts, pin_locked_until
FROM viewers WHERE emby_user_id = $1 AND id = $2` , embyUserID , viewerID ). Scan ( & hash , & failures , & lockedUntil )
if errors . Is ( err , pgx . ErrNoRows ) {
return false , ErrViewerNotFound
}
if err != nil {
return false , fmt . Errorf ( "store: read viewer pin: %w" , err )
}
if len ( hash ) == 0 {
return true , nil
}
if lockedUntil != nil && time . Now (). Before ( * lockedUntil ) {
return false , ErrPINLocked
}
if valid ( hash ) {
_ , err = s . pool . Exec ( ctx , `UPDATE viewers SET pin_failed_attempts = 0, pin_locked_until = NULL WHERE emby_user_id = $1 AND id = $2` , embyUserID , viewerID )
return err == nil , err
}
failures ++
if failures >= 5 {
lockedUntil = func () * time . Time { t := time . Now (). Add ( time . Duration ( failures - 4 ) * 15 * time . Second ); return & t }()
}
_ , err = s . pool . Exec ( ctx , `UPDATE viewers SET pin_failed_attempts = $3, pin_locked_until = $4 WHERE emby_user_id = $1 AND id = $2` , embyUserID , viewerID , failures , lockedUntil )
if err != nil {
return false , err
}
return false , ErrPINInvalid
}
2026-08-20 15:06:00 +12:00
// 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
}