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
+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) {