diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index 335f1e4b..2b0d68fd 100644 --- a/internal/metadata/match_candidates.go +++ b/internal/metadata/match_candidates.go @@ -430,6 +430,22 @@ func pickByProviderPriority(group []scoredMatchCandidate, providerPriority []str return &group[0].candidate } +// distinctSourceCount returns how many distinct providers (case-insensitive +// Sources values) appear across the candidate group — i.e. how many independent +// providers returned this show. +func distinctSourceCount(group []scoredMatchCandidate) int { + seen := make(map[string]struct{}) + for _, c := range group { + for _, s := range c.candidate.Sources { + s = strings.ToLower(strings.TrimSpace(s)) + if s != "" { + seen[s] = struct{}{} + } + } + } + return len(seen) +} + func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate, providerPriority []string) (*MatchCandidate, bool) { if len(candidates) == 0 { return nil, false @@ -495,14 +511,19 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate, // Residual risk: two different shows with an identical title+year and no // provider IDs would both pass; accepted as low-risk given the title+year+type // corroboration. - if hints.Year != 0 && best.candidate.Year == hints.Year && - candidateTypeMatchesHint(hints.Type, best.candidate.ContentType) { + if candidateTypeMatchesHint(hints.Type, best.candidate.ContentType) { topGroup := topTieGroup(scoredCandidates) if candidatesAreSingleDistinctShow(best.candidate, topGroup) { - // One distinct show, possibly returned by several providers and clearly - // ahead of any different show below. Accept it, choosing the winner by - // the library's metadata-provider priority (falls back to top-scored). - return pickByProviderPriority(topGroup, providerPriority), true + yearCorroborated := hints.Year != 0 && best.candidate.Year == hints.Year + // Cross-source agreement (the same title+year returned by 2+ distinct + // providers, which candidatesAreSingleDistinctShow already verified) is + // strong independent corroboration — it stands in for a missing hint year + // (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 { + return pickByProviderPriority(topGroup, providerPriority), true + } } } if len(scoredCandidates) == 1 { diff --git a/internal/metadata/match_candidates_select_test.go b/internal/metadata/match_candidates_select_test.go index 6af943d6..49e23858 100644 --- a/internal/metadata/match_candidates_select_test.go +++ b/internal/metadata/match_candidates_select_test.go @@ -77,6 +77,48 @@ func TestSelectInitialMatchCandidate_ConflictingProviderIDsNotAccepted(t *testin } } +func TestSelectInitialMatchCandidate_CrossSourceNoHintYearAcceptedByMultiSource(t *testing.T) { + // Hint has NO year (0). Both providers return the same show (year 1999), tied. + // Multi-source agreement substitutes for the missing hint year. + hints := &MatchHints{Title: "100 Deeds for Eddie McDowd", Year: 0, Type: "series"} + cands := []MatchCandidate{ + {Title: "100 Deeds for Eddie McDowd", Year: 1999, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "72450"}}, + {Title: "100 Deeds for Eddie McDowd", Year: 1999, ContentType: "series", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "6518"}}, + } + got, ok := selectInitialMatchCandidate(hints, cands, []string{"tvdb", "tmdb"}) + if !ok || got == nil || got.ProviderIDs["tvdb"] != "72450" { + t.Fatalf("expected tvdb winner via multi-source corroboration, got ok=%v cand=%+v", ok, got) + } +} + +func TestSelectInitialMatchCandidate_CrossSourceNoCandidateYearNotAccepted(t *testing.T) { + // Both providers return the same title but neither carries a release year. + // Year-equality between two 0-years is meaningless, so + // candidatesAreSingleDistinctShow must reject and the multi-source + // corroboration arm must NOT fire — otherwise two no-year cross-source + // results would auto-accept, over-accepting ambiguous matches. + hints := &MatchHints{Title: "Untitled Show", Year: 0, Type: "series"} + cands := []MatchCandidate{ + {Title: "Untitled Show", Year: 0, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "111"}}, + {Title: "Untitled Show", Year: 0, ContentType: "series", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "222"}}, + } + if got, ok := selectInitialMatchCandidate(hints, cands, []string{"tvdb", "tmdb"}); ok { + t.Fatalf("no-year cross-source candidates must not be auto-accepted, got %+v", got) + } +} + +func TestSelectInitialMatchCandidate_LoneNoYearSingleSourceNotAccepted(t *testing.T) { + // Single candidate, no hint year, single source: no year corroboration AND only + // one source -> must NOT auto-accept (falls to the single-candidate >=70 gate). + hints := &MatchHints{Title: "Some Obscure Show", Year: 0, Type: "series"} + cands := []MatchCandidate{ + {Title: "Some Obscure Show", Year: 1999, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "999"}}, + } + if got, ok := selectInitialMatchCandidate(hints, cands, []string{"tvdb"}); ok { + t.Fatalf("lone no-year single-source result must not be auto-accepted, got %+v", got) + } +} + func TestSelectInitialMatchCandidate_CrossSourceTieResolvedByProviderPriority(t *testing.T) { // "100 Days Wild" (2020, series, no shared IDs): TVDB and TMDB each return the correct // show (score 83 each: 45 exact title + 20 year + 12 source + 5 has IDs + 1 richness).