fix: guard series metadata from episode identity pollution

This commit is contained in:
ShukeBta
2026-06-25 07:22:00 +08:00
parent 3419077b67
commit 087a00b5d0
8 changed files with 338 additions and 13 deletions
+85 -1
View File
@@ -93,6 +93,86 @@ func (s *ScannerService) cloudDirectoryMetadata(ctx context.Context, typ, displa
return meta
}
func cloudFileJSONIsEpisodeMetadata(seriesLike bool, parsedSeason, parsedEpisode int, meta *LocalMetadata) bool {
if !seriesLike || meta == nil {
return false
}
return parsedSeason > 0 ||
parsedEpisode > 0 ||
meta.SeasonNum > 0 ||
meta.EpisodeNum > 0 ||
strings.TrimSpace(meta.EpisodeTitle) != ""
}
func mergeCloudEpisodeMetadata(dst, episode *LocalMetadata) *LocalMetadata {
if episode == nil {
return dst
}
if dst == nil {
dst = &LocalMetadata{}
}
mergeCloudEpisodeIdentity(dst, episode)
mergeCloudEpisodeDisplay(dst, episode)
mergeCloudEpisodeNumbersAndTaxonomy(dst, episode)
dst.NSFW = dst.NSFW || episode.NSFW
dst.HasNFO = dst.HasNFO || episode.HasNFO
dst.HasArtwork = dst.HasArtwork || episode.HasArtwork
return dst
}
func mergeCloudEpisodeIdentity(dst, episode *LocalMetadata) {
showTitle := ""
if episode.EpisodeTitle != "" && episode.Title != "" && !strings.EqualFold(episode.Title, episode.EpisodeTitle) {
showTitle = episode.Title
}
if showTitle != "" {
dst.Title = showTitle
}
episodeTitle := strings.TrimSpace(episode.EpisodeTitle)
if episodeTitle == "" && (episode.SeasonNum > 0 || episode.EpisodeNum > 0) {
episodeTitle = strings.TrimSpace(episode.Title)
}
if episodeTitle != "" && !strings.EqualFold(episodeTitle, strings.TrimSpace(dst.Title)) {
dst.EpisodeTitle = episodeTitle
}
}
func mergeCloudEpisodeDisplay(dst, episode *LocalMetadata) {
if dst.Year == 0 && episode.Year > 0 {
dst.Year = episode.Year
}
if episode.Overview != "" {
dst.Overview = episode.Overview
}
if episode.Rating > 0 {
dst.Rating = episode.Rating
}
if episode.PosterURL != "" {
dst.PosterURL = episode.PosterURL
}
if episode.BackdropURL != "" {
dst.BackdropURL = episode.BackdropURL
}
}
func mergeCloudEpisodeNumbersAndTaxonomy(dst, episode *LocalMetadata) {
if episode.SeasonNum > 0 {
dst.SeasonNum = episode.SeasonNum
}
if episode.EpisodeNum > 0 {
dst.EpisodeNum = episode.EpisodeNum
}
if dst.Genres == "" && episode.Genres != "" {
dst.Genres = episode.Genres
}
if dst.Countries == "" && episode.Countries != "" {
dst.Countries = episode.Countries
}
if dst.Languages == "" && episode.Languages != "" {
dst.Languages = episode.Languages
}
}
func (s *ScannerService) cloudFileMetadata(ctx context.Context, typ, displayPath, fileName string, sidecars cloudSidecarSet, inherited *LocalMetadata, seriesLike bool) *LocalMetadata {
season, episode := ParseEpisode(displayPath)
seriesLike = seriesLike || season > 0 || episode > 0
@@ -121,7 +201,11 @@ func (s *ScannerService) cloudFileMetadata(ctx context.Context, typ, displayPath
continue
}
if local, err := s.readCloudJSONMetadata(ctx, typ, ref, sidecars); err == nil && local != nil {
meta = mergeCloudMetadata(meta, local)
if cloudFileJSONIsEpisodeMetadata(seriesLike, season, episode, local) {
meta = mergeCloudEpisodeMetadata(meta, local)
} else {
meta = mergeCloudMetadata(meta, local)
}
break
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
)
// pollutedEpisodeCleanupSettingKey marks that the one-time normalization has run.
const pollutedEpisodeCleanupSettingKey = "media.polluted_episode_cleanup_done"
const pollutedEpisodeCleanupSettingKey = "media.polluted_episode_cleanup_v2_done"
// seasonFolderTailRE 去掉路径末尾的「季文件夹 + 文件名」,得到整剧目录(show_dir)。
// 例: /tv/国漫/遮天 (2023)/Season 01/遮天 - S01E01.mkv → /tv/国漫/遮天 (2023)
+28 -5
View File
@@ -1,6 +1,7 @@
package service
import (
"path/filepath"
"regexp"
"strings"
)
@@ -71,11 +72,17 @@ func (h mediaExternalIDHints) applyToLocalMetadata(meta *LocalMetadata) *LocalMe
return meta
}
func pathHintMetadata(raw string, _ bool) (*LocalMetadata, mediaExternalIDHints) {
hints := externalIDHintsFromText(raw)
title, year := cloudSeriesTitleFromMediaPath(raw)
if title == "" {
title, year = CleanQuery(raw)
func pathHintMetadata(raw string, seriesLike bool) (*LocalMetadata, mediaExternalIDHints) {
source := pathHintSourceText(raw, seriesLike)
hints := externalIDHintsFromText(source)
title, year := "", 0
if seriesLike {
title, year = CleanQuery(source)
} else {
title, year = cloudSeriesTitleFromMediaPath(source)
if title == "" {
title, year = CleanQuery(source)
}
}
if !hints.useful() && title == "" && year <= 0 {
return nil, hints
@@ -94,3 +101,19 @@ func pathHintMetadata(raw string, _ bool) (*LocalMetadata, mediaExternalIDHints)
}
return meta, hints
}
func pathHintSourceText(raw string, seriesLike bool) string {
raw = strings.TrimSpace(raw)
if !seriesLike || raw == "" {
return raw
}
base := pathBaseSlash(raw)
ext := strings.ToLower(filepath.Ext(base))
if _, ok := videoExtensions[ext]; !ok {
return raw
}
if showDir := showDirFromEpisodePath(raw); showDir != "" {
return showDir
}
return raw
}
+2 -2
View File
@@ -430,7 +430,7 @@ func mergeEpisodeMetadata(dst, episode *LocalMetadata, doc *nfoDocument) {
}
// 单集级展示字段: 每个媒体行本就对应一集,这些可安全按集回填。
if episode.Year > 0 {
if dst.Year == 0 && episode.Year > 0 {
dst.Year = episode.Year
}
if episode.Overview != "" {
@@ -446,7 +446,7 @@ func mergeEpisodeMetadata(dst, episode *LocalMetadata, doc *nfoDocument) {
dst.BackdropURL = episode.BackdropURL
}
// 整剧外部 id: 单集 NFO 的 id 都是单集级,绝不写入整剧字段(见上方说明)。
if episode.SeasonNum > 0 || episode.EpisodeNum > 0 {
if episode.SeasonNum > 0 {
dst.SeasonNum = episode.SeasonNum
}
if episode.EpisodeNum > 0 {
+12
View File
@@ -112,6 +112,18 @@ func TestReadLocalEpisodeMetadataWithoutShowTitleDoesNotUseEpisodeTitleAsSeries(
}
}
func TestMergeEpisodeMetadataKeepsExistingSeasonWhenEpisodeNFOOmitsIt(t *testing.T) {
dst := &LocalMetadata{Title: "哈哈哈哈哈", SeasonNum: 6}
episodeDoc := &nfoDocument{Title: "第 11 集", Episode: 11}
episode := metadataFromDoc(episodeDoc, "", true)
mergeEpisodeMetadata(dst, episode, episodeDoc)
if dst.SeasonNum != 6 || dst.EpisodeNum != 11 {
t.Fatalf("episode NFO without season should keep parsed season, got %+v", dst)
}
}
func TestReadLocalEpisodeMetadataIgnoresNoneNumericFields(t *testing.T) {
root := t.TempDir()
showDir := filepath.Join(root, "链锯人 总集篇 (2025)")
@@ -263,6 +263,32 @@ func TestScanCloudLibraryReadsRemoteJSONMetadataAndArtwork(t *testing.T) {
}
}
func TestCloudEpisodeJSONDoesNotPolluteSeriesIdentity(t *testing.T) {
local, _ := metadataFromCloudJSON([]byte(`{
"title": "九龙拉棺",
"episode": 1,
"tmdb_id": 4375419,
"original_title": "Burial",
"overview": "本集简介",
"poster": "episode-poster.jpg",
"genres": ["动画"]
}`))
if local == nil {
t.Fatal("episode json metadata was not parsed")
}
dst := &LocalMetadata{Title: "遮天", Year: 2023, SeasonNum: 1}
got := mergeCloudEpisodeMetadata(dst, local)
if got.Title != "遮天" || got.OriginalName != "" || got.TMDbID != 0 {
t.Fatalf("episode json polluted series identity: %+v", got)
}
if got.EpisodeTitle != "九龙拉棺" || got.Overview != "本集简介" || got.SeasonNum != 1 || got.EpisodeNum != 1 {
t.Fatalf("episode json fields not preserved: %+v", got)
}
if got.Genres != "动画" {
t.Fatalf("episode json taxonomy should fill empty series taxonomy, got %q", got.Genres)
}
}
func TestScanCloudLibraryEnrichesPathHintTMDbArtwork(t *testing.T) {
tmdb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/movie/755679" {
+56 -4
View File
@@ -212,26 +212,34 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
mediaType = "tv"
}
if match := s.manualTMDbMatchByID(ctx, m.TMDbID, normalizeMediaType(mediaType, m.Title, "")); match != nil {
return match
if s.mediaExternalIDMatchTrusted(m, lib, match, "tmdb") {
return match
}
}
}
if strings.TrimSpace(m.DoubanID) != "" && s.douban != nil && s.douban.Enabled() {
if match, err := s.douban.GetMatchByID(ctx, strings.TrimSpace(m.DoubanID)); err == nil && match != nil {
return match
if s.mediaExternalIDMatchTrusted(m, lib, match, "douban") {
return match
}
} else if err != nil {
s.log.Debug("douban id lookup failed", zap.String("media_id", m.ID), zap.String("douban_id", m.DoubanID), zap.Error(err))
}
}
if m.BangumiID > 0 && s.bangumi != nil && s.bangumi.Enabled() {
if match, err := s.bangumi.GetSubject(ctx, m.BangumiID); err == nil && match != nil {
return match
if s.mediaExternalIDMatchTrusted(m, lib, match, "bangumi") {
return match
}
} else if err != nil {
s.log.Debug("bangumi id lookup failed", zap.String("media_id", m.ID), zap.Int("bangumi_id", m.BangumiID), zap.Error(err))
}
}
if strings.TrimSpace(m.TheTVDBID) != "" && s.thetvdb != nil && s.thetvdb.Enabled() {
if match, err := s.thetvdb.GetSeriesMatchByID(ctx, strings.TrimSpace(m.TheTVDBID)); err == nil && match != nil {
return match
if s.mediaExternalIDMatchTrusted(m, lib, match, "thetvdb") {
return match
}
} else if err != nil {
s.log.Debug("thetvdb id lookup failed", zap.String("media_id", m.ID), zap.String("thetvdb_id", m.TheTVDBID), zap.Error(err))
}
@@ -239,6 +247,35 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
return nil
}
func (s *ScraperService) mediaExternalIDMatchTrusted(m *model.Media, lib *model.Library, match *Match, source string) bool {
if match == nil || strings.TrimSpace(match.Title) == "" {
return false
}
if !mediaIsEpisodic(m, lib) {
return true
}
for _, candidate := range scrapeQueryCandidates(m, lib) {
if unsafeAutomaticEpisodeQuery(candidate) {
continue
}
if automaticMetadataTitleTrusted(candidate, match) {
return true
}
}
if s != nil && s.log != nil && m != nil {
s.log.Warn("episode external id match rejected",
zap.String("media_id", m.ID),
zap.String("source", source),
zap.String("title", match.Title),
zap.String("original_name", match.OriginalName),
zap.Int("tmdb_id", match.TMDbID),
zap.Int("bangumi_id", match.BangumiID),
zap.String("douban_id", match.DoubanID),
zap.String("thetvdb_id", match.TheTVDBID))
}
return false
}
func (s *ScraperService) applyFanartArtwork(ctx context.Context, match *Match) {
if s == nil || s.fanart == nil || !s.fanart.Enabled() || match == nil {
return
@@ -274,6 +311,12 @@ func mediaYearHint(m *model.Media) int {
if m == nil {
return 0
}
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
return seriesPathYearHint(m.Path)
}
if season, episode := ParseEpisode(m.Path); season > 0 || episode > 0 {
return seriesPathYearHint(m.Path)
}
if m.Year > 0 {
return m.Year
}
@@ -283,6 +326,15 @@ func mediaYearHint(m *model.Media) int {
return yearFromText(m.Path)
}
func seriesPathYearHint(path string) int {
if showDir := showDirFromEpisodePath(path); showDir != "" {
if _, year := CleanQuery(showDir); year > 0 {
return year
}
}
return 0
}
func yearFromText(raw string) int {
if raw == "" {
return 0
+128
View File
@@ -98,6 +98,16 @@ func TestPathHintMetadataDoesNotMarkMediaMatched(t *testing.T) {
}
}
func TestPathHintMetadataIgnoresEpisodeFileIDForSeries(t *testing.T) {
meta, hints := pathHintMetadata("cloud://openlist/国漫/遮天 (2023)/Season 1/遮天.S01E01.{tmdb-4375419}.mkv", true)
if meta == nil || meta.Title != "遮天" || meta.Year != 2023 {
t.Fatalf("series path hint metadata = %+v", meta)
}
if hints.TMDbID != 0 || meta.TMDbID != 0 {
t.Fatalf("episode filename tmdb id must not become series id: meta=%+v hints=%+v", meta, hints)
}
}
func TestEnrichOneCloudPathHintOverridesStaleTMDbID(t *testing.T) {
var requested []string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -168,6 +178,124 @@ func TestEnrichOneCloudPathHintOverridesStaleTMDbID(t *testing.T) {
}
}
func TestEnrichOneRejectsStaleEpisodeTMDbIDBySeriesTitle(t *testing.T) {
var requested []string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requested = append(requested, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/tv/220269":
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 220269,
"name": "错误剧集",
"overview": "stale episode id resolved as another show",
"first_air_date": "2024-01-01",
})
case "/search/tv":
if r.URL.Query().Get("query") != "折腰" {
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 296753,
"name": "折腰",
"overview": "正确的剧集条目",
"poster_path": "/zheyao.jpg",
"first_air_date": "2025-05-13",
"origin_country": []string{"CN"},
}},
})
case "/tv/296753":
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 296753,
"name": "折腰",
"overview": "正确的剧集条目",
"poster_path": "/zheyao.jpg",
"first_air_date": "2025-05-13",
"origin_country": []string{"CN"},
})
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.TMDbAPIKey = "test-key"
cfg.Secrets.TMDbAPIProxy = upstream.URL
cfg.Secrets.TMDbImageProxy = upstream.URL + "/images"
log := zap.NewNop()
scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
lib := model.Library{Name: "OpenList · 国产剧", Path: "cloud://openlist/国产剧", Type: "tv", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "折腰",
Path: "cloud://openlist/国产剧/折腰 (2025)/Season 1/折腰.S01E01.mkv",
SeasonNum: 1,
EpisodeNum: 1,
TMDbID: 220269,
ScrapeStatus: "pending",
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
if err := scraper.EnrichOne(t.Context(), &media); err != nil {
t.Fatal(err)
}
var got model.Media
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
t.Fatal(err)
}
if got.ScrapeStatus != "matched" || got.TMDbID != 296753 || got.Title != "折腰" || got.PosterURL == "" {
t.Fatalf("stale tmdb id should be rejected and repaired by title search: status=%q tmdb=%d title=%q poster=%q requests=%v",
got.ScrapeStatus, got.TMDbID, got.Title, got.PosterURL, requested)
}
if firstIndexFunc(requested, func(path string) bool { return path == "/tv/220269" }) < 0 {
t.Fatalf("test did not exercise stale id lookup: requests=%v", requested)
}
if firstIndexFunc(requested, func(path string) bool { return path == "/search/tv" }) < 0 {
t.Fatalf("scraper did not fall back to title search after stale id rejection: requests=%v", requested)
}
}
func TestMediaYearHintUsesSeriesFolderYearForEpisodes(t *testing.T) {
media := &model.Media{
Year: 2026,
Path: "cloud://openlist/国产剧/折腰 (2025)/Season 1/折腰.S01E01.mkv",
SeasonNum: 1,
EpisodeNum: 1,
}
if got := mediaYearHint(media); got != 2025 {
t.Fatalf("mediaYearHint = %d, want series folder year 2025", got)
}
}
func TestMediaYearHintIgnoresEpisodeRowYearWithoutSeriesFolderYear(t *testing.T) {
media := &model.Media{
Year: 2026,
Path: "cloud://openlist/综艺/哈哈哈哈哈/Season 6/哈哈哈哈哈 - S06E01.mkv",
SeasonNum: 6,
EpisodeNum: 1,
}
if got := mediaYearHint(media); got != 0 {
t.Fatalf("mediaYearHint = %d, want no year when only episode row year is available", got)
}
}
func TestEnrichOneUsesLocalPathExternalIDHints(t *testing.T) {
var requested []string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {