Files
silo-server/internal/jellycompat/batch_loaders.go
T
Quick 578100f5ef fix(jellycompat): stabilize signed image tags across restarts
{"subject":"fix(jellycompat): stabilize signed image tags across restarts","body":"- Sign library poster and episode parent series image tags from canonical paths/thumbhashes instead of presigned URLs so tags survive restarts\n- Accept signed canonical tags in the image handler without a session and fall back to legacy URL-derived cache tags\n- Always fetch series detail for episodes to build stable parent image tags"}
2026-05-26 22:29:49 -04:00

510 lines
16 KiB
Go

package jellycompat
import (
"context"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
type compatEpisodeTarget struct {
Item upstreamListItem
SeriesImages seriesImageSet
}
type libraryMembershipChecker interface {
GetItemsInLibrary(ctx context.Context, contentIDs []string, libraryID int) (map[string]bool, error)
}
// itemRepoForBatchLoader is the subset of *catalog.ItemRepository used by
// ItemsHandler. Defined as an interface so tests can substitute a counting
// fake without standing up a Postgres pool. The compatPool() == nil fallback
// uses GetByIDsWithAccess to push library/rating gating into a single SQL
// statement instead of a per-item EnsureAccessible loop (audit 2026-05-01 §3.3).
type itemRepoForBatchLoader interface {
GetByIDs(ctx context.Context, contentIDs []string) ([]*models.MediaItem, error)
GetByIDsWithAccess(ctx context.Context, contentIDs []string, access catalog.AccessFilter) ([]*models.MediaItem, error)
GetItemsInLibrary(ctx context.Context, contentIDs []string, libraryID int) (map[string]bool, error)
}
// episodeRepoForBatchLoader is the subset of *catalog.EpisodeRepository used
// by ItemsHandler. Episode access in the compatPool() == nil fallback is
// gated through the parent series item via GetByIDsWithAccess on media_items.
type episodeRepoForBatchLoader interface {
GetByIDs(ctx context.Context, contentIDs []string) ([]*models.Episode, error)
ListBySeason(ctx context.Context, seriesID string, seasonNum int) ([]*models.Episode, error)
ListBySeries(ctx context.Context, seriesID string) ([]*models.Episode, error)
}
func (h *ItemsHandler) fetchCompatItemsByContentIDs(ctx context.Context, session *Session, contentIDs []string, libraryID *int) (map[string]upstreamListItem, error) {
normalized := normalizeContentIDs(contentIDs)
if len(normalized) == 0 {
return map[string]upstreamListItem{}, nil
}
pool := h.compatPool()
if pool == nil {
return h.fetchCompatItemsByContentIDsFallback(ctx, session, normalized, libraryID)
}
access := h.resolveAccessFilter(ctx, session)
fromClause := "media_items mi"
conditions := []string{"mi.content_id = ANY($1)"}
args := []any{normalized}
argIdx := 2
if libraryID != nil || access.AllowedLibraryIDs != nil || len(access.DisabledLibraryIDs) > 0 {
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
}
if libraryID != nil {
conditions = append(conditions, fmt.Sprintf("mil.media_folder_id = $%d", argIdx))
args = append(args, *libraryID)
argIdx++
}
if access.AllowedLibraryIDs != nil {
if len(access.AllowedLibraryIDs) == 0 {
return map[string]upstreamListItem{}, nil
}
conditions = append(conditions, fmt.Sprintf("mil.media_folder_id = ANY($%d)", argIdx))
args = append(args, access.AllowedLibraryIDs)
argIdx++
}
if len(access.DisabledLibraryIDs) > 0 {
conditions = append(conditions, fmt.Sprintf("NOT (mil.media_folder_id = ANY($%d))", argIdx))
args = append(args, access.DisabledLibraryIDs)
argIdx++
}
catalog.ApplySectionAccessFilter("mi", access, &conditions, &args, &argIdx)
query := fmt.Sprintf(
`SELECT DISTINCT ON (mi.content_id) %s FROM %s WHERE %s ORDER BY mi.content_id`,
compatItemColumns("mi"), fromClause, strings.Join(conditions, " AND "),
)
rows, err := pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items, err := scanCompatMediaItems(rows)
if err != nil {
return nil, err
}
result := make(map[string]upstreamListItem, len(items))
for _, item := range items {
listItem := mediaItemToListItem(item)
h.presignCompatListItem(ctx, &listItem)
result[item.ContentID] = listItem
}
return result, nil
}
func (h *ItemsHandler) fetchCompatItemsByContentIDsFallback(ctx context.Context, session *Session, contentIDs []string, libraryID *int) (map[string]upstreamListItem, error) {
result := make(map[string]upstreamListItem, len(contentIDs))
if h.itemRepo == nil {
return result, nil
}
access := h.resolveAccessFilter(ctx, session)
if libraryID != nil && *libraryID > 0 {
if !narrowAccessToLibrary(&access, *libraryID) {
return result, nil
}
}
items, err := h.itemRepo.GetByIDsWithAccess(ctx, contentIDs, access)
if err != nil {
return nil, err
}
for _, item := range items {
listItem := mediaItemToListItem(item)
h.presignCompatListItem(ctx, &listItem)
result[item.ContentID] = listItem
}
return result, nil
}
// narrowAccessToLibrary intersects the viewer's effective access policy with a
// caller-supplied libraryID. Returns false when the libraryID falls outside an
// existing AllowedLibraryIDs allowlist (caller should short-circuit with an
// empty result). Mutates access.AllowedLibraryIDs in place to the single ID.
func narrowAccessToLibrary(access *catalog.AccessFilter, libraryID int) bool {
if access.AllowedLibraryIDs != nil {
allowed := false
for _, id := range access.AllowedLibraryIDs {
if id == libraryID {
allowed = true
break
}
}
if !allowed {
return false
}
}
access.AllowedLibraryIDs = []int{libraryID}
return true
}
func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context, session *Session, contentIDs []string, libraryID *int) (map[string]compatEpisodeTarget, error) {
normalized := normalizeContentIDs(contentIDs)
if len(normalized) == 0 {
return map[string]compatEpisodeTarget{}, nil
}
pool := h.compatPool()
if pool == nil {
return h.fetchCompatEpisodeTargetsByContentIDsFallback(ctx, session, normalized, libraryID)
}
access := h.resolveAccessFilter(ctx, session)
fromClause := "episodes e JOIN media_items si ON e.series_id = si.content_id"
conditions := []string{"e.content_id = ANY($1)"}
args := []any{normalized}
argIdx := 2
if libraryID != nil || access.AllowedLibraryIDs != nil || len(access.DisabledLibraryIDs) > 0 {
fromClause += " JOIN media_item_libraries mil ON si.content_id = mil.content_id"
}
if libraryID != nil {
conditions = append(conditions, fmt.Sprintf("mil.media_folder_id = $%d", argIdx))
args = append(args, *libraryID)
argIdx++
}
if access.AllowedLibraryIDs != nil {
if len(access.AllowedLibraryIDs) == 0 {
return map[string]compatEpisodeTarget{}, nil
}
conditions = append(conditions, fmt.Sprintf("mil.media_folder_id = ANY($%d)", argIdx))
args = append(args, access.AllowedLibraryIDs)
argIdx++
}
if len(access.DisabledLibraryIDs) > 0 {
conditions = append(conditions, fmt.Sprintf("NOT (mil.media_folder_id = ANY($%d))", argIdx))
args = append(args, access.DisabledLibraryIDs)
argIdx++
}
catalog.ApplySectionAccessFilter("si", access, &conditions, &args, &argIdx)
query := fmt.Sprintf(`
SELECT DISTINCT ON (e.content_id)
e.content_id,
e.title,
e.overview,
e.runtime,
e.rating_imdb,
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,
si.title,
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.updated_at
FROM %s
WHERE %s
ORDER BY e.content_id
`, fromClause, strings.Join(conditions, " AND "))
rows, err := pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]compatEpisodeTarget, len(normalized))
for rows.Next() {
var (
contentID string
title string
overview string
runtime int
ratingIMDB *float64
ratingTMDB *float64
airDate *time.Time
stillPath string
stillThumbhash string
updatedAt time.Time
seasonNumber int
episodeNumber int
seriesID string
seriesTitle string
genres []string
contentRating string
seriesPosterPath string
seriesPosterTH string
seriesBackdrop string
seriesBackdropTH string
seriesLogoPath string
status string
seriesUpdatedAt time.Time
)
if err := rows.Scan(
&contentID,
&title,
&overview,
&runtime,
&ratingIMDB,
&ratingTMDB,
&airDate,
&stillPath,
&stillThumbhash,
&updatedAt,
&seasonNumber,
&episodeNumber,
&seriesID,
&seriesTitle,
&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,
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,
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 {
return nil, fmt.Errorf("iterating compat episode targets: %w", err)
}
return result, nil
}
func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDsFallback(ctx context.Context, session *Session, contentIDs []string, libraryID *int) (map[string]compatEpisodeTarget, error) {
result := make(map[string]compatEpisodeTarget, len(contentIDs))
if h.episodeRepo == nil || h.itemRepo == nil {
return result, nil
}
// Library filter for the episode IDs themselves (uses episode_libraries
// for the membership check). Series-level access (allowed/disabled
// libraries, rating ladder) is enforced below in a single batched query.
filteredContentIDs, err := filterContentIDsForLibrary(ctx, h.itemRepo, contentIDs, libraryID)
if err != nil {
return nil, err
}
if len(filteredContentIDs) == 0 {
return result, nil
}
episodes, err := h.episodeRepo.GetByIDs(ctx, filteredContentIDs)
if err != nil {
return nil, err
}
seriesIDs := make([]string, 0, len(episodes))
seenSeries := make(map[string]struct{}, len(episodes))
for _, episode := range episodes {
if _, ok := seenSeries[episode.SeriesID]; ok {
continue
}
seenSeries[episode.SeriesID] = struct{}{}
seriesIDs = append(seriesIDs, episode.SeriesID)
}
access := h.resolveAccessFilter(ctx, session)
if libraryID != nil && *libraryID > 0 {
if !narrowAccessToLibrary(&access, *libraryID) {
return result, nil
}
}
// Single query that gates series-level access (libraries + rating) instead
// of a per-series EnsureAccessible loop (audit 2026-05-01 §3.3).
seriesItems, err := h.itemRepo.GetByIDsWithAccess(ctx, seriesIDs, access)
if err != nil {
return nil, err
}
seriesByID := make(map[string]*models.MediaItem, len(seriesItems))
for _, item := range seriesItems {
seriesByID[item.ContentID] = item
}
for _, episode := range episodes {
series, ok := seriesByID[episode.SeriesID]
if !ok {
continue
}
listItem := upstreamListItem{
ContentID: episode.ContentID,
Type: "episode",
Title: episode.Title,
Genres: series.Genres,
ContentRating: series.ContentRating,
Status: series.Status,
RatingIMDB: episode.RatingIMDB,
RatingTMDB: episode.RatingTMDB,
Overview: episode.Overview,
PosterURL: h.presignCompatImagePath(ctx, episode.StillPath, "still"),
BackdropURL: h.presignCompatImagePath(ctx, series.BackdropPath, "backdrop"),
LogoURL: h.presignCompatImagePath(ctx, series.LogoPath, "logo"),
StillURL: h.presignCompatImagePath(ctx, episode.StillPath, "still"),
PosterPath: episode.StillPath,
BackdropPath: series.BackdropPath,
BackdropThumbhash: series.BackdropThumbhash,
LogoPath: series.LogoPath,
StillPath: episode.StillPath,
StillThumbhash: episode.StillThumbhash,
UpdatedAt: episode.UpdatedAt,
SeriesID: episode.SeriesID,
SeriesTitle: series.Title,
SeasonNumber: intPtr(episode.SeasonNumber),
EpisodeNumber: intPtr(episode.EpisodeNumber),
Runtime: episode.Runtime,
}
if episode.AirDate != nil {
listItem.AirDate = episode.AirDate.Format(time.DateOnly)
}
result[episode.ContentID] = compatEpisodeTarget{
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,
},
}
}
return result, nil
}
func (h *ItemsHandler) compatPool() *pgxpool.Pool {
if h.browseRepo == nil {
return nil
}
return h.browseRepo.Pool()
}
func compatItemColumns(alias string) string {
cols := []string{
"content_id", "type", "title", "sort_title", "original_title", "year", "genres",
"content_rating", "runtime", "overview", "tagline",
"rating_imdb", "rating_tmdb", "rating_rt_critic", "rating_rt_audience",
"imdb_id", "tmdb_id", "tvdb_id",
"poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path",
"metadata_s3_path", "metadata_etag", "season_count",
"studios", "networks", "countries", "first_air_date", "last_air_date",
"matched_at", "status", "created_at", "updated_at",
}
prefixed := make([]string, len(cols))
for i, col := range cols {
prefixed[i] = alias + "." + col
}
return strings.Join(prefixed, ", ")
}
func scanCompatMediaItems(rows pgx.Rows) ([]*models.MediaItem, error) {
var items []*models.MediaItem
for rows.Next() {
var item models.MediaItem
if err := rows.Scan(
&item.ContentID, &item.Type, &item.Title, &item.SortTitle, &item.OriginalTitle,
&item.Year, &item.Genres, &item.ContentRating, &item.Runtime, &item.Overview, &item.Tagline,
&item.RatingIMDB, &item.RatingTMDB, &item.RatingRTCritic, &item.RatingRTAudience,
&item.ImdbID, &item.TmdbID, &item.TvdbID,
&item.PosterPath, &item.PosterThumbhash, &item.BackdropPath, &item.BackdropThumbhash, &item.LogoPath,
&item.MetadataS3Path, &item.MetadataEtag, &item.SeasonCount,
&item.Studios, &item.Networks, &item.Countries, &item.FirstAirDate, &item.LastAirDate,
&item.MatchedAt, &item.Status, &item.CreatedAt, &item.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scanning compat media item: %w", err)
}
items = append(items, &item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating compat media items: %w", err)
}
return items, nil
}
func filterContentIDsForLibrary(ctx context.Context, checker libraryMembershipChecker, contentIDs []string, libraryID *int) ([]string, error) {
normalized := normalizeContentIDs(contentIDs)
if libraryID == nil || *libraryID <= 0 || len(normalized) == 0 || checker == nil {
return normalized, nil
}
membership, err := checker.GetItemsInLibrary(ctx, normalized, *libraryID)
if err != nil {
return nil, err
}
filtered := make([]string, 0, len(normalized))
for _, contentID := range normalized {
if membership[contentID] {
filtered = append(filtered, contentID)
}
}
return filtered, nil
}