package api import ( "errors" "io" "net/http" "net/url" "strconv" "strings" "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) } } 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") } else { w.Header().Set("Cache-Control", "private, max-age=3600") } w.WriteHeader(http.StatusOK) if _, err := io.Copy(w, resp.Body); err != nil { s.log.Warn("image copy failed", "error", err) } } 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) if _, err := io.Copy(w, resp.Body); err != nil { s.log.Warn("sonarr image copy failed", "error", err) } }