From ce9830cd02c391b641eb9873eda008a4a60ff743 Mon Sep 17 00:00:00 2001 From: d3v1l1989 <64855033+d3v1l1989@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:59:55 +0200 Subject: [PATCH] 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 --------- Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- internal/catalog/episode_repo.go | 26 +++++ internal/jellycompat/batch_loaders.go | 15 ++- internal/jellycompat/batch_loaders_test.go | 11 ++ internal/jellycompat/handlers_items.go | 1 + internal/jellycompat/mapping.go | 29 ++++-- internal/jellycompat/upstream_types.go | 8 ++ internal/jellycompat/virtual_episode_test.go | 104 +++++++++++++++++++ 7 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 internal/jellycompat/virtual_episode_test.go diff --git a/internal/catalog/episode_repo.go b/internal/catalog/episode_repo.go index eceeaf33..88658ca8 100644 --- a/internal/catalog/episode_repo.go +++ b/internal/catalog/episode_repo.go @@ -638,6 +638,32 @@ func (r *EpisodeRepository) GetByIDs(ctx context.Context, contentIDs []string) ( return scanEpisodes(rows) } +// HasFilesByIDs reports which of the given episodes are backed by at least one +// live (non-missing) media file. Episodes absent from the result map have no +// file — e.g. provider-metadata-only entries for unaired episodes. +func (r *EpisodeRepository) HasFilesByIDs(ctx context.Context, contentIDs []string) (map[string]bool, error) { + result := make(map[string]bool, len(contentIDs)) + if len(contentIDs) == 0 { + return result, nil + } + query := `SELECT episode_id FROM media_files + WHERE episode_id = ANY($1) AND missing_since IS NULL + GROUP BY episode_id` + rows, err := r.pool.Query(ctx, query, contentIDs) + if err != nil { + return nil, fmt.Errorf("checking episode file presence: %w", err) + } + defer rows.Close() + for rows.Next() { + var episodeID string + if err := rows.Scan(&episodeID); err != nil { + return nil, fmt.Errorf("scanning episode file presence: %w", err) + } + result[episodeID] = true + } + return result, rows.Err() +} + // ListBySeries returns all episodes for a given series, ordered by season and // episode number. func (r *EpisodeRepository) ListBySeries(ctx context.Context, seriesID string) ([]*models.Episode, error) { diff --git a/internal/jellycompat/batch_loaders.go b/internal/jellycompat/batch_loaders.go index 23001a22..d8661ba0 100644 --- a/internal/jellycompat/batch_loaders.go +++ b/internal/jellycompat/batch_loaders.go @@ -38,6 +38,7 @@ type itemRepoForBatchLoader interface { // 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) } @@ -217,7 +218,11 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context COALESCE(si.backdrop_thumbhash, ''), si.logo_path, si.status, - si.updated_at + 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 @@ -255,6 +260,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context seriesLogoPath string status string seriesUpdatedAt time.Time + hasMediaFiles bool ) if err := rows.Scan( &contentID, @@ -280,6 +286,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context &seriesLogoPath, &status, &seriesUpdatedAt, + &hasMediaFiles, ); err != nil { return nil, fmt.Errorf("scanning compat episode target: %w", err) } @@ -310,6 +317,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context SeasonNumber: intPtr(seasonNumber), EpisodeNumber: intPtr(episodeNumber), Runtime: runtime, + HasMediaFiles: &hasMediaFiles, } if airDate != nil { listItem.AirDate = airDate.Format(time.DateOnly) @@ -357,6 +365,10 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDsFallback(ctx context 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)) @@ -417,6 +429,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDsFallback(ctx context 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) diff --git a/internal/jellycompat/batch_loaders_test.go b/internal/jellycompat/batch_loaders_test.go index 9feb68af..02c72434 100644 --- a/internal/jellycompat/batch_loaders_test.go +++ b/internal/jellycompat/batch_loaders_test.go @@ -78,6 +78,7 @@ func (r *countingItemRepo) GetItemsInLibrary(_ context.Context, contentIDs []str // countingEpisodeRepo is an in-memory episodeRepoForBatchLoader fake. type countingEpisodeRepo struct { episodesByID map[string]*models.Episode + hasFilesByID map[string]bool getByIDsCalls int } @@ -92,6 +93,16 @@ func (r *countingEpisodeRepo) GetByIDs(_ context.Context, contentIDs []string) ( return out, nil } +func (r *countingEpisodeRepo) HasFilesByIDs(_ context.Context, contentIDs []string) (map[string]bool, error) { + out := make(map[string]bool, len(contentIDs)) + for _, id := range contentIDs { + if r.hasFilesByID[id] { + out[id] = true + } + } + return out, nil +} + func (r *countingEpisodeRepo) ListBySeason(context.Context, string, int) ([]*models.Episode, error) { return nil, errors.New("ListBySeason not used in fallback test") } diff --git a/internal/jellycompat/handlers_items.go b/internal/jellycompat/handlers_items.go index e55a5757..cfeb2d0b 100644 --- a/internal/jellycompat/handlers_items.go +++ b/internal/jellycompat/handlers_items.go @@ -1121,6 +1121,7 @@ func (h *ItemsHandler) HandleEpisodes(w http.ResponseWriter, r *http.Request) { if target, ok := episodeTargets[episode.ContentID]; ok { upstreamEpisode.StillURL = firstNonEmpty(target.Item.StillURL, target.Item.PosterURL, upstreamEpisode.StillURL) upstreamEpisode.SeriesTitle = firstNonEmpty(target.Item.SeriesTitle, upstreamEpisode.SeriesTitle) + upstreamEpisode.HasMediaFiles = target.Item.HasMediaFiles } dto := h.mapper.episodeFromUpstream(upstreamEpisode, favorites[episode.ContentID], progress[episode.ContentID]) dto.SeasonName = firstNonEmpty(seasonTitleByID[episode.SeasonID], seasonTitleByNumber[episode.SeasonNumber]) diff --git a/internal/jellycompat/mapping.go b/internal/jellycompat/mapping.go index 50b27fcb..694cf712 100644 --- a/internal/jellycompat/mapping.go +++ b/internal/jellycompat/mapping.go @@ -65,6 +65,21 @@ func (m *mapper) viewFromLibrary(library upstreamUserLibrary) baseItemDTO { } } +// applyPlayableLocation stamps location fields for a movie/episode based on +// whether any media file backs it. Fileless (provider-metadata-only) items are +// Jellyfin "Virtual" and carry no VideoType, so clients exclude them from +// playback queues. Items whose file went missing are currently also reported +// as Virtual rather than Jellyfin's "Offline". +func applyPlayableLocation(dto *baseItemDTO, hasFile bool) { + if hasFile { + dto.LocationType = "FileSystem" + dto.VideoType = "VideoFile" + } else { + dto.LocationType = "Virtual" + dto.VideoType = "" + } +} + func (m *mapper) itemFromList(item upstreamListItem, isFavorite bool, progress *upstreamProgress, fields map[string]bool) baseItemDTO { _, allFields := fields["*"] // detail views pass allDetailFields sentinel @@ -88,8 +103,7 @@ func (m *mapper) itemFromList(item upstreamListItem, isFavorite bool, progress * dto.RunTimeTicks = minutesToTicks(item.Runtime) } if item.Type == "movie" || item.Type == "episode" { - dto.LocationType = "FileSystem" - dto.VideoType = "VideoFile" + applyPlayableLocation(&dto, item.HasMediaFiles == nil || *item.HasMediaFiles) } if item.SeriesID != "" { dto.SeriesID = m.codec.EncodeStringID(EncodedIDItem, item.SeriesID) @@ -372,8 +386,7 @@ func (m *mapper) itemFromDetailWithFields(item upstreamItemDetail, isFavorite bo dto.HasSubtitles = versionsHaveSubtitles(item.Versions) dto.SupportsSync = false dto.Container = strings.ToLower(firstVersion.Container) - dto.LocationType = "FileSystem" - dto.VideoType = "VideoFile" + applyPlayableLocation(&dto, true) dto.Path = compatMediaPath(firstVersion) if len(firstVersion.VideoTracks) > 0 { dto.Width = firstVersion.VideoTracks[0].Width @@ -408,6 +421,11 @@ func (m *mapper) itemFromDetailWithFields(item upstreamItemDetail, isFavorite bo if wantField("chapters") { dto.Chapters = compatChapters(firstVersion.Chapters, firstVersion.AddedAt) } + } else if isPlayableItemType(item.Type) { + // Provider-metadata-only (unaired/missing) item: version data is + // authoritative here, so override the FileSystem default set by + // itemFromList with Jellyfin's Virtual contract. + applyPlayableLocation(&dto, false) } slog.Info("jellycompat item detail mapped", "content_id", item.ContentID, @@ -452,13 +470,12 @@ func (m *mapper) episodeFromUpstream(ep upstreamEpisode, isFavorite bool, progre Name: ep.Title, ServerID: m.serverID, Overview: ep.Overview, - LocationType: "FileSystem", - VideoType: "VideoFile", RunTimeTicks: minutesToTicks(ep.Runtime), ImageTags: map[string]string{}, SeriesName: ep.SeriesTitle, UserData: userDataDTO(m.codec.EncodeStringID(EncodedIDItem, ep.ContentID), ep.UserData, isFavorite, progress), } + applyPlayableLocation(&dto, ep.HasMediaFiles == nil || *ep.HasMediaFiles) dto.IndexNumber = &ep.EpisodeNumber dto.ParentIndexNumber = &ep.SeasonNumber if ep.SeriesID != "" { diff --git a/internal/jellycompat/upstream_types.go b/internal/jellycompat/upstream_types.go index 5b4d5a11..bdff2679 100644 --- a/internal/jellycompat/upstream_types.go +++ b/internal/jellycompat/upstream_types.go @@ -55,6 +55,10 @@ type upstreamListItem struct { Tagline string `json:"tagline,omitempty"` RatingTMDB *float64 `json:"rating_tmdb,omitempty"` UserData *catalog.SeasonUserData `json:"user_data,omitempty"` + // HasMediaFiles reports whether at least one live media file backs this + // item. nil means unknown (the producing query did not check); the mapper + // then keeps the historical LocationType=FileSystem default. + HasMediaFiles *bool `json:"-"` } type upstreamBrowseResponse struct { @@ -144,6 +148,10 @@ type upstreamEpisode struct { SeriesID string `json:"series_id,omitempty"` SeriesTitle string `json:"series_title,omitempty"` SeasonID string `json:"season_id,omitempty"` + // HasMediaFiles reports whether at least one live media file backs this + // episode. nil means unknown; the mapper then keeps the historical + // LocationType=FileSystem default. + HasMediaFiles *bool `json:"-"` } type upstreamProgress struct { diff --git a/internal/jellycompat/virtual_episode_test.go b/internal/jellycompat/virtual_episode_test.go new file mode 100644 index 00000000..043f6a6c --- /dev/null +++ b/internal/jellycompat/virtual_episode_test.go @@ -0,0 +1,104 @@ +package jellycompat + +import ( + "testing" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/config" +) + +// TestVirtualEpisodeLocationType verifies the fix: a playable episode with no +// underlying file versions (provider-metadata-only / unaired) reports +// LocationType=Virtual with no MediaSources, while a real episode with a +// version reports LocationType=FileSystem and gets MediaSources. +func TestVirtualEpisodeLocationType(t *testing.T) { + m := newMapper(NewResourceIDCodec(), &config.Config{}) + fields := map[string]bool{"mediasources": true} + + // Fileless episode -> Virtual, no media sources. + fileless := upstreamItemDetail{ + ContentID: "1001", + Type: "episode", + Title: "Unaired Episode", + Versions: nil, + } + dto := m.itemFromDetailWithFields(fileless, false, nil, fields) + if dto.LocationType != "Virtual" { + t.Errorf("fileless episode LocationType = %q, want Virtual", dto.LocationType) + } + if dto.VideoType != "" { + t.Errorf("fileless episode VideoType = %q, want empty", dto.VideoType) + } + if len(dto.MediaSources) != 0 { + t.Errorf("fileless episode MediaSources = %d, want 0", len(dto.MediaSources)) + } + + // Episode with a real file version -> FileSystem + MediaSources present. + withFile := upstreamItemDetail{ + ContentID: "1002", + Type: "episode", + Title: "Real Episode", + Versions: []catalog.FileVersion{{ + FileID: 42, + Container: "mkv", + Duration: 1200, + }}, + } + dto2 := m.itemFromDetailWithFields(withFile, false, nil, fields) + if dto2.LocationType != "FileSystem" { + t.Errorf("real episode LocationType = %q, want FileSystem", dto2.LocationType) + } + if len(dto2.MediaSources) != 1 { + t.Errorf("real episode MediaSources = %d, want 1", len(dto2.MediaSources)) + } +} + +// TestVirtualEpisodeListPaths verifies the list-side mappers honor the +// HasMediaFiles signal: false -> Virtual, true -> FileSystem, and nil +// (unknown, producing query did not check) preserves the historical +// FileSystem default. +func TestVirtualEpisodeListPaths(t *testing.T) { + m := newMapper(NewResourceIDCodec(), &config.Config{}) + boolp := func(v bool) *bool { return &v } + + cases := []struct { + name string + hasMediaFiles *bool + wantLocation string + wantVideoType string + }{ + {"fileless", boolp(false), "Virtual", ""}, + {"with file", boolp(true), "FileSystem", "VideoFile"}, + {"unknown", nil, "FileSystem", "VideoFile"}, + } + + for _, tc := range cases { + t.Run("itemFromList/"+tc.name, func(t *testing.T) { + dto := m.itemFromList(upstreamListItem{ + ContentID: "2001", + Type: "episode", + Title: "Episode", + HasMediaFiles: tc.hasMediaFiles, + }, false, nil, nil) + if dto.LocationType != tc.wantLocation { + t.Errorf("LocationType = %q, want %q", dto.LocationType, tc.wantLocation) + } + if dto.VideoType != tc.wantVideoType { + t.Errorf("VideoType = %q, want %q", dto.VideoType, tc.wantVideoType) + } + }) + t.Run("episodeFromUpstream/"+tc.name, func(t *testing.T) { + dto := m.episodeFromUpstream(upstreamEpisode{ + ContentID: "3001", + Title: "Episode", + HasMediaFiles: tc.hasMediaFiles, + }, false, nil) + if dto.LocationType != tc.wantLocation { + t.Errorf("LocationType = %q, want %q", dto.LocationType, tc.wantLocation) + } + if dto.VideoType != tc.wantVideoType { + t.Errorf("VideoType = %q, want %q", dto.VideoType, tc.wantVideoType) + } + }) + } +}