Files
memby/server/internal/api/viewers.go
T

463 lines
18 KiB
Go
Raw Normal View History

2026-08-20 15:06:00 +12:00
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"sync"
"time"
2026-08-25 11:39:55 +12:00
"golang.org/x/crypto/bcrypt"
2026-08-20 15:06:00 +12:00
"github.com/ponzischeme89/memby/server/internal/store"
)
// viewerHeader names the person watching, as distinct from the account streaming.
//
// It is a header rather than part of the session because a viewer does not belong to a
// television: somebody starts an episode in the lounge and finishes it in the bedroom, and
// switching between two people on one set must not be a re-authentication. The session
// still answers "which Emby account is this and what may it read"; this answers "whose
// evening is it", and the two are separate questions.
const viewerHeader = "X-Memby-Viewer"
// The header is *stated, never inferred* — the stance Credentials.Gateway takes. An app
// that predates viewers sends nothing and resolves to the account's main viewer, which is
// exactly the behaviour it had before this existed; guessing from anything else would file
// a household's ordinary watching under somebody who does not exist.
// How long an account's viewer list is trusted in memory.
//
// Every authenticated request resolves a viewer, and /v1/status alone is every open
// television every ten seconds — a Postgres round trip each, for a list that changes when
// somebody adds a person to the household. A write clears it, so the window is "how long
// until another instance notices" rather than "how long until my change takes effect", the
// bargain featurePolicyCache already makes.
const viewerListTTL = 30 * time.Second
type viewerListCache struct {
mu sync.Mutex
entries map[string]viewerListEntry
}
type viewerListEntry struct {
viewers []store.Viewer
loadedAt time.Time
}
// viewersFor lists an account's viewers, from memory where it can.
func (s *Server) viewersFor(ctx context.Context, sess store.Session) ([]store.Viewer, error) {
if s.store == nil {
return nil, errors.New("no store")
}
c := &s.viewerLists
now := time.Now()
c.mu.Lock()
entry, ok := c.entries[sess.EmbyUserID]
c.mu.Unlock()
if ok && now.Sub(entry.loadedAt) < viewerListTTL {
return entry.viewers, nil
}
viewers, err := s.store.Viewers(ctx, sess.EmbyUserID, sess.Username)
if err != nil {
// A list that will not load is not evidence that the household has no viewers, so
// a stale reading is preferred to none: losing it would silently move a shadow
// viewer's playback back onto the Emby account, which is the one failure this
// feature must never have.
if ok {
return entry.viewers, nil
}
return nil, err
}
c.mu.Lock()
if c.entries == nil {
c.entries = map[string]viewerListEntry{}
}
c.entries[sess.EmbyUserID] = viewerListEntry{viewers: viewers, loadedAt: now}
c.mu.Unlock()
return viewers, nil
}
// forgetViewers drops an account's cached list so a viewer added, renamed or removed is
// live on the next request rather than at the end of the window.
func (s *Server) forgetViewers(embyUserID string) {
c := &s.viewerLists
c.mu.Lock()
delete(c.entries, embyUserID)
c.mu.Unlock()
}
// activeViewer resolves who is watching.
//
// Every unknown case resolves to the main viewer, and that is deliberate: this is on the
// path of every authenticated request, and the failure it is protecting against — a
// television left unable to do anything because a header could not be checked — is far
// worse than a shadow viewer's episode being attributed to the account for one request.
// The one thing it will not do is accept an id it could not confirm belongs to this
// account, because that would let one household's television read another's viewing.
func (s *Server) activeViewer(ctx context.Context, sess store.Session, r *http.Request) store.Viewer {
fallback := store.Viewer{ID: sess.EmbyUserID, Name: sess.Username, Kind: store.ViewerMain}
// The operator's switch is read here rather than at each of the four mutations,
// because this is the one place a request learns who is watching: with it off there is
// no shadow viewer to resolve to, so every branch downstream — the gated writes, the
// substituted rows, the per-viewer cache keys — falls back to the account by
// construction rather than by fifteen separate checks.
if !s.viewersEnabled(ctx) {
return fallback
}
requested := strings.TrimSpace(r.Header.Get(viewerHeader))
if requested == "" || requested == sess.EmbyUserID {
return fallback
}
viewers, err := s.viewersFor(ctx, sess)
if err != nil {
s.loggerFor(ctx).Warn("viewer list unavailable", "error", err)
return fallback
}
for _, viewer := range viewers {
if viewer.ID == requested {
return viewer
}
}
// An id this account does not own. It is logged rather than refused: the ordinary
// cause is a television still holding a viewer somebody has since deleted, and
// answering 403 to every request would leave that set unable to reach the picker that
// would fix it.
s.loggerFor(ctx).Warn("unknown viewer requested", "viewer", requested)
return fallback
}
// viewersEnabled reports whether the household is running viewers at all.
//
// Off is not a deletion. A viewer's rows stay in Postgres untouched and come back intact
// when it is switched on again; what stops is the gateway routing anybody's watching
// anywhere but Emby, which is exactly the state a household was in before this existed.
func (s *Server) viewersEnabled(ctx context.Context) bool {
return s.featureEnabled(ctx, featureViewers)
}
// mainViewerOnly is what an account's list looks like with the feature switched off.
//
// It is a *shortened list* rather than an error or an empty one, because the television
// decides whether to offer the picker by counting what it was sent: one viewer is an
// account nobody has added anybody to, which is the reading that makes a switched-off
// household look like one that never used the feature rather than like one whose picker
// has broken.
func mainViewerOnly(viewers []store.Viewer) []store.Viewer {
for _, viewer := range viewers {
if viewer.IsMain() {
return []store.Viewer{viewer}
}
}
return nil
}
// --- carrying the viewer through one request --------------------------------
type viewerContextKey struct{}
// withViewer installs the resolved viewer for the rest of the request.
//
// It is resolved once, in [Server.authed], and read from the context everywhere else. The
// alternative — every handler that needs a cache key calling activeViewer for itself —
// is fifteen call sites that must each remember to, and the failure of forgetting one is
// silent: the handler simply keys that view under the account, and one viewer is served
// another viewer's rows. Resolving it at the boundary makes forgetting impossible.
func withViewer(ctx context.Context, viewer store.Viewer) context.Context {
return context.WithValue(ctx, viewerContextKey{}, viewer)
}
// viewerOf reports who this request belongs to.
//
// A request with no viewer in its context is one that never passed through authed — a
// scheduled task, a probe, a test — and the account's own id is the honest answer for it,
// which is also the value every one of these keys held before viewers existed.
func viewerOf(ctx context.Context, sess store.Session) store.Viewer {
if viewer, ok := ctx.Value(viewerContextKey{}).(store.Viewer); ok && viewer.ID != "" {
return viewer
}
return store.Viewer{ID: sess.EmbyUserID, Name: sess.Username, Kind: store.ViewerMain}
}
// viewerKeyOf is the shorthand the cache keys use: the id everything about this person is
// filed under. For the main viewer it is the Emby user id, so an existing household's
// cached views keep the names they already had.
func viewerKeyOf(ctx context.Context, sess store.Session) string {
return viewerOf(ctx, sess).ID
}
// viewerID is the key everything about a *person* is stored under — preferences,
// notifications, followed shows, row statistics, recommendation profiles.
//
// For the main viewer it is the Emby user id, which is why this feature needed no
// migration: an existing household's rows are already filed under exactly this value.
func viewerID(viewer store.Viewer) string { return viewer.ID }
// --- the common state layer -------------------------------------------------
// viewerUserData renders one viewer's state in the shape of Emby's UserData block.
//
// This is the seam the client never sees. A television asks for a row and draws a progress
// bar, a tick and a heart from UserData; whether that block came from Emby or from Postgres
// is not a question anything above this line asks, which is what keeps viewers from
// becoming a special case in every screen.
func viewerUserData(state store.ViewerState) json.RawMessage {
payload := map[string]any{
"IsFavorite": state.Favourite,
"Played": state.Played,
"PlaybackPositionTicks": state.PositionTicks,
"PlayCount": state.PlayCount,
}
if state.RuntimeTicks > 0 && state.PositionTicks > 0 {
payload["PlayedPercentage"] = float64(state.PositionTicks) / float64(state.RuntimeTicks) * 100
}
if state.LastPlayedAt != nil {
payload["LastPlayedDate"] = state.LastPlayedAt.UTC().Format(time.RFC3339)
}
raw, err := json.Marshal(payload)
if err != nil {
return json.RawMessage(`{}`)
}
return raw
}
// viewerAggregateUserData renders a series' or a season's block from a count of episodes.
//
// Emby fills those in from an item's children, and a shadow viewer has no children Emby
// has ever heard of — so the count comes from the shared catalogue and this viewer's own
// state. The favourite is the one field that is genuinely the container's own: somebody
// marks a *show* a favourite, not the sum of its episodes, so it is read from the row
// against the series id rather than derived.
//
// Two things are omitted rather than sent as zero, the rule the leaf block follows. A
// container the catalogue cannot count for — a library not yet imported, a show it has
// never seen — has no unwatched count, because "0 left" and "I cannot say" are different
// answers and only one of them is true. And a container nothing has been watched of has no
// last-played date.
func viewerAggregateUserData(state store.ViewerState, aggregate store.ViewerAggregate) json.RawMessage {
payload := map[string]any{
"IsFavorite": state.Favourite,
// A container is never resumable: what resumes is an episode, and Emby reports
// zero here for the same reason.
"PlaybackPositionTicks": 0,
"PlayCount": aggregate.Played,
// Played only where there is something to have finished. An empty catalogue must
// not tick every show in the house.
"Played": aggregate.Total > 0 && aggregate.Played >= aggregate.Total,
}
if aggregate.Total > 0 {
unplayed := aggregate.Total - aggregate.Played
if unplayed < 0 {
unplayed = 0
}
payload["UnplayedItemCount"] = unplayed
payload["PlayedPercentage"] = float64(aggregate.Played) / float64(aggregate.Total) * 100
}
if aggregate.LastPlayedAt != nil {
payload["LastPlayedDate"] = aggregate.LastPlayedAt.UTC().Format(time.RFC3339)
}
raw, err := json.Marshal(payload)
if err != nil {
return json.RawMessage(`{}`)
}
return raw
}
// --- routes -----------------------------------------------------------------
type viewersResponse struct {
Viewers []store.Viewer `json:"viewers"`
Active string `json:"active"`
}
type viewerRequest struct {
Name string `json:"name"`
ShortName string `json:"shortName"`
Colour string `json:"colour"`
2026-08-25 11:39:55 +12:00
PIN string `json:"pin,omitempty"`
ClearPIN bool `json:"clearPin,omitempty"`
}
type viewerPINRequest struct {
PIN string `json:"pin"`
}
func (s *Server) handleViewerPIN(w http.ResponseWriter, r *http.Request, sess store.Session) {
var req viewerPINRequest
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req) != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
viewerID := strings.TrimSpace(r.PathValue("viewerID"))
hash, err := pinHash(req.PIN)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not save PIN")
return
}
if err := s.store.SetViewerPIN(r.Context(), sess.EmbyUserID, viewerID, hash); err != nil {
if errors.Is(err, store.ErrViewerNotFound) {
writeError(w, http.StatusNotFound, "no such viewer")
return
}
writeError(w, http.StatusInternalServerError, "could not save PIN")
return
}
if err := s.store.SetViewerPINValue(r.Context(), sess.EmbyUserID, viewerID, req.PIN); err != nil {
writeError(w, http.StatusInternalServerError, "could not save PIN")
return
}
s.forgetViewers(sess.EmbyUserID)
writeJSON(w, http.StatusOK, map[string]bool{"saved": true})
}
func pinHash(pin string) ([]byte, error) {
if len(pin) < 4 || len(pin) > 12 {
return nil, errors.New("PIN must be 4 to 12 characters")
}
for _, r := range pin {
if r < '0' || r > '9' {
return nil, errors.New("PIN must contain only numbers")
}
}
return bcrypt.GenerateFromPassword([]byte(pin), bcrypt.DefaultCost)
2026-08-20 15:06:00 +12:00
}
func (s *Server) handleViewers(w http.ResponseWriter, r *http.Request, sess store.Session) {
if r.Method == http.MethodPost {
s.handleCreateViewer(w, r, sess)
return
}
viewers, err := s.viewersFor(r.Context(), sess)
if err != nil {
s.writeUpstreamError(r.Context(), w, err, "could not load viewers")
return
}
if !s.viewersEnabled(r.Context()) {
viewers = mainViewerOnly(viewers)
}
writeJSON(w, http.StatusOK, viewersResponse{
Viewers: viewers,
Active: s.activeViewer(r.Context(), sess, r).ID,
})
}
func (s *Server) handleCreateViewer(w http.ResponseWriter, r *http.Request, sess store.Session) {
// A refusal rather than a silent success: the television is about to draw a card for
// somebody, and an operator who has switched the feature off has said the household
// does not use it. The wording names the reason, because a television has no log and
// no support channel and that sentence is the whole diagnosis.
if !s.viewersEnabled(r.Context()) {
writeError(w, http.StatusForbidden, "viewers are switched off for this server")
return
}
var req viewerRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if strings.TrimSpace(req.Name) == "" {
writeError(w, http.StatusBadRequest, "a name is required")
return
}
if len([]rune(req.Name)) > 40 {
writeError(w, http.StatusBadRequest, "that name is too long")
return
}
viewer, err := s.store.CreateShadowViewer(
r.Context(), sess.EmbyUserID, req.Name, req.ShortName, req.Colour,
)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.forgetViewers(sess.EmbyUserID)
s.loggerFor(r.Context()).Info("viewer added", "viewer", viewer.ID, "name", viewer.Name)
writeJSON(w, http.StatusOK, viewer)
}
func (s *Server) handleViewer(w http.ResponseWriter, r *http.Request, sess store.Session) {
id := r.PathValue("viewerID")
if id == "" {
writeError(w, http.StatusBadRequest, "viewer id is required")
return
}
if !s.viewersEnabled(r.Context()) {
writeError(w, http.StatusForbidden, "viewers are switched off for this server")
return
}
if r.Method == http.MethodDelete {
if err := s.store.DeleteShadowViewer(r.Context(), sess.EmbyUserID, id); err != nil {
if errors.Is(err, store.ErrViewerNotFound) {
writeError(w, http.StatusNotFound, "no such viewer")
return
}
s.writeUpstreamError(r.Context(), w, err, "could not remove that viewer")
return
}
s.forgetViewers(sess.EmbyUserID)
// Everything cached under this viewer's own key is now about nobody.
if err := s.cache.InvalidateUser(r.Context(), id); err != nil {
s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err)
}
s.loggerFor(r.Context()).Info("viewer removed", "viewer", id)
writeJSON(w, http.StatusOK, map[string]bool{"removed": true})
return
}
var req viewerRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
viewer, err := s.store.UpdateShadowViewer(
r.Context(), sess.EmbyUserID, id, req.Name, req.ShortName, req.Colour,
)
if err != nil {
if errors.Is(err, store.ErrViewerNotFound) {
writeError(w, http.StatusNotFound, "no such viewer")
return
}
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.forgetViewers(sess.EmbyUserID)
writeJSON(w, http.StatusOK, viewer)
}
// invalidateAccountViews drops the cached views of every viewer on an account.
//
// Signing a television out, or an operator resetting somebody, is a statement about the
// account rather than about whoever happened to be watching — so invalidating the account's
// own key alone would leave each shadow viewer's rows behind, to be served intact to the
// next person who signs in on that set.
//
// The list is read directly rather than through the cache, because this is called at
// exactly the moments the cached copy is least trustworthy, and it is best-effort: the
// entries it misses expire on their own TTL, and nothing here is worth failing a sign-out
// over.
func (s *Server) invalidateAccountViews(ctx context.Context, sess store.Session) {
if err := s.cache.InvalidateUser(ctx, sess.EmbyUserID); err != nil {
s.loggerFor(ctx).Warn("cache invalidation failed", "error", err)
}
if s.store == nil {
return
}
viewers, err := s.store.Viewers(ctx, sess.EmbyUserID, sess.Username)
if err != nil {
s.loggerFor(ctx).Warn("viewer list unavailable for invalidation", "error", err)
return
}
for _, viewer := range viewers {
if viewer.IsMain() {
continue
}
if err := s.cache.InvalidateUser(ctx, viewer.ID); err != nil {
s.loggerFor(ctx).Warn("viewer cache invalidation failed",
"viewer", viewer.ID, "error", err)
}
}
}