mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-08 14: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
|
title = r.Name
|
||||||
}
|
}
|
||||||
m := &Match{
|
m := &Match{
|
||||||
BangumiID: r.ID,
|
BangumiID: r.ID,
|
||||||
Title: title,
|
Title: title,
|
||||||
Overview: r.Summary,
|
OriginalName: r.Name,
|
||||||
PosterURL: normalizeBangumiImageURL(r.Images.Large),
|
Overview: r.Summary,
|
||||||
Rating: r.Rating.Score,
|
PosterURL: normalizeBangumiImageURL(r.Images.Large),
|
||||||
|
Rating: r.Rating.Score,
|
||||||
}
|
}
|
||||||
if len(r.Air) >= 4 {
|
if len(r.Air) >= 4 {
|
||||||
_, _ = fmt.Sscanf(r.Air[:4], "%d", &m.Year)
|
_, _ = 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 false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return automaticMetadataTitleTrusted(query, match)
|
||||||
}
|
}
|
||||||
|
|
||||||
func organizeMatchFromLocalMetadata(local *LocalMetadata) *Match {
|
func organizeMatchFromLocalMetadata(local *LocalMetadata) *Match {
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ var noiseTokens = []string{
|
|||||||
"hdrip", "bluray", "blu-ray", "webrip", "web-dl", "web",
|
"hdrip", "bluray", "blu-ray", "webrip", "web-dl", "web",
|
||||||
"x264", "x265", "h264", "h265", "hevc", "avc", "10bit", "8bit", "hi10p", "hi10",
|
"x264", "x265", "h264", "h265", "hevc", "avc", "10bit", "8bit", "hi10p", "hi10",
|
||||||
"hdr", "hdr10", "sdr", "dts", "ddp", "ddp5", "dd5", "dd2", "eac3", "truehd",
|
"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",
|
"remux", "extended", "uncut", "remastered", "repack", "proper", "internal",
|
||||||
"limited", "imax", "directors-cut", "directors_cut",
|
"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",
|
"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},
|
{"亏成首富从游戏开始 The Richest in Game - S01E11 - 4K.mp4", "亏成首富从游戏开始 the richest in game", 0},
|
||||||
{"紫川.2024.S02E24.第24集.2160p.WEB-DL.H.265-ColorTV.mkv", "紫川", 2024},
|
{"紫川.2024.S02E24.第24集.2160p.WEB-DL.H.265-ColorTV.mkv", "紫川", 2024},
|
||||||
{"紫川 (2024) {tmdb-247590}", "紫川", 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 {
|
for _, tc := range cases {
|
||||||
t.Run(tc.in, func(t *testing.T) {
|
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) {
|
func TestSeriesTitleFromMediaPathIgnoresEpisodeOnlyFolder(t *testing.T) {
|
||||||
got := seriesTitleFromMediaPath(`F:\media\电视剧\欧美剧\第 11 集 (2026)\Season 6\第 11 集 - S06E11.mkv`)
|
got := seriesTitleFromMediaPath(`F:\media\电视剧\欧美剧\第 11 集 (2026)\Season 6\第 11 集 - S06E11.mkv`)
|
||||||
if got != "" {
|
if got != "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user