415 lines
16 KiB
Go
415 lines
16 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|