diff --git a/internal/jellycompat/dto.go b/internal/jellycompat/dto.go index eeb21054..3c56ee2c 100644 --- a/internal/jellycompat/dto.go +++ b/internal/jellycompat/dto.go @@ -48,6 +48,7 @@ type baseItemDTO struct { ProviderIDs map[string]string `json:"ProviderIds,omitempty"` ProductionLocations []string `json:"ProductionLocations,omitempty"` ImageTags map[string]string `json:"ImageTags"` + PrimaryImageItemID string `json:"PrimaryImageItemId,omitempty"` BackdropImageTags []string `json:"BackdropImageTags,omitempty"` PrimaryImageAspectRatio *float64 `json:"PrimaryImageAspectRatio,omitempty"` ImageBlurHashes map[string]map[string]string `json:"ImageBlurHashes,omitempty"` diff --git a/internal/jellycompat/handlers_images.go b/internal/jellycompat/handlers_images.go index 5dd65885..9e524a28 100644 --- a/internal/jellycompat/handlers_images.go +++ b/internal/jellycompat/handlers_images.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "strings" "time" @@ -16,7 +17,6 @@ import ( type ImagesHandler struct { content ContentService codec *ResourceIDCodec - httpClient *http.Client sessions *SessionStore images *ImageCache personRepo *catalog.PersonRepository @@ -29,6 +29,7 @@ type ImagesHandler struct { posterSigner LibraryPosterPresigner presignTTL time.Duration imageTags *imageTagSigner + httpClient *http.Client } type imageItemRepository interface { @@ -48,15 +49,14 @@ type imageFolderRepository interface { GetByID(ctx context.Context, id int) (*models.MediaFolder, error) } -// NewImagesHandler creates an image proxy handler. -func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient *http.Client, sessions *SessionStore, images *ImageCache, personRepo *catalog.PersonRepository, detailSvc *catalog.DetailService, itemRepo *catalog.ItemRepository, folderRepo *catalog.FolderRepository, seasonRepo *catalog.SeasonRepository, episodeRepo *catalog.EpisodeRepository, accessFilter AccessFilterResolver, posterSigner LibraryPosterPresigner, presignTTL time.Duration, imageTagSecret string) *ImagesHandler { +// NewImagesHandler creates a Jellyfin-compatible image route handler. +func NewImagesHandler(content ContentService, codec *ResourceIDCodec, sessions *SessionStore, images *ImageCache, personRepo *catalog.PersonRepository, detailSvc *catalog.DetailService, itemRepo *catalog.ItemRepository, folderRepo *catalog.FolderRepository, seasonRepo *catalog.SeasonRepository, episodeRepo *catalog.EpisodeRepository, accessFilter AccessFilterResolver, posterSigner LibraryPosterPresigner, presignTTL time.Duration, imageTagSecret string, httpClient *http.Client) *ImagesHandler { if httpClient == nil { httpClient = http.DefaultClient } return &ImagesHandler{ content: content, codec: codec, - httpClient: httpClient, sessions: sessions, images: images, personRepo: personRepo, @@ -69,6 +69,7 @@ func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient posterSigner: posterSigner, presignTTL: presignTTL, imageTags: newImageTagSigner(imageTagSecret), + httpClient: httpClient, } } @@ -80,6 +81,10 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request) imageType := chiURLParam(r, "imageType") imageSize := compatRequestImageSize(r, imageType) tag := strings.TrimSpace(r.URL.Query().Get("tag")) + if canonicalRouteID, ok := canonicalCompatImageRouteID(h.codec, routeID); ok { + routeID = canonicalRouteID + r = withCompatImageProxyRouteRequest(r) + } if tag != "" { imageURL, ok, err := h.resolveItemImageURLFromTag(r.Context(), routeID, imageType, imageSize, tag) if err != nil { @@ -88,15 +93,15 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request) } if ok { h.images.RememberSizedUntil(routeID, imageType, imageURL.URL, imageSize, imageURL.ExpiresAt) - h.proxyImageURL(w, r, imageURL.URL) + h.serveImageURL(w, r, imageURL.URL) return } if imageURL, ok := h.images.LookupTag(tag); ok { - h.proxyImageURL(w, r, imageURL) + h.serveImageURL(w, r, imageURL) return } } else if imageURL, ok := h.images.LookupSized(routeID, imageType, "", imageSize); ok { - h.proxyImageURL(w, r, imageURL) + h.serveImageURL(w, r, imageURL) return } @@ -133,7 +138,7 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request) return } h.images.RememberSizedUntil(routeID, imageType, imageURL, imageSize, resolvedImage.ExpiresAt) - h.proxyImageURL(w, r, imageURL) + h.serveImageURL(w, r, imageURL) } // handlePersonImage serves person photo images. @@ -159,7 +164,7 @@ func (h *ImagesHandler) handlePersonImage(w http.ResponseWriter, r *http.Request return } h.images.RememberSizedUntil(routeID, imageType, imageURL, imageSize, resolvedImage.ExpiresAt) - h.proxyImageURL(w, r, imageURL) + h.serveImageURL(w, r, imageURL) } func (h *ImagesHandler) resolveItemImageURL(ctx context.Context, session *Session, contentID, imageType string, r *http.Request) (catalog.ResolvedImageURL, error) { @@ -414,18 +419,45 @@ func firstResolvedImageURL(values ...catalog.ResolvedImageURL) catalog.ResolvedI return catalog.ResolvedImageURL{} } +func (h *ImagesHandler) serveImageURL(w http.ResponseWriter, r *http.Request, imageURL string) { + if shouldProxyCompatImageRequest(r) { + h.proxyImageURL(w, r, imageURL) + return + } + h.redirectImageURL(w, r, imageURL) +} + +func (h *ImagesHandler) redirectImageURL(w http.ResponseWriter, r *http.Request, imageURL string) { + if _, err := parseRemoteImageURL(imageURL); err != nil { + writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image") + return + } + + // Do not let clients cache the temporary redirect itself. The object-store + // response can still carry its own cache headers after the client follows it. + setCompatImageRouteNoStore(w.Header()) + http.Redirect(w, r, imageURL, http.StatusFound) +} + func (h *ImagesHandler) proxyImageURL(w http.ResponseWriter, r *http.Request, imageURL string) { - req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, imageURL, nil) + target, err := parseRemoteImageURL(imageURL) if err != nil { writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image") return } - for _, header := range []string{"If-None-Match", "If-Modified-Since"} { - if value := r.Header.Get(header); value != "" { - req.Header.Set(header, value) - } + + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil) + if err != nil { + writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image") + return } - resp, err := h.httpClient.Do(req) + copyConditionalImageRequestHeaders(req.Header, r.Header) + + client := h.httpClient + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) if err != nil { writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image") return @@ -433,6 +465,15 @@ func (h *ImagesHandler) proxyImageURL(w http.ResponseWriter, r *http.Request, im proxyImage(w, resp) } +func parseRemoteImageURL(imageURL string) (*url.URL, error) { + target, err := url.Parse(imageURL) + if err != nil || target.Scheme == "" || target.Host == "" || + (target.Scheme != "http" && target.Scheme != "https") { + return nil, errors.New("invalid remote image URL") + } + return target, nil +} + // HandleUserImage returns a deterministic placeholder avatar. func (h *ImagesHandler) HandleUserImage(w http.ResponseWriter, r *http.Request) { session := SessionFromContext(r.Context()) diff --git a/internal/jellycompat/idcodec.go b/internal/jellycompat/idcodec.go index bcae3dce..bdd60fbe 100644 --- a/internal/jellycompat/idcodec.go +++ b/internal/jellycompat/idcodec.go @@ -21,6 +21,7 @@ const ( EncodedIDGenre EncodedIDType = 6 EncodedIDStudio EncodedIDType = 7 EncodedIDPerson EncodedIDType = 8 + EncodedIDImageProxy EncodedIDType = 9 ) var ( diff --git a/internal/jellycompat/image_cache.go b/internal/jellycompat/image_cache.go index ab0939f4..177e8b95 100644 --- a/internal/jellycompat/image_cache.go +++ b/internal/jellycompat/image_cache.go @@ -93,7 +93,7 @@ func (c *ImageCache) LookupSized(routeID, imageType, tag, size string) (string, return "", false } - if tag = strings.TrimSpace(tag); tag != "" { + if tag = canonicalCompatImageTag(tag); tag != "" { return c.LookupTag(tag) } @@ -108,7 +108,7 @@ func (c *ImageCache) LookupTag(tag string) (string, bool) { if c == nil { return "", false } - return c.lookupTag(strings.TrimSpace(tag)) + return c.lookupTag(canonicalCompatImageTag(tag)) } // lookupTag resolves a tag without size partitioning. Tags are sha1 of the diff --git a/internal/jellycompat/image_proxy_tags.go b/internal/jellycompat/image_proxy_tags.go new file mode 100644 index 00000000..79711304 --- /dev/null +++ b/internal/jellycompat/image_proxy_tags.go @@ -0,0 +1,233 @@ +package jellycompat + +import ( + "bytes" + "encoding/json" + "net/http" + "strings" +) + +func compatImageProxyTagVariantMiddleware(codec *ResourceIDCodec) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !isCompatImageProxyClientRequest(r) || isWebSocketUpgrade(r) { + next.ServeHTTP(w, r) + return + } + + rw := &compatImageProxyTagResponseWriter{ResponseWriter: w, codec: codec} + next.ServeHTTP(rw, r) + rw.finish() + }) + } +} + +type compatImageProxyTagResponseWriter struct { + http.ResponseWriter + status int + body bytes.Buffer + passthrough bool + codec *ResourceIDCodec +} + +func (w *compatImageProxyTagResponseWriter) WriteHeader(status int) { + if w.passthrough { + w.ResponseWriter.WriteHeader(status) + return + } + w.status = status +} + +func (w *compatImageProxyTagResponseWriter) Write(p []byte) (int, error) { + if w.passthrough { + return w.ResponseWriter.Write(p) + } + if w.status == 0 { + w.status = http.StatusOK + } + if !isJSONResponse(w.Header().Get("Content-Type")) { + w.passthrough = true + w.ResponseWriter.WriteHeader(w.status) + return w.ResponseWriter.Write(p) + } + return w.body.Write(p) +} + +func (w *compatImageProxyTagResponseWriter) finish() { + if w.passthrough { + return + } + if w.status == 0 && w.body.Len() == 0 { + return + } + status := w.status + if status == 0 { + status = http.StatusOK + } + body := w.body.Bytes() + if isJSONResponse(w.Header().Get("Content-Type")) && len(body) > 0 { + body = rewriteCompatImageProxyTags(w.codec, body) + } + w.ResponseWriter.WriteHeader(status) + if len(body) > 0 { + _, _ = w.ResponseWriter.Write(body) + } +} + +func isJSONResponse(contentType string) bool { + contentType = strings.ToLower(strings.TrimSpace(contentType)) + return contentType == "application/json" || strings.HasPrefix(contentType, "application/json;") +} + +func isWebSocketUpgrade(r *http.Request) bool { + return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") +} + +func rewriteCompatImageProxyTags(codec *ResourceIDCodec, body []byte) []byte { + var value any + if err := json.Unmarshal(body, &value); err != nil { + return body + } + if !appendCompatImageProxyTags(codec, value) { + return body + } + rewritten, err := json.Marshal(value) + if err != nil { + return body + } + return append(rewritten, '\n') +} + +func appendCompatImageProxyTags(codec *ResourceIDCodec, value any) bool { + switch typed := value.(type) { + case map[string]any: + changed := false + hasPrimaryImageTag := compatMapHasPrimaryImageTag(typed) + for key, child := range typed { + switch key { + case "ImageTags": + changed = appendCompatImageProxyTagsInMap(child) || changed + case "ImageBlurHashes": + changed = appendCompatImageProxyTagsInBlurHashes(child) || changed + case "BackdropImageTags", "ParentBackdropImageTags": + changed = appendCompatImageProxyTagsInSlice(child) || changed + case "PrimaryImageTag", "SeriesPrimaryImageTag", "ParentThumbImageTag", "BackdropImageTag", "ImageTag": + if tag, ok := child.(string); ok && tag != "" { + typed[key] = compatImageProxyTag(tag) + changed = true + } + default: + changed = appendCompatImageProxyTags(codec, child) || changed + } + } + if hasPrimaryImageTag { + changed = appendCompatPrimaryImageItemID(codec, typed) || changed + } + return changed + case []any: + changed := false + for _, child := range typed { + changed = appendCompatImageProxyTags(codec, child) || changed + } + return changed + default: + return false + } +} + +func compatMapHasPrimaryImageTag(value map[string]any) bool { + if tags, ok := value["ImageTags"].(map[string]any); ok { + if tag, ok := tags["Primary"].(string); ok && strings.TrimSpace(tag) != "" { + return true + } + } + for _, key := range []string{"PrimaryImageTag", "ImageTag"} { + if tag, ok := value[key].(string); ok && strings.TrimSpace(tag) != "" { + return true + } + } + return false +} + +func appendCompatPrimaryImageItemID(codec *ResourceIDCodec, value map[string]any) bool { + if codec == nil { + return false + } + routeID := compatStringMapValue(value, "PrimaryImageItemId") + if routeID == "" { + routeID = compatStringMapValue(value, "Id") + } + if routeID == "" { + routeID = compatStringMapValue(value, "ItemId") + } + if routeID == "" { + return false + } + proxyRouteID := compatImageProxyRouteID(codec, routeID) + if proxyRouteID == "" || proxyRouteID == routeID { + return false + } + if compatStringMapValue(value, "PrimaryImageItemId") == proxyRouteID { + return false + } + value["PrimaryImageItemId"] = proxyRouteID + return true +} + +func compatStringMapValue(value map[string]any, key string) string { + if raw, ok := value[key].(string); ok { + return strings.TrimSpace(raw) + } + return "" +} + +func appendCompatImageProxyTagsInMap(value any) bool { + tags, ok := value.(map[string]any) + if !ok { + return false + } + changed := false + for key, raw := range tags { + if tag, ok := raw.(string); ok && tag != "" { + tags[key] = compatImageProxyTag(tag) + changed = true + } + } + return changed +} + +func appendCompatImageProxyTagsInSlice(value any) bool { + tags, ok := value.([]any) + if !ok { + return false + } + changed := false + for idx, raw := range tags { + if tag, ok := raw.(string); ok && tag != "" { + tags[idx] = compatImageProxyTag(tag) + changed = true + } + } + return changed +} + +func appendCompatImageProxyTagsInBlurHashes(value any) bool { + byImageType, ok := value.(map[string]any) + if !ok { + return false + } + changed := false + for imageType, rawTags := range byImageType { + tags, ok := rawTags.(map[string]any) + if !ok { + continue + } + next := make(map[string]any, len(tags)) + for tag, hash := range tags { + next[compatImageProxyTag(tag)] = hash + changed = true + } + byImageType[imageType] = next + } + return changed +} diff --git a/internal/jellycompat/image_proxy_tags_test.go b/internal/jellycompat/image_proxy_tags_test.go new file mode 100644 index 00000000..aaa8a07e --- /dev/null +++ b/internal/jellycompat/image_proxy_tags_test.go @@ -0,0 +1,111 @@ +package jellycompat + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestCompatImageProxyTagVariantMiddlewareSuffixesInfuseImageTags(t *testing.T) { + codec := NewResourceIDCodec() + handler := compatImageProxyTagVariantMiddleware(codec)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, queryResultDTO{ + Items: []baseItemDTO{{ + ID: "item-1", + Name: "Movie", + ImageTags: map[string]string{"Primary": "primary-tag"}, + BackdropImageTags: []string{"backdrop-tag"}, + SeriesPrimaryImageTag: "series-tag", + ParentBackdropImageTags: []string{"parent-backdrop-tag"}, + ParentThumbImageTag: "parent-thumb-tag", + ImageBlurHashes: map[string]map[string]string{ + "Primary": {"primary-tag": "thumbhash"}, + }, + }}, + TotalRecordCount: 1, + }) + })) + + req := httptest.NewRequest(http.MethodGet, "/Items", nil) + req.Header.Set("User-Agent", "Infuse-Direct/8.4.6") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String()) + } + var got queryResultDTO + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + item := got.Items[0] + if item.ImageTags["Primary"] != "primary-tag-p" { + t.Fatalf("primary tag = %q, want primary-tag-p", item.ImageTags["Primary"]) + } + if item.PrimaryImageItemID != compatImageProxyRouteID(codec, "item-1") { + t.Fatalf("PrimaryImageItemId = %q, want proxy route id", item.PrimaryImageItemID) + } + if canonical, ok := canonicalCompatImageRouteID(codec, item.PrimaryImageItemID); !ok || canonical != "item-1" { + t.Fatalf("canonical proxy route = %q, %v; want item-1, true", canonical, ok) + } + if item.BackdropImageTags[0] != "backdrop-tag-p" { + t.Fatalf("backdrop tag = %q, want backdrop-tag-p", item.BackdropImageTags[0]) + } + if item.SeriesPrimaryImageTag != "series-tag-p" { + t.Fatalf("series tag = %q, want series-tag-p", item.SeriesPrimaryImageTag) + } + if item.ParentBackdropImageTags[0] != "parent-backdrop-tag-p" { + t.Fatalf("parent backdrop tag = %q, want parent-backdrop-tag-p", item.ParentBackdropImageTags[0]) + } + if item.ParentThumbImageTag != "parent-thumb-tag-p" { + t.Fatalf("parent thumb tag = %q, want parent-thumb-tag-p", item.ParentThumbImageTag) + } + if _, ok := item.ImageBlurHashes["Primary"]["primary-tag-p"]; !ok { + t.Fatalf("blurhash keys = %#v, want suffixed primary tag", item.ImageBlurHashes["Primary"]) + } +} + +func TestCompatImageProxyTagVariantMiddlewareLeavesOtherClientsUnchanged(t *testing.T) { + codec := NewResourceIDCodec() + handler := compatImageProxyTagVariantMiddleware(codec)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, queryResultDTO{ + Items: []baseItemDTO{{ + ID: "item-1", + Name: "Movie", + ImageTags: map[string]string{"Primary": "primary-tag"}, + }}, + TotalRecordCount: 1, + }) + })) + + req := httptest.NewRequest(http.MethodGet, "/Items", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String()) + } + var got queryResultDTO + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.Items[0].ImageTags["Primary"] != "primary-tag" { + t.Fatalf("primary tag = %q, want primary-tag", got.Items[0].ImageTags["Primary"]) + } + if got.Items[0].PrimaryImageItemID != "" { + t.Fatalf("PrimaryImageItemId = %q, want empty", got.Items[0].PrimaryImageItemID) + } +} + +func TestCompatImageProxyRouteIDCanonicalizesNumericRouteWithoutRegistration(t *testing.T) { + routeID := EncodeNumericID(EncodedIDItem, 12345).String() + proxyRouteID := compatImageProxyRouteID(NewResourceIDCodec(), routeID) + + canonical, ok := canonicalCompatImageRouteID(NewResourceIDCodec(), proxyRouteID) + if !ok || canonical != routeID { + t.Fatalf("canonical proxy route = %q, %v; want %q, true", canonical, ok, routeID) + } +} diff --git a/internal/jellycompat/image_tag_signer.go b/internal/jellycompat/image_tag_signer.go index a2669043..00bc770a 100644 --- a/internal/jellycompat/image_tag_signer.go +++ b/internal/jellycompat/image_tag_signer.go @@ -40,7 +40,7 @@ func (s *imageTagSigner) Equal(seed, fallbackURL, actual string) bool { if s == nil { return false } - actual = strings.TrimSpace(actual) + actual = canonicalCompatImageTag(actual) expected := s.Tag(seed, fallbackURL) if expected == "" || actual == "" || len(expected) != len(actual) { return false diff --git a/internal/jellycompat/images.go b/internal/jellycompat/images.go index 083735d5..61ec5d89 100644 --- a/internal/jellycompat/images.go +++ b/internal/jellycompat/images.go @@ -2,48 +2,226 @@ package jellycompat import ( "bytes" + "context" "crypto/sha1" + "encoding/binary" "image" "image/color" "image/png" "io" "net/http" + "strings" + + "github.com/google/uuid" ) -const defaultImageProxyCacheControl = "public, max-age=300, must-revalidate" +var compatImageProxyUserAgentSubstrings = []string{ + "infuse", +} + +const compatImageRouteCacheControl = "private, no-store, no-cache, max-age=0, s-maxage=0, must-revalidate" +const compatImageProxyTagSuffix = "-p" +const compatImageProxyRoutePrefix = "__jellycompat_image_proxy__:" + +type compatImageProxyRouteContextKey struct{} + +var compatImageProxyHeaders = []string{ + "Accept-Ranges", + "Content-Length", + "Content-Type", + "ETag", + "Last-Modified", +} + +func shouldProxyCompatImageRequest(r *http.Request) bool { + return isCompatImageProxyClientRequest(r) || + isCompatImageProxyRouteRequest(r) || + isCompatImageProxyTag(r.URL.Query().Get("tag")) +} + +func isCompatImageProxyClientRequest(r *http.Request) bool { + if r == nil { + return false + } + clientText := strings.ToLower(strings.Join([]string{ + r.UserAgent(), + r.Header.Get("X-Emby-Authorization"), + }, " ")) + for _, substring := range compatImageProxyUserAgentSubstrings { + if strings.Contains(clientText, substring) { + return true + } + } + return false +} + +func compatImageProxyTag(tag string) string { + tag = strings.TrimSpace(tag) + if tag == "" || isCompatImageProxyTag(tag) { + return tag + } + return tag + compatImageProxyTagSuffix +} + +func isCompatImageProxyTag(tag string) bool { + return strings.HasSuffix(strings.TrimSpace(tag), compatImageProxyTagSuffix) +} + +func canonicalCompatImageTag(tag string) string { + tag = strings.TrimSpace(tag) + return strings.TrimSuffix(tag, compatImageProxyTagSuffix) +} + +func compatImageProxyRouteID(codec *ResourceIDCodec, routeID string) string { + routeID = strings.TrimSpace(routeID) + if codec == nil || routeID == "" { + return routeID + } + if canonical, ok := canonicalCompatImageRouteID(codec, routeID); ok { + return compatImageProxyRouteID(codec, canonical) + } + if decoded, ok := decodePackedCompatRouteID(routeID); ok { + return encodeNumericCompatImageProxyRouteID(decoded) + } + return codec.EncodeStringID(EncodedIDItem, compatImageProxyRoutePrefix+routeID) +} + +func canonicalCompatImageRouteID(codec *ResourceIDCodec, routeID string) (string, bool) { + if canonical, ok := decodeNumericCompatImageProxyRouteID(routeID); ok { + return canonical, true + } + if codec == nil { + return routeID, false + } + value, err := codec.DecodeStringID(EncodedIDItem, strings.TrimSpace(routeID)) + if err != nil || !strings.HasPrefix(value, compatImageProxyRoutePrefix) { + return routeID, false + } + canonical := strings.TrimSpace(strings.TrimPrefix(value, compatImageProxyRoutePrefix)) + if canonical == "" { + return routeID, false + } + return canonical, true +} + +func encodeNumericCompatImageProxyRouteID(decoded DecodedID) string { + var raw [16]byte + raw[0] = byte(EncodedIDImageProxy) + raw[1] = byte(decoded.Type) + binary.BigEndian.PutUint64(raw[8:], decoded.Value) + return uuid.UUID(raw).String() +} + +func decodeNumericCompatImageProxyRouteID(routeID string) (string, bool) { + parsed, err := uuid.Parse(strings.TrimSpace(routeID)) + if err != nil || EncodedIDType(parsed[0]) != EncodedIDImageProxy { + return "", false + } + kind := EncodedIDType(parsed[1]) + if !isPackedCompatRouteIDType(kind) || !zeroUUIDBytes(parsed[2:8]) { + return "", false + } + return EncodeNumericID(kind, binary.BigEndian.Uint64(parsed[8:])).String(), true +} + +func decodePackedCompatRouteID(routeID string) (DecodedID, bool) { + parsed, err := uuid.Parse(strings.TrimSpace(routeID)) + if err != nil { + return DecodedID{}, false + } + kind := EncodedIDType(parsed[0]) + if !isPackedCompatRouteIDType(kind) || !zeroUUIDBytes(parsed[1:8]) { + return DecodedID{}, false + } + return DecodedID{Type: kind, Value: binary.BigEndian.Uint64(parsed[8:])}, true +} + +func isPackedCompatRouteIDType(kind EncodedIDType) bool { + switch kind { + case EncodedIDLibrary, + EncodedIDItem, + EncodedIDMediaSource, + EncodedIDSeason, + EncodedIDPlaySession, + EncodedIDGenre, + EncodedIDStudio, + EncodedIDPerson: + return true + default: + return false + } +} + +func zeroUUIDBytes(values []byte) bool { + for _, value := range values { + if value != 0 { + return false + } + } + return true +} + +func withCompatImageProxyRouteRequest(r *http.Request) *http.Request { + if r == nil { + return r + } + return r.WithContext(context.WithValue(r.Context(), compatImageProxyRouteContextKey{}, true)) +} + +func isCompatImageProxyRouteRequest(r *http.Request) bool { + if r == nil { + return false + } + forceProxy, _ := r.Context().Value(compatImageProxyRouteContextKey{}).(bool) + return forceProxy +} + +func copyConditionalImageRequestHeaders(dst, src http.Header) { + for _, key := range []string{"If-Match", "If-None-Match", "If-Modified-Since", "If-Unmodified-Since"} { + if value := src.Values(key); len(value) > 0 { + dst[key] = append([]string(nil), value...) + } + } +} func proxyImage(w http.ResponseWriter, resp *http.Response) { defer resp.Body.Close() - copyImageProxyHeaders(w.Header(), resp.Header) + if resp.StatusCode == http.StatusNotModified { + copyImageProxyHeaders(w.Header(), resp.Header) + setCompatImageRouteNoStore(w.Header()) w.WriteHeader(http.StatusNotModified) return } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + setCompatImageRouteNoStore(w.Header()) writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image") return } - w.WriteHeader(http.StatusOK) + + copyImageProxyHeaders(w.Header(), resp.Header) + setCompatImageRouteNoStore(w.Header()) + w.WriteHeader(resp.StatusCode) _, _ = io.Copy(w, resp.Body) } func copyImageProxyHeaders(dst, src http.Header) { - for _, name := range []string{"Content-Type", "Cache-Control", "ETag", "Last-Modified", "Expires"} { - values := src.Values(name) - if len(values) == 0 { - values = src[name] - } - if len(values) == 0 { - continue - } - dst.Del(name) - for _, value := range values { - dst.Add(name, value) + for _, key := range compatImageProxyHeaders { + if value := src.Values(key); len(value) > 0 { + dst[key] = append([]string(nil), value...) } } - if dst.Get("Cache-Control") == "" { - dst.Set("Cache-Control", defaultImageProxyCacheControl) - } +} + +func setCompatImageRouteNoStore(header http.Header) { + header.Set("Cache-Control", compatImageRouteCacheControl) + header.Set("CDN-Cache-Control", "private, no-store, no-cache, max-age=0") + header.Set("Expires", "Thu, 01 Jan 1970 00:00:00 GMT") + header.Set("Pragma", "no-cache") + header.Set("Surrogate-Control", "no-store") + header.Set("X-Accel-Expires", "0") + header.Add("Vary", "User-Agent") } func placeholderAvatarPNG(seed string) ([]byte, error) { diff --git a/internal/jellycompat/images_test.go b/internal/jellycompat/images_test.go index 43e38e29..0f0acd90 100644 --- a/internal/jellycompat/images_test.go +++ b/internal/jellycompat/images_test.go @@ -2,10 +2,8 @@ package jellycompat import ( "context" - "io" "net/http" "net/http/httptest" - "strings" "testing" "time" @@ -16,88 +14,66 @@ import ( "github.com/Silo-Server/silo-server/internal/models" ) -func TestProxyImageDefaultsToRevalidatingCachePolicy(t *testing.T) { - rec := httptest.NewRecorder() - resp := &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{ - "Content-Type": []string{"image/jpeg"}, - "ETag": []string{`"abc"`}, - "Last-Modified": []string{"Tue, 12 May 2026 12:00:00 GMT"}, - "Expires": []string{"Tue, 12 May 2026 12:05:00 GMT"}, - }, - Body: io.NopCloser(strings.NewReader("image-bytes")), - } - - proxyImage(rec, resp) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - if got := rec.Header().Get("Cache-Control"); got != defaultImageProxyCacheControl { - t.Fatalf("Cache-Control = %q, want %q", got, defaultImageProxyCacheControl) - } - if got := rec.Header().Get("ETag"); got != `"abc"` { - t.Fatalf("ETag = %q", got) - } - if strings.Contains(rec.Header().Get("Cache-Control"), "immutable") { - t.Fatalf("Cache-Control unexpectedly immutable: %q", rec.Header().Get("Cache-Control")) - } -} - -func TestProxyImageRelaysNotModified(t *testing.T) { - rec := httptest.NewRecorder() - resp := &http.Response{ - StatusCode: http.StatusNotModified, - Header: http.Header{ - "ETag": []string{`"abc"`}, - "Cache-Control": []string{"public, max-age=60"}, - }, - Body: io.NopCloser(strings.NewReader("")), - } - - proxyImage(rec, resp) - - if rec.Code != http.StatusNotModified { - t.Fatalf("status = %d, want 304", rec.Code) - } - if got := rec.Header().Get("ETag"); got != `"abc"` { - t.Fatalf("ETag = %q", got) - } - if rec.Body.Len() != 0 { - t.Fatalf("304 body length = %d, want 0", rec.Body.Len()) - } -} - -func TestProxyImageURLForwardsConditionalHeaders(t *testing.T) { +func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) { + upstreamCalled := false upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("If-None-Match"); got != `"abc"` { - t.Fatalf("If-None-Match = %q", got) - } - if got := r.Header.Get("If-Modified-Since"); got != "Tue, 12 May 2026 12:00:00 GMT" { - t.Fatalf("If-Modified-Since = %q", got) - } - w.Header().Set("ETag", `"abc"`) - w.WriteHeader(http.StatusNotModified) + upstreamCalled = true + w.WriteHeader(http.StatusTeapot) })) defer upstream.Close() - h := &ImagesHandler{httpClient: upstream.Client()} - req := httptest.NewRequest(http.MethodGet, "/Items/1/Images/Primary", nil) - req.Header.Set("If-None-Match", `"abc"`) - req.Header.Set("If-Modified-Since", "Tue, 12 May 2026 12:00:00 GMT") + codec := NewResourceIDCodec() + contentID := "movie-1" + routeID := codec.EncodeStringID(EncodedIDItem, contentID) + updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) + item := &models.MediaItem{ + ContentID: contentID, + PosterPath: upstream.URL, + PosterThumbhash: "poster-thumbhash", + UpdatedAt: updatedAt, + } + cfg := &config.Config{Auth: config.AuthConfig{JWTSecret: "image-secret"}} + tag := newMapper(codec, cfg).itemFromList(upstreamListItem{ + ContentID: contentID, + Type: "movie", + Title: "Movie", + PosterURL: item.PosterPath, + PosterPath: item.PosterPath, + PosterThumbhash: item.PosterThumbhash, + UpdatedAt: item.UpdatedAt, + }, false, nil, nil).ImageTags["Primary"] + h := &ImagesHandler{ + codec: codec, + images: NewImageCache(time.Hour, func() time.Time { return updatedAt }), + itemRepo: fakeImageItemRepo{item: item}, + imageTags: newImageTagSigner(cfg.Auth.JWTSecret), + } + + req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+tag, nil) + req = withImageRouteParams(req, routeID, "Primary") rec := httptest.NewRecorder() - h.proxyImageURL(rec, req, upstream.URL) + h.HandleItemImage(rec, req) - if rec.Code != http.StatusNotModified { - t.Fatalf("status = %d, want 304", rec.Code) + assertImageRedirect(t, rec, upstream.URL) + if upstreamCalled { + t.Fatal("compat image route proxied the upstream image instead of redirecting") + } + if cached, ok := h.images.LookupSized(routeID, "Primary", "", compatRequestImageSize(req, "Primary")); !ok || cached == "" { + t.Fatal("signed-tag image URL was not cached after resolution") } } -func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) { +func TestHandleItemImageProxiesInfuseSignedTagWithoutSessionOrCache(t *testing.T) { + upstreamCalled := false + var gotIfNoneMatch string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalled = true + gotIfNoneMatch = r.Header.Get("If-None-Match") + w.Header().Set("Cache-Control", "public, max-age=14400") w.Header().Set("Content-Type", "image/jpeg") + w.Header().Set("ETag", `"poster-v1"`) + w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("image-bytes")) })) defer upstream.Close() @@ -124,13 +100,15 @@ func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) { }, false, nil, nil).ImageTags["Primary"] h := &ImagesHandler{ codec: codec, - httpClient: upstream.Client(), images: NewImageCache(time.Hour, func() time.Time { return updatedAt }), itemRepo: fakeImageItemRepo{item: item}, imageTags: newImageTagSigner(cfg.Auth.JWTSecret), + httpClient: upstream.Client(), } - req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+tag, nil) + req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+compatImageProxyTag(tag), nil) + req.Header.Set("If-None-Match", `"poster-v1"`) + req.Header.Set("User-Agent", "Infuse-Direct/8.4.6") req = withImageRouteParams(req, routeID, "Primary") rec := httptest.NewRecorder() @@ -140,10 +118,90 @@ func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) { t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String()) } if got := rec.Body.String(); got != "image-bytes" { - t.Fatalf("body = %q, want image bytes", got) + t.Fatalf("body = %q, want image-bytes", got) + } + if got := rec.Header().Get("Location"); got != "" { + t.Fatalf("Location = %q, want empty", got) + } + if got := rec.Header().Get("Content-Type"); got != "image/jpeg" { + t.Fatalf("Content-Type = %q, want image/jpeg", got) + } + if got := rec.Header().Get("Cache-Control"); got != compatImageRouteCacheControl { + t.Fatalf("Cache-Control = %q, want %q", got, compatImageRouteCacheControl) + } + if got := rec.Header().Get("CDN-Cache-Control"); got != "private, no-store, no-cache, max-age=0" { + t.Fatalf("CDN-Cache-Control = %q, want private, no-store, no-cache, max-age=0", got) + } + if got := rec.Header().Get("X-Accel-Expires"); got != "0" { + t.Fatalf("X-Accel-Expires = %q, want 0", got) + } + if got := gotIfNoneMatch; got != `"poster-v1"` { + t.Fatalf("forwarded If-None-Match = %q, want poster-v1", got) + } + if !upstreamCalled { + t.Fatal("Infuse compat image route did not proxy the upstream image") + } +} + +func TestHandleItemImageProxyRouteIDUsesCanonicalItemAndProxy(t *testing.T) { + upstreamCalled := false + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalled = true + w.Header().Set("Content-Type", "image/webp") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("proxy-route-image")) + })) + defer upstream.Close() + + codec := NewResourceIDCodec() + contentID := "movie-1" + routeID := codec.EncodeStringID(EncodedIDItem, contentID) + proxyRouteID := compatImageProxyRouteID(codec, routeID) + updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) + item := &models.MediaItem{ + ContentID: contentID, + PosterPath: upstream.URL, + PosterThumbhash: "poster-thumbhash", + UpdatedAt: updatedAt, + } + cfg := &config.Config{Auth: config.AuthConfig{JWTSecret: "image-secret"}} + tag := newMapper(codec, cfg).itemFromList(upstreamListItem{ + ContentID: contentID, + Type: "movie", + Title: "Movie", + PosterURL: item.PosterPath, + PosterPath: item.PosterPath, + PosterThumbhash: item.PosterThumbhash, + UpdatedAt: item.UpdatedAt, + }, false, nil, nil).ImageTags["Primary"] + h := &ImagesHandler{ + codec: codec, + images: NewImageCache(time.Hour, func() time.Time { return updatedAt }), + itemRepo: fakeImageItemRepo{item: item}, + imageTags: newImageTagSigner(cfg.Auth.JWTSecret), + httpClient: upstream.Client(), + } + + req := httptest.NewRequest(http.MethodGet, "/Items/"+proxyRouteID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+compatImageProxyTag(tag), nil) + req = withImageRouteParams(req, proxyRouteID, "Primary") + rec := httptest.NewRecorder() + + h.HandleItemImage(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String()) + } + if got := rec.Body.String(); got != "proxy-route-image" { + t.Fatalf("body = %q, want proxy-route-image", got) + } + if got := rec.Header().Get("Location"); got != "" { + t.Fatalf("Location = %q, want empty", got) } if cached, ok := h.images.LookupSized(routeID, "Primary", "", compatRequestImageSize(req, "Primary")); !ok || cached == "" { - t.Fatal("signed-tag image URL was not cached after resolution") + t.Fatal("proxy route image URL was not cached under the canonical route ID") + } + if !upstreamCalled { + t.Fatal("proxy route did not fetch the upstream image") } } @@ -168,10 +226,9 @@ func TestHandleItemImageRejectsUnsignedTagWhenSecretBlank(t *testing.T) { UpdatedAt: item.UpdatedAt, }, false, nil, nil).ImageTags["Primary"] h := &ImagesHandler{ - codec: codec, - httpClient: http.DefaultClient, - itemRepo: fakeImageItemRepo{item: item}, - imageTags: newImageTagSigner(""), + codec: codec, + itemRepo: fakeImageItemRepo{item: item}, + imageTags: newImageTagSigner(""), } req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil) @@ -186,9 +243,10 @@ func TestHandleItemImageRejectsUnsignedTagWhenSecretBlank(t *testing.T) { } func TestHandleItemImageAcceptsSignedCanonicalBackdropTagWithoutSessionOrCache(t *testing.T) { + upstreamCalled := false upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write([]byte("backdrop-bytes")) + upstreamCalled = true + w.WriteHeader(http.StatusTeapot) })) defer upstream.Close() @@ -201,9 +259,8 @@ func TestHandleItemImageAcceptsSignedCanonicalBackdropTagWithoutSessionOrCache(t upstream.URL, ) h := &ImagesHandler{ - codec: codec, - httpClient: upstream.Client(), - images: NewImageCache(time.Hour, time.Now), + codec: codec, + images: NewImageCache(time.Hour, time.Now), itemRepo: fakeImageItemRepo{item: &models.MediaItem{ ContentID: contentID, BackdropPath: upstream.URL, @@ -217,18 +274,17 @@ func TestHandleItemImageAcceptsSignedCanonicalBackdropTagWithoutSessionOrCache(t h.HandleItemImage(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String()) - } - if got := rec.Body.String(); got != "backdrop-bytes" { - t.Fatalf("body = %q, want backdrop bytes", got) + assertImageRedirect(t, rec, upstream.URL) + if upstreamCalled { + t.Fatal("compat image route proxied the upstream image instead of redirecting") } } func TestHandleItemImageAcceptsLibraryPosterTagWithoutSessionOrCache(t *testing.T) { + upstreamCalled := false upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write([]byte("library-poster")) + upstreamCalled = true + w.WriteHeader(http.StatusTeapot) })) defer upstream.Close() @@ -243,7 +299,6 @@ func TestHandleItemImageAcceptsLibraryPosterTagWithoutSessionOrCache(t *testing. ) h := &ImagesHandler{ codec: codec, - httpClient: upstream.Client(), images: NewImageCache(time.Hour, time.Now), folderRepo: fakeImageFolderRepo{folder: &models.MediaFolder{ID: libraryID, PosterPath: posterPath}}, posterSigner: fakeLibraryPosterPresigner{url: upstream.URL}, @@ -256,18 +311,17 @@ func TestHandleItemImageAcceptsLibraryPosterTagWithoutSessionOrCache(t *testing. h.HandleItemImage(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String()) - } - if got := rec.Body.String(); got != "library-poster" { - t.Fatalf("body = %q, want library poster", got) + assertImageRedirect(t, rec, upstream.URL) + if upstreamCalled { + t.Fatal("compat image route proxied the upstream image instead of redirecting") } } func TestHandleItemImageAcceptsLegacyCachedURLTagWithoutRouteFallback(t *testing.T) { + upstreamCalled := false upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write([]byte("cached-image")) + upstreamCalled = true + w.WriteHeader(http.StatusTeapot) })) defer upstream.Close() @@ -276,10 +330,9 @@ func TestHandleItemImageAcceptsLegacyCachedURLTagWithoutRouteFallback(t *testing cache := NewImageCache(time.Hour, time.Now) cache.RememberSized(routeID, "Primary", upstream.URL, compatCardImageSize) h := &ImagesHandler{ - codec: codec, - httpClient: upstream.Client(), - images: cache, - imageTags: newImageTagSigner("image-secret"), + codec: codec, + images: cache, + imageTags: newImageTagSigner("image-secret"), } req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tagValue(upstream.URL), nil) @@ -288,11 +341,9 @@ func TestHandleItemImageAcceptsLegacyCachedURLTagWithoutRouteFallback(t *testing h.HandleItemImage(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String()) - } - if got := rec.Body.String(); got != "cached-image" { - t.Fatalf("body = %q, want cached image", got) + assertImageRedirect(t, rec, upstream.URL) + if upstreamCalled { + t.Fatal("compat image route proxied the upstream image instead of redirecting") } } @@ -328,11 +379,10 @@ func TestHandleItemImageRevalidatesTagBeforeRouteCacheHit(t *testing.T) { UpdatedAt: item.UpdatedAt, }, false, nil, nil).ImageTags["Primary"] h := &ImagesHandler{ - codec: codec, - httpClient: upstream.Client(), - images: cache, - itemRepo: fakeImageItemRepo{item: item}, - imageTags: newImageTagSigner("new-secret"), + codec: codec, + images: cache, + itemRepo: fakeImageItemRepo{item: item}, + imageTags: newImageTagSigner("new-secret"), } req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil) @@ -349,6 +399,34 @@ func TestHandleItemImageRevalidatesTagBeforeRouteCacheHit(t *testing.T) { } } +func TestRedirectImageURLRejectsNonHTTPURL(t *testing.T) { + h := &ImagesHandler{} + req := httptest.NewRequest(http.MethodGet, "/Items/1/Images/Primary", nil) + rec := httptest.NewRecorder() + + h.redirectImageURL(rec, req, "catalog/poster.jpg") + + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, body = %s; want 502", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Location"); got != "" { + t.Fatalf("Location = %q, want empty", got) + } +} + +func assertImageRedirect(t *testing.T, rec *httptest.ResponseRecorder, wantLocation string) { + t.Helper() + if rec.Code != http.StatusFound { + t.Fatalf("status = %d, body = %s; want 302", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Location"); got != wantLocation { + t.Fatalf("Location = %q, want %q", got, wantLocation) + } + if got := rec.Header().Get("Cache-Control"); got != compatImageRouteCacheControl { + t.Fatalf("Cache-Control = %q, want %q", got, compatImageRouteCacheControl) + } +} + type fakeImageItemRepo struct { item *models.MediaItem } diff --git a/internal/jellycompat/router.go b/internal/jellycompat/router.go index a2239969..74e87647 100644 --- a/internal/jellycompat/router.go +++ b/internal/jellycompat/router.go @@ -56,6 +56,7 @@ func NewRouter(deps Dependencies) chi.Router { } slog.Info("jellycompat debug logging enabled", logAttrs...) } + r.Use(compatImageProxyTagVariantMiddleware(deps.IDCodec)) r.Use(requestLoggerMiddleware) r.Use(middleware.Recoverer) @@ -99,7 +100,7 @@ func NewRouter(deps Dependencies) chi.Router { playbackHandler.S3Client = deps.S3Client playbackHandler.S3Bucket = deps.S3Bucket } - imagesHandler := NewImagesHandler(deps.ContentService, deps.IDCodec, deps.HTTPClient, deps.SessionStore, deps.ImageCache, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.FolderRepo, deps.SeasonRepo, deps.EpisodeRepo, deps.AccessFilterFn, deps.PosterPresigner, deps.PresignTTL, deps.JWTSecret) + imagesHandler := NewImagesHandler(deps.ContentService, deps.IDCodec, deps.SessionStore, deps.ImageCache, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.FolderRepo, deps.SeasonRepo, deps.EpisodeRepo, deps.AccessFilterFn, deps.PosterPresigner, deps.PresignTTL, deps.JWTSecret, deps.HTTPClient) displayPrefsHandler := NewDisplayPreferencesHandler(deps.UserStoreProvider) recsHandler := NewRecommendationsHandler(deps.Recommender, deps.ItemRepo, deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config, deps.AccessFilterFn) @@ -261,15 +262,15 @@ func withDefaults(deps Dependencies) Dependencies { if deps.Config != nil && deps.Config.JellyfinCompat.PlaybackSessionTTL > 0 { playbackTTL = deps.Config.JellyfinCompat.PlaybackSessionTTL } - if deps.HTTPClient == nil { - deps.HTTPClient = &http.Client{Timeout: 30 * time.Second} - } if deps.DeviceProfiles == nil { deps.DeviceProfiles = NewDeviceProfileStore(playbackTTL, deps.Now) } if deps.PlaybackStore == nil { deps.PlaybackStore = NewPlaybackSessionStore(playbackTTL, deps.Now) } + if deps.HTTPClient == nil { + deps.HTTPClient = &http.Client{Timeout: 30 * time.Second} + } // Build ContentService from repos if not provided if deps.ContentService == nil && deps.BrowseRepo != nil && deps.ItemRepo != nil && deps.DetailSvc != nil { diff --git a/internal/jellycompat/server.go b/internal/jellycompat/server.go index f5733aea..ef6cf956 100644 --- a/internal/jellycompat/server.go +++ b/internal/jellycompat/server.go @@ -27,7 +27,6 @@ type Dependencies struct { DB *pgxpool.Pool SecretCipher *secret.Cipher // at-rest credential cipher (required when DB is set) ClientIPResolver *clientip.Resolver - HTTPClient *http.Client Now func() time.Time TokenGenerator func() string SessionStore *SessionStore @@ -38,6 +37,7 @@ type Dependencies struct { LoginResolver loginResolver Authenticator *Authenticator WebFS fs.FS + HTTPClient *http.Client // Direct service dependencies (replaces Client) ContentService ContentService @@ -145,6 +145,5 @@ func NewDependencies(cfg *config.Config) Dependencies { Config: cfg, Now: time.Now, TokenGenerator: uuid.NewString, - HTTPClient: &http.Client{Timeout: 30 * time.Second}, } }