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:
co-authored by
Claude Opus 4.7
parent
f4ba5faddc
commit
db34df0a35
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user