diff --git a/internal/jellycompat/batch_loaders.go b/internal/jellycompat/batch_loaders.go index ad42130b..23001a22 100644 --- a/internal/jellycompat/batch_loaders.go +++ b/internal/jellycompat/batch_loaders.go @@ -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, + }, } } diff --git a/internal/jellycompat/content_direct.go b/internal/jellycompat/content_direct.go index 2aa2f0df..05e0bd4d 100644 --- a/internal/jellycompat/content_direct.go +++ b/internal/jellycompat/content_direct.go @@ -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 diff --git a/internal/jellycompat/handlers_images.go b/internal/jellycompat/handlers_images.go index 0103a667..5dd65885 100644 --- a/internal/jellycompat/handlers_images.go +++ b/internal/jellycompat/handlers_images.go @@ -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) diff --git a/internal/jellycompat/handlers_items.go b/internal/jellycompat/handlers_items.go index f0d6d459..f9986152 100644 --- a/internal/jellycompat/handlers_items.go +++ b/internal/jellycompat/handlers_items.go @@ -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) } } diff --git a/internal/jellycompat/handlers_items_test.go b/internal/jellycompat/handlers_items_test.go index 2eae9614..91006012 100644 --- a/internal/jellycompat/handlers_items_test.go +++ b/internal/jellycompat/handlers_items_test.go @@ -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) } } diff --git a/internal/jellycompat/image_cache.go b/internal/jellycompat/image_cache.go index e965bf9d..ab0939f4 100644 --- a/internal/jellycompat/image_cache.go +++ b/internal/jellycompat/image_cache.go @@ -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 diff --git a/internal/jellycompat/image_tag_signer.go b/internal/jellycompat/image_tag_signer.go new file mode 100644 index 00000000..a2669043 --- /dev/null +++ b/internal/jellycompat/image_tag_signer.go @@ -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 +} diff --git a/internal/jellycompat/images_test.go b/internal/jellycompat/images_test.go index d8059425..43e38e29 100644 --- a/internal/jellycompat/images_test.go +++ b/internal/jellycompat/images_test.go @@ -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)) +} diff --git a/internal/jellycompat/mapping.go b/internal/jellycompat/mapping.go index fbbc01de..e9609bdb 100644 --- a/internal/jellycompat/mapping.go +++ b/internal/jellycompat/mapping.go @@ -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 "" diff --git a/internal/jellycompat/mapping_images_test.go b/internal/jellycompat/mapping_images_test.go index 9e185cca..0b141e8f 100644 --- a/internal/jellycompat/mapping_images_test.go +++ b/internal/jellycompat/mapping_images_test.go @@ -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) + } +} diff --git a/internal/jellycompat/router.go b/internal/jellycompat/router.go index f7c2a03d..ae6bf322 100644 --- a/internal/jellycompat/router.go +++ b/internal/jellycompat/router.go @@ -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 } diff --git a/internal/jellycompat/upstream_types.go b/internal/jellycompat/upstream_types.go index 03e6b982..5b4d5a11 100644 --- a/internal/jellycompat/upstream_types.go +++ b/internal/jellycompat/upstream_types.go @@ -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"`