package api import ( "context" "encoding/json" "errors" "net/http" "strings" "sync" "time" "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"` } 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) } } }