Merge pull request #14 from Silo-Server/t3code/116da365

feat(jellycompat): sign image tags and accept them without session
This commit is contained in:
Quick
2026-05-26 22:31:23 -04:00
committed by GitHub
12 changed files with 853 additions and 125 deletions
+66 -32
View File
@@ -14,9 +14,8 @@ import (
)
type compatEpisodeTarget struct {
Item upstreamListItem
SeriesPosterURL string
SeriesBackdropURL string
Item upstreamListItem
SeriesImages seriesImageSet
}
type libraryMembershipChecker interface {
@@ -204,6 +203,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
e.rating_tmdb,
e.air_date,
e.still_path,
COALESCE(e.still_thumbhash, ''),
e.updated_at,
e.season_number,
e.episode_number,
si.content_id,
@@ -211,9 +212,12 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
si.genres,
si.content_rating,
si.poster_path,
COALESCE(si.poster_thumbhash, ''),
si.backdrop_path,
COALESCE(si.backdrop_thumbhash, ''),
si.logo_path,
si.status
si.status,
si.updated_at
FROM %s
WHERE %s
ORDER BY e.content_id
@@ -236,6 +240,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
ratingTMDB *float64
airDate *time.Time
stillPath string
stillThumbhash string
updatedAt time.Time
seasonNumber int
episodeNumber int
seriesID string
@@ -243,9 +249,12 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
genres []string
contentRating string
seriesPosterPath string
seriesPosterTH string
seriesBackdrop string
seriesBackdropTH string
seriesLogoPath string
status string
seriesUpdatedAt time.Time
)
if err := rows.Scan(
&contentID,
@@ -256,6 +265,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
&ratingTMDB,
&airDate,
&stillPath,
&stillThumbhash,
&updatedAt,
&seasonNumber,
&episodeNumber,
&seriesID,
@@ -263,45 +274,59 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
&genres,
&contentRating,
&seriesPosterPath,
&seriesPosterTH,
&seriesBackdrop,
&seriesBackdropTH,
&seriesLogoPath,
&status,
&seriesUpdatedAt,
); err != nil {
return nil, fmt.Errorf("scanning compat episode target: %w", err)
}
listItem := upstreamListItem{
ContentID: contentID,
Type: "episode",
Title: title,
Genres: genres,
ContentRating: contentRating,
Status: status,
RatingIMDB: ratingIMDB,
RatingTMDB: ratingTMDB,
Overview: overview,
PosterURL: h.presignCompatImagePath(ctx, stillPath, "still"),
BackdropURL: h.presignCompatImagePath(ctx, seriesBackdrop, "backdrop"),
LogoURL: h.presignCompatImagePath(ctx, seriesLogoPath, "logo"),
StillURL: h.presignCompatImagePath(ctx, stillPath, "still"),
PosterPath: stillPath,
BackdropPath: seriesBackdrop,
LogoPath: seriesLogoPath,
StillPath: stillPath,
SeriesID: seriesID,
SeriesTitle: seriesTitle,
SeasonNumber: intPtr(seasonNumber),
EpisodeNumber: intPtr(episodeNumber),
Runtime: runtime,
ContentID: contentID,
Type: "episode",
Title: title,
Genres: genres,
ContentRating: contentRating,
Status: status,
RatingIMDB: ratingIMDB,
RatingTMDB: ratingTMDB,
Overview: overview,
PosterURL: h.presignCompatImagePath(ctx, stillPath, "still"),
BackdropURL: h.presignCompatImagePath(ctx, seriesBackdrop, "backdrop"),
LogoURL: h.presignCompatImagePath(ctx, seriesLogoPath, "logo"),
StillURL: h.presignCompatImagePath(ctx, stillPath, "still"),
PosterPath: stillPath,
BackdropPath: seriesBackdrop,
BackdropThumbhash: seriesBackdropTH,
LogoPath: seriesLogoPath,
StillPath: stillPath,
StillThumbhash: stillThumbhash,
UpdatedAt: updatedAt,
SeriesID: seriesID,
SeriesTitle: seriesTitle,
SeasonNumber: intPtr(seasonNumber),
EpisodeNumber: intPtr(episodeNumber),
Runtime: runtime,
}
if airDate != nil {
listItem.AirDate = airDate.Format(time.DateOnly)
}
result[contentID] = compatEpisodeTarget{
Item: listItem,
SeriesPosterURL: h.presignCompatImagePath(ctx, seriesPosterPath, "poster"),
SeriesBackdropURL: h.presignCompatImagePath(ctx, seriesBackdrop, "backdrop"),
Item: listItem,
SeriesImages: seriesImageSet{
ContentID: seriesID,
PosterURL: h.presignCompatImagePath(ctx, seriesPosterPath, "poster"),
PosterPath: seriesPosterPath,
PosterThumbhash: seriesPosterTH,
BackdropURL: h.presignCompatImagePath(ctx, seriesBackdrop, "backdrop"),
BackdropPath: seriesBackdrop,
BackdropThumbhash: seriesBackdropTH,
UpdatedAt: seriesUpdatedAt,
},
}
}
if err := rows.Err(); err != nil {
@@ -385,6 +410,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDsFallback(ctx context
BackdropThumbhash: series.BackdropThumbhash,
LogoPath: series.LogoPath,
StillPath: episode.StillPath,
StillThumbhash: episode.StillThumbhash,
UpdatedAt: episode.UpdatedAt,
SeriesID: episode.SeriesID,
SeriesTitle: series.Title,
@@ -396,9 +422,17 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDsFallback(ctx context
listItem.AirDate = episode.AirDate.Format(time.DateOnly)
}
result[episode.ContentID] = compatEpisodeTarget{
Item: listItem,
SeriesPosterURL: h.presignCompatImagePath(ctx, series.PosterPath, "poster"),
SeriesBackdropURL: h.presignCompatImagePath(ctx, series.BackdropPath, "backdrop"),
Item: listItem,
SeriesImages: seriesImageSet{
ContentID: series.ContentID,
PosterURL: h.presignCompatImagePath(ctx, series.PosterPath, "poster"),
PosterPath: series.PosterPath,
PosterThumbhash: series.PosterThumbhash,
BackdropURL: h.presignCompatImagePath(ctx, series.BackdropPath, "backdrop"),
BackdropPath: series.BackdropPath,
BackdropThumbhash: series.BackdropThumbhash,
UpdatedAt: series.UpdatedAt,
},
}
}
+4 -3
View File
@@ -142,9 +142,10 @@ func (s *directContentService) ListUserLibraries(ctx context.Context, session *S
libraries := make([]upstreamUserLibrary, 0, len(folders))
for _, f := range folders {
lib := upstreamUserLibrary{
ID: f.ID,
Name: f.Name,
Type: f.Type,
ID: f.ID,
Name: f.Name,
Type: f.Type,
PosterPath: f.PosterPath,
}
if f.PosterPath != "" && s.posterPresigner != nil {
ttl := s.presignTTL
+191 -5
View File
@@ -5,8 +5,11 @@ import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
// ImagesHandler serves Jellyfin-compatible image routes.
@@ -18,14 +21,35 @@ type ImagesHandler struct {
images *ImageCache
personRepo *catalog.PersonRepository
detailSvc *catalog.DetailService
itemRepo *catalog.ItemRepository
seasonRepo *catalog.SeasonRepository
episodeRepo *catalog.EpisodeRepository
itemRepo imageItemRepository
folderRepo imageFolderRepository
seasonRepo imageSeasonRepository
episodeRepo imageEpisodeRepository
accessFilter AccessFilterResolver
posterSigner LibraryPosterPresigner
presignTTL time.Duration
imageTags *imageTagSigner
}
type imageItemRepository interface {
GetByID(ctx context.Context, contentID string) (*models.MediaItem, error)
EnsureAccessible(ctx context.Context, contentID string, filter catalog.AccessFilter) error
}
type imageSeasonRepository interface {
GetByID(ctx context.Context, contentID string) (*models.Season, error)
}
type imageEpisodeRepository interface {
GetByID(ctx context.Context, contentID string) (*models.Episode, error)
}
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, seasonRepo *catalog.SeasonRepository, episodeRepo *catalog.EpisodeRepository, accessFilter AccessFilterResolver) *ImagesHandler {
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 {
if httpClient == nil {
httpClient = http.DefaultClient
}
@@ -38,9 +62,13 @@ func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient
personRepo: personRepo,
detailSvc: detailSvc,
itemRepo: itemRepo,
folderRepo: folderRepo,
seasonRepo: seasonRepo,
episodeRepo: episodeRepo,
accessFilter: accessFilter,
posterSigner: posterSigner,
presignTTL: presignTTL,
imageTags: newImageTagSigner(imageTagSecret),
}
}
@@ -51,7 +79,23 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request)
routeID := chiURLParam(r, "id")
imageType := chiURLParam(r, "imageType")
imageSize := compatRequestImageSize(r, imageType)
if imageURL, ok := h.images.LookupSized(routeID, imageType, r.URL.Query().Get("tag"), imageSize); ok {
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
if tag != "" {
imageURL, ok, err := h.resolveItemImageURLFromTag(r.Context(), routeID, imageType, imageSize, tag)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
if ok {
h.images.RememberSizedUntil(routeID, imageType, imageURL.URL, imageSize, imageURL.ExpiresAt)
h.proxyImageURL(w, r, imageURL.URL)
return
}
if imageURL, ok := h.images.LookupTag(tag); ok {
h.proxyImageURL(w, r, imageURL)
return
}
} else if imageURL, ok := h.images.LookupSized(routeID, imageType, "", imageSize); ok {
h.proxyImageURL(w, r, imageURL)
return
}
@@ -202,6 +246,148 @@ func (h *ImagesHandler) resolveItemImageURLFromRepos(ctx context.Context, sessio
return catalog.ResolvedImageURL{}, false, nil
}
func (h *ImagesHandler) resolveItemImageURLFromTag(ctx context.Context, routeID, imageType, imageSize, tag string) (catalog.ResolvedImageURL, bool, error) {
if h.imageTags == nil || tag == "" {
return catalog.ResolvedImageURL{}, false, nil
}
if libraryID, err := h.codec.DecodeIntID(EncodedIDLibrary, routeID); err == nil {
return h.resolveLibraryImageURLFromTag(ctx, routeID, int(libraryID), imageType, imageSize, tag)
}
contentID, err := decodeContentID(h.codec, routeID)
if err != nil {
return catalog.ResolvedImageURL{}, false, nil
}
return h.resolveItemImageURLFromReposWithoutSession(ctx, routeID, contentID, imageType, imageSize, tag)
}
func (h *ImagesHandler) resolveLibraryImageURLFromTag(ctx context.Context, routeID string, libraryID int, imageType, _ string, tag string) (catalog.ResolvedImageURL, bool, error) {
if imageType != "Primary" || h.folderRepo == nil || h.posterSigner == nil {
return catalog.ResolvedImageURL{}, false, nil
}
folder, err := h.folderRepo.GetByID(ctx, libraryID)
if err != nil {
return catalog.ResolvedImageURL{}, false, nil
}
if folder.PosterPath == "" || !h.imageTags.Equal(
imageTagSeed(routeID, "Primary", compatCardImageSize, folder.PosterPath, "", time.Time{}),
"",
tag,
) {
return catalog.ResolvedImageURL{}, false, nil
}
imageURL := h.presignLibraryPosterURL(ctx, folder.PosterPath)
if imageURL == "" {
return catalog.ResolvedImageURL{}, false, nil
}
return catalog.ResolvedImageURL{URL: imageURL}, true, nil
}
func (h *ImagesHandler) presignLibraryPosterURL(ctx context.Context, posterPath string) string {
if posterPath == "" || h.posterSigner == nil {
return ""
}
ttl := h.presignTTL
if ttl <= 0 {
ttl = 4 * time.Hour
}
imageURL, err := h.posterSigner.PresignGetURL(ctx, h.posterSigner.Bucket(), posterPath, ttl)
if err != nil {
return ""
}
return imageURL
}
func (h *ImagesHandler) resolveItemImageURLFromReposWithoutSession(ctx context.Context, routeID, contentID, imageType, imageSize, tag string) (catalog.ResolvedImageURL, bool, error) {
if h.itemRepo != nil {
if item, err := h.itemRepo.GetByID(ctx, contentID); err == nil {
if imageURL := h.imageURLForItem(ctx, item.PosterPath, "poster", item.BackdropPath, item.LogoPath, imageType, imageSize); imageURL.URL != "" {
if !h.signedImageTagMatches(routeID, contentID, imageType, tag, item.PosterPath, item.PosterThumbhash, item.BackdropPath, item.BackdropThumbhash, item.LogoPath, item.UpdatedAt, imageURL.URL) {
return catalog.ResolvedImageURL{}, false, nil
}
return imageURL, true, nil
}
} else if !errors.Is(err, catalog.ErrItemNotFound) {
return catalog.ResolvedImageURL{}, false, wrapCatalogError(err)
}
}
if h.episodeRepo != nil && h.itemRepo != nil {
if episode, err := h.episodeRepo.GetByID(ctx, contentID); err == nil {
series, seriesErr := h.itemRepo.GetByID(ctx, episode.SeriesID)
if seriesErr != nil {
if !errors.Is(seriesErr, catalog.ErrItemNotFound) {
return catalog.ResolvedImageURL{}, false, wrapCatalogError(seriesErr)
}
} else {
if imageURL := h.imageURLForItem(ctx, episode.StillPath, "still", series.BackdropPath, series.LogoPath, imageType, imageSize); imageURL.URL != "" {
if !h.signedImageTagMatches(routeID, contentID, imageType, tag, episode.StillPath, episode.StillThumbhash, series.BackdropPath, series.BackdropThumbhash, series.LogoPath, episode.UpdatedAt, imageURL.URL) {
return catalog.ResolvedImageURL{}, false, nil
}
return imageURL, true, nil
}
}
} else if !errors.Is(err, catalog.ErrEpisodeNotFound) {
return catalog.ResolvedImageURL{}, false, wrapCatalogError(err)
}
}
if h.seasonRepo != nil && h.itemRepo != nil {
if season, err := h.seasonRepo.GetByID(ctx, contentID); err == nil {
series, seriesErr := h.itemRepo.GetByID(ctx, season.SeriesID)
if seriesErr != nil {
if errors.Is(seriesErr, catalog.ErrItemNotFound) {
return catalog.ResolvedImageURL{}, false, nil
}
return catalog.ResolvedImageURL{}, false, wrapCatalogError(seriesErr)
}
if imageURL := h.imageURLForItem(ctx, season.PosterPath, "poster", series.BackdropPath, series.LogoPath, imageType, imageSize); imageURL.URL != "" {
if !h.signedImageTagMatches(routeID, contentID, imageType, tag, season.PosterPath, season.PosterThumbhash, series.BackdropPath, series.BackdropThumbhash, series.LogoPath, season.UpdatedAt, imageURL.URL) {
return catalog.ResolvedImageURL{}, false, nil
}
return imageURL, true, nil
}
} else if !errors.Is(err, catalog.ErrSeasonNotFound) {
return catalog.ResolvedImageURL{}, false, wrapCatalogError(err)
}
}
return catalog.ResolvedImageURL{}, false, nil
}
func (h *ImagesHandler) signedImageTagMatches(routeID, contentID, imageType, tag, primaryPath, primaryThumbhash, backdropPath, backdropThumbhash, logoPath string, updatedAt time.Time, resolvedURL string) bool {
var path, thumbhash, tagImageType string
switch imageType {
case "Primary":
path = primaryPath
thumbhash = primaryThumbhash
tagImageType = "Primary"
case "Backdrop", "Thumb":
path = backdropPath
thumbhash = backdropThumbhash
tagImageType = "Backdrop"
case "Logo":
path = logoPath
tagImageType = "Logo"
default:
return false
}
if path != "" && h.imageTags.Equal(
imageTagSeed(contentID, tagImageType, compatCardImageSize, path, thumbhash, updatedAt),
path,
tag,
) {
return true
}
if resolvedURL == "" {
return false
}
return h.imageTags.Equal(
imageTagSeed(routeID, tagImageType, compatCardImageSize, resolvedURL, "", time.Time{}),
resolvedURL,
tag,
)
}
func (h *ImagesHandler) imageURLForItem(ctx context.Context, primaryPath, primaryImageType, backdropPath, logoPath, imageType, size string) catalog.ResolvedImageURL {
primaryURL := compatPresignImageWithExpiry(h.detailSvc, ctx, primaryPath, primaryImageType, size)
backdropURL := compatPresignImageWithExpiry(h.detailSvc, ctx, backdropPath, "backdrop", size)
+23 -39
View File
@@ -212,26 +212,8 @@ func (h *ItemsHandler) HandleItem(w http.ResponseWriter, r *http.Request) {
}
}
if strings.EqualFold(detail.Type, "episode") && detail.SeriesID != "" {
seriesRouteID := h.codec.EncodeStringID(EncodedIDItem, detail.SeriesID)
cachedPoster, _ := h.images.LookupSized(seriesRouteID, "Primary", "", compatCardImageSize)
cachedBackdrop, _ := h.images.LookupSized(seriesRouteID, "Backdrop", "", compatCardImageSize)
if cachedPoster != "" && cachedBackdrop != "" {
// Both poster and backdrop hit — populate from cache and skip the
// second GetItemDetail call against the parent series. Cache is
// populated by browse/list/recommendation responses for the series.
// Audit 2026-05-01 §3.4. We require BOTH because a partial hit
// (only one URL cached) would silently degrade the response — the
// fallback fetch can populate both.
h.mapper.applySeriesImages(&dto, cachedPoster, cachedBackdrop)
if h.images != nil {
h.images.RememberSized(dto.SeriesID, "Thumb", cachedBackdrop, compatCardImageSize)
}
} else {
// Cache miss or partial — fall back to original series-detail fetch.
seriesImgCache := make(map[string]seriesImageURLs)
h.enrichEpisodeSeriesImages(r.Context(), session, &dto, detail.SeriesID, seriesImgCache)
}
seriesImgCache := make(map[string]seriesImageSet)
h.enrichEpisodeSeriesImages(r.Context(), session, &dto, detail.SeriesID, seriesImgCache)
if detail.SeasonNumber != nil {
season, seasonErr := h.content.GetSeason(r.Context(), session, detail.SeriesID, *detail.SeasonNumber, nil)
if seasonErr == nil && season != nil {
@@ -1910,22 +1892,22 @@ func (h *ItemsHandler) presignCompatImagePath(ctx context.Context, path, imageTy
return compatPresignImage(h.detailSvc, ctx, path, imageType, compatCardImageSize)
}
func (h *ItemsHandler) rememberCompatEpisodeImages(dto baseItemDTO, stillURL, seriesPosterURL, seriesBackdropURL string) {
func (h *ItemsHandler) rememberCompatEpisodeImages(dto baseItemDTO, stillURL string, series seriesImageSet) {
if h.images == nil {
return
}
h.images.RememberSized(dto.ID, "Primary", stillURL, compatCardImageSize)
h.images.RememberSized(dto.ID, "Backdrop", seriesBackdropURL, compatCardImageSize)
h.images.RememberSized(dto.ID, "Backdrop", series.BackdropURL, compatCardImageSize)
if dto.SeriesID != "" {
h.images.RememberSized(dto.SeriesID, "Primary", seriesPosterURL, compatCardImageSize)
h.images.RememberSized(dto.SeriesID, "Backdrop", seriesBackdropURL, compatCardImageSize)
h.images.RememberSized(dto.SeriesID, "Thumb", seriesBackdropURL, compatCardImageSize)
h.images.RememberSized(dto.SeriesID, "Primary", series.PosterURL, compatCardImageSize)
h.images.RememberSized(dto.SeriesID, "Backdrop", series.BackdropURL, compatCardImageSize)
h.images.RememberSized(dto.SeriesID, "Thumb", series.BackdropURL, compatCardImageSize)
}
}
func (h *ItemsHandler) applyCompatEpisodeTarget(dto *baseItemDTO, target compatEpisodeTarget) {
h.mapper.applySeriesImages(dto, target.SeriesPosterURL, target.SeriesBackdropURL)
h.rememberCompatEpisodeImages(*dto, firstNonEmpty(target.Item.StillURL, target.Item.PosterURL), target.SeriesPosterURL, target.SeriesBackdropURL)
h.mapper.applySeriesImages(dto, target.SeriesImages)
h.rememberCompatEpisodeImages(*dto, firstNonEmpty(target.Item.StillURL, target.Item.PosterURL), target.SeriesImages)
}
func (h *ItemsHandler) listSeriesEpisodes(ctx context.Context, session *Session, seriesID string, seasons []upstreamSeason, requestedSeasonID string) ([]*models.Episode, error) {
@@ -2164,17 +2146,10 @@ func (h *ItemsHandler) rememberEpisodeImages(episodes []upstreamEpisode) {
}
}
// seriesImageURLs holds poster/backdrop URLs for a series, used to populate
// series image tags on episode DTOs for clients like Infuse.
type seriesImageURLs struct {
posterURL string
backdropURL string
}
// enrichEpisodeSeriesImages looks up the parent series poster/backdrop and
// applies them to an episode DTO. The cache avoids repeated lookups when
// multiple episodes belong to the same series.
func (h *ItemsHandler) enrichEpisodeSeriesImages(ctx context.Context, session *Session, dto *baseItemDTO, seriesContentID string, cache map[string]seriesImageURLs) {
func (h *ItemsHandler) enrichEpisodeSeriesImages(ctx context.Context, session *Session, dto *baseItemDTO, seriesContentID string, cache map[string]seriesImageSet) {
if seriesContentID == "" || dto.SeriesID == "" {
return
}
@@ -2182,14 +2157,23 @@ func (h *ItemsHandler) enrichEpisodeSeriesImages(ctx context.Context, session *S
if !ok {
detail, err := h.content.GetItemDetail(ctx, session, seriesContentID, nil)
if err == nil {
imgs = seriesImageURLs{posterURL: detail.PosterURL, backdropURL: detail.BackdropURL}
imgs = seriesImageSet{
ContentID: detail.ContentID,
PosterURL: detail.PosterURL,
PosterPath: detail.PosterPath,
PosterThumbhash: detail.PosterThumbhash,
BackdropURL: detail.BackdropURL,
BackdropPath: detail.BackdropPath,
BackdropThumbhash: detail.BackdropThumbhash,
UpdatedAt: detail.UpdatedAt,
}
h.rememberDetailImages(*detail)
}
cache[seriesContentID] = imgs
}
h.mapper.applySeriesImages(dto, imgs.posterURL, imgs.backdropURL)
if imgs.backdropURL != "" && h.images != nil {
h.images.RememberSized(dto.SeriesID, "Thumb", imgs.backdropURL, compatCardImageSize)
h.mapper.applySeriesImages(dto, imgs)
if imgs.BackdropURL != "" && h.images != nil {
h.images.RememberSized(dto.SeriesID, "Thumb", imgs.BackdropURL, compatCardImageSize)
}
}
+7 -7
View File
@@ -70,11 +70,11 @@ func (s *countingContentService) ListItemFilters(context.Context, *Session, url.
panic("unused")
}
// TestHandleItem_Episode_UsesImageCacheBeforeSeriesDetail verifies that when an
// episode detail is requested and the series's poster/backdrop are already in
// the ImageCache (e.g. from a prior browse response), the handler does NOT
// fetch the parent series detail a second time. Audit 2026-05-01 §3.4.
func TestHandleItem_Episode_UsesImageCacheBeforeSeriesDetail(t *testing.T) {
// TestHandleItem_Episode_FetchesSeriesDetailForStableParentImageTags verifies
// that episode detail responses fetch parent series image metadata even when
// image URLs are already cached. Cached URLs are not enough to build stable
// signed tags after Jellycompat restarts.
func TestHandleItem_Episode_FetchesSeriesDetailForStableParentImageTags(t *testing.T) {
codec := NewResourceIDCodec()
episodeContentID := "ep1"
seriesContentID := "series-1"
@@ -123,8 +123,8 @@ func TestHandleItem_Episode_UsesImageCacheBeforeSeriesDetail(t *testing.T) {
if rec.Code != 200 {
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
}
if contentSvc.getItemDetailCalls != 1 {
t.Errorf("expected exactly 1 GetItemDetail (episode only); got %d",
if contentSvc.getItemDetailCalls != 2 {
t.Errorf("expected episode and series GetItemDetail calls for stable parent image tags; got %d",
contentSvc.getItemDetailCalls)
}
}
+9 -3
View File
@@ -94,9 +94,7 @@ func (c *ImageCache) LookupSized(routeID, imageType, tag, size string) (string,
}
if tag = strings.TrimSpace(tag); tag != "" {
if url, ok := c.lookupTag(tag); ok {
return url, true
}
return c.LookupTag(tag)
}
if routeID == "" || imageType == "" {
@@ -105,6 +103,14 @@ func (c *ImageCache) LookupSized(routeID, imageType, tag, size string) (string,
return c.lookupRoute(routeImageKey(routeID, imageType, size))
}
// LookupTag resolves a cached image URL only by its legacy URL-derived tag.
func (c *ImageCache) LookupTag(tag string) (string, bool) {
if c == nil {
return "", false
}
return c.lookupTag(strings.TrimSpace(tag))
}
// lookupTag resolves a tag without size partitioning. Tags are sha1 of the
// presigned URL: for S3-cached paths the size variant is embedded in the URL
// (so different sizes produce different tags), and for HTTP-passthrough URLs
+49
View File
@@ -0,0 +1,49 @@
package jellycompat
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"strings"
)
const imageTagSignatureDomain = "silo:jellycompat:image-tag:v1"
type imageTagSigner struct {
secret []byte
}
func newImageTagSigner(secret string) *imageTagSigner {
if strings.TrimSpace(secret) == "" {
return nil
}
return &imageTagSigner{secret: []byte(secret)}
}
func (s *imageTagSigner) Tag(seed, fallbackURL string) string {
if strings.TrimSpace(seed) == "" {
return tagValue(fallbackURL)
}
if s == nil {
return tagValue(seed)
}
mac := hmac.New(sha256.New, s.secret)
_, _ = mac.Write([]byte(imageTagSignatureDomain))
_, _ = mac.Write([]byte{0})
_, _ = mac.Write([]byte(seed))
sum := mac.Sum(nil)
return hex.EncodeToString(sum[:8])
}
func (s *imageTagSigner) Equal(seed, fallbackURL, actual string) bool {
if s == nil {
return false
}
actual = strings.TrimSpace(actual)
expected := s.Tag(seed, fallbackURL)
if expected == "" || actual == "" || len(expected) != len(actual) {
return false
}
return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
}
+307
View File
@@ -1,11 +1,19 @@
package jellycompat
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestProxyImageDefaultsToRevalidatingCachePolicy(t *testing.T) {
@@ -86,3 +94,302 @@ func TestProxyImageURLForwardsConditionalHeaders(t *testing.T) {
t.Fatalf("status = %d, want 304", rec.Code)
}
}
func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte("image-bytes"))
}))
defer upstream.Close()
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,
httpClient: upstream.Client(),
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.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 != "image-bytes" {
t.Fatalf("body = %q, want image bytes", 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")
}
}
func TestHandleItemImageRejectsUnsignedTagWhenSecretBlank(t *testing.T) {
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: "https://cdn.example.test/poster.jpg",
PosterThumbhash: "poster-thumbhash",
UpdatedAt: updatedAt,
}
tag := newMapper(codec, &config.Config{}).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,
httpClient: http.DefaultClient,
itemRepo: fakeImageItemRepo{item: item},
imageTags: newImageTagSigner(""),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil)
req = withImageRouteParams(req, routeID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, body = %s; want 401", rec.Code, rec.Body.String())
}
}
func TestHandleItemImageAcceptsSignedCanonicalBackdropTagWithoutSessionOrCache(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte("backdrop-bytes"))
}))
defer upstream.Close()
codec := NewResourceIDCodec()
contentID := "series-1"
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
secret := "image-secret"
tag := newImageTagSigner(secret).Tag(
imageTagSeed(contentID, "Backdrop", compatCardImageSize, upstream.URL, "", time.Time{}),
upstream.URL,
)
h := &ImagesHandler{
codec: codec,
httpClient: upstream.Client(),
images: NewImageCache(time.Hour, time.Now),
itemRepo: fakeImageItemRepo{item: &models.MediaItem{
ContentID: contentID,
BackdropPath: upstream.URL,
}},
imageTags: newImageTagSigner(secret),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Thumb?fillHeight=267&fillWidth=474&quality=96&tag="+tag, nil)
req = withImageRouteParams(req, routeID, "Thumb")
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 != "backdrop-bytes" {
t.Fatalf("body = %q, want backdrop bytes", got)
}
}
func TestHandleItemImageAcceptsLibraryPosterTagWithoutSessionOrCache(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte("library-poster"))
}))
defer upstream.Close()
codec := NewResourceIDCodec()
libraryID := 1
routeID := codec.EncodeIntID(EncodedIDLibrary, int64(libraryID))
posterPath := "library-posters/1/original.jpg"
secret := "image-secret"
tag := newImageTagSigner(secret).Tag(
imageTagSeed(routeID, "Primary", compatCardImageSize, posterPath, "", time.Time{}),
"",
)
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},
imageTags: newImageTagSigner(secret),
}
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.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)
}
}
func TestHandleItemImageAcceptsLegacyCachedURLTagWithoutRouteFallback(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte("cached-image"))
}))
defer upstream.Close()
codec := NewResourceIDCodec()
routeID := codec.EncodeStringID(EncodedIDItem, "movie-1")
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"),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tagValue(upstream.URL), nil)
req = withImageRouteParams(req, routeID, "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 != "cached-image" {
t.Fatalf("body = %q, want cached image", got)
}
}
func TestHandleItemImageRevalidatesTagBeforeRouteCacheHit(t *testing.T) {
called := false
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
_, _ = w.Write([]byte("stale-image"))
}))
defer upstream.Close()
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,
}
cache := NewImageCache(time.Hour, func() time.Time { return updatedAt })
cache.RememberSized(routeID, "Primary", upstream.URL, compatCardImageSize)
tag := newMapper(codec, &config.Config{
Auth: config.AuthConfig{JWTSecret: "old-secret"},
}).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,
httpClient: upstream.Client(),
images: cache,
itemRepo: fakeImageItemRepo{item: item},
imageTags: newImageTagSigner("new-secret"),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil)
req = withImageRouteParams(req, routeID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, body = %s; want 401", rec.Code, rec.Body.String())
}
if called {
t.Fatal("served cached image before validating the signed tag")
}
}
type fakeImageItemRepo struct {
item *models.MediaItem
}
func (r fakeImageItemRepo) GetByID(_ context.Context, contentID string) (*models.MediaItem, error) {
if r.item != nil && r.item.ContentID == contentID {
return r.item, nil
}
return nil, catalog.ErrItemNotFound
}
func (r fakeImageItemRepo) EnsureAccessible(context.Context, string, catalog.AccessFilter) error {
return nil
}
type fakeImageFolderRepo struct {
folder *models.MediaFolder
}
func (r fakeImageFolderRepo) GetByID(_ context.Context, id int) (*models.MediaFolder, error) {
if r.folder != nil && r.folder.ID == id {
return r.folder, nil
}
return nil, catalog.ErrFolderNotFound
}
type fakeLibraryPosterPresigner struct {
url string
}
func (p fakeLibraryPosterPresigner) PresignGetURL(context.Context, string, string, time.Duration) (string, error) {
return p.url, nil
}
func (p fakeLibraryPosterPresigner) Bucket() string {
return "test-bucket"
}
func withImageRouteParams(r *http.Request, routeID, imageType string) *http.Request {
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", routeID)
routeCtx.URLParams.Add("imageType", imageType)
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeCtx))
}
+57 -31
View File
@@ -23,26 +23,33 @@ var allDetailFields = map[string]bool{
}
type mapper struct {
codec *ResourceIDCodec
serverID string
codec *ResourceIDCodec
serverID string
imageTagSigner *imageTagSigner
}
func newMapper(codec *ResourceIDCodec, cfg *config.Config) *mapper {
serverID := ""
imageTagSecret := ""
if cfg != nil {
serverID = cfg.JellyfinCompat.ServerID
imageTagSecret = cfg.Auth.JWTSecret
}
return &mapper{codec: codec, serverID: serverID}
return &mapper{codec: codec, serverID: serverID, imageTagSigner: newImageTagSigner(imageTagSecret)}
}
func (m *mapper) viewFromLibrary(library upstreamUserLibrary) baseItemDTO {
imgTags := map[string]string{}
if library.PosterURL != "" {
imgTags["Primary"] = tagValue(library.PosterURL)
routeID := m.codec.EncodeIntID(EncodedIDLibrary, int64(library.ID))
if library.PosterPath != "" {
imgTags["Primary"] = m.imageTagSigner.Tag(
imageTagSeed(routeID, "Primary", compatCardImageSize, library.PosterPath, "", time.Time{}),
library.PosterURL,
)
}
return baseItemDTO{
ID: m.codec.EncodeIntID(EncodedIDLibrary, int64(library.ID)),
ID: routeID,
Type: "CollectionFolder",
MediaType: "Unknown",
IsFolder: true,
@@ -52,8 +59,8 @@ func (m *mapper) viewFromLibrary(library upstreamUserLibrary) baseItemDTO {
SortName: strings.ToLower(library.Name),
ImageTags: imgTags,
UserData: &itemUserDataDTO{
Key: m.codec.EncodeIntID(EncodedIDLibrary, int64(library.ID)),
ItemID: m.codec.EncodeIntID(EncodedIDLibrary, int64(library.ID)),
Key: routeID,
ItemID: routeID,
},
}
}
@@ -102,13 +109,14 @@ func (m *mapper) itemFromList(item upstreamListItem, isFavorite bool, progress *
dto.ChildCount = *item.SeasonCount
dto.RecursiveItemCount = *item.SeasonCount
}
if tags := imageTagsWithSeed(
imageTagSeed(item.ContentID, "Primary", compatCardImageSize, firstNonEmpty(item.PosterPath, item.StillPath), item.PosterThumbhash, item.UpdatedAt),
primaryPath, primaryThumbhash := listItemPrimaryImageSeedParts(item)
if tags := imageTagsWithSeed(m.imageTagSigner,
imageTagSeed(item.ContentID, "Primary", compatCardImageSize, primaryPath, primaryThumbhash, item.UpdatedAt),
item.PosterURL,
); tags != nil {
dto.ImageTags = tags
}
if tags := backdropTagsWithSeed(
if tags := backdropTagsWithSeed(m.imageTagSigner,
imageTagSeed(item.ContentID, "Backdrop", compatCardImageSize, item.BackdropPath, item.BackdropThumbhash, item.UpdatedAt),
item.BackdropURL,
); tags != nil {
@@ -397,7 +405,7 @@ func (m *mapper) seasonFromUpstream(season upstreamSeason, seriesID string, isFa
RecursiveItemCount: season.EpisodeCount,
}
dto.IndexNumber = &season.SeasonNumber
if tags := imageTagsWithSeed(
if tags := imageTagsWithSeed(m.imageTagSigner,
imageTagSeed(season.ContentID, "Primary", compatCardImageSize, season.PosterPath, season.PosterThumbhash, season.UpdatedAt),
season.PosterURL,
); tags != nil {
@@ -430,7 +438,7 @@ func (m *mapper) episodeFromUpstream(ep upstreamEpisode, isFavorite bool, progre
dto.SeasonID = m.codec.EncodeStringID(EncodedIDSeason, ep.SeasonID)
dto.ParentID = m.codec.EncodeStringID(EncodedIDSeason, ep.SeasonID)
}
if tags := imageTagsWithSeed(
if tags := imageTagsWithSeed(m.imageTagSigner,
imageTagSeed(ep.ContentID, "Primary", compatCardImageSize, ep.StillPath, ep.StillThumbhash, ep.UpdatedAt),
ep.StillURL,
); tags != nil {
@@ -439,19 +447,37 @@ func (m *mapper) episodeFromUpstream(ep upstreamEpisode, isFavorite bool, progre
return dto
}
type seriesImageSet struct {
ContentID string
PosterURL string
PosterPath string
PosterThumbhash string
BackdropURL string
BackdropPath string
BackdropThumbhash string
UpdatedAt time.Time
}
// applySeriesImages sets series/parent image tags on an episode DTO so clients
// can display the series poster and backdrop in Continue Watching / Next Up.
func (m *mapper) applySeriesImages(dto *baseItemDTO, seriesPosterURL, seriesBackdropURL string) {
func (m *mapper) applySeriesImages(dto *baseItemDTO, series seriesImageSet) {
if dto.SeriesID == "" {
return
}
if seriesPosterURL != "" {
dto.SeriesPrimaryImageTag = tagValue(seriesPosterURL)
if series.PosterURL != "" {
dto.SeriesPrimaryImageTag = m.imageTagSigner.Tag(
imageTagSeed(series.ContentID, "Primary", compatCardImageSize, series.PosterPath, series.PosterThumbhash, series.UpdatedAt),
series.PosterURL,
)
}
if seriesBackdropURL != "" {
dto.ParentBackdropImageTags = backdropTags(seriesBackdropURL)
if series.BackdropURL != "" {
tag := m.imageTagSigner.Tag(
imageTagSeed(series.ContentID, "Backdrop", compatCardImageSize, series.BackdropPath, series.BackdropThumbhash, series.UpdatedAt),
series.BackdropURL,
)
dto.ParentBackdropImageTags = []string{tag}
dto.ParentBackdropItemID = dto.SeriesID
dto.ParentThumbImageTag = tagValue(seriesBackdropURL)
dto.ParentThumbImageTag = tag
dto.ParentThumbItemID = dto.SeriesID
}
}
@@ -638,22 +664,29 @@ func resumePositionTicks(position, duration float64, played bool) int64 {
return secondsToTicks(position)
}
func imageTagsWithSeed(seed, imageURL string) map[string]string {
func imageTagsWithSeed(signer *imageTagSigner, seed, imageURL string) map[string]string {
if imageURL == "" {
return nil
}
return map[string]string{"Primary": imageTagValue(seed, imageURL)}
return map[string]string{"Primary": signer.Tag(seed, imageURL)}
}
func backdropTags(imageURL string) []string {
return backdropTagsWithSeed("", imageURL)
return backdropTagsWithSeed(nil, "", imageURL)
}
func backdropTagsWithSeed(seed, imageURL string) []string {
func backdropTagsWithSeed(signer *imageTagSigner, seed, imageURL string) []string {
if imageURL == "" {
return nil
}
return []string{imageTagValue(seed, imageURL)}
return []string{signer.Tag(seed, imageURL)}
}
func listItemPrimaryImageSeedParts(item upstreamListItem) (string, string) {
if item.Type == "episode" && item.StillPath != "" {
return item.StillPath, item.StillThumbhash
}
return firstNonEmpty(item.PosterPath, item.StillPath), item.PosterThumbhash
}
func imageTagSeed(routeID, imageType, size, rawPath, thumbhash string, updatedAt time.Time) string {
@@ -675,13 +708,6 @@ func imageTagSeed(routeID, imageType, size, rawPath, thumbhash string, updatedAt
return strings.Join(parts, "\x00")
}
func imageTagValue(seed, fallbackURL string) string {
if seed != "" {
return tagValue(seed)
}
return tagValue(fallbackURL)
}
func tagValue(raw string) string {
if raw == "" {
return ""
+130
View File
@@ -55,3 +55,133 @@ func TestItemImageTagsFallbackToURLWhenCanonicalSeedMissing(t *testing.T) {
t.Fatalf("fallback image tag did not change with URL: %q", first.ImageTags["Primary"])
}
}
func TestItemImageTagsUseConfiguredSecret(t *testing.T) {
item := upstreamListItem{
ContentID: "movie-1",
Type: "movie",
Title: "Movie",
PosterURL: "https://cdn.example.test/poster.jpg?sig=one",
PosterPath: "metadb://poster/movie-1",
PosterThumbhash: "thumbhash",
UpdatedAt: time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC),
}
first := newMapper(NewResourceIDCodec(), &config.Config{
Auth: config.AuthConfig{JWTSecret: "secret-one"},
}).itemFromList(item, false, nil, nil)
second := newMapper(NewResourceIDCodec(), &config.Config{
Auth: config.AuthConfig{JWTSecret: "secret-two"},
}).itemFromList(item, false, nil, nil)
if first.ImageTags["Primary"] == second.ImageTags["Primary"] {
t.Fatalf("signed image tag did not change with configured secret: %q", first.ImageTags["Primary"])
}
}
func TestEpisodeListImageTagsUseStillThumbhash(t *testing.T) {
secret := "image-secret"
updatedAt := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
item := upstreamListItem{
ContentID: "episode-1",
Type: "episode",
Title: "Episode",
PosterURL: "https://cdn.example.test/still.jpg?sig=one",
PosterPath: "metadb://still/episode-1",
PosterThumbhash: "poster-thumbhash",
StillPath: "metadb://still/episode-1",
StillThumbhash: "still-thumbhash",
UpdatedAt: updatedAt,
}
dto := newMapper(NewResourceIDCodec(), &config.Config{
Auth: config.AuthConfig{JWTSecret: secret},
}).itemFromList(item, false, nil, nil)
expected := newImageTagSigner(secret).Tag(
imageTagSeed(item.ContentID, "Primary", compatCardImageSize, item.StillPath, item.StillThumbhash, updatedAt),
item.PosterURL,
)
if dto.ImageTags["Primary"] != expected {
t.Fatalf("primary tag = %q, want still-thumbhash seed %q", dto.ImageTags["Primary"], expected)
}
}
func TestLibraryImageTagsUseStablePosterPath(t *testing.T) {
secret := "image-secret"
codec := NewResourceIDCodec()
library := upstreamUserLibrary{
ID: 1,
Name: "Movies",
Type: "movies",
PosterURL: "https://cdn.example.test/library.jpg?sig=one",
PosterPath: "library-posters/1/original.jpg",
}
first := newMapper(codec, &config.Config{
Auth: config.AuthConfig{JWTSecret: secret},
}).viewFromLibrary(library)
library.PosterURL = "https://cdn.example.test/library.jpg?sig=two"
second := newMapper(codec, &config.Config{
Auth: config.AuthConfig{JWTSecret: secret},
}).viewFromLibrary(library)
routeID := codec.EncodeIntID(EncodedIDLibrary, int64(library.ID))
expected := newImageTagSigner(secret).Tag(
imageTagSeed(routeID, "Primary", compatCardImageSize, library.PosterPath, "", time.Time{}),
library.PosterURL,
)
if first.ImageTags["Primary"] == "" {
t.Fatal("library primary image tag is empty")
}
if first.ImageTags["Primary"] != second.ImageTags["Primary"] {
t.Fatalf("library tag changed when only signed URL changed: %q vs %q", first.ImageTags["Primary"], second.ImageTags["Primary"])
}
if second.ImageTags["Primary"] != expected {
t.Fatalf("library tag = %q, want %q", second.ImageTags["Primary"], expected)
}
}
func TestApplySeriesImagesUsesCanonicalSeriesSeeds(t *testing.T) {
secret := "image-secret"
codec := NewResourceIDCodec()
seriesContentID := "series-1"
seriesRouteID := codec.EncodeStringID(EncodedIDItem, seriesContentID)
updatedAt := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
dto := baseItemDTO{SeriesID: seriesRouteID}
series := seriesImageSet{
ContentID: seriesContentID,
PosterURL: "https://cdn.example.test/poster.jpg?sig=one",
PosterPath: "metadb://poster/series-1",
PosterThumbhash: "poster-thumbhash",
BackdropURL: "https://cdn.example.test/backdrop.jpg?sig=one",
BackdropPath: "metadb://backdrop/series-1",
BackdropThumbhash: "backdrop-thumbhash",
UpdatedAt: updatedAt,
}
newMapper(codec, &config.Config{
Auth: config.AuthConfig{JWTSecret: secret},
}).applySeriesImages(&dto, series)
signer := newImageTagSigner(secret)
expectedPrimary := signer.Tag(
imageTagSeed(series.ContentID, "Primary", compatCardImageSize, series.PosterPath, series.PosterThumbhash, updatedAt),
series.PosterURL,
)
expectedBackdrop := signer.Tag(
imageTagSeed(series.ContentID, "Backdrop", compatCardImageSize, series.BackdropPath, series.BackdropThumbhash, updatedAt),
series.BackdropURL,
)
if dto.SeriesPrimaryImageTag != expectedPrimary {
t.Fatalf("SeriesPrimaryImageTag = %q, want %q", dto.SeriesPrimaryImageTag, expectedPrimary)
}
if len(dto.ParentBackdropImageTags) != 1 || dto.ParentBackdropImageTags[0] != expectedBackdrop {
t.Fatalf("ParentBackdropImageTags = %#v, want [%q]", dto.ParentBackdropImageTags, expectedBackdrop)
}
if dto.ParentThumbImageTag != expectedBackdrop {
t.Fatalf("ParentThumbImageTag = %q, want %q", dto.ParentThumbImageTag, expectedBackdrop)
}
}
+4 -1
View File
@@ -98,7 +98,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.SeasonRepo, deps.EpisodeRepo, deps.AccessFilterFn)
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)
displayPrefsHandler := NewDisplayPreferencesHandler(deps.UserStoreProvider)
recsHandler := NewRecommendationsHandler(deps.Recommender, deps.ItemRepo, deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config, deps.AccessFilterFn)
@@ -228,6 +228,9 @@ func withDefaults(deps Dependencies) Dependencies {
if deps.Now == nil {
deps.Now = timeNow
}
if deps.JWTSecret == "" && deps.Config != nil {
deps.JWTSecret = deps.Config.Auth.JWTSecret
}
if deps.TokenGenerator == nil {
deps.TokenGenerator = uuidNewString
}
+6 -4
View File
@@ -11,10 +11,11 @@ import (
// catalog/service layer and the Jellyfin DTO mapping layer.
type upstreamUserLibrary struct {
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
PosterURL string `json:"poster_url,omitempty"`
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
PosterURL string `json:"poster_url,omitempty"`
PosterPath string `json:"-"`
}
type upstreamListItem struct {
@@ -36,6 +37,7 @@ type upstreamListItem struct {
BackdropThumbhash string `json:"-"`
LogoPath string `json:"-"`
StillPath string `json:"-"`
StillThumbhash string `json:"-"`
UpdatedAt time.Time `json:"-"`
SeasonCount *int `json:"season_count,omitempty"`
SeriesID string `json:"series_id,omitempty"`