From d6355d558292eedfeb488e794dbfa685982bdbe5 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:52:49 +0800 Subject: [PATCH] Keep TV episodes as mixed-series representatives --- internal/service/media_series.go | 31 +++++++++++++++++++-------- internal/service/media_series_test.go | 20 ++++++++++++----- web/src/utils/groupSeries.ts | 25 +++++++++++++++------ 3 files changed, 55 insertions(+), 21 deletions(-) diff --git a/internal/service/media_series.go b/internal/service/media_series.go index 6d34fdc..75997b3 100644 --- a/internal/service/media_series.go +++ b/internal/service/media_series.go @@ -301,16 +301,8 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard { if betterSeriesLinkMedia(item, card.LinkMedia) { card.LinkMedia = item } - currentArtwork := seriesArtworkScore(item) - representativeArtwork := seriesArtworkScore(card.Rep) - if currentArtwork > representativeArtwork { + if betterSeriesRepresentative(item, card.Rep) { card.Rep = item - } else if currentArtwork == representativeArtwork { - cur := item.SeasonNum*10000 + item.EpisodeNum - rep := card.Rep.SeasonNum*10000 + card.Rep.EpisodeNum - if cur > 0 && (rep == 0 || cur < rep) { - card.Rep = item - } } continue } @@ -335,6 +327,27 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard { return cards } +func betterSeriesRepresentative(candidate, current model.Media) bool { + // A theatrical feature can have local poster.jpg/background.jpg files and + // therefore a higher artwork score than its TV episodes. Keep the TV row as + // the visible identity of a mixed series card so a movie cannot hijack the + // series title, overview, and artwork. + candidateTheatrical := mediaLooksLikeTheatricalFeature(&candidate) + currentTheatrical := mediaLooksLikeTheatricalFeature(¤t) + if candidateTheatrical != currentTheatrical { + return !candidateTheatrical + } + + currentArtwork := seriesArtworkScore(candidate) + representativeArtwork := seriesArtworkScore(current) + if currentArtwork != representativeArtwork { + return currentArtwork > representativeArtwork + } + cur := candidate.SeasonNum*10000 + candidate.EpisodeNum + rep := current.SeasonNum*10000 + current.EpisodeNum + return cur > 0 && (rep == 0 || cur < rep) +} + func seriesMediaTime(media model.Media) time.Time { if releaseDate := strings.TrimSpace(media.ReleaseDate); releaseDate != "" { if parsed, err := time.Parse("2006-01-02", releaseDate); err == nil { diff --git a/internal/service/media_series_test.go b/internal/service/media_series_test.go index e7b52d3..601ddc3 100644 --- a/internal/service/media_series_test.go +++ b/internal/service/media_series_test.go @@ -410,15 +410,19 @@ func TestGroupMediaSeriesCardsKeepsTheatricalMovieWithTVSeries(t *testing.T) { LibraryID: "anime", Title: "摇曳露营△", Path: `/media/动漫/摇曳露营△ (2018)/Season 01/摇曳露营△.S01E01.mkv`, + PosterURL: "https://image.tmdb.org/t/p/w500/episode.jpg", SeasonNum: 1, EpisodeNum: 1, } theatrical := model.Media{ - Base: model.Base{ID: "theatrical"}, - LibraryID: "anime", - Title: "摇曳露营△ 剧场版", - Path: `/media/动漫/摇曳露营△ (2018)/摇曳露营△ 剧场版 (2022)/Eiga.Yurukyan.2022.Bluray.mkv`, - TMDbID: 566466, + Base: model.Base{ID: "theatrical"}, + LibraryID: "anime", + Title: "摇曳露营△ 剧场版", + Path: `/media/动漫/摇曳露营△ (2018)/摇曳露营△ 剧场版 (2022)/Eiga.Yurukyan.2022.Bluray.mkv`, + PosterURL: "/media/动漫/摇曳露营△ (2018)/剧场版/poster.jpg", + BackdropURL: "/media/动漫/摇曳露营△ (2018)/剧场版/background.jpg", + Overview: "剧场版简介", + TMDbID: 566466, } cards := groupMediaSeriesCards([]model.Media{episode, theatrical}) @@ -428,6 +432,12 @@ func TestGroupMediaSeriesCardsKeepsTheatricalMovieWithTVSeries(t *testing.T) { if cards[0].Count != 2 { t.Fatalf("series card count=%d, want TV episode plus theatrical movie", cards[0].Count) } + if cards[0].Rep.ID != episode.ID { + t.Fatalf("series representative=%q, want TV episode %q", cards[0].Rep.ID, episode.ID) + } + if cards[0].Rep.Title != episode.Title { + t.Fatalf("series representative title=%q, want %q", cards[0].Rep.Title, episode.Title) + } } func TestGroupMediaSeriesCardsDoesNotCollideMovieAndTVExternalIDs(t *testing.T) { diff --git a/web/src/utils/groupSeries.ts b/web/src/utils/groupSeries.ts index 916f5fa..0feb0ff 100644 --- a/web/src/utils/groupSeries.ts +++ b/web/src/utils/groupSeries.ts @@ -480,20 +480,31 @@ export function groupSeries(items: Media[] = []): SeriesCard[] { if (betterSeriesLinkMedia(m, g.linkMedia)) { g.linkMedia = m } - const currentArtwork = artworkScore(m) - const representativeArtwork = artworkScore(g.rep) - if (currentArtwork > representativeArtwork) { + if (betterSeriesRepresentative(m, g.rep)) { g.rep = m - } else if (currentArtwork === representativeArtwork) { - const cur = (m.season_num ?? 0) * 10000 + (m.episode_num ?? 0) - const rep = (g.rep.season_num ?? 0) * 10000 + (g.rep.episode_num ?? 0) - if (cur > 0 && (rep === 0 || cur < rep)) g.rep = m } } } return Array.from(groups.values()) } +function betterSeriesRepresentative(candidate: Media, current: Media): boolean { + // A theatrical feature can have local poster/background artwork that scores + // higher than the TV poster. Keep the TV row as the series-card identity so + // the movie cannot replace the whole series title and overview. + const candidateTheatrical = isTheatricalFeature(candidate) + const currentTheatrical = isTheatricalFeature(current) + if (candidateTheatrical !== currentTheatrical) return !candidateTheatrical + + const candidateArtwork = artworkScore(candidate) + const currentArtwork = artworkScore(current) + if (candidateArtwork !== currentArtwork) return candidateArtwork > currentArtwork + + const cur = (candidate.season_num ?? 0) * 10000 + (candidate.episode_num ?? 0) + const rep = (current.season_num ?? 0) * 10000 + (current.episode_num ?? 0) + return cur > 0 && (rep === 0 || cur < rep) +} + function repeatedSeriesExternalRawKey(media: Media): string { if (!isEpisodeLike(media) && !pathLooksEpisodic(media)) return '' let identity = ''