Redirect Jellyfin image routes to upstream URLs (#101)
* Redirect Jellyfin image routes to upstream URLs * fix(jellycompat): proxy images for incompatible clients
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -21,6 +21,7 @@ const (
|
||||
EncodedIDGenre EncodedIDType = 6
|
||||
EncodedIDStudio EncodedIDType = 7
|
||||
EncodedIDPerson EncodedIDType = 8
|
||||
EncodedIDImageProxy EncodedIDType = 9
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+196
-18
@@ -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) {
|
||||
|
||||
+192
-114
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user