From ae65678ba4bcc6f429faa6ad7a59f7367cbdb4f9 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:10:40 -0400 Subject: [PATCH] fix(jellycompat): populate episode season parent metadata --- internal/jellycompat/batch_loaders.go | 17 ++++- internal/jellycompat/handlers_items.go | 12 ++++ .../hydrate_progress_no_detail_test.go | 62 +++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/internal/jellycompat/batch_loaders.go b/internal/jellycompat/batch_loaders.go index d8661ba0..dc024ee3 100644 --- a/internal/jellycompat/batch_loaders.go +++ b/internal/jellycompat/batch_loaders.go @@ -16,6 +16,8 @@ import ( type compatEpisodeTarget struct { Item upstreamListItem SeriesImages seriesImageSet + SeasonID string + SeasonName string } type libraryMembershipChecker interface { @@ -166,7 +168,11 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context } access := h.resolveAccessFilter(ctx, session) - fromClause := "episodes e JOIN media_items si ON e.series_id = si.content_id" + fromClause := strings.Join([]string{ + "episodes e", + "JOIN media_items si ON e.series_id = si.content_id", + "LEFT JOIN seasons se ON se.content_id = e.season_id", + }, " ") conditions := []string{"e.content_id = ANY($1)"} args := []any{normalized} argIdx := 2 @@ -208,6 +214,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context e.updated_at, e.season_number, e.episode_number, + COALESCE(e.season_id, ''), + COALESCE(se.title, ''), si.content_id, si.title, si.genres, @@ -249,6 +257,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context updatedAt time.Time seasonNumber int episodeNumber int + seasonID string + seasonName string seriesID string seriesTitle string genres []string @@ -275,6 +285,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context &updatedAt, &seasonNumber, &episodeNumber, + &seasonID, + &seasonName, &seriesID, &seriesTitle, &genres, @@ -335,6 +347,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context BackdropThumbhash: seriesBackdropTH, UpdatedAt: seriesUpdatedAt, }, + SeasonID: seasonID, + SeasonName: seasonName, } } if err := rows.Err(); err != nil { @@ -446,6 +460,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDsFallback(ctx context BackdropThumbhash: series.BackdropThumbhash, UpdatedAt: series.UpdatedAt, }, + SeasonID: episode.SeasonID, } } diff --git a/internal/jellycompat/handlers_items.go b/internal/jellycompat/handlers_items.go index c37c8566..48eafcdd 100644 --- a/internal/jellycompat/handlers_items.go +++ b/internal/jellycompat/handlers_items.go @@ -2170,6 +2170,18 @@ func (h *ItemsHandler) rememberCompatEpisodeImages(dto baseItemDTO, stillURL str } func (h *ItemsHandler) applyCompatEpisodeTarget(dto *baseItemDTO, target compatEpisodeTarget) { + if target.SeasonID != "" && h.codec != nil { + encodedSeasonID := h.codec.EncodeStringID(EncodedIDSeason, target.SeasonID) + if dto.SeasonID == "" { + dto.SeasonID = encodedSeasonID + } + if dto.ParentID == "" { + dto.ParentID = encodedSeasonID + } + } + if dto.SeasonName == "" && target.SeasonName != "" { + dto.SeasonName = target.SeasonName + } h.mapper.applySeriesImages(dto, target.SeriesImages) h.rememberCompatEpisodeImages(*dto, firstNonEmpty(target.Item.StillURL, target.Item.PosterURL), target.SeriesImages) } diff --git a/internal/jellycompat/hydrate_progress_no_detail_test.go b/internal/jellycompat/hydrate_progress_no_detail_test.go index 4a736930..7ec14e22 100644 --- a/internal/jellycompat/hydrate_progress_no_detail_test.go +++ b/internal/jellycompat/hydrate_progress_no_detail_test.go @@ -152,3 +152,65 @@ func TestHydrateProgressItems_NoFieldsLeavesDetailFieldsNil(t *testing.T) { t.Errorf("MediaSources should be nil with no Fields; got %v", got[0].dto.MediaSources) } } + +func TestHydrateProgressItems_EpisodeIncludesSeasonParentMetadata(t *testing.T) { + codec := NewResourceIDCodec() + episodeID := "episode-1" + seasonID := "season-1" + seriesID := "series-1" + + itemRepo := &countingItemRepo{ + itemsByID: map[string]*models.MediaItem{ + seriesID: {ContentID: seriesID, Type: "series", Title: "Test Series"}, + }, + } + episodeRepo := &countingEpisodeRepo{ + episodesByID: map[string]*models.Episode{ + episodeID: { + ContentID: episodeID, + SeriesID: seriesID, + SeasonID: seasonID, + Title: "Episode Four", + SeasonNumber: 1, + EpisodeNumber: 4, + }, + }, + } + + h := &ItemsHandler{ + content: &panicOnGetItemDetailContent{}, + userData: &mockUserDataService{}, + itemRepo: itemRepo, + episodeRepo: episodeRepo, + codec: codec, + mapper: newMapper(codec, &config.Config{}), + } + + got, err := h.hydrateProgressItems( + context.Background(), + &Session{StreamAppUserID: 1, ProfileID: "profile-1"}, + []upstreamProgress{{MediaItemID: episodeID, PositionSeconds: 120, DurationSeconds: 1800}}, + nil, + nil, + ) + if err != nil { + t.Fatalf("hydrateProgressItems returned error: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 hydrated item; got %d (%v)", len(got), got) + } + + wantSeasonID := codec.EncodeStringID(EncodedIDSeason, seasonID) + if got[0].dto.SeasonID != wantSeasonID { + t.Fatalf("SeasonId = %q, want %q", got[0].dto.SeasonID, wantSeasonID) + } + if got[0].dto.ParentID != wantSeasonID { + t.Fatalf("ParentId = %q, want season id %q", got[0].dto.ParentID, wantSeasonID) + } + if got[0].dto.IndexNumber == nil || *got[0].dto.IndexNumber != 4 { + t.Fatalf("IndexNumber = %v, want 4", got[0].dto.IndexNumber) + } + if got[0].dto.ParentIndexNumber == nil || *got[0].dto.ParentIndexNumber != 1 { + t.Fatalf("ParentIndexNumber = %v, want 1", got[0].dto.ParentIndexNumber) + } +}