diff --git a/internal/service/cloud_metadata.go b/internal/service/cloud_metadata.go index a9acdd4..0f8caeb 100644 --- a/internal/service/cloud_metadata.go +++ b/internal/service/cloud_metadata.go @@ -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 } } diff --git a/internal/service/episode_metadata_cleanup.go b/internal/service/episode_metadata_cleanup.go index 9665cb0..5a92886 100644 --- a/internal/service/episode_metadata_cleanup.go +++ b/internal/service/episode_metadata_cleanup.go @@ -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) diff --git a/internal/service/external_id_hints.go b/internal/service/external_id_hints.go index 0250aa9..7122585 100644 --- a/internal/service/external_id_hints.go +++ b/internal/service/external_id_hints.go @@ -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 +} diff --git a/internal/service/local_metadata.go b/internal/service/local_metadata.go index 10b0abb..05afedc 100644 --- a/internal/service/local_metadata.go +++ b/internal/service/local_metadata.go @@ -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 { diff --git a/internal/service/local_metadata_test.go b/internal/service/local_metadata_test.go index cd3b2c7..28ac33b 100644 --- a/internal/service/local_metadata_test.go +++ b/internal/service/local_metadata_test.go @@ -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)") diff --git a/internal/service/scanner_cloud_metadata_test.go b/internal/service/scanner_cloud_metadata_test.go index ba14f71..f1d7bdb 100644 --- a/internal/service/scanner_cloud_metadata_test.go +++ b/internal/service/scanner_cloud_metadata_test.go @@ -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" { diff --git a/internal/service/scraper.go b/internal/service/scraper.go index f26123a..871d41c 100644 --- a/internal/service/scraper.go +++ b/internal/service/scraper.go @@ -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 diff --git a/internal/service/scraper_query_test.go b/internal/service/scraper_query_test.go index 03d7163..89904bd 100644 --- a/internal/service/scraper_query_test.go +++ b/internal/service/scraper_query_test.go @@ -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) {