feat(matcher): auto-accept a year-corroborated single distinct show
A search that resolves to one distinct show (one candidate, or the same title+year returned once per source as unmerged TVDB/TMDB rows) whose year matches the parsed year is now auto-accepted via the existing top-ranked candidate, even when the fuzzy title score is in the 55-69 band. The 55/70/15 thresholds are unchanged; this only adds a year-gated acceptance for effectively-unique results (recovers lone-correct-result items like 1201 (1993)). 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
a6da4a4a98
commit
8ff06ed008
@@ -342,6 +342,42 @@ type scoredMatchCandidate struct {
|
||||
score float64
|
||||
}
|
||||
|
||||
// candidatesAreSingleDistinctShow reports whether every scored candidate refers
|
||||
// to the same show as best — same year, an exact normalized title match, and no
|
||||
// conflicting provider IDs. This is true when the search effectively returned
|
||||
// one distinct title, possibly as separate per-source rows (e.g. a TVDB row and
|
||||
// a TMDB row that weren't merged because each carries only its own provider's
|
||||
// ID). Candidates that share a canonical provider key but carry different values
|
||||
// are considered distinct shows and cause the function to return false.
|
||||
func candidatesAreSingleDistinctShow(best MatchCandidate, scored []scoredMatchCandidate) bool {
|
||||
// Year==0 means the provider didn't supply a release year, so we cannot
|
||||
// claim the candidates refer to the *same* show via year-equality. Without
|
||||
// this guard, two no-year candidates from different providers would satisfy
|
||||
// the multi-source corroboration arm of the lone-result rule and get
|
||||
// auto-accepted, which over-accepts ambiguous matches.
|
||||
if best.Year == 0 {
|
||||
return false
|
||||
}
|
||||
for _, c := range scored {
|
||||
if c.candidate.Year == 0 || c.candidate.Year != best.Year {
|
||||
return false
|
||||
}
|
||||
if inferTitleSimilarity(best.Title, c.candidate.Title, best.Year) != 1 {
|
||||
return false
|
||||
}
|
||||
// If both candidates carry the same canonical provider key (e.g. both
|
||||
// have a tmdb ID) but different values, they are different shows.
|
||||
for _, key := range canonicalCandidateIDKeys {
|
||||
bv := strings.TrimSpace(best.ProviderIDs[key])
|
||||
cv := strings.TrimSpace(c.candidate.ProviderIDs[key])
|
||||
if bv != "" && cv != "" && bv != cv {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) (*MatchCandidate, bool) {
|
||||
if len(candidates) == 0 {
|
||||
return nil, false
|
||||
@@ -369,6 +405,16 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate)
|
||||
if best.score < 55 {
|
||||
return nil, false
|
||||
}
|
||||
// A search that resolves to a single distinct show (one candidate, or the
|
||||
// same title+year returned once per source) whose year matches the parsed
|
||||
// year is high-confidence even when the fuzzy title score sits in the 55-69
|
||||
// band (short/numeric/alternate titles). Accept the top-ranked candidate
|
||||
// without lowering the score thresholds.
|
||||
if hints.Year != 0 && best.candidate.Year == hints.Year &&
|
||||
candidateTypeMatchesHint(hints.Type, best.candidate.ContentType) &&
|
||||
candidatesAreSingleDistinctShow(best.candidate, scoredCandidates) {
|
||||
return &best.candidate, true
|
||||
}
|
||||
if len(scoredCandidates) == 1 {
|
||||
if best.score < 70 {
|
||||
return nil, false
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package metadata
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSelectInitialMatchCandidate_LoneResultYearMatchBelow70(t *testing.T) {
|
||||
// Exact title, matching year, NO sources, NO provider IDs => score 45+20 = 65 (<70).
|
||||
// Old behavior rejected this (single candidate <70); the new rule accepts it.
|
||||
hints := &MatchHints{Title: "1201", Year: 1993, Type: "movie"}
|
||||
cands := []MatchCandidate{{Title: "1201", Year: 1993, ContentType: "movie"}}
|
||||
got, ok := selectInitialMatchCandidate(hints, cands)
|
||||
if !ok || got == nil || got.Title != "1201" {
|
||||
t.Fatalf("expected lone year-matching result to be accepted, got ok=%v cand=%+v", ok, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_SameShowAcrossTwoSources(t *testing.T) {
|
||||
// Same title+year returned once per source (TVDB-only and TMDB-only, no shared ID
|
||||
// so they were NOT merged). Old behavior: tie-break bails -> nil. New: accept best.
|
||||
hints := &MatchHints{Title: "Blue Lock", Year: 2022, Type: "series"}
|
||||
cands := []MatchCandidate{
|
||||
{Title: "Blue Lock", Year: 2022, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "404404"}},
|
||||
{Title: "Blue Lock", Year: 2022, ContentType: "series", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "120089"}},
|
||||
}
|
||||
got, ok := selectInitialMatchCandidate(hints, cands)
|
||||
if !ok || got == nil {
|
||||
t.Fatalf("expected same-show-across-sources to be accepted, got ok=%v cand=%+v", ok, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_LoneResultYearMismatchStillRejected(t *testing.T) {
|
||||
// Exact title but year mismatch, one source => score 45+12 = 57 (in [55,70), no year bonus).
|
||||
// Year does NOT corroborate, so the new rule must NOT fire; single candidate <70 => reject.
|
||||
hints := &MatchHints{Title: "1201", Year: 1993, Type: "movie"}
|
||||
cands := []MatchCandidate{{Title: "1201", Year: 1990, ContentType: "movie", Sources: []string{"tmdb"}}}
|
||||
if got, ok := selectInitialMatchCandidate(hints, cands); ok {
|
||||
t.Fatalf("expected year-mismatch lone result to be rejected, got cand=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_TwoDifferentShowsUnchanged(t *testing.T) {
|
||||
// Two DIFFERENT shows (different titles, similar scores => gap=0 => tie-break path).
|
||||
// candidatesAreSingleDistinctShow returns false (titles differ), so the new rule
|
||||
// must NOT fire; falls through to the existing tie-break which returns nil because
|
||||
// DetailScore is 0. Guards against over-accepting distinct results.
|
||||
//
|
||||
// "The Show Special" and "The Show Extra" both score 38 against hint "The Show" /
|
||||
// 2010 (0-similarity title match, 1 source, 1 provider ID), gap = 0 < 15 =>
|
||||
// tie-break; inferTitleSimilarity between the two candidates is 0 => not same show.
|
||||
hints := &MatchHints{Title: "The Show", Year: 2010, Type: "movie"}
|
||||
cands := []MatchCandidate{
|
||||
{Title: "The Show Special", Year: 2010, ContentType: "movie", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "1"}},
|
||||
{Title: "The Show Extra", Year: 2010, ContentType: "movie", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "2"}},
|
||||
}
|
||||
if _, ok := selectInitialMatchCandidate(hints, cands); ok {
|
||||
t.Fatalf("two distinct shows must not be auto-accepted by the lone-result rule")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user