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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user