Files
silo-server/internal/jellycompat/batch_loaders.go
T
ce9830cd02 fix(jellycompat): report fileless episodes as LocationType=Virtual (#111)
* fix(jellycompat): report fileless episodes as LocationType=Virtual

Provider-metadata-only episodes (unaired/missing entries pulled from
TVDB/TMDB that have no underlying media file) were mapped with
LocationType=FileSystem and an empty MediaSources list. Jellyfin's
contract is that such items report LocationType=Virtual.

Because they were not marked Virtual, clients that build playback queues
from the episode list (Wholphin, Infuse, Findroid, ...) treated them as
playable, queued them, and failed on advance with "no media sources".
Wholphin specifically filters LocationType=Virtual out of its
auto-advance playlist, so marking these Virtual lets next-episode /
skip-outro jump cleanly to the next real episode, and the episode list
greys them out as expected.

itemFromDetailWithFields now stamps LocationType=Virtual on playable
items (movie/episode) that have zero file versions.

* fix(jellycompat): mark fileless episodes Virtual on list paths too

The Virtual fix only covered itemFromDetailWithFields, which clients reach
only when requesting detail-level Fields (MediaSources, MediaStreams, ...).
itemFromList and episodeFromUpstream still stamped LocationType=FileSystem
unconditionally, so the same fileless episode reported Virtual or FileSystem
depending on the endpoint/Fields combination used.

Centralize the decision in applyPlayableLocation (which also clears VideoType
on virtual items, matching Jellyfin) and plumb a HasMediaFiles signal into the
list paths:

- episode targets query gains an EXISTS check against media_files
- the pool-less fallback uses a new EpisodeRepository.HasFilesByIDs
- the /Shows/{id}/Episodes non-detail path reuses the already-fetched
  episode targets, so no extra query is needed

A nil signal preserves the historical FileSystem default for producers that
do not check file presence (movies, series-level lists).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 16:59:55 -04:00

523 lines
17 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)
HasFilesByIDs(ctx context.Context, contentIDs []string) (map[string]bool, 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,
EXISTS (
SELECT 1 FROM media_files mf
WHERE mf.episode_id = e.content_id AND mf.missing_since IS NULL
)
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
hasMediaFiles bool
)
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,
&hasMediaFiles,
); 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,
HasMediaFiles: &hasMediaFiles,
}
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
}
hasFiles, err := h.episodeRepo.HasFilesByIDs(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,
HasMediaFiles: boolPtr(hasFiles[episode.ContentID]),
}
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
}