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) + } + }) + } +}