feat(matcher): accept cross-source-corroborated ties without a hint year

When the top tie-group is one distinct show returned by 2+ distinct providers
(candidatesAreSingleDistinctShow already verifies matching title+year), accept it
even if the hint has no parsed year (year-less folders like '100 Deeds for Eddie
McDowd'). Multi-source agreement substitutes for the year guard; lone single-source
no-year results stay subject to the single-candidate >=70 gate. Thresholds unchanged.
This commit is contained in:
Silo Server Developer
2026-05-28 17:58:06 +02:00
parent db34df0a35
commit f4ea8a6d75
2 changed files with 69 additions and 6 deletions
+27 -6
View File
@@ -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 {
@@ -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).