Merge pull request #14 from Silo-Server/t3code/116da365
feat(jellycompat): sign image tags and accept them without session
This commit is contained in:
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user