From 5eb0d4c8af8365e3e644b7e19777e3884a0868da Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:58:07 +0800 Subject: [PATCH] fix: reject low-confidence automatic metadata matches --- internal/service/bangumi.go | 11 ++- internal/service/metadata_match_trust.go | 98 +++++++++++++++++++ .../service/organizer_directory_metadata.go | 2 +- internal/service/scraper_query.go | 4 +- internal/service/scraper_query_test.go | 31 ++++++ 5 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 internal/service/metadata_match_trust.go diff --git a/internal/service/bangumi.go b/internal/service/bangumi.go index b0e2895..bb1fb32 100644 --- a/internal/service/bangumi.go +++ b/internal/service/bangumi.go @@ -93,11 +93,12 @@ func (b *BangumiProvider) Search(ctx context.Context, query string) (*Match, err title = r.Name } m := &Match{ - BangumiID: r.ID, - Title: title, - Overview: r.Summary, - PosterURL: normalizeBangumiImageURL(r.Images.Large), - Rating: r.Rating.Score, + BangumiID: r.ID, + Title: title, + OriginalName: r.Name, + Overview: r.Summary, + PosterURL: normalizeBangumiImageURL(r.Images.Large), + Rating: r.Rating.Score, } if len(r.Air) >= 4 { _, _ = fmt.Sscanf(r.Air[:4], "%d", &m.Year) diff --git a/internal/service/metadata_match_trust.go b/internal/service/metadata_match_trust.go new file mode 100644 index 0000000..8a9d90b --- /dev/null +++ b/internal/service/metadata_match_trust.go @@ -0,0 +1,98 @@ +package service + +import ( + "regexp" + "strings" +) + +var ( + metadataTrustTokenRE = regexp.MustCompile(`[\p{L}\p{N}]+`) + metadataTrustDanglingEpisodeRE = regexp.MustCompile(`(?i)^s\d{1,2}e$`) +) + +func automaticMetadataTitleTrusted(query string, match *Match) bool { + queryKey := metadataTrustKey(query) + if queryKey == "" || match == nil { + return false + } + for _, title := range []string{match.Title, match.OriginalName} { + titleKey := metadataTrustKey(title) + if titleKey == "" { + continue + } + if queryKey == titleKey || metadataTrustTokenOverlap(queryKey, titleKey) { + return true + } + } + return false +} + +func metadataTrustKey(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "" + } + value = bracketedTag.ReplaceAllString(value, " ") + value = yearPattern.ReplaceAllString(value, " ") + for _, re := range []*regexp.Regexp{patSEnE, patNxE, patEP, patCN, patSeasonOnly, patCNSeason} { + value = re.ReplaceAllString(value, " ") + } + tokens := metadataTrustTokenRE.FindAllString(value, -1) + out := make([]string, 0, len(tokens)) + for _, token := range tokens { + token = strings.TrimSpace(token) + if token == "" || metadataTrustNoiseToken(token) { + continue + } + out = append(out, token) + } + return strings.Join(out, " ") +} + +func metadataTrustNoiseToken(token string) bool { + if token == "x" || token == "×" { + return true + } + if _, ok := noiseTokenSet[token]; ok { + return true + } + if metadataTrustDanglingEpisodeRE.MatchString(token) { + return true + } + return false +} + +func metadataTrustTokenOverlap(queryKey, titleKey string) bool { + queryTokens := metadataTrustSignificantTokens(queryKey) + titleTokens := metadataTrustSignificantTokens(titleKey) + if len(queryTokens) <= 1 || len(titleTokens) <= 1 { + return false + } + titleSet := make(map[string]struct{}, len(titleTokens)) + for _, token := range titleTokens { + titleSet[token] = struct{}{} + } + overlap := 0 + for _, token := range queryTokens { + if _, ok := titleSet[token]; ok { + overlap++ + } + } + queryCoverage := float64(overlap) / float64(len(queryTokens)) + titleCoverage := float64(overlap) / float64(len(titleTokens)) + return queryCoverage >= 0.80 && titleCoverage >= 0.50 +} + +func metadataTrustSignificantTokens(key string) []string { + fields := strings.Fields(key) + out := make([]string, 0, len(fields)) + for _, field := range fields { + switch field { + case "the", "a", "an", "of", "and": + continue + default: + out = append(out, field) + } + } + return out +} diff --git a/internal/service/organizer_directory_metadata.go b/internal/service/organizer_directory_metadata.go index 0d5144c..f058892 100644 --- a/internal/service/organizer_directory_metadata.go +++ b/internal/service/organizer_directory_metadata.go @@ -150,7 +150,7 @@ func organizeMetadataMatchTrusted(query string, sourceYear int, match *Match) bo return false } } - return true + return automaticMetadataTitleTrusted(query, match) } func organizeMatchFromLocalMetadata(local *LocalMetadata) *Match { diff --git a/internal/service/scraper_query.go b/internal/service/scraper_query.go index 96123af..e58b56f 100644 --- a/internal/service/scraper_query.go +++ b/internal/service/scraper_query.go @@ -20,10 +20,10 @@ var noiseTokens = []string{ "hdrip", "bluray", "blu-ray", "webrip", "web-dl", "web", "x264", "x265", "h264", "h265", "hevc", "avc", "10bit", "8bit", "hi10p", "hi10", "hdr", "hdr10", "sdr", "dts", "ddp", "ddp5", "dd5", "dd2", "eac3", "truehd", - "dovi", "atmos", "aac", "ac3", "flac", + "dovi", "atmos", "aac", "ac3", "flac", "fps", "hlg", "dv", "remux", "extended", "uncut", "remastered", "repack", "proper", "internal", "limited", "imax", "directors-cut", "directors_cut", - "hkfree", "yify", "rarbg", "ettv", "fgt", "tgx", "ctrlhd", "ntb", "flux", + "hkfree", "yify", "rarbg", "ettv", "fgt", "tgx", "ctrlhd", "ntb", "flux", "qhstudio", // 流媒体平台 / 字幕组 / 国家版本(动漫常见) "netflix", "nf", "amzn", "hulu", "disney", "max", "hbo", diff --git a/internal/service/scraper_query_test.go b/internal/service/scraper_query_test.go index 0b0f70b..600237f 100644 --- a/internal/service/scraper_query_test.go +++ b/internal/service/scraper_query_test.go @@ -31,6 +31,7 @@ func TestCleanQuery(t *testing.T) { {"亏成首富从游戏开始 The Richest in Game - S01E11 - 4K.mp4", "亏成首富从游戏开始 the richest in game", 0}, {"紫川.2024.S02E24.第24集.2160p.WEB-DL.H.265-ColorTV.mkv", "紫川", 2024}, {"紫川 (2024) {tmdb-247590}", "紫川", 2024}, + {"HNTV.Spring.Festival.Gala.FPS.HLG-QHStudio.S01E202-DD5.QHstudIo.6.4K.ts", "hntv spring festival gala", 0}, } for _, tc := range cases { t.Run(tc.in, func(t *testing.T) { @@ -301,6 +302,36 @@ func TestOrganizeMetadataRejectsEpisodeOnlyQuery(t *testing.T) { } } +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 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 != "" {