diff --git a/internal/jellycompat/batch_loaders.go b/internal/jellycompat/batch_loaders.go index 910c92f5..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 { @@ -213,10 +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 @@ -248,10 +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, @@ -271,10 +274,12 @@ 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) } @@ -311,9 +316,17 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context } 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 { @@ -409,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 8eb19c69..5dd65885 100644 --- a/internal/jellycompat/handlers_images.go +++ b/internal/jellycompat/handlers_images.go @@ -22,9 +22,12 @@ type ImagesHandler struct { personRepo *catalog.PersonRepository detailSvc *catalog.DetailService itemRepo imageItemRepository + folderRepo imageFolderRepository seasonRepo imageSeasonRepository episodeRepo imageEpisodeRepository accessFilter AccessFilterResolver + posterSigner LibraryPosterPresigner + presignTTL time.Duration imageTags *imageTagSigner } @@ -41,8 +44,12 @@ 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, imageTagSecret string) *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 } @@ -55,9 +62,12 @@ 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), } } @@ -81,6 +91,10 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request) 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 @@ -236,20 +250,60 @@ func (h *ImagesHandler) resolveItemImageURLFromTag(ctx context.Context, routeID, 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, contentID, imageType, imageSize, tag) + return h.resolveItemImageURLFromReposWithoutSession(ctx, routeID, contentID, imageType, imageSize, tag) } -func (h *ImagesHandler) resolveItemImageURLFromReposWithoutSession(ctx context.Context, contentID, imageType, imageSize, tag string) (catalog.ResolvedImageURL, bool, error) { +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 !h.signedImageTagMatches(contentID, imageType, tag, item.PosterPath, item.PosterThumbhash, item.BackdropPath, item.BackdropThumbhash, item.LogoPath, item.UpdatedAt) { - return catalog.ResolvedImageURL{}, false, 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) { @@ -265,10 +319,10 @@ func (h *ImagesHandler) resolveItemImageURLFromReposWithoutSession(ctx context.C return catalog.ResolvedImageURL{}, false, wrapCatalogError(seriesErr) } } else { - if !h.signedImageTagMatches(contentID, imageType, tag, episode.StillPath, episode.StillThumbhash, series.BackdropPath, series.BackdropThumbhash, series.LogoPath, episode.UpdatedAt) { - return catalog.ResolvedImageURL{}, false, nil - } 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 } } @@ -286,10 +340,10 @@ func (h *ImagesHandler) resolveItemImageURLFromReposWithoutSession(ctx context.C } return catalog.ResolvedImageURL{}, false, wrapCatalogError(seriesErr) } - if !h.signedImageTagMatches(contentID, imageType, tag, season.PosterPath, season.PosterThumbhash, series.BackdropPath, series.BackdropThumbhash, series.LogoPath, season.UpdatedAt) { - return catalog.ResolvedImageURL{}, false, nil - } 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) { @@ -300,7 +354,7 @@ func (h *ImagesHandler) resolveItemImageURLFromReposWithoutSession(ctx context.C return catalog.ResolvedImageURL{}, false, nil } -func (h *ImagesHandler) signedImageTagMatches(contentID, imageType, tag, primaryPath, primaryThumbhash, backdropPath, backdropThumbhash, logoPath string, updatedAt time.Time) bool { +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": @@ -317,10 +371,21 @@ func (h *ImagesHandler) signedImageTagMatches(contentID, imageType, tag, primary default: return false } - if path == "" { + 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(contentID, tagImageType, compatCardImageSize, path, thumbhash, updatedAt), path, tag) + 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 { 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/images_test.go b/internal/jellycompat/images_test.go index d164f3e8..43e38e29 100644 --- a/internal/jellycompat/images_test.go +++ b/internal/jellycompat/images_test.go @@ -185,6 +185,117 @@ func TestHandleItemImageRejectsUnsignedTagWhenSecretBlank(t *testing.T) { } } +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) { @@ -253,6 +364,29 @@ func (r fakeImageItemRepo) EnsureAccessible(context.Context, string, catalog.Acc 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) diff --git a/internal/jellycompat/mapping.go b/internal/jellycompat/mapping.go index 4cfd0b24..e9609bdb 100644 --- a/internal/jellycompat/mapping.go +++ b/internal/jellycompat/mapping.go @@ -40,12 +40,16 @@ func newMapper(codec *ResourceIDCodec, cfg *config.Config) *mapper { 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, @@ -55,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, }, } } @@ -443,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 } } diff --git a/internal/jellycompat/mapping_images_test.go b/internal/jellycompat/mapping_images_test.go index 7726fc67..0b141e8f 100644 --- a/internal/jellycompat/mapping_images_test.go +++ b/internal/jellycompat/mapping_images_test.go @@ -106,3 +106,82 @@ func TestEpisodeListImageTagsUseStillThumbhash(t *testing.T) { 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 2abffdd2..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, deps.JWTSecret) + 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) diff --git a/internal/jellycompat/upstream_types.go b/internal/jellycompat/upstream_types.go index 7a3c60e6..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 {