From db34df0a35ec6be76d3c49aa815831dd23ab10b9 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Wed, 27 May 2026 23:14:33 +0200 Subject: [PATCH] feat(matcher): resolve cross-source ties by library provider priority Accept a year-corroborated single distinct show when the TOP tie-group (within 15 pts of best) is one show across providers, ignoring low-score noise below it, and pick the winner by the library's metadata-provider chain order (providerPriority, highest-first; falls back to top-scored). Recovers items like '100 Days Wild' that are returned identically by TVDB and TMDB. Thresholds (55/70/15) unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/metadata/match_candidates.go | 52 +++++++++++++++++-- .../metadata/match_candidates_select_test.go | 48 ++++++++++++++--- internal/metadata/match_candidates_test.go | 2 + internal/metadata/service.go | 6 ++- 4 files changed, 96 insertions(+), 12 deletions(-) diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index e6ad3646..335f1e4b 100644 --- a/internal/metadata/match_candidates.go +++ b/internal/metadata/match_candidates.go @@ -397,7 +397,40 @@ func candidatesAreSingleDistinctShow(best MatchCandidate, scored []scoredMatchCa return true } -func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) (*MatchCandidate, bool) { +// topTieGroup returns the highest-scored candidate plus every candidate within +// the 15-point tie window of it — i.e. the set of candidates that are not +// clearly beaten. Assumes scored is sorted descending by score. +func topTieGroup(scored []scoredMatchCandidate) []scoredMatchCandidate { + if len(scored) == 0 { + return nil + } + group := []scoredMatchCandidate{scored[0]} + for _, c := range scored[1:] { + if scored[0].score-c.score < 15 { + group = append(group, c) + } + } + return group +} + +// pickByProviderPriority returns the group candidate whose Sources include the +// highest-priority provider (providerPriority is ordered highest-first, e.g. the +// library's chain order). Falls back to the top-scored candidate when there is no +// priority info or no source matches. +func pickByProviderPriority(group []scoredMatchCandidate, providerPriority []string) *MatchCandidate { + for _, prov := range providerPriority { + for i := range group { + for _, s := range group[i].candidate.Sources { + if strings.EqualFold(s, prov) { + return &group[i].candidate + } + } + } + } + return &group[0].candidate +} + +func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate, providerPriority []string) (*MatchCandidate, bool) { if len(candidates) == 0 { return nil, false } @@ -455,13 +488,22 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) // 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. + // We check only the TOP tie-group (candidates within 15 pts of best) so that + // low-score noise from unrelated shows below the group does not veto a clear + // cross-source agreement. When the top group is one distinct show, pick the + // winner by the library's metadata-provider priority (falls back to top-scored). // 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) && - candidatesAreSingleDistinctShow(best.candidate, scoredCandidates) { - return &best.candidate, true + 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 + } } if len(scoredCandidates) == 1 { if best.score < 70 { @@ -553,7 +595,7 @@ func selectRefreshMatchCandidate(existing *models.MediaItem, candidates []MatchC TvdbID: existing.TvdbID, ImdbID: existing.ImdbID, } - return selectInitialMatchCandidate(hints, candidates) + return selectInitialMatchCandidate(hints, candidates, nil) } func trustedHintIDsPresent(hints *MatchHints) bool { diff --git a/internal/metadata/match_candidates_select_test.go b/internal/metadata/match_candidates_select_test.go index ed2af379..6af943d6 100644 --- a/internal/metadata/match_candidates_select_test.go +++ b/internal/metadata/match_candidates_select_test.go @@ -1,13 +1,15 @@ package metadata -import "testing" +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) + got, ok := selectInitialMatchCandidate(hints, cands, nil) if !ok || got == nil || got.Title != "1201" { t.Fatalf("expected lone year-matching result to be accepted, got ok=%v cand=%+v", ok, got) } @@ -21,7 +23,7 @@ func TestSelectInitialMatchCandidate_SameShowAcrossTwoSources(t *testing.T) { {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) + got, ok := selectInitialMatchCandidate(hints, cands, nil) if !ok || got == nil { t.Fatalf("expected same-show-across-sources to be accepted, got ok=%v cand=%+v", ok, got) } @@ -32,7 +34,7 @@ func TestSelectInitialMatchCandidate_LoneResultYearMismatchStillRejected(t *test // 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 { + if got, ok := selectInitialMatchCandidate(hints, cands, nil); ok { t.Fatalf("expected year-mismatch lone result to be rejected, got cand=%+v", got) } } @@ -54,7 +56,7 @@ func TestSelectInitialMatchCandidate_TwoDifferentShowsUnchanged(t *testing.T) { {Title: "The Real History of the World War Europe", Year: 2010, ContentType: "series", Sources: []string{"tvdb", "tmdb"}, ProviderIDs: map[string]string{"tvdb": "1"}}, {Title: "The Real History of the World War Pacific", Year: 2010, ContentType: "series", Sources: []string{"tvdb", "tmdb"}, ProviderIDs: map[string]string{"tvdb": "2"}}, } - if _, ok := selectInitialMatchCandidate(hints, cands); ok { + if _, ok := selectInitialMatchCandidate(hints, cands, nil); ok { t.Fatalf("two distinct shows must not be auto-accepted by the lone-result rule") } } @@ -70,7 +72,41 @@ func TestSelectInitialMatchCandidate_ConflictingProviderIDsNotAccepted(t *testin {Title: "Alpha", Year: 2022, ContentType: "movie", ProviderIDs: map[string]string{"tmdb": "111"}}, {Title: "Alpha", Year: 2022, ContentType: "movie", ProviderIDs: map[string]string{"tmdb": "222"}}, } - if _, ok := selectInitialMatchCandidate(hints, cands); ok { + if _, ok := selectInitialMatchCandidate(hints, cands, nil); ok { t.Fatal("conflicting tmdb IDs must not be auto-accepted") } } + +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). + // Two noise candidates (unrelated title/year) score 18 — well outside the 15-pt tie + // window of 83, so topTieGroup contains only the two correct candidates. + // candidatesAreSingleDistinctShow passes (same title/year, no conflicting IDs), + // and pickByProviderPriority selects the winner by the library's chain order. + hints := &MatchHints{Title: "100 Days Wild", Year: 2020, Type: "series"} + cands := []MatchCandidate{ + {Title: "100 Days Wild", Year: 2020, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "386908"}}, + {Title: "100 Days Wild", Year: 2020, ContentType: "series", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "109476"}}, + {Title: "Some Other Show", Year: 2026, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "476741"}}, + {Title: "Live to 100", Year: 2023, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "437829"}}, + } + + // tvdb ranked first in provider chain -> tvdb candidate wins + got, ok := selectInitialMatchCandidate(hints, cands, []string{"tvdb", "tmdb"}) + if !ok || got == nil || got.ProviderIDs["tvdb"] != "386908" { + t.Fatalf("expected tvdb winner (386908), got ok=%v cand=%+v", ok, got) + } + + // tmdb ranked first in provider chain -> tmdb candidate wins + got, ok = selectInitialMatchCandidate(hints, cands, []string{"tmdb", "tvdb"}) + if !ok || got == nil || got.ProviderIDs["tmdb"] != "109476" { + t.Fatalf("expected tmdb winner (109476), got ok=%v cand=%+v", ok, got) + } + + // nil priority -> still accepts (fallback to top-scored, i.e. first in sorted order) + got, ok = selectInitialMatchCandidate(hints, cands, nil) + if !ok || got == nil { + t.Fatalf("expected nil-priority to still accept a match, got ok=%v cand=%+v", ok, got) + } +} diff --git a/internal/metadata/match_candidates_test.go b/internal/metadata/match_candidates_test.go index acb5c37e..ce4a1300 100644 --- a/internal/metadata/match_candidates_test.go +++ b/internal/metadata/match_candidates_test.go @@ -508,6 +508,7 @@ func TestSelectInitialMatchCandidate_UsesProviderOrderForExactCrossProviderTie(t Sources: []string{"tmdb"}, }, }, + nil, ) if !ok || winner == nil { t.Fatal("expected exact cross-provider tie to use provider order") @@ -540,6 +541,7 @@ func TestSelectInitialMatchCandidate_ProviderOrderTieRequiresExactTitleYear(t *t Sources: []string{"imdb", "metadb", "tmdb", "xattr"}, }, }, + nil, ) if ok || winner != nil { t.Fatal("expected non-equivalent cross-provider tie to remain unmatched") diff --git a/internal/metadata/service.go b/internal/metadata/service.go index 33c963a7..db4d5f0a 100644 --- a/internal/metadata/service.go +++ b/internal/metadata/service.go @@ -902,7 +902,11 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques } candidates := NormalizeCandidates(allResults, contentType) - if winner, ok := selectInitialMatchCandidate(req.Hints, candidates); ok && winner != nil { + providerPriority := make([]string, 0, len(itemChain)) + for _, p := range itemChain { + providerPriority = append(providerPriority, p.Slug()) + } + if winner, ok := selectInitialMatchCandidate(req.Hints, candidates, providerPriority); ok && winner != nil { for k, v := range winner.ProviderIDs { if v != "" { accumulatedIDs[k] = v