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

725 lines
29 KiB
Go
Raw Normal View History

// 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"
2026-08-02 22:10:19 +12:00
"slices"
"strconv"
"strings"
2026-07-27 21:06:51 +12:00
"sync"
"time"
2026-08-14 09:40:03 +12:00
"github.com/ponzischeme89/memby/server/internal/adminevents"
2026-08-10 20:54:00 +12:00
"github.com/ponzischeme89/memby/server/internal/appupdate"
2026-08-06 22:33:56 +12:00
"github.com/ponzischeme89/memby/server/internal/bazarr"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/config"
2026-08-15 09:23:26 +12:00
"github.com/ponzischeme89/memby/server/internal/credits"
"github.com/ponzischeme89/memby/server/internal/emby"
2026-07-29 15:26:27 +12:00
"github.com/ponzischeme89/memby/server/internal/foryou"
2026-08-14 09:40:03 +12:00
"github.com/ponzischeme89/memby/server/internal/integrations"
2026-07-29 15:26:27 +12:00
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
2026-08-03 08:52:55 +12:00
"github.com/ponzischeme89/memby/server/internal/mdblist"
2026-08-09 08:25:50 +12:00
"github.com/ponzischeme89/memby/server/internal/opensubtitles"
2026-08-02 22:10:19 +12:00
"github.com/ponzischeme89/memby/server/internal/radarr"
"github.com/ponzischeme89/memby/server/internal/recommend"
2026-08-14 09:40:03 +12:00
"github.com/ponzischeme89/memby/server/internal/scheduler"
2026-07-27 21:06:51 +12:00
"github.com/ponzischeme89/memby/server/internal/sonarr"
"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
2026-07-29 15:26:27 +12:00
forYou *foryou.Service
2026-07-27 21:06:51 +12:00
sonarr *sonarr.Client
2026-08-02 22:10:19 +12:00
radarr *radarr.Client
2026-08-06 22:33:56 +12:00
bazarr *bazarr.Client
2026-08-03 08:52:55 +12:00
mdblist *mdblist.Client
2026-08-15 09:23:26 +12:00
// credits discovers where an episode's closing credits begin, for the small number of
// episodes the household is about to watch. Nil when the subsystem is switched off, and
// every call site tolerates that — a missing marker simply means no Skip Credits button,
// which is the same state a library with no chapter markers is already in.
credits *credits.Service
creditsLoad *credits.PlaybackLoad
syncer syncerHandle
log *slog.Logger
2026-07-29 15:26:27 +12:00
events *serverlogging.Buffer
2026-08-14 09:40:03 +12:00
// adminEvents is the administrative feed: the console's notification bell and every
// outgoing integration read from it. Distinct from `events` above, which is the
// structured log ring — a log line is what the gateway did, an admin event is
// something an operator would want to be told.
adminEvents *adminevents.Bus
scheduler *scheduler.Scheduler
integrations *integrations.Dispatcher
sonarrMu sync.Mutex
2026-08-09 08:25:50 +12:00
// sonarrSeriesMu guards the catalogue cache separately from the calendar's, so an add
// to My Shows never waits behind a launcher rebuilding the schedule row.
sonarrSeriesMu sync.Mutex
radarrMu sync.Mutex
bazarrMu sync.Mutex
// openSubtitles is built from the operator's saved credentials rather than from
// configuration, so it is cached against a fingerprint of them and rebuilt when they
// change. It is cached at all because the client holds a login token, and logging in
// per download would spend a different allowance than the one being conserved.
openSubtitlesMu sync.Mutex
openSubtitles *opensubtitles.Client
openSubtitlesKey string
mdblistMu sync.Mutex
2026-08-06 22:33:56 +12:00
// mdblistSettingsCache spares every row and keystroke a settings read.
mdblistSettingsCache mdblistSettingsCache
// ratingsWarm fills and renews the durable rating cache behind the viewer, so a row
// never waits on MDBList and the operator's daily allowance is spent once per title.
ratingsWarm ratingsWarmer
2026-08-02 22:10:19 +12:00
// alertMu serialises the read-modify-write of the shared alert list. Its producers
// are events — a webhook, a finished sync, a health probe — none of them paced by
// this server, so two can land at once.
alertMu sync.Mutex
2026-08-06 22:33:56 +12:00
// playbackTitles lets a progress or stop report, which carries only an item id, be
// logged by name.
playbackTitles playbackTitles
recommendationBuilds recommendationBuilds
maintenance maintenanceState
updatePolicy updatePolicyCache
2026-08-06 22:33:56 +12:00
// embyHealth is the reachability probe's live finding, which /v1/status publishes so
// a TV can show why playback stopped even if it missed the announcement.
embyHealth embyHealth
2026-08-14 09:40:03 +12:00
// The console is a separate container the gateway proxies; see admin_spa.go.
adminUIHandle
}
// 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
2026-07-29 15:26:27 +12:00
ForYou *foryou.Service
2026-07-27 21:06:51 +12:00
Sonarr *sonarr.Client
2026-08-02 22:10:19 +12:00
Radarr *radarr.Client
2026-08-06 22:33:56 +12:00
Bazarr *bazarr.Client
2026-08-03 08:52:55 +12:00
MDBList *mdblist.Client
2026-08-15 09:23:26 +12:00
Credits *credits.Service
CreditsLoad *credits.PlaybackLoad
Syncer syncerHandle
Log *slog.Logger
2026-07-29 15:26:27 +12:00
Events *serverlogging.Buffer
2026-08-14 09:40:03 +12:00
AdminEvents *adminevents.Bus
Scheduler *scheduler.Scheduler
Integrations *integrations.Dispatcher
}
func New(cfg config.Config, deps Deps) *Server {
return &Server{
cfg: cfg,
emby: deps.Emby,
store: deps.Store,
cache: deps.Cache,
recommender: deps.Recommender,
2026-07-29 15:26:27 +12:00
forYou: deps.ForYou,
2026-07-27 21:06:51 +12:00
sonarr: deps.Sonarr,
2026-08-02 22:10:19 +12:00
radarr: deps.Radarr,
2026-08-06 22:33:56 +12:00
bazarr: deps.Bazarr,
2026-08-03 08:52:55 +12:00
mdblist: deps.MDBList,
2026-08-15 09:23:26 +12:00
credits: deps.Credits,
creditsLoad: deps.CreditsLoad,
syncer: deps.Syncer,
log: deps.Log,
2026-07-29 15:26:27 +12:00
events: deps.Events,
2026-08-14 09:40:03 +12:00
adminEvents: deps.AdminEvents,
scheduler: deps.Scheduler,
integrations: deps.Integrations,
}
}
2026-08-14 09:40:03 +12:00
// publishAdmin reports something an operator would want to know about.
//
// Every caller treats it as fire-and-forget, which is why it returns nothing: the feed is
// a convenience over things that are already logged, and a bell that could fail a sign-in
// would be worse than no bell. A server built without a bus — every unit test in this
// package — publishes into a nil receiver, which is a no-op.
func (s *Server) publishAdmin(ctx context.Context, event adminevents.Event) {
s.adminEvents.Publish(ctx, event)
}
2026-08-14 11:47:32 +12:00
func (s *Server) sonarrEnabled(ctx context.Context) bool {
if s.sonarr == nil || s.store == nil {
return false
}
policy, err := s.store.ArrIntegrationPolicy(ctx)
if err != nil {
s.loggerFor(ctx).Warn("arr integration policy read failed", "error", err)
return false
}
return policy.SonarrEnabled
}
func (s *Server) radarrEnabled(ctx context.Context) bool {
if s.radarr == nil || s.store == nil {
return false
}
policy, err := s.store.ArrIntegrationPolicy(ctx)
if err != nil {
s.loggerFor(ctx).Warn("arr integration policy read failed", "error", err)
return false
}
return policy.RadarrEnabled
}
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.requireSupportedClient(s.handleLogin))
v1.Handle("POST /v1/auth/logout", s.authed(s.handleLogout))
v1.Handle("GET /v1/auth/session", s.authed(s.handleSession))
2026-08-02 22:10:19 +12:00
v1.Handle("GET /v1/auth/devices", s.authed(s.handleDevices))
v1.Handle("PUT /v1/auth/devices/{deviceID}", s.authed(s.handleRenameDevice))
v1.Handle("DELETE /v1/auth/devices/{deviceID}", s.authed(s.handleDeleteDevice))
v1.Handle("GET /v1/home", s.authed(s.handleHome))
2026-08-14 13:32:14 +12:00
v1.Handle("GET /v1/heroes/active", s.authed(s.handleActiveHero))
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
v1.Handle("GET /v1/search", s.authed(s.handleSearch))
2026-08-09 08:25:50 +12:00
// A genre is browsed, not searched: the chip is a filter and this is the route that
// treats it as one. Paged, because a household's Drama shelf is not a screenful.
v1.Handle("GET /v1/genres/{genre}/items", s.authed(s.handleGenreItems))
2026-08-09 12:53:25 +12:00
v1.Handle("GET /v1/library/items", s.authed(s.handleLibraryItems))
2026-07-29 15:26:27 +12:00
v1.Handle("GET /v1/search/history", s.authed(s.handleRecentSearches))
v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory))
2026-08-02 22:10:19 +12:00
v1.Handle("GET /v1/requests/lookup", s.authed(s.handleRequestLookup))
2026-08-12 14:13:19 +12:00
v1.Handle("GET /v1/requests", s.authed(s.handleMyRequests))
2026-08-02 22:10:19 +12:00
v1.Handle("POST /v1/requests", s.authed(s.handleRequest))
2026-08-12 14:13:19 +12:00
v1.Handle("DELETE /v1/requests/{mediaType}/{foreignId}", s.authed(s.handleDeleteRequest))
v1.Handle("GET /v1/recommendations", s.authed(s.handleRecommendations))
2026-08-02 22:10:19 +12:00
v1.Handle("PUT /v1/recommendations/{id}/action", s.authed(s.handleRecommendationAction))
v1.Handle("DELETE /v1/recommendations/{id}/action", s.authed(s.handleRecommendationAction))
v1.Handle("PUT /v1/recommendations/preferences", s.authed(s.handleRecommendationPreferences))
v1.Handle("GET /v1/recommendations/preferences", s.authed(s.handleRecommendationPreferences))
2026-07-29 15:26:27 +12:00
v1.Handle("GET /v1/for-you", s.authed(s.handleForYou))
v1.Handle("GET /v1/preroll", s.authed(s.handlePreroll))
2026-08-11 12:08:51 +12:00
v1.Handle("GET /v1/calendar", s.authed(s.handleCalendar))
2026-08-02 22:10:19 +12:00
v1.Handle("GET /v1/my-shows", s.authed(s.handleMyShows))
v1.Handle("POST /v1/my-shows", s.authed(s.handleMyShows))
v1.Handle("DELETE /v1/my-shows/{id}", s.authed(s.handleMyShow))
v1.Handle("GET /v1/notifications", s.authed(s.handleNotifications))
v1.Handle("PUT /v1/notifications", s.authed(s.handleNotifications))
v1.Handle("POST /v1/notifications/{id}/{action}", s.authed(s.handleNotificationAction))
v1.Handle("GET /v1/features", s.authed(s.handleFeatures))
2026-08-06 22:33:56 +12:00
// A viewer's settings follow the person, not the television. Both verbs land on one
// handler because a write answers with the stored document, not the submitted one.
2026-08-09 08:25:50 +12:00
// The palette, fetched only when the revision on the status poll moves. A GET with no
// write beside it: what a viewer may change is themeId, and that is an ordinary
// setting on the route above — this route only answers with what came of it.
v1.Handle("GET /v1/theme", s.authed(s.handleTheme))
2026-08-06 22:33:56 +12:00
v1.Handle("GET /v1/preferences", s.authed(s.handlePreferences))
v1.Handle("PUT /v1/preferences", s.authed(s.handlePreferences))
v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem))
2026-08-10 07:11:14 +12:00
v1.Handle("GET /v1/people/{id}", s.authed(s.handlePerson))
v1.Handle("GET /v1/people/{id}/filmography", s.authed(s.handlePersonFilmography))
2026-08-03 08:52:55 +12:00
v1.Handle("GET /v1/items/{id}/ratings", s.authed(s.handleMovieRatings))
2026-08-02 22:10:19 +12:00
v1.Handle("GET /v1/items/{id}/season-finale", s.authed(s.handleSeasonFinale))
2026-07-29 15:26:27 +12:00
v1.Handle("GET /v1/items/{id}/episodes", s.authed(s.handleSeriesEpisodes))
2026-08-02 22:10:19 +12:00
v1.Handle("GET /v1/items/{id}/related", s.authed(s.handleRelated))
2026-08-16 09:02:17 +12:00
v1.Handle("GET /v1/items/{id}/extras", s.authed(s.handleExtras))
v1.Handle("POST /v1/items/{id}/favorite", s.authed(s.handleFavorite))
v1.Handle("POST /v1/items/{id}/played", s.authed(s.handlePlayed))
2026-08-09 08:25:50 +12:00
v1.Handle("POST /v1/items/{id}/hide-from-resume", s.authed(s.handleHideFromResume))
v1.Handle("GET /v1/items/{id}/playback", s.authed(s.handlePlayback))
2026-07-29 15:26:27 +12:00
v1.Handle("GET /v1/items/{id}/next", s.authed(s.handleNextEpisode))
2026-08-06 22:33:56 +12:00
v1.Handle("GET /v1/items/{id}/subtitles/search", s.authed(s.handleSubtitleSearch))
v1.Handle("POST /v1/items/{id}/subtitles/download", s.authed(s.handleSubtitleDownload))
2026-08-09 08:25:50 +12:00
// Repairing the timing of a subtitle the title already has, which is a different
// question from fetching another copy of it — see subtitle_fix.go.
v1.Handle("POST /v1/items/{id}/subtitles/fix", s.authed(s.handleSubtitleFix))
// The one route that serves a subtitle rather than pointing at Emby's. It exists for
// the provider that hands back bytes instead of writing beside the media file; the
// token arrives in the query string, the way artwork's does, because a media player
// fetching a sidecar sends none of Memby's headers.
v1.Handle("GET /v1/subtitles/{file}", s.authed(s.handleStoredSubtitle))
v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer))
2026-08-12 11:05:07 +12:00
v1.Handle("GET /v1/items/{id}/trailers", s.authed(s.handleTrailers))
v1.Handle("POST /v1/items/{id}/trailers/resolve", s.authed(s.handleResolveTrailer))
2026-08-12 13:08:53 +12:00
v1.Handle("POST /v1/items/{id}/trailers/report", s.authed(s.handleTrailerReport))
2026-08-14 13:32:14 +12:00
v1.Handle("POST /v1/items/{id}/report", s.authed(s.handleMediaReport))
2026-08-07 10:44:17 +12:00
v1.Handle("GET /v1/items/{id}/intro", s.authed(s.handleIntro))
v1.Handle("GET /v1/items/{id}/trickplay", s.authed(s.handleTrickplay))
v1.Handle("GET /v1/items/{id}/trickplay/{frame}", s.authed(s.handleTrickplayFrame))
v1.Handle("POST /v1/playback/{phase}", s.authed(s.handlePlaybackReport))
v1.Handle("POST /v1/analytics/rows", s.authed(s.handleRowAnalytics))
v1.Handle("POST /v1/analytics/events", s.authed(s.handleJourneyAnalytics))
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)
2026-08-12 09:57:56 +12:00
// Remote Config is app-scoped, contains presentation data only, and warms the next
// process. Keep it outside authentication and maintenance so offline/start-up fallback
// never depends on a session being available.
mux.HandleFunc("GET /v1/config", s.handleRemoteConfig)
2026-08-15 09:23:26 +12:00
// Update compatibility is app-scoped; a signed-in viewer may only mute the optional
// prompt. Keep this outside authentication and maintenance so a fresh install, a
// signed-out TV, and especially a retired build can still learn what it must do.
mux.Handle("GET /v1/update", s.identifyOptionalSession(http.HandlerFunc(s.handleUpdate)))
2026-07-27 21:06:51 +12:00
// Exact route outside the maintenance gate: signed-in clients poll this lightweight
// status even while every normal /v1 operation is deliberately unavailable.
mux.Handle("GET /v1/status", s.authed(s.handleServiceStatus))
mux.Handle("/v1/", s.maintenanceGate(v1))
2026-08-02 22:10:19 +12:00
// Radarr pushes here when an import finishes. Outside the gate on purpose: an event
// arriving during maintenance would otherwise be lost rather than delayed.
mux.HandleFunc("POST /hooks/radarr", s.handleRadarrWebhook)
2026-08-14 09:40:03 +12:00
// State the canonical console URL explicitly. The console and its assets live below
// /admin/, while a bare /admin is routinely typed and some reverse proxies do not
// preserve ServeMux's implicit trailing-slash redirect for a mounted subtree.
mux.HandleFunc("GET /admin", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/", http.StatusFound)
})
mux.Handle("/admin/", s.adminRoutes())
2026-08-02 22:10:19 +12:00
mux.HandleFunc("GET /{$}", s.handleInstallPage)
mux.HandleFunc("GET /install", s.handleInstallPage)
mux.HandleFunc("GET /install/{$}", s.handleInstallPage)
mux.HandleFunc("POST /install/login", s.handleInstallLogin)
mux.HandleFunc("POST /install/logout", s.handleInstallLogout)
mux.HandleFunc("GET /robots.txt", handleRobots)
mux.HandleFunc("GET /updates/latest.apk", s.handleLatestReleaseDownload)
2026-07-27 21:06:51 +12:00
mux.HandleFunc("GET /updates/{filename}", s.handleReleaseDownload)
return s.withLogging(mux)
}
// --- middleware -------------------------------------------------------------
type authedFunc func(http.ResponseWriter, *http.Request, store.Session)
// identifyOptionalSession gives public routes the viewer and television attached to a
// valid bearer token without turning authentication into a condition of access. The
// update check must remain reachable before sign-in, but an offer made to a signed-in
// client should still say whose session is affected in the logs.
func (s *Server) identifyOptionalSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if token := bearerToken(r); token != "" {
if sess, err := s.sessionFor(r.Context(), token); err == nil {
identify(r.Context(), sess)
}
}
next.ServeHTTP(w, r)
})
}
// 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
}
2026-07-29 15:26:27 +12:00
sess = s.captureClientIdentity(r, sess)
2026-08-06 22:33:56 +12:00
identify(r.Context(), sess)
2026-08-10 20:54:00 +12:00
policy := s.updatePolicy.get()
decision := appupdate.Decide(effectiveUpdatePolicy(policy), clientVersion(r))
retireBelow := destructiveUpdateFloor(policy)
if mustRetireForUpdate(decision, clientVersion(r), retireBelow) {
// Mirror an ordinary sign-out closely enough that this token cannot be restored
// from either database or Redis. The 401 is intentional: every supported client
// treats it as authoritative and removes the rejected local profile.
if err := s.store.DeleteSession(r.Context(), sess.TokenHash); err != nil {
s.loggerFor(r.Context()).Error("required-update session delete failed", "error", err)
}
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(sess.TokenHash)))
_ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID)
s.loggerFor(r.Context()).Info("signed out for required update",
"device_id", sess.DeviceID,
"from", clientLogValue(clientVersion(r)),
2026-08-10 20:54:00 +12:00
"minimum", retireBelow,
"to", decision.Version,
)
w.Header().Set("X-Memby-Update-Required", decision.Version)
writeError(w, http.StatusUnauthorized, "Memby must be updated before signing in again")
return
}
h(w, r, sess)
})
}
2026-07-29 15:26:27 +12:00
// captureClientIdentity makes the session the durable source of attribution. Normal API
// calls refresh it from headers; authenticated artwork requests, which can only carry a
// query token, inherit the last identity reported by that same TV.
func (s *Server) captureClientIdentity(r *http.Request, sess store.Session) store.Session {
2026-08-06 22:33:56 +12:00
previousVersion := sess.ClientVersion
2026-07-29 15:26:27 +12:00
changed := mergeClientIdentity(r, &sess)
if changed {
if err := s.store.UpdateSessionClientIdentity(
r.Context(), sess.TokenHash, sess.ClientVersion, sess.ClientProtocol,
2026-08-02 22:10:19 +12:00
sess.ClientCapabilities,
2026-07-29 15:26:27 +12:00
); err != nil {
s.log.Warn("client identity update failed", "error", err)
} else {
s.cacheSession(r.Context(), sess)
}
2026-08-06 22:33:56 +12:00
// A television that updates itself never signs in again, so this is the only
// place the new build would otherwise be seen. Guarded on the version actually
// having moved: every request reaches here, and all but the first after an
// update would be a write of what is already stored.
if sess.ClientVersion != previousVersion {
if err := s.store.RecordDeviceVersion(
r.Context(), sess.DeviceID, sess.ClientVersion,
); err != nil {
s.log.Warn("device version record failed",
"device_id", sess.DeviceID, "error", err)
}
}
2026-07-29 15:26:27 +12:00
}
return sess
}
func mergeClientIdentity(r *http.Request, sess *store.Session) bool {
version := clientVersion(r)
protocol := clientProtocol(r)
2026-08-02 22:10:19 +12:00
capabilities := clientCapabilities(r)
2026-07-29 15:26:27 +12:00
changed := false
if version != "" && version != sess.ClientVersion {
sess.ClientVersion = version
changed = true
}
if protocol != "" && protocol != sess.ClientProtocol {
sess.ClientProtocol = protocol
changed = true
}
2026-08-02 22:10:19 +12:00
if len(capabilities) > 0 && !slices.Equal(capabilities, sess.ClientCapabilities) {
sess.ClientCapabilities = capabilities
changed = true
}
2026-07-29 15:26:27 +12:00
if version == "" && sess.ClientVersion != "" {
r.Header.Set("X-Memby-Version", sess.ClientVersion)
}
if protocol == "" && sess.ClientProtocol != "" {
r.Header.Set("X-Memby-Protocol", sess.ClientProtocol)
}
return changed
}
func (s *Server) withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
2026-08-06 22:33:56 +12:00
r, identity := withRequestIdentity(r)
2026-08-14 11:47:32 +12:00
w.Header().Set("X-Memby-Correlation", identity.correlation)
s.loggerFor(r.Context()).Log(r.Context(), serverlogging.LevelTrace, "request started",
"method", r.Method, "path", r.URL.Path, "query_keys", queryKeys(r))
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
2026-07-29 15:26:27 +12:00
// Polling the live-log endpoint must not create another live-log record and
// become a self-sustaining stream.
if r.URL.Path == "/admin/api/events" {
return
}
2026-08-06 22:33:56 +12:00
// The request line is a transcript of one exchange, not the record of what the
// viewer did — that is what the events the handlers log are for. It stays terse
// and identical in shape for every route so it can be scanned in a column.
//
// Path only: query strings can carry image tokens.
2026-07-27 21:06:51 +12:00
level := requestLogLevel(r.URL.Path, rec.status)
2026-08-14 11:47:32 +12:00
fields := []any{"component", identity.component, "correlation", identity.correlation}
2026-08-06 22:33:56 +12:00
fields = append(fields, identity.viewerAttrs()...)
// The app build keeps its placeholder where the viewer does not, because "which
// build made this call" always has an answer worth seeing, including "it did
// not say".
fields = append(fields,
"client", clientLogValue(identity.client),
"protocol", clientLogValue(identity.protocol),
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
2026-07-27 21:06:51 +12:00
"duration", time.Since(start).Round(time.Millisecond),
)
2026-08-06 22:33:56 +12:00
// Whether an answer came from cache is the first thing anyone asks of a slow
// screen, and only the handler knows.
if cached := rec.Header().Get("X-Memby-Cache"); cached != "" {
fields = append(fields, "cache", cached)
}
s.log.Log(r.Context(), level, "request", fields...)
})
}
2026-08-14 11:47:32 +12:00
// queryKeys is diagnostic context without values: query values can contain title searches,
// tokens or other private values, while their keys are enough to explain the route shape.
func queryKeys(r *http.Request) string {
keys := make([]string, 0, len(r.URL.Query()))
for key := range r.URL.Query() {
keys = append(keys, key)
}
slices.Sort(keys)
return strings.Join(keys, ",")
}
2026-07-29 15:26:27 +12:00
func clientLogValue(value string) string {
if value == "" {
return "unknown"
}
return value
}
2026-07-27 21:06:51 +12:00
// Successful high-frequency probes and artwork fetches stay available at DEBUG without
// overwhelming the normal Docker log. Failures are always promoted so they remain
// visible regardless of path.
func requestLogLevel(path string, status int) slog.Level {
switch {
2026-08-12 15:39:08 +12:00
// A request nobody is waiting for any more is not a failure of anything. It is only
// ever answered this way deliberately, so it never hides a fault.
case status == statusClientClosedRequest:
return slog.LevelDebug
2026-07-27 21:06:51 +12:00
case status >= http.StatusInternalServerError:
return slog.LevelError
case status >= http.StatusBadRequest:
return slog.LevelWarn
case path == "/healthz", path == "/readyz", path == "/v1/status",
2026-08-06 22:33:56 +12:00
path == "/admin/api/status",
2026-07-27 21:06:51 +12:00
strings.HasPrefix(path, "/v1/images/"):
return slog.LevelDebug
default:
return slog.LevelInfo
}
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
2026-08-14 09:40:03 +12:00
// Flush preserves streaming support through the request logger. In particular, the admin
// notification feed is Server-Sent Events and correctly refuses to start unless its writer
// implements http.Flusher. Embedding ResponseWriter alone does not promote optional
// interfaces, so the old recorder turned every stream request into a 500.
func (r *statusRecorder) Flush() {
if flusher, ok := r.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
// --- 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 {
2026-07-29 15:26:27 +12:00
EmbyUserID string `json:"u"`
EmbyToken string `json:"t"`
Username string `json:"n"`
ServerID string `json:"s"`
DeviceID string `json:"d"`
DeviceName string `json:"dn,omitempty"`
ClientVersion string `json:"v,omitempty"`
ClientProtocol string `json:"p,omitempty"`
}
// 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{
2026-07-29 15:26:27 +12:00
TokenHash: hash,
EmbyUserID: cs.EmbyUserID,
EmbyToken: cs.EmbyToken,
Username: cs.Username,
ServerID: cs.ServerID,
DeviceID: cs.DeviceID,
DeviceName: cs.DeviceName,
ClientVersion: cs.ClientVersion,
ClientProtocol: cs.ClientProtocol,
}, 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
}
2026-07-29 15:26:27 +12:00
s.cacheSession(ctx, sess)
// 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
}
2026-07-29 15:26:27 +12:00
func (s *Server) cacheSession(ctx context.Context, sess store.Session) {
if raw, err := json.Marshal(cachedSession{
EmbyUserID: sess.EmbyUserID,
EmbyToken: sess.EmbyToken,
Username: sess.Username,
ServerID: sess.ServerID,
DeviceID: sess.DeviceID,
DeviceName: sess.DeviceName,
ClientVersion: sess.ClientVersion,
ClientProtocol: sess.ClientProtocol,
}); err == nil {
_ = s.cache.Set(
ctx,
cache.SessionKey(hex.EncodeToString(sess.TokenHash)),
raw,
s.cfg.SessionTTL,
)
}
}
func credentials(sess store.Session) emby.Credentials {
2026-07-27 21:06:51 +12:00
return emby.Credentials{
UserID: sess.EmbyUserID, Token: sess.EmbyToken,
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
2026-08-06 22:33:56 +12:00
ClientVersion: sess.ClientVersion,
2026-07-27 21:06:51 +12:00
}
}
// --- 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)
}
2026-08-12 15:39:08 +12:00
// statusClientClosedRequest is nginx's 499. Go has no constant for it because it is not
// in the RFC — it exists to say "this was not answered, and that is nobody's fault",
// which is a distinction a log is read for and a 5xx destroys.
const statusClientClosedRequest = 499
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.
2026-08-06 22:33:56 +12:00
func (s *Server) writeUpstreamError(
ctx context.Context, w http.ResponseWriter, err error, message string,
) {
2026-08-12 15:39:08 +12:00
// The television having navigated on is not a fault, and it is the ordinary case here:
// artwork loaders abandon requests as cards leave the screen, and a detail page warmed
// on focus is cancelled the moment the D-pad moves. Reported as 502 it filled the
// operator's log with errors describing a launcher working exactly as designed, and
// buried the ones that meant something. Nobody is left to read the answer, so it goes
// out as 499 — nginx's "client closed request" — and is recorded at DEBUG.
if clientGaveUp(ctx, err) {
s.loggerFor(ctx).Debug("abandoned before the answer", "detail", message, "error", err)
writeError(w, statusClientClosedRequest, "the request was abandoned")
return
}
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
2026-08-11 15:18:26 +12:00
case apiErr.StatusCode == http.StatusTooManyRequests:
retryAfter := strings.TrimSpace(apiErr.RetryAfter)
if retryAfter == "" {
retryAfter = "60"
}
w.Header().Set("Retry-After", retryAfter)
writeError(w, http.StatusTooManyRequests, "the emby server is receiving too many requests")
return
}
}
2026-08-06 22:33:56 +12:00
s.loggerFor(ctx).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
}