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

549 lines
21 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-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"
"github.com/ponzischeme89/memby/server/internal/emby"
2026-07-29 15:26:27 +12:00
"github.com/ponzischeme89/memby/server/internal/foryou"
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-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
syncer syncerHandle
log *slog.Logger
2026-07-29 15:26:27 +12:00
events *serverlogging.Buffer
2026-07-27 21:06:51 +12:00
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
}
// 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
Syncer syncerHandle
Log *slog.Logger
2026-07-29 15:26:27 +12:00
Events *serverlogging.Buffer
}
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,
syncer: deps.Syncer,
log: deps.Log,
2026-07-29 15:26:27 +12:00
events: deps.Events,
}
}
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))
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))
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))
v1.Handle("POST /v1/requests", s.authed(s.handleRequest))
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-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))
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-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("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-02 22:10:19 +12:00
// Update policy is app-scoped, not user-scoped. Keep it outside authentication and
// maintenance so a fresh install, a signed-out TV, and a retired build can all learn
// whether the server requires an update without touching a viewer session.
mux.HandleFunc("GET /v1/update", 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)
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)
// 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)
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)
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-06 22:33:56 +12:00
fields := []any{"component", identity.component}
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-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 {
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)
}
// --- 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)
}
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,
) {
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-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
}