feat(metadata): match sole exact-title candidate despite year off by <=2
Folder years routinely differ from provider release years by a year or two (festival vs wide release, regional dates), zeroing the year bonus and leaving a lone exact-title candidate at 63-68 — just under the single-candidate >=70 gate (e.g. Dead Reckoning 1947 vs 1946, 17 Blocks 2021 vs 2019, Stasi FC). Add title corroboration to the existing lone-result rule: a sole distinct show whose normalized title exactly matches and whose year is within +/-2 is accepted. The 55 floor still rejects low-similarity titles (e.g. Hotel Transylvania Puppy! vs Puppy!). No 55/70/15 threshold change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
17d156a7ca
commit
416fba8dab
@@ -446,6 +446,14 @@ func distinctSourceCount(group []scoredMatchCandidate) int {
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
// absYearDelta returns the absolute difference between two release years.
|
||||
func absYearDelta(a, b int) int {
|
||||
if a > b {
|
||||
return a - b
|
||||
}
|
||||
return b - a
|
||||
}
|
||||
|
||||
func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate, providerPriority []string) (*MatchCandidate, bool) {
|
||||
if len(candidates) == 0 {
|
||||
return nil, false
|
||||
@@ -521,7 +529,17 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate,
|
||||
// (folders without a "(YYYY)"). A lone single-source no-year result is NOT
|
||||
// accepted here and stays subject to the single-candidate >=70 gate.
|
||||
multiSourceCorroborated := distinctSourceCount(topGroup) >= 2
|
||||
if yearCorroborated || multiSourceCorroborated {
|
||||
// An exact normalized-title match on a sole distinct show is strong
|
||||
// corroboration on its own, even when the folder year is off by a year
|
||||
// or two (festival vs wide-release date, regional release) — e.g.
|
||||
// "Dead Reckoning (1947)" vs TMDB's 1946, "17 Blocks (2021)" vs 2019.
|
||||
// Bounded to ±2 years so same-title remakes decades apart still require
|
||||
// a year or multi-source match. Uses the same normalizer as title scoring
|
||||
// so "exact" here means a perfect title-similarity component.
|
||||
titleCorroborated := hints.Year != 0 && best.candidate.Year != 0 &&
|
||||
absYearDelta(best.candidate.Year, hints.Year) <= 2 &&
|
||||
normalizeTitleForScoring(best.candidate.Title) == normalizeTitleForScoring(hints.Title)
|
||||
if yearCorroborated || multiSourceCorroborated || titleCorroborated {
|
||||
return pickByProviderPriority(topGroup, providerPriority), true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,85 @@ func TestSelectInitialMatchCandidate_IgnoresLocalContentIDForTrustedSelection(t
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if !ok || winner == nil {
|
||||
t.Fatal("expected local content_id not to force trusted-ID matching")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_SoleExactTitleYearOffByTwoMatches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Sole distinct candidate, exact title, year off by 2 (e.g. "Stasi FC (2023)"
|
||||
// vs TMDB's 2025). Scores in the 55-69 band — below the single-candidate >=70
|
||||
// gate — but the exact title on a lone result should now match via title
|
||||
// corroboration without lowering any threshold.
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{Title: "Stasi FC", Year: 2023, Type: "movie"},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "Stasi FC",
|
||||
Year: 2025,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "111"},
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if !ok || winner == nil || winner.ProviderIDs["tmdb"] != "111" {
|
||||
t.Fatalf("expected sole exact-title year-off-by-2 candidate to match, got ok=%v winner=%+v", ok, winner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_SoleExactTitleYearOffByThreeRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A 3-year gap exceeds the ±2 bound: a same-title film three years apart is
|
||||
// not corroborated and stays subject to the single-candidate >=70 gate.
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{Title: "Stasi FC", Year: 2023, Type: "movie"},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "Stasi FC",
|
||||
Year: 2026,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "111"},
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatalf("expected year-off-by-3 sole candidate to be rejected, got ok=%v winner=%+v", ok, winner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_SoleDifferentTitleExactYearStillFloored(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// "Hotel Transylvania Puppy!" vs TMDB's "Puppy!" (same year) scores below the
|
||||
// 55 floor on title similarity, so it must stay rejected — title corroboration
|
||||
// must not rescue a low-similarity title just because the year matches.
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{Title: "Hotel Transylvania Puppy!", Year: 2017, Type: "movie"},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "Puppy!",
|
||||
Year: 2017,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "222"},
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatalf("expected low-similarity sole candidate to stay rejected, got ok=%v winner=%+v", ok, winner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuppressTitleYearFallbackForTrustedIDs_IgnoresMetadb(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -377,6 +450,7 @@ func TestSelectInitialMatchCandidate_AcceptsSinglePunctuationEquivalentCandidate
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if !ok || winner == nil {
|
||||
t.Fatalf("expected lone punctuation-equivalent candidate to be accepted")
|
||||
@@ -411,6 +485,7 @@ func TestSelectInitialMatchCandidate_AcceptsProviderTitleWithRepeatedYear(t *tes
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if !ok || winner == nil {
|
||||
t.Fatal("expected provider title with repeated release year to be accepted")
|
||||
@@ -445,6 +520,7 @@ func TestSelectInitialMatchCandidate_UsesDetailScoreForDuplicateProviderTie(t *t
|
||||
DetailScore: 46,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if !ok || winner == nil {
|
||||
t.Fatal("expected richer duplicate TMDB candidate to be accepted")
|
||||
@@ -479,6 +555,7 @@ func TestSelectInitialMatchCandidate_RejectsDuplicateTieWithoutClearDetailGap(t
|
||||
DetailScore: 34,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatal("expected duplicate tie without clear detail gap to remain unmatched")
|
||||
@@ -573,6 +650,7 @@ func TestSelectInitialMatchCandidate_DetailScoreDoesNotOverrideDifferentTitleTie
|
||||
DetailScore: 80,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatal("expected richer different-title candidate to be rejected")
|
||||
@@ -602,6 +680,7 @@ func TestSelectInitialMatchCandidate_DetailScoreRequiresDatedDuplicateCandidates
|
||||
DetailScore: 46,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatal("expected duplicate detail tie-breaker to reject candidates without matching years")
|
||||
@@ -633,6 +712,7 @@ func TestSelectInitialMatchCandidate_DetailScoreRequiresHintCompatibleType(t *te
|
||||
DetailScore: 46,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatal("expected duplicate detail tie-breaker to reject candidates with hint-incompatible type")
|
||||
@@ -655,6 +735,7 @@ func TestSelectInitialMatchCandidate_RejectsWeakSingleCandidate(t *testing.T) {
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatalf("expected weak lone candidate to be rejected")
|
||||
|
||||
Reference in New Issue
Block a user