Keep TV episodes as mixed-series representatives

This commit is contained in:
truewhile
2026-09-11 19:52:49 +08:00
parent 0f06bdf929
commit d6355d5582
3 changed files with 55 additions and 21 deletions
+22 -9
View File
@@ -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(&current)
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 {
+15 -5
View File
@@ -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) {
+18 -7
View File
@@ -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 = ''