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:
Silo Server Developer
2026-05-28 17:58:06 +02:00
co-authored by Claude Opus 4.7
parent 17d156a7ca
commit 416fba8dab
2 changed files with 100 additions and 1 deletions
+19 -1
View File
@@ -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")