mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
fix: reject low-confidence automatic metadata matches
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
Reference in New Issue
Block a user