// Package api exposes the gateway's HTTP surface. // // The API is shaped for one TV screen at a time rather than mirroring Emby: /v1/home // returns everything the launcher renders in a single round trip, which is the whole // point of putting a gateway in front of Emby. package api import ( "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" "encoding/json" "errors" "log/slog" "net/http" "strconv" "strings" "time" "github.com/ponzischeme89/memby/server/internal/cache" "github.com/ponzischeme89/memby/server/internal/config" "github.com/ponzischeme89/memby/server/internal/emby" "github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/store" ) type Server struct { cfg config.Config emby *emby.Client store *store.Store cache *cache.Cache recommender *recommend.Engine syncer syncerHandle log *slog.Logger recommendationBuilds recommendationBuilds maintenance maintenanceState updatePolicy updatePolicyCache } // Deps are the collaborators the API needs. A struct rather than positional arguments: // this list has grown three times already. type Deps struct { Emby *emby.Client Store *store.Store Cache *cache.Cache Recommender *recommend.Engine Syncer syncerHandle Log *slog.Logger } func New(cfg config.Config, deps Deps) *Server { return &Server{ cfg: cfg, emby: deps.Emby, store: deps.Store, cache: deps.Cache, recommender: deps.Recommender, syncer: deps.Syncer, log: deps.Log, } } func (s *Server) Routes() http.Handler { // The client API lives on its own mux so maintenance mode can gate all of it at // once, without the gate ever touching health checks or the admin page. v1 := http.NewServeMux() v1.HandleFunc("POST /v1/auth/login", s.handleLogin) v1.Handle("POST /v1/auth/logout", s.authed(s.handleLogout)) v1.Handle("GET /v1/auth/session", s.authed(s.handleSession)) v1.Handle("GET /v1/home", s.authed(s.handleHome)) v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver)) v1.Handle("GET /v1/search", s.authed(s.handleSearch)) v1.Handle("GET /v1/recommendations", s.authed(s.handleRecommendations)) v1.Handle("GET /v1/update", s.authed(s.handleUpdate)) v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem)) v1.Handle("POST /v1/items/{id}/favorite", s.authed(s.handleFavorite)) v1.Handle("POST /v1/items/{id}/played", s.authed(s.handlePlayed)) v1.Handle("GET /v1/items/{id}/playback", s.authed(s.handlePlayback)) v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer)) v1.Handle("POST /v1/playback/{phase}", s.authed(s.handlePlaybackReport)) v1.Handle("POST /v1/analytics/rows", s.authed(s.handleRowAnalytics)) v1.Handle("GET /v1/images/{itemId}/{imageType}", s.authed(s.handleImage)) mux := http.NewServeMux() mux.HandleFunc("GET /healthz", s.handleHealth) mux.HandleFunc("GET /readyz", s.handleReady) mux.Handle("/v1/", s.maintenanceGate(v1)) mux.Handle("/admin/", s.adminRoutes()) return s.withLogging(mux) } // --- middleware ------------------------------------------------------------- type authedFunc func(http.ResponseWriter, *http.Request, store.Session) // authed resolves the bearer token to a session before running h. // // Images are also accepted with a `t=` query parameter: Coil builds plain URLs from the // repository's helpers and cannot attach headers to them. func (s *Server) authed(h authedFunc) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := bearerToken(r) if token == "" { writeError(w, http.StatusUnauthorized, "missing token") return } sess, err := s.sessionFor(r.Context(), token) if err != nil { if errors.Is(err, store.ErrNotFound) { writeError(w, http.StatusUnauthorized, "invalid token") return } s.log.Error("session lookup failed", "error", err) writeError(w, http.StatusInternalServerError, "session lookup failed") return } h(w, r, sess) }) } func (s *Server) withLogging(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(rec, r) // Path only: query strings can carry image tokens. s.log.Info("request", "method", r.Method, "path", r.URL.Path, "status", rec.status, "ms", time.Since(start).Milliseconds(), ) }) } type statusRecorder struct { http.ResponseWriter status int } func (r *statusRecorder) WriteHeader(code int) { r.status = code r.ResponseWriter.WriteHeader(code) } // --- sessions --------------------------------------------------------------- func bearerToken(r *http.Request) string { if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { return strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")) } if h := r.Header.Get("X-Memby-Token"); h != "" { return strings.TrimSpace(h) } return strings.TrimSpace(r.URL.Query().Get("t")) } func hashToken(token string) []byte { sum := sha256.Sum256([]byte(token)) return sum[:] } func newToken() (string, error) { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(buf), nil } type cachedSession struct { EmbyUserID string `json:"u"` EmbyToken string `json:"t"` Username string `json:"n"` ServerID string `json:"s"` DeviceID string `json:"d"` } // sessionFor resolves a token, using Redis to keep the hot path off Postgres. func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, error) { hash := hashToken(token) key := cache.SessionKey(hex.EncodeToString(hash)) if raw, err := s.cache.Get(ctx, key); err == nil { var cs cachedSession if json.Unmarshal(raw, &cs) == nil { return store.Session{ TokenHash: hash, EmbyUserID: cs.EmbyUserID, EmbyToken: cs.EmbyToken, Username: cs.Username, ServerID: cs.ServerID, DeviceID: cs.DeviceID, }, nil } } sess, err := s.store.SessionByTokenHash(ctx, hash) if err != nil { return store.Session{}, err } // Constant-time confirmation that the stored hash matches the presented token. if subtle.ConstantTimeCompare(sess.TokenHash, hash) != 1 { return store.Session{}, store.ErrNotFound } if raw, err := json.Marshal(cachedSession{ EmbyUserID: sess.EmbyUserID, EmbyToken: sess.EmbyToken, Username: sess.Username, ServerID: sess.ServerID, DeviceID: sess.DeviceID, }); err == nil { _ = s.cache.Set(ctx, key, raw, s.cfg.SessionTTL) } // Best-effort activity stamp; a failure here must not fail the request. if err := s.store.Touch(ctx, hash); err != nil { s.log.Warn("touch session failed", "error", err) } return sess, nil } func credentials(sess store.Session) emby.Credentials { return emby.Credentials{UserID: sess.EmbyUserID, Token: sess.EmbyToken, DeviceID: sess.DeviceID} } // --- responses -------------------------------------------------------------- func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(body); err != nil { // Headers are already out; nothing useful left to do but stop. return } } func writeRaw(w http.ResponseWriter, status int, body []byte) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _, _ = w.Write(body) } func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]string{"error": message}) } // writeUpstreamError mirrors Emby's status so the TV can tell "signed out" (401) from // "server is unwell" (5xx) without parsing strings. func (s *Server) writeUpstreamError(w http.ResponseWriter, err error, message string) { var apiErr *emby.APIError if errors.As(err, &apiErr) { switch { case apiErr.StatusCode == http.StatusUnauthorized, apiErr.StatusCode == http.StatusForbidden: writeError(w, http.StatusUnauthorized, "emby rejected the session") return case apiErr.StatusCode == http.StatusNotFound: writeError(w, http.StatusNotFound, "not found on the emby server") return } } s.log.Error(message, "error", err) writeError(w, http.StatusBadGateway, message) } func queryInt(r *http.Request, key string, fallback, max int) int { raw := r.URL.Query().Get(key) if raw == "" { return fallback } v, err := strconv.Atoi(raw) if err != nil || v <= 0 { return fallback } if v > max { return max } return v }