package api import ( "context" "errors" "io" "log/slog" "net" "net/http" "net/url" "strconv" "strings" "syscall" "github.com/ponzischeme89/memby/server/internal/sonarr" "github.com/ponzischeme89/memby/server/internal/store" ) // allowedImageTypes guards the path segment we forward to Emby. var allowedImageTypes = map[string]string{ "backdrop": "Backdrop", "primary": "Primary", "logo": "Logo", "thumb": "Thumb", } // handleImage proxies artwork. // // Going through the gateway means the TV's image URLs carry a gateway token instead of a // live Emby api_key, and it gives Emby's resized output a cacheable home. Images are // tag-addressed, so a hit can be cached hard by any layer in front of this. func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.Session) { itemID := r.PathValue("itemId") imageType, ok := allowedImageTypes[strings.ToLower(r.PathValue("imageType"))] if !ok || itemID == "" { writeError(w, http.StatusNotFound, "unknown image") return } if strings.HasPrefix(itemID, "sonarr:") { s.handleSonarrImage(w, r, itemID, imageType) return } params := url.Values{} for _, key := range []string{"tag", "maxWidth", "maxHeight", "quality"} { if v := r.URL.Query().Get(key); v != "" { params.Set(key, v) } } if writeNotModifiedForTag(w, r, params.Get("tag")) { return } resp, err := s.emby.ImageResponse(r.Context(), credentials(sess), itemID, imageType, params) if err != nil { s.writeUpstreamError(w, err, "could not load the image") return } defer resp.Body.Close() if ct := resp.Header.Get("Content-Type"); ct != "" { w.Header().Set("Content-Type", ct) } if cl := resp.Header.Get("Content-Length"); cl != "" { w.Header().Set("Content-Length", cl) } // A tag identifies exact image bytes, so it can be cached indefinitely. Without one, // stay conservative. if params.Get("tag") != "" { w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") w.Header().Set("ETag", imageETag(params.Get("tag"))) } else { w.Header().Set("Cache-Control", "private, max-age=3600") } w.WriteHeader(http.StatusOK) copyImage(w, r, resp.Body, s.log, "source", "emby", "item_id", itemID, "image_type", imageType) } func (s *Server) handleSonarrImage(w http.ResponseWriter, r *http.Request, itemID, imageType string) { if s.sonarr == nil { writeError(w, http.StatusNotFound, "unknown image") return } parts := strings.Split(itemID, ":") if len(parts) != 3 { writeError(w, http.StatusNotFound, "unknown image") return } seriesID, err := strconv.Atoi(parts[1]) if err != nil || seriesID <= 0 { writeError(w, http.StatusNotFound, "unknown image") return } coverType := map[string]string{"Primary": "poster", "Backdrop": "fanart"}[imageType] if coverType == "" { writeError(w, http.StatusNotFound, "unknown image") return } resp, err := s.sonarr.MediaCover(r.Context(), seriesID, coverType) if err != nil { var apiErr *sonarr.APIError if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { writeError(w, http.StatusNotFound, "image not found") return } s.log.Warn("sonarr image failed", "series_id", seriesID, "type", coverType, "error", err) writeError(w, http.StatusBadGateway, "could not load the image") return } defer resp.Body.Close() if ct := resp.Header.Get("Content-Type"); ct != "" { w.Header().Set("Content-Type", ct) } if cl := resp.Header.Get("Content-Length"); cl != "" { w.Header().Set("Content-Length", cl) } w.Header().Set("Cache-Control", "private, max-age=3600") w.WriteHeader(http.StatusOK) copyImage(w, r, resp.Body, s.log, "source", "sonarr", "item_id", itemID, "image_type", imageType) } func copyImage( w io.Writer, r *http.Request, body io.Reader, log *slog.Logger, attributes ...any, ) { written, err := io.Copy(w, body) if err == nil { return } if expectedClientDisconnect(r, err) { // Image loaders cancel work aggressively as cards leave the viewport. That is a // successful resource-saving decision by the TV, not an unhealthy gateway. return } fields := append([]any{"bytes_written", written, "error", err}, attributes...) log.Warn("image stream interrupted", fields...) } func expectedClientDisconnect(r *http.Request, err error) bool { if r.Context().Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, net.ErrClosed) || errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ECONNRESET) { return true } message := strings.ToLower(err.Error()) for _, fragment := range []string{ "broken pipe", "connection reset by peer", "client disconnected", "request canceled", "request cancelled", "stream closed", } { if strings.Contains(message, fragment) { return true } } return false } func writeNotModifiedForTag(w http.ResponseWriter, r *http.Request, tag string) bool { if tag == "" { return false } etag := imageETag(tag) w.Header().Set("ETag", etag) w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") for _, candidate := range strings.Split(r.Header.Get("If-None-Match"), ",") { if strings.TrimSpace(candidate) == etag || strings.TrimSpace(candidate) == "*" { w.WriteHeader(http.StatusNotModified) return true } } return false } func imageETag(tag string) string { // Emby image tags are normally hex, but quote defensively for a valid HTTP entity tag. return `"` + strings.NewReplacer(`\`, "", `"`, "").Replace(tag) + `"` }