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) <noreply@anthropic.com>
This commit is contained in:
Silo Server Developer
2026-05-28 17:58:06 +02:00
co-authored by Claude Opus 4.7
parent f4ba5faddc
commit db34df0a35
4 changed files with 96 additions and 12 deletions
+47 -5
View File
@@ -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 {
@@ -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)
}
}
@@ -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")
+5 -1
View File
@@ -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