From 8ff06ed008402cd64591f5eab82cd3a125298aa4 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Wed, 27 May 2026 19:59:00 +0200 Subject: [PATCH] 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) --- internal/metadata/match_candidates.go | 46 +++++++++++++++ .../metadata/match_candidates_select_test.go | 57 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 internal/metadata/match_candidates_select_test.go diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index 4b9acaa3..e13306c9 100644 --- a/internal/metadata/match_candidates.go +++ b/internal/metadata/match_candidates.go @@ -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 diff --git a/internal/metadata/match_candidates_select_test.go b/internal/metadata/match_candidates_select_test.go new file mode 100644 index 00000000..df7685e8 --- /dev/null +++ b/internal/metadata/match_candidates_select_test.go @@ -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") + } +}