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:
Quick
2026-06-08 22:36:22 -04:00
committed by GitHub
parent 2fa30379ad
commit 7c64c06e3d
11 changed files with 799 additions and 156 deletions
+1
View File
@@ -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"`
+56 -15
View File
@@ -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())
+1
View File
@@ -21,6 +21,7 @@ const (
EncodedIDGenre EncodedIDType = 6
EncodedIDStudio EncodedIDType = 7
EncodedIDPerson EncodedIDType = 8
EncodedIDImageProxy EncodedIDType = 9
)
var (
+2 -2
View File
@@ -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
+233
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+5 -4
View File
@@ -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 {
+1 -2
View File
@@ -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},
}
}