diff --git a/internal/service/scraper_query_candidates_test.go b/internal/service/scraper_query_candidates_test.go new file mode 100644 index 0000000..bdb005f --- /dev/null +++ b/internal/service/scraper_query_candidates_test.go @@ -0,0 +1,142 @@ +package service + +import ( + "strings" + "testing" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func TestScrapeQueryCandidatesPreferSeriesFolderAndCJKTitle(t *testing.T) { + lib := &model.Library{ + Path: `F:\downloads\国产剧`, + Type: "movie", + } + media := &model.Media{ + Title: "亏成首富从游戏开始 the ri est in game", + Path: `F:\downloads\国产剧\亏成首富从游戏开始 The Richest in Game\Season 01\亏成首富从游戏开始 The Richest in Game - S01E11 - 4K.mp4`, + SeasonNum: 1, + EpisodeNum: 11, + } + + got := scrapeQueryCandidates(media, lib) + if len(got) == 0 { + t.Fatal("scrapeQueryCandidates returned no candidates") + } + if got[0] != "亏成首富从游戏开始" { + t.Fatalf("first query candidate = %q, want Chinese series title", got[0]) + } + for _, candidate := range got { + if strings.Contains(candidate, "ri est") { + t.Fatalf("query candidate kept substring-stripped title: %#v", got) + } + } +} + +func TestScrapeQueryCandidatesUseCloudSeriesFolder(t *testing.T) { + lib := &model.Library{ + Path: "cloud://openlist/国产剧", + Type: "movie", + } + media := &model.Media{ + Title: "折腰 S01E01", + Path: "cloud://openlist/国产剧/折腰 (2025)/Season 1/折腰.S01E01.mkv", + SeasonNum: 1, + EpisodeNum: 1, + } + + got := scrapeQueryCandidates(media, lib) + if len(got) == 0 { + t.Fatal("scrapeQueryCandidates returned no candidates") + } + if got[0] != "折腰" { + t.Fatalf("first query candidate = %q, want cloud series folder title; all candidates=%#v", got[0], got) + } +} + +func TestScrapeQueryCandidatesUseSeriesLibraryRootWhenMountedAtShowFolder(t *testing.T) { + lib := &model.Library{ + Path: `/downloads/国产剧/折腰 (2025)`, + Type: "tv", + } + media := &model.Media{ + Title: "第 1 集", + Path: `/downloads/国产剧/折腰 (2025)/Season 01/第01集.mkv`, + SeasonNum: 1, + EpisodeNum: 1, + } + + got := scrapeQueryCandidates(media, lib) + if len(got) == 0 { + t.Fatal("scrapeQueryCandidates returned no candidates") + } + if got[0] != "折腰" { + t.Fatalf("first query candidate = %q, want library root show title; all candidates=%#v", got[0], got) + } +} + +func TestScrapeQueryCandidatesSkipEpisodeOnlyTitles(t *testing.T) { + lib := &model.Library{ + Path: `F:\media\电视剧\欧美剧`, + Type: "tv", + } + media := &model.Media{ + Title: "第1期上:最狠开局!五哈团命悬一线好刺激", + Path: `F:\media\电视剧\欧美剧\第1期上:最狠开局!五哈团命悬一线好刺激 (2026)\Season 6\第1期上:最狠开局!五哈团命悬一线好刺激 - S06E01 - 第 1 集.mkv`, + SeasonNum: 6, + EpisodeNum: 1, + } + + got := scrapeQueryCandidates(media, lib) + for _, candidate := range got { + if unsafeAutomaticEpisodeQuery(candidate) { + t.Fatalf("query candidates kept unsafe episode title %q: %#v", candidate, got) + } + } +} + +func TestSeriesTitleFromMediaPathIgnoresEpisodeOnlyFolder(t *testing.T) { + got := seriesTitleFromMediaPath(`F:\media\电视剧\欧美剧\第 11 集 (2026)\Season 6\第 11 集 - S06E11.mkv`) + if got != "" { + t.Fatalf("series title from episode-only folder = %q, want empty", got) + } +} + +func TestMediaIsEpisodicUsesEpisodePatternInPath(t *testing.T) { + lib := &model.Library{ + Path: `/media/movies`, + Type: "movie", + } + media := &model.Media{ + Title: "折腰 S01E01", + Path: `/media/movies/折腰/Season 01/折腰.S01E01.mkv`, + } + + if !mediaIsEpisodic(media, lib) { + t.Fatal("media with an SxxEyy path should be treated as episodic even in a movie library") + } +} + +func TestScrapeQueryCandidatesSkipCategoryFolderAsSeriesTitle(t *testing.T) { + lib := &model.Library{ + Path: `/downloads`, + Type: "tv", + } + media := &model.Media{ + Title: "Ashes To Crown", + Path: `/downloads/国产剧/Ashes.to.Crown.S01E06.1080p.WEB-DL.mkv`, + SeasonNum: 1, + EpisodeNum: 6, + } + + got := scrapeQueryCandidates(media, lib) + if len(got) == 0 { + t.Fatal("scrapeQueryCandidates returned no candidates") + } + if got[0] == "国产剧" { + t.Fatalf("first query candidate = %q, category folders must not be used as title candidates: %#v", got[0], got) + } + if !strings.EqualFold(got[0], "Ashes To Crown") { + t.Fatalf("first query candidate = %q, want release title; all candidates=%#v", got[0], got) + } +} diff --git a/internal/service/scraper_query_movie_test.go b/internal/service/scraper_query_movie_test.go new file mode 100644 index 0000000..32789b6 --- /dev/null +++ b/internal/service/scraper_query_movie_test.go @@ -0,0 +1,132 @@ +package service + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func TestScrapeQueryCandidatesUseMovieFolderForGenericFilename(t *testing.T) { + lib := &model.Library{ + Path: `/media/movies`, + Type: "movie", + } + media := &model.Media{ + Title: "00000", + Path: `/media/movies/Inception (2010)/BDMV/STREAM/00000.m2ts`, + } + + got := scrapeQueryCandidates(media, lib) + if len(got) == 0 { + t.Fatal("scrapeQueryCandidates returned no candidates") + } + if got[0] != "inception" { + t.Fatalf("first query candidate = %q, want movie folder title; all candidates=%#v", got[0], got) + } + for _, candidate := range got { + switch strings.ToLower(candidate) { + case "bdmv", "stream": + t.Fatalf("query candidates kept technical filename/folder: %#v", got) + } + } +} + +func TestScrapeQueryCandidatesUseMovieLibraryRootWhenMountedAtMovieFolder(t *testing.T) { + lib := &model.Library{ + Path: `/media/movies/Inception (2010)`, + Type: "movie", + } + media := &model.Media{ + Title: "00000", + Path: `/media/movies/Inception (2010)/BDMV/STREAM/00000.m2ts`, + } + + got := scrapeQueryCandidates(media, lib) + if len(got) == 0 { + t.Fatal("scrapeQueryCandidates returned no candidates") + } + if got[0] != "inception" { + t.Fatalf("first query candidate = %q, want movie library root title; all candidates=%#v", got[0], got) + } +} + +func TestEnrichOneUsesMovieFolderWhenFilenameIsGeneric(t *testing.T) { + var queries []string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + queries = append(queries, r.URL.Query().Get("query")) + w.Header().Set("Content-Type", "application/json") + if r.URL.Path != "/search/movie" { + http.NotFound(w, r) + return + } + if r.URL.Query().Get("query") != "inception" { + _ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{{ + "id": 27205, + "title": "Inception", + "overview": "A thief enters dreams.", + "poster_path": "/inception.jpg", + "release_date": "2010-07-16", + "vote_average": 8.4, + "original_title": "Inception", + }}, + }) + })) + 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 + log := zap.NewNop() + scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log)) + + lib := model.Library{Name: "Movies", Path: `/media/movies`, Type: "movie", Enabled: true} + if err := repos.DB.Create(&lib).Error; err != nil { + t.Fatal(err) + } + media := model.Media{ + LibraryID: lib.ID, + Title: "00000", + Path: `/media/movies/Inception (2010)/BDMV/STREAM/00000.m2ts`, + 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 != 27205 || got.Title != "Inception" { + t.Fatalf("generic filename scrape did not use folder title: status=%q tmdb=%d title=%q queries=%v", got.ScrapeStatus, got.TMDbID, got.Title, queries) + } + if len(queries) == 0 || queries[0] != "inception" { + t.Fatalf("first tmdb query = %q, want folder title; all queries=%v", firstQuery(queries), queries) + } +} diff --git a/internal/service/scraper_query_test.go b/internal/service/scraper_query_test.go index 363ddff..9d54c0b 100644 --- a/internal/service/scraper_query_test.go +++ b/internal/service/scraper_query_test.go @@ -363,346 +363,3 @@ func TestEnrichOneUsesLocalPathExternalIDHints(t *testing.T) { t.Fatalf("scraper should query by hinted tmdb id first, requests=%v", requested) } } - -func TestScrapeQueryCandidatesPreferSeriesFolderAndCJKTitle(t *testing.T) { - lib := &model.Library{ - Path: `F:\downloads\国产剧`, - Type: "movie", - } - media := &model.Media{ - Title: "亏成首富从游戏开始 the ri est in game", - Path: `F:\downloads\国产剧\亏成首富从游戏开始 The Richest in Game\Season 01\亏成首富从游戏开始 The Richest in Game - S01E11 - 4K.mp4`, - SeasonNum: 1, - EpisodeNum: 11, - } - - got := scrapeQueryCandidates(media, lib) - if len(got) == 0 { - t.Fatal("scrapeQueryCandidates returned no candidates") - } - if got[0] != "亏成首富从游戏开始" { - t.Fatalf("first query candidate = %q, want Chinese series title", got[0]) - } - for _, candidate := range got { - if strings.Contains(candidate, "ri est") { - t.Fatalf("query candidate kept substring-stripped title: %#v", got) - } - } -} - -func TestScrapeQueryCandidatesUseCloudSeriesFolder(t *testing.T) { - lib := &model.Library{ - Path: "cloud://openlist/国产剧", - Type: "movie", - } - media := &model.Media{ - Title: "折腰 S01E01", - Path: "cloud://openlist/国产剧/折腰 (2025)/Season 1/折腰.S01E01.mkv", - SeasonNum: 1, - EpisodeNum: 1, - } - - got := scrapeQueryCandidates(media, lib) - if len(got) == 0 { - t.Fatal("scrapeQueryCandidates returned no candidates") - } - if got[0] != "折腰" { - t.Fatalf("first query candidate = %q, want cloud series folder title; all candidates=%#v", got[0], got) - } -} - -func TestScrapeQueryCandidatesUseSeriesLibraryRootWhenMountedAtShowFolder(t *testing.T) { - lib := &model.Library{ - Path: `/downloads/国产剧/折腰 (2025)`, - Type: "tv", - } - media := &model.Media{ - Title: "第 1 集", - Path: `/downloads/国产剧/折腰 (2025)/Season 01/第01集.mkv`, - SeasonNum: 1, - EpisodeNum: 1, - } - - got := scrapeQueryCandidates(media, lib) - if len(got) == 0 { - t.Fatal("scrapeQueryCandidates returned no candidates") - } - if got[0] != "折腰" { - t.Fatalf("first query candidate = %q, want library root show title; all candidates=%#v", got[0], got) - } -} - -func TestScrapeQueryCandidatesSkipEpisodeOnlyTitles(t *testing.T) { - lib := &model.Library{ - Path: `F:\media\电视剧\欧美剧`, - Type: "tv", - } - media := &model.Media{ - Title: "第1期上:最狠开局!五哈团命悬一线好刺激", - Path: `F:\media\电视剧\欧美剧\第1期上:最狠开局!五哈团命悬一线好刺激 (2026)\Season 6\第1期上:最狠开局!五哈团命悬一线好刺激 - S06E01 - 第 1 集.mkv`, - SeasonNum: 6, - EpisodeNum: 1, - } - - got := scrapeQueryCandidates(media, lib) - for _, candidate := range got { - if unsafeAutomaticEpisodeQuery(candidate) { - t.Fatalf("query candidates kept unsafe episode title %q: %#v", candidate, got) - } - } -} - -func TestOrganizeMetadataRejectsEpisodeOnlyQuery(t *testing.T) { - match := &Match{Title: "错误节目", Year: 2026, TMDbID: 284725} - for _, query := range []string{"第 11 集", "第1期上:最狠开局!五哈团命悬一线好刺激"} { - if organizeMetadataMatchTrusted(query, 2026, match) { - t.Fatalf("episode-only query %q must not be trusted for automatic match", query) - } - } -} - -func TestOrganizeMetadataTrustsExactOriginalTitle(t *testing.T) { - match := &Match{Title: "镖人", OriginalName: "Blades of the Guardians", Year: 2023, TMDbID: 107463} - if !organizeMetadataMatchTrusted("blades of the guardians", 2023, match) { - t.Fatal("exact original title should be trusted") - } -} - -func TestOrganizeMetadataTrustsCleanedBroadcastReleaseQuery(t *testing.T) { - match := &Match{Title: "湖南卫视春节联欢晚会", OriginalName: "HNTV Spring Festival Gala", Year: 2026, TMDbID: 123456} - query := "hntv spring festival gala fps hlg qhstudio s01e qhstudio" - if !organizeMetadataMatchTrusted(query, 2026, match) { - t.Fatal("release noise should not block an otherwise matching title") - } -} - -func TestOrganizeMetadataTrustsChineseReleaseAliasWithoutOriginalName(t *testing.T) { - match := &Match{ - Title: "莫离", - Languages: []string{"zh"}, - Countries: []string{"CN"}, - Year: 2026, - TMDbID: 292696, - } - if !organizeMetadataMatchTrusted("the first jasmine", 2026, match) { - t.Fatal("multi-word English release alias should be trusted for Chinese-origin metadata") - } -} - -func TestOrganizeMetadataTrustsLocalizedSearchKeyword(t *testing.T) { - match := &Match{ - Title: "Monarch: Legacy of Monsters", - OriginalName: "Monarch: Legacy of Monsters", - Year: 2023, - TMDbID: 202411, - SearchKeyword: "帝王计划:怪兽遗产", - } - if !organizeMetadataMatchTrusted("帝王计划:怪兽遗产", 2023, match) { - t.Fatal("localized TMDb search keyword should be trusted even when returned title is not localized") - } - preferLocalizedSearchTitle("帝王计划:怪兽遗产", match) - if match.Title != "帝王计划:怪兽遗产" || match.OriginalName != "Monarch: Legacy of Monsters" { - t.Fatalf("localized title not preserved: title=%q original=%q", match.Title, match.OriginalName) - } -} - -func TestOrganizeMetadataRejectsShortLocalizedSearchKeyword(t *testing.T) { - match := &Match{ - Title: "Hello, Saturday", - OriginalName: "Hello, Saturday", - TMDbID: 123456, - SearchKeyword: "你好", - } - if organizeMetadataMatchTrusted("你好", 0, match) { - t.Fatal("short generic localized search keyword must not be trusted") - } -} - -func TestOrganizeMetadataRejectsSingleTokenChineseAlias(t *testing.T) { - match := &Match{ - Title: "莫离", - Languages: []string{"zh"}, - Countries: []string{"CN"}, - Year: 2026, - TMDbID: 292696, - } - if organizeMetadataMatchTrusted("jasmine", 2026, match) { - t.Fatal("single token alias should stay too weak for automatic matching") - } -} - -func TestOrganizeMetadataRejectsLooseSingleTokenQuery(t *testing.T) { - match := &Match{Title: "步步惊心泰版", OriginalName: "Scarlet Heart Thailand", Year: 2025, TMDbID: 252886} - if organizeMetadataMatchTrusted("thailand", 0, match) { - t.Fatal("single generic token must not trust a broader title") - } -} - -func TestOrganizeMetadataRejectsDirtyUnrelatedQuery(t *testing.T) { - match := &Match{Title: "春节联欢晚会", OriginalName: "Spring Festival Gala", Year: 2026, TMDbID: 284725} - query := "hntv spring festival gala fps hlg qhstudio s01e qhstudio" - if organizeMetadataMatchTrusted(query, 2026, match) { - t.Fatal("dirty query with a missing broadcaster token must not trust a partial title") - } -} - -func TestSeriesTitleFromMediaPathIgnoresEpisodeOnlyFolder(t *testing.T) { - got := seriesTitleFromMediaPath(`F:\media\电视剧\欧美剧\第 11 集 (2026)\Season 6\第 11 集 - S06E11.mkv`) - if got != "" { - t.Fatalf("series title from episode-only folder = %q, want empty", got) - } -} - -func TestMediaIsEpisodicUsesEpisodePatternInPath(t *testing.T) { - lib := &model.Library{ - Path: `/media/movies`, - Type: "movie", - } - media := &model.Media{ - Title: "折腰 S01E01", - Path: `/media/movies/折腰/Season 01/折腰.S01E01.mkv`, - } - - if !mediaIsEpisodic(media, lib) { - t.Fatal("media with an SxxEyy path should be treated as episodic even in a movie library") - } -} - -func TestScrapeQueryCandidatesSkipCategoryFolderAsSeriesTitle(t *testing.T) { - lib := &model.Library{ - Path: `/downloads`, - Type: "tv", - } - media := &model.Media{ - Title: "Ashes To Crown", - Path: `/downloads/国产剧/Ashes.to.Crown.S01E06.1080p.WEB-DL.mkv`, - SeasonNum: 1, - EpisodeNum: 6, - } - - got := scrapeQueryCandidates(media, lib) - if len(got) == 0 { - t.Fatal("scrapeQueryCandidates returned no candidates") - } - if got[0] == "国产剧" { - t.Fatalf("first query candidate = %q, category folders must not be used as title candidates: %#v", got[0], got) - } - if !strings.EqualFold(got[0], "Ashes To Crown") { - t.Fatalf("first query candidate = %q, want release title; all candidates=%#v", got[0], got) - } -} - -func TestScrapeQueryCandidatesUseMovieFolderForGenericFilename(t *testing.T) { - lib := &model.Library{ - Path: `/media/movies`, - Type: "movie", - } - media := &model.Media{ - Title: "00000", - Path: `/media/movies/Inception (2010)/BDMV/STREAM/00000.m2ts`, - } - - got := scrapeQueryCandidates(media, lib) - if len(got) == 0 { - t.Fatal("scrapeQueryCandidates returned no candidates") - } - if got[0] != "inception" { - t.Fatalf("first query candidate = %q, want movie folder title; all candidates=%#v", got[0], got) - } - for _, candidate := range got { - switch strings.ToLower(candidate) { - case "bdmv", "stream": - t.Fatalf("query candidates kept technical filename/folder: %#v", got) - } - } -} - -func TestScrapeQueryCandidatesUseMovieLibraryRootWhenMountedAtMovieFolder(t *testing.T) { - lib := &model.Library{ - Path: `/media/movies/Inception (2010)`, - Type: "movie", - } - media := &model.Media{ - Title: "00000", - Path: `/media/movies/Inception (2010)/BDMV/STREAM/00000.m2ts`, - } - - got := scrapeQueryCandidates(media, lib) - if len(got) == 0 { - t.Fatal("scrapeQueryCandidates returned no candidates") - } - if got[0] != "inception" { - t.Fatalf("first query candidate = %q, want movie library root title; all candidates=%#v", got[0], got) - } -} - -func TestEnrichOneUsesMovieFolderWhenFilenameIsGeneric(t *testing.T) { - var queries []string - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - queries = append(queries, r.URL.Query().Get("query")) - w.Header().Set("Content-Type", "application/json") - if r.URL.Path != "/search/movie" { - http.NotFound(w, r) - return - } - if r.URL.Query().Get("query") != "inception" { - _ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}}) - return - } - _ = json.NewEncoder(w).Encode(map[string]any{ - "results": []map[string]any{{ - "id": 27205, - "title": "Inception", - "overview": "A thief enters dreams.", - "poster_path": "/inception.jpg", - "release_date": "2010-07-16", - "vote_average": 8.4, - "original_title": "Inception", - }}, - }) - })) - 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 - log := zap.NewNop() - scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log)) - - lib := model.Library{Name: "Movies", Path: `/media/movies`, Type: "movie", Enabled: true} - if err := repos.DB.Create(&lib).Error; err != nil { - t.Fatal(err) - } - media := model.Media{ - LibraryID: lib.ID, - Title: "00000", - Path: `/media/movies/Inception (2010)/BDMV/STREAM/00000.m2ts`, - 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 != 27205 || got.Title != "Inception" { - t.Fatalf("generic filename scrape did not use folder title: status=%q tmdb=%d title=%q queries=%v", got.ScrapeStatus, got.TMDbID, got.Title, queries) - } - if len(queries) == 0 || queries[0] != "inception" { - t.Fatalf("first tmdb query = %q, want folder title; all queries=%v", firstQuery(queries), queries) - } -} diff --git a/internal/service/scraper_query_trust_test.go b/internal/service/scraper_query_trust_test.go new file mode 100644 index 0000000..a2670a5 --- /dev/null +++ b/internal/service/scraper_query_trust_test.go @@ -0,0 +1,97 @@ +package service + +import "testing" + +func TestOrganizeMetadataRejectsEpisodeOnlyQuery(t *testing.T) { + match := &Match{Title: "错误节目", Year: 2026, TMDbID: 284725} + for _, query := range []string{"第 11 集", "第1期上:最狠开局!五哈团命悬一线好刺激"} { + if organizeMetadataMatchTrusted(query, 2026, match) { + t.Fatalf("episode-only query %q must not be trusted for automatic match", query) + } + } +} + +func TestOrganizeMetadataTrustsExactOriginalTitle(t *testing.T) { + match := &Match{Title: "镖人", OriginalName: "Blades of the Guardians", Year: 2023, TMDbID: 107463} + if !organizeMetadataMatchTrusted("blades of the guardians", 2023, match) { + t.Fatal("exact original title should be trusted") + } +} + +func TestOrganizeMetadataTrustsCleanedBroadcastReleaseQuery(t *testing.T) { + match := &Match{Title: "湖南卫视春节联欢晚会", OriginalName: "HNTV Spring Festival Gala", Year: 2026, TMDbID: 123456} + query := "hntv spring festival gala fps hlg qhstudio s01e qhstudio" + if !organizeMetadataMatchTrusted(query, 2026, match) { + t.Fatal("release noise should not block an otherwise matching title") + } +} + +func TestOrganizeMetadataTrustsChineseReleaseAliasWithoutOriginalName(t *testing.T) { + match := &Match{ + Title: "莫离", + Languages: []string{"zh"}, + Countries: []string{"CN"}, + Year: 2026, + TMDbID: 292696, + } + if !organizeMetadataMatchTrusted("the first jasmine", 2026, match) { + t.Fatal("multi-word English release alias should be trusted for Chinese-origin metadata") + } +} + +func TestOrganizeMetadataTrustsLocalizedSearchKeyword(t *testing.T) { + match := &Match{ + Title: "Monarch: Legacy of Monsters", + OriginalName: "Monarch: Legacy of Monsters", + Year: 2023, + TMDbID: 202411, + SearchKeyword: "帝王计划:怪兽遗产", + } + if !organizeMetadataMatchTrusted("帝王计划:怪兽遗产", 2023, match) { + t.Fatal("localized TMDb search keyword should be trusted even when returned title is not localized") + } + preferLocalizedSearchTitle("帝王计划:怪兽遗产", match) + if match.Title != "帝王计划:怪兽遗产" || match.OriginalName != "Monarch: Legacy of Monsters" { + t.Fatalf("localized title not preserved: title=%q original=%q", match.Title, match.OriginalName) + } +} + +func TestOrganizeMetadataRejectsShortLocalizedSearchKeyword(t *testing.T) { + match := &Match{ + Title: "Hello, Saturday", + OriginalName: "Hello, Saturday", + TMDbID: 123456, + SearchKeyword: "你好", + } + if organizeMetadataMatchTrusted("你好", 0, match) { + t.Fatal("short generic localized search keyword must not be trusted") + } +} + +func TestOrganizeMetadataRejectsSingleTokenChineseAlias(t *testing.T) { + match := &Match{ + Title: "莫离", + Languages: []string{"zh"}, + Countries: []string{"CN"}, + Year: 2026, + TMDbID: 292696, + } + if organizeMetadataMatchTrusted("jasmine", 2026, match) { + t.Fatal("single token alias should stay too weak for automatic matching") + } +} + +func TestOrganizeMetadataRejectsLooseSingleTokenQuery(t *testing.T) { + match := &Match{Title: "步步惊心泰版", OriginalName: "Scarlet Heart Thailand", Year: 2025, TMDbID: 252886} + if organizeMetadataMatchTrusted("thailand", 0, match) { + t.Fatal("single generic token must not trust a broader title") + } +} + +func TestOrganizeMetadataRejectsDirtyUnrelatedQuery(t *testing.T) { + match := &Match{Title: "春节联欢晚会", OriginalName: "Spring Festival Gala", Year: 2026, TMDbID: 284725} + query := "hntv spring festival gala fps hlg qhstudio s01e qhstudio" + if organizeMetadataMatchTrusted(query, 2026, match) { + t.Fatal("dirty query with a missing broadcaster token must not trust a partial title") + } +}