This commit is contained in:
Quick
2026-05-28 13:11:49 -04:00
11 changed files with 897 additions and 16 deletions
+181 -2
View File
@@ -2,6 +2,8 @@ package metadata
import (
"context"
"fmt"
"log/slog"
"math"
"sort"
"strconv"
@@ -342,7 +344,117 @@ type scoredMatchCandidate struct {
score float64
}
func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) (*MatchCandidate, bool) {
// candidatesAreSingleDistinctShow reports whether every scored candidate refers
// to the same show as best — same year, an exact normalized title match, and no
// conflicting provider IDs. This is true when the search effectively returned
// one distinct title, possibly as separate per-source rows (e.g. a TVDB row and
// a TMDB row that weren't merged because each carries only its own provider's
// ID). Candidates that share a canonical provider key but carry different values
// are considered distinct shows and cause the function to return false.
// Candidates with Year == 0 are treated as year-mismatched (a provider that
// omitted the year yields false here) — conservative by design.
func candidatesAreSingleDistinctShow(best MatchCandidate, scored []scoredMatchCandidate) bool {
// Year==0 means the provider didn't supply a release year, so we cannot
// claim the candidates refer to the *same* show via year-equality. Without
// this guard, two no-year candidates from different providers would satisfy
// the multi-source corroboration arm of the lone-result rule and get
// auto-accepted, which over-accepts ambiguous matches.
if best.Year == 0 {
return false
}
// Track the first non-empty value seen per canonical provider key across
// best AND every scored candidate. If any key ends up with more than one
// distinct value, the tie group spans multiple shows — including the case
// where `best` lacks a key but two non-best candidates carry conflicting
// values for it.
seenIDs := make(map[string]string, len(canonicalCandidateIDKeys))
for _, key := range canonicalCandidateIDKeys {
if v := strings.TrimSpace(best.ProviderIDs[key]); v != "" {
seenIDs[key] = v
}
}
for _, c := range scored {
if c.candidate.Year == 0 || c.candidate.Year != best.Year {
return false
}
if inferTitleSimilarity(best.Title, c.candidate.Title, best.Year) != 1 {
return false
}
for _, key := range canonicalCandidateIDKeys {
cv := strings.TrimSpace(c.candidate.ProviderIDs[key])
if cv == "" {
continue
}
if existing, ok := seenIDs[key]; ok {
if existing != cv {
return false
}
} else {
seenIDs[key] = cv
}
}
}
return true
}
// 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
}
// 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)
}
// absYearDelta returns the absolute difference between two release years.
func absYearDelta(a, b int) int {
if a > b {
return a - b
}
return b - a
}
func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate, providerPriority []string) (*MatchCandidate, bool) {
if len(candidates) == 0 {
return nil, false
}
@@ -358,6 +470,32 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate)
return scoredCandidates[i].score > scoredCandidates[j].score
})
if slog.Default().Enabled(context.Background(), slog.LevelDebug) {
// hints can be nil on some callers (scoreMatchCandidate tolerates it).
// Guard the debug log so DEBUG-enabled runs don't panic on a nil deref.
hintTitle, hintType := "", ""
hintYear := 0
if hints != nil {
hintTitle = hints.Title
hintYear = hints.Year
hintType = hints.Type
}
for rank, sc := range scoredCandidates {
slog.Debug("match candidate scoring",
"hint_title", hintTitle,
"hint_year", hintYear,
"hint_type", hintType,
"rank", rank,
"candidate_title", sc.candidate.Title,
"candidate_year", sc.candidate.Year,
"candidate_type", sc.candidate.ContentType,
"sources", strings.Join(sc.candidate.Sources, ","),
"provider_ids", fmt.Sprintf("%v", sc.candidate.ProviderIDs),
"score", sc.score,
)
}
}
best := scoredCandidates[0]
if trustedHintIDsPresent(hints) {
if candidateMatchesTrustedIDs(hints, best.candidate) {
@@ -369,6 +507,47 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate)
if best.score < 55 {
return nil, false
}
// A search that resolves to a single distinct show (one candidate, or the
// same title+year returned once per source) whose year matches the parsed
// 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 candidateTypeMatchesHint(hints.Type, best.candidate.ContentType) {
topGroup := topTieGroup(scoredCandidates)
if candidatesAreSingleDistinctShow(best.candidate, topGroup) {
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
// An exact normalized-title match on a sole distinct show is strong
// corroboration on its own, even when the folder year is off by a year
// or two (festival vs wide-release date, regional release) — e.g.
// "Dead Reckoning (1947)" vs TMDB's 1946, "17 Blocks (2021)" vs 2019.
// Bounded to ±2 years so same-title remakes decades apart still require
// a year or multi-source match. Uses the same normalizer as title scoring
// so "exact" here means a perfect title-similarity component.
titleCorroborated := hints.Year != 0 && best.candidate.Year != 0 &&
absYearDelta(best.candidate.Year, hints.Year) <= 2 &&
normalizeTitleForScoring(best.candidate.Title) == normalizeTitleForScoring(hints.Title)
// Year or source-count corroboration is only meaningful when the winning
// candidate is at least title-coherent with the scanner hint. Otherwise a
// high source/provider score can auto-accept an unrelated same-year result.
hintTitleCoherent := inferTitleSimilarity(hints.Title, best.candidate.Title, hints.Year) > 0
if (hintTitleCoherent && (yearCorroborated || multiSourceCorroborated)) || titleCorroborated {
return pickByProviderPriority(topGroup, providerPriority), true
}
}
}
if len(scoredCandidates) == 1 {
if best.score < 70 {
return nil, false
@@ -459,7 +638,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 {
@@ -0,0 +1,174 @@
package metadata
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, 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)
}
}
func TestSelectInitialMatchCandidate_SameShowAcrossTwoSources(t *testing.T) {
// Same title+year returned once per source (TVDB-only and TMDB-only, no shared ID
// so they were NOT merged). Old behavior: tie-break bails -> nil. New: accept best.
hints := &MatchHints{Title: "Blue Lock", Year: 2022, Type: "series"}
cands := []MatchCandidate{
{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, nil)
if !ok || got == nil {
t.Fatalf("expected same-show-across-sources to be accepted, got ok=%v cand=%+v", ok, got)
}
}
func TestSelectInitialMatchCandidate_LoneResultYearMismatchStillRejected(t *testing.T) {
// Exact title but year mismatch, one source => score 45+12 = 57 (in [55,70), no year bonus).
// 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, nil); ok {
t.Fatalf("expected year-mismatch lone result to be rejected, got cand=%+v", got)
}
}
func TestSelectInitialMatchCandidate_CorroborationRequiresHintTitleCoherence(t *testing.T) {
// Same-year, multi-source candidate scores above the 55 floor via provider
// evidence, but the title is not coherent with the scanner hint. The lone
// result rule must not rescue it just because the year/source evidence is
// strong enough to reach the corroboration branch.
hints := &MatchHints{Title: "Hotel Transylvania Puppy!", Year: 2017, Type: "movie"}
cands := []MatchCandidate{
{
Title: "Puppy!",
Year: 2017,
ContentType: "movie",
Sources: []string{"tmdb", "tvdb", "imdb"},
ProviderIDs: map[string]string{"tmdb": "222"},
},
}
if got, ok := selectInitialMatchCandidate(hints, cands, []string{"tmdb", "tvdb", "imdb"}); ok {
t.Fatalf("unrelated same-year candidate must not be auto-accepted, got %+v", got)
}
}
func TestSelectInitialMatchCandidate_TwoDifferentShowsUnchanged(t *testing.T) {
// Two genuinely different shows that BOTH score >=55 (so the new rule IS evaluated,
// not short-circuited by the <55 floor): candidatesAreSingleDistinctShow must return
// false (titles differ), so the new rule does NOT fire and behavior falls through to
// the existing tie-break (which returns nil here because DetailScore is 0). Guards
// against over-accepting distinct results.
//
// Each candidate shares 7 tokens with the hint plus one distinct trailing word, so
// each is coherent with the hint (Jaccard 7/8 = 0.875 >= 0.85 => sim 0.8 => +28) and
// scores 28 + 20(year) + 24(2 sources) + 5 + 1(1 id) = 78 (>=55, reaches the guard).
// The two candidates differ from each other (Jaccard 7/9 = 0.78 < 0.85 => sim 0), so
// candidatesAreSingleDistinctShow returns false. Equal scores => gap 0 < 15 => tie-break.
hints := &MatchHints{Title: "The Real History of the World War", Year: 2010, Type: "series"}
cands := []MatchCandidate{
{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, nil); ok {
t.Fatalf("two distinct shows must not be auto-accepted by the lone-result rule")
}
}
func TestSelectInitialMatchCandidate_ConflictingProviderIDsNotAccepted(t *testing.T) {
// Same title+year but different tmdb IDs => two distinct shows; must NOT auto-accept.
// Each scores 65 (45 exact title + 20 year) + 5 + 1(richness) = ... actually
// 45+20+5+1 = 71 (no sources, one provider ID), well above the 55 floor, so the new
// branch is reached. candidatesAreSingleDistinctShow must return false because the two
// candidates carry the same canonical provider key (tmdb) with conflicting values.
hints := &MatchHints{Title: "Alpha", Year: 2022, Type: "movie"}
cands := []MatchCandidate{
{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, nil); ok {
t.Fatal("conflicting tmdb IDs must not be auto-accepted")
}
}
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).
// 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)
}
}
@@ -25,12 +25,85 @@ func TestSelectInitialMatchCandidate_IgnoresLocalContentIDForTrustedSelection(t
Sources: []string{"tmdb"},
},
},
nil,
)
if !ok || winner == nil {
t.Fatal("expected local content_id not to force trusted-ID matching")
}
}
func TestSelectInitialMatchCandidate_SoleExactTitleYearOffByTwoMatches(t *testing.T) {
t.Parallel()
// Sole distinct candidate, exact title, year off by 2 (e.g. "Stasi FC (2023)"
// vs TMDB's 2025). Scores in the 55-69 band — below the single-candidate >=70
// gate — but the exact title on a lone result should now match via title
// corroboration without lowering any threshold.
winner, ok := selectInitialMatchCandidate(
&MatchHints{Title: "Stasi FC", Year: 2023, Type: "movie"},
[]MatchCandidate{
{
Title: "Stasi FC",
Year: 2025,
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "111"},
Sources: []string{"tmdb"},
},
},
nil,
)
if !ok || winner == nil || winner.ProviderIDs["tmdb"] != "111" {
t.Fatalf("expected sole exact-title year-off-by-2 candidate to match, got ok=%v winner=%+v", ok, winner)
}
}
func TestSelectInitialMatchCandidate_SoleExactTitleYearOffByThreeRejected(t *testing.T) {
t.Parallel()
// A 3-year gap exceeds the ±2 bound: a same-title film three years apart is
// not corroborated and stays subject to the single-candidate >=70 gate.
winner, ok := selectInitialMatchCandidate(
&MatchHints{Title: "Stasi FC", Year: 2023, Type: "movie"},
[]MatchCandidate{
{
Title: "Stasi FC",
Year: 2026,
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "111"},
Sources: []string{"tmdb"},
},
},
nil,
)
if ok || winner != nil {
t.Fatalf("expected year-off-by-3 sole candidate to be rejected, got ok=%v winner=%+v", ok, winner)
}
}
func TestSelectInitialMatchCandidate_SoleDifferentTitleExactYearStillFloored(t *testing.T) {
t.Parallel()
// "Hotel Transylvania Puppy!" vs TMDB's "Puppy!" (same year) scores below the
// 55 floor on title similarity, so it must stay rejected — title corroboration
// must not rescue a low-similarity title just because the year matches.
winner, ok := selectInitialMatchCandidate(
&MatchHints{Title: "Hotel Transylvania Puppy!", Year: 2017, Type: "movie"},
[]MatchCandidate{
{
Title: "Puppy!",
Year: 2017,
ContentType: "movie",
ProviderIDs: map[string]string{"tmdb": "222"},
Sources: []string{"tmdb"},
},
},
nil,
)
if ok || winner != nil {
t.Fatalf("expected low-similarity sole candidate to stay rejected, got ok=%v winner=%+v", ok, winner)
}
}
func TestSuppressTitleYearFallbackForTrustedIDs_IgnoresMetadb(t *testing.T) {
t.Parallel()
@@ -377,6 +450,7 @@ func TestSelectInitialMatchCandidate_AcceptsSinglePunctuationEquivalentCandidate
Sources: []string{"tmdb"},
},
},
nil,
)
if !ok || winner == nil {
t.Fatalf("expected lone punctuation-equivalent candidate to be accepted")
@@ -411,6 +485,7 @@ func TestSelectInitialMatchCandidate_AcceptsProviderTitleWithRepeatedYear(t *tes
Sources: []string{"tmdb"},
},
},
nil,
)
if !ok || winner == nil {
t.Fatal("expected provider title with repeated release year to be accepted")
@@ -445,6 +520,7 @@ func TestSelectInitialMatchCandidate_UsesDetailScoreForDuplicateProviderTie(t *t
DetailScore: 46,
},
},
nil,
)
if !ok || winner == nil {
t.Fatal("expected richer duplicate TMDB candidate to be accepted")
@@ -479,6 +555,7 @@ func TestSelectInitialMatchCandidate_RejectsDuplicateTieWithoutClearDetailGap(t
DetailScore: 34,
},
},
nil,
)
if ok || winner != nil {
t.Fatal("expected duplicate tie without clear detail gap to remain unmatched")
@@ -508,6 +585,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 +618,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")
@@ -571,6 +650,7 @@ func TestSelectInitialMatchCandidate_DetailScoreDoesNotOverrideDifferentTitleTie
DetailScore: 80,
},
},
nil,
)
if ok || winner != nil {
t.Fatal("expected richer different-title candidate to be rejected")
@@ -600,6 +680,7 @@ func TestSelectInitialMatchCandidate_DetailScoreRequiresDatedDuplicateCandidates
DetailScore: 46,
},
},
nil,
)
if ok || winner != nil {
t.Fatal("expected duplicate detail tie-breaker to reject candidates without matching years")
@@ -631,6 +712,7 @@ func TestSelectInitialMatchCandidate_DetailScoreRequiresHintCompatibleType(t *te
DetailScore: 46,
},
},
nil,
)
if ok || winner != nil {
t.Fatal("expected duplicate detail tie-breaker to reject candidates with hint-incompatible type")
@@ -653,6 +735,7 @@ func TestSelectInitialMatchCandidate_RejectsWeakSingleCandidate(t *testing.T) {
Sources: []string{"tmdb"},
},
},
nil,
)
if ok || winner != nil {
t.Fatalf("expected weak lone candidate to be rejected")
+17 -1
View File
@@ -886,6 +886,12 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques
"provider", p.Slug(), "error", err)
continue
}
slog.Debug("metadata: provider search result",
"provider", p.Slug(),
"query_title", searchQuery.Title,
"query_year", searchQuery.Year,
"result_count", len(results),
)
for _, result := range results {
if searchResultConflictsWithTrustedIDs(accumulatedIDs, result.ProviderIDs) {
slog.Warn("metadata: skipping conflicting search result",
@@ -902,7 +908,17 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques
}
candidates := NormalizeCandidates(allResults, contentType)
if winner, ok := selectInitialMatchCandidate(req.Hints, candidates); ok && winner != nil {
slog.Debug("metadata: search candidates assembled",
"query_title", searchQuery.Title,
"query_year", searchQuery.Year,
"raw_results", len(allResults),
"candidates", len(candidates),
)
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
+26 -6
View File
@@ -2,6 +2,7 @@ package metadata
import (
"context"
"errors"
"fmt"
"log/slog"
"path/filepath"
@@ -12,6 +13,7 @@ import (
"sync/atomic"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
@@ -837,10 +839,21 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo
}
}
if err := w.service.ensureSeriesEpisodeLinks(ctx, representative.ContentID); err != nil {
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil {
return 0, updateErr
if errors.Is(err, catalog.ErrItemNotFound) {
// The series item was concurrently merged into another (provider-ID
// dedup moves its seasons+episodes to the survivor, then deletes the
// source row). The episodes are already reattached, so there is nothing
// to link here — benign; finish normally instead of failing the batch.
slog.Info("metadata: series item gone during episode-link ensure (likely concurrent merge); skipping",
"content_id", representative.ContentID,
"folder_id", job.MediaFolderID,
"observed_root_path", job.ObservedRootPath)
} else {
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil {
return 0, updateErr
}
return 0, fmt.Errorf("ensuring series episode links for %s: %w", representative.ContentID, err)
}
return 0, fmt.Errorf("ensuring series episode links for %s: %w", representative.ContentID, err)
}
}
if err := w.seriesClaimer.Delete(ctx, job.MediaFolderID, job.ObservedRootPath); err != nil {
@@ -933,10 +946,17 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo
}
if strings.TrimSpace(finalContentID) != "" {
if err := w.service.ensureSeriesEpisodeLinks(ctx, finalContentID); err != nil {
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil {
return 0, updateErr
if errors.Is(err, catalog.ErrItemNotFound) {
slog.Info("metadata: series item gone during episode-link ensure (likely concurrent merge); skipping",
"content_id", finalContentID,
"folder_id", job.MediaFolderID,
"observed_root_path", job.ObservedRootPath)
} else {
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil {
return 0, updateErr
}
return 0, fmt.Errorf("ensuring series episode links for %s: %w", finalContentID, err)
}
return 0, fmt.Errorf("ensuring series episode links for %s: %w", finalContentID, err)
}
if _, ok := w.service.confirmedOwnershipItem(ctx, finalContentID); ok {
w.service.claimConfirmedSeriesRootOwnership(ctx, job.MediaFolderID, job.ObservedRootPath, finalContentID, groupFiles)
+274
View File
@@ -8,6 +8,7 @@ import (
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
@@ -1200,3 +1201,276 @@ func TestWorkerProcessBatchByFolderAndPathPrefix_MovieQueueClaimsOnlyOncePerScan
t.Fatal("expected queue error to be recorded")
}
}
// ---------------------------------------------------------------------------
// Tests for concurrent-merge ErrItemNotFound tolerance (hotfix 2026-05-27)
// ---------------------------------------------------------------------------
// TestProcessSeriesRoot_Site1_ErrItemNotFoundIsToleratedNotFailed verifies that
// when ensureSeriesEpisodeLinks returns catalog.ErrItemNotFound at Site 1
// (the "all files already linked" fast path), the batch succeeds instead of
// failing. This covers the concurrent-merge case where the source series row
// was deleted after its episodes were moved to the survivor.
func TestProcessSeriesRoot_Site1_ErrItemNotFoundIsToleratedNotFailed(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
// Need a series-type folder so queueUsageForFolder enables the series queue.
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "series", Enabled: true},
},
}
// Pre-populate the item as "matched" so reusableQueuedMovieSkeleton
// returns false (matched is not skeleton-like), bypassing the re-process
// block and falling through directly to ensureSeriesEpisodeLinks.
const contentID = "series-gone-after-merge"
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: contentID,
Status: "matched",
Title: "Example Show",
Type: "series",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert item: %v", err)
}
// All files are already linked — hasUnlinkedGroupFile returns false.
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E01.mkv",
ObservedRootPath: "/media/shows/Example Show",
GroupKeyVersion: 1,
ContentGroupKey: "v1|series|example_show|2024",
ContentID: contentID,
}
h.fileRepo.setGroupFiles(10, 1, "v1|series|example_show|2024", file)
h.fileRepo.contentIDs[file.ID] = contentID
// Hook ensureSeriesEpisodeLinks to simulate the source row being gone.
h.service.hooks.ensureSeriesEpisodeLinks = func(_ context.Context, _ string) error {
return fmt.Errorf("loading series item: %w", catalog.ErrItemNotFound)
}
queueRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{
MediaFolderID: 10,
ObservedRootPath: "/media/shows/Example Show",
SampleFilePath: file.FilePath,
ObservedFileCount: 1,
})
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.SetSeriesRootClaimer(queueRepo, true)
processed, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/shows/Example Show", time.Time{})
if err != nil {
t.Fatalf("expected no error for ErrItemNotFound (benign concurrent merge), got: %v", err)
}
if processed != 1 {
t.Fatalf("processed = %d, want 1", processed)
}
// Queue row must be deleted on normal completion.
if _, ok := queueRepo.deleted["10:/media/shows/Example Show"]; !ok {
t.Fatal("expected queue row to be deleted on normal completion")
}
// No error must have been recorded in the queue.
if queueRepo.errors["10:/media/shows/Example Show"] != "" {
t.Fatalf("expected no queue error, got %q", queueRepo.errors["10:/media/shows/Example Show"])
}
}
// TestProcessSeriesRoot_Site1_NonNotFoundErrorStillFails verifies that a
// genuine (non-ErrItemNotFound) error from ensureSeriesEpisodeLinks at Site 1
// still fails the batch — the fix must not swallow real errors.
func TestProcessSeriesRoot_Site1_NonNotFoundErrorStillFails(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "series", Enabled: true},
},
}
const contentID = "series-link-failure"
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: contentID,
Status: "matched",
Title: "Example Show",
Type: "series",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert item: %v", err)
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E01.mkv",
ObservedRootPath: "/media/shows/Example Show",
GroupKeyVersion: 1,
ContentGroupKey: "v1|series|example_show|2024",
ContentID: contentID,
}
h.fileRepo.setGroupFiles(10, 1, "v1|series|example_show|2024", file)
h.fileRepo.contentIDs[file.ID] = contentID
// Return a genuine (non-not-found) error.
linkErr := errors.New("database connection reset")
h.service.hooks.ensureSeriesEpisodeLinks = func(_ context.Context, _ string) error {
return linkErr
}
queueRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{
MediaFolderID: 10,
ObservedRootPath: "/media/shows/Example Show",
SampleFilePath: file.FilePath,
ObservedFileCount: 1,
})
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.SetSeriesRootClaimer(queueRepo, true)
_, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/shows/Example Show", time.Time{})
if err == nil {
t.Fatal("expected error for genuine link failure, got nil")
}
if !strings.Contains(err.Error(), "ensuring series episode links") {
t.Fatalf("unexpected error message: %v", err)
}
}
// TestProcessSeriesRoot_Site2_ErrItemNotFoundIsToleratedNotFailed verifies that
// when ensureSeriesEpisodeLinks returns catalog.ErrItemNotFound at Site 2
// (the new-skeleton / enrichment path), the batch succeeds.
func TestProcessSeriesRoot_Site2_ErrItemNotFoundIsToleratedNotFailed(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "series", Enabled: true},
},
}
// File has no ContentID yet → hasUnlinkedGroupFile returns true →
// proceeds through createOrFindSkeleton + enrichment path (Site 2).
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E01.mkv",
ObservedRootPath: "/media/shows/Example Show",
GroupKeyVersion: 1,
ContentGroupKey: "v1|series|example_show|2024",
BaseTitle: "Example Show",
BaseType: "series",
}
h.fileRepo.setGroupFiles(10, 1, "v1|series|example_show|2024", file)
const skeletonID = "skeleton-series-id"
h.service.hooks.createOrFindSkeleton = func(_ context.Context, _ *models.MediaFile, _ int) (*skeletonResult, error) {
return &skeletonResult{
ContentID: skeletonID,
IsNew: true,
ItemStatus: "pending",
Type: "series",
}, nil
}
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
return &ProcessResult{Updated: true}, nil
}
// Simulate the concurrent-merge: ensureSeriesEpisodeLinks finds the source gone.
h.service.hooks.ensureSeriesEpisodeLinks = func(_ context.Context, _ string) error {
return fmt.Errorf("loading series item: %w", catalog.ErrItemNotFound)
}
queueRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{
MediaFolderID: 10,
ObservedRootPath: "/media/shows/Example Show",
SampleFilePath: file.FilePath,
ObservedFileCount: 1,
})
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.SetSeriesRootClaimer(queueRepo, true)
processed, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/shows/Example Show", time.Time{})
if err != nil {
t.Fatalf("expected no error for ErrItemNotFound (benign concurrent merge), got: %v", err)
}
if processed != 1 {
t.Fatalf("processed = %d, want 1", processed)
}
if _, ok := queueRepo.deleted["10:/media/shows/Example Show"]; !ok {
t.Fatal("expected queue row to be deleted on normal completion")
}
if queueRepo.errors["10:/media/shows/Example Show"] != "" {
t.Fatalf("expected no queue error, got %q", queueRepo.errors["10:/media/shows/Example Show"])
}
}
// TestProcessSeriesRoot_Site2_NonNotFoundErrorStillFails verifies that a genuine
// error from ensureSeriesEpisodeLinks at Site 2 still fails the batch.
func TestProcessSeriesRoot_Site2_NonNotFoundErrorStillFails(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "series", Enabled: true},
},
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E01.mkv",
ObservedRootPath: "/media/shows/Example Show",
GroupKeyVersion: 1,
ContentGroupKey: "v1|series|example_show|2024",
BaseTitle: "Example Show",
BaseType: "series",
}
h.fileRepo.setGroupFiles(10, 1, "v1|series|example_show|2024", file)
const skeletonID = "skeleton-series-id-2"
h.service.hooks.createOrFindSkeleton = func(_ context.Context, _ *models.MediaFile, _ int) (*skeletonResult, error) {
return &skeletonResult{
ContentID: skeletonID,
IsNew: true,
ItemStatus: "pending",
Type: "series",
}, nil
}
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
return &ProcessResult{Updated: true}, nil
}
linkErr := errors.New("db timeout")
h.service.hooks.ensureSeriesEpisodeLinks = func(_ context.Context, _ string) error {
return linkErr
}
queueRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{
MediaFolderID: 10,
ObservedRootPath: "/media/shows/Example Show",
SampleFilePath: file.FilePath,
ObservedFileCount: 1,
})
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.SetSeriesRootClaimer(queueRepo, true)
_, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/shows/Example Show", time.Time{})
if err == nil {
t.Fatal("expected error for genuine link failure at site 2, got nil")
}
if !strings.Contains(err.Error(), "ensuring series episode links") {
t.Fatalf("unexpected error message: %v", err)
}
}
+30 -6
View File
@@ -3,6 +3,7 @@ package naming
import (
"regexp"
"strings"
"unicode"
)
// folderIDPattern matches patterns like [tmdbid-27205], {tmdb-27205},
@@ -12,15 +13,16 @@ var folderIDPattern = regexp.MustCompile(`[{\[](tmdb|tmdbid|imdb|imdbid|tvdb|tvd
var trailingImdbIDPattern = regexp.MustCompile(`(?i)(?:^|\s)(tt\d{7,8})$`)
var trailingNumericIDPattern = regexp.MustCompile(`(?:^|\s)(\d+)$`)
// ParseStructuredFolderIDs extracts only explicit structured provider IDs from
// a folder or file name, such as {tmdb-27205} or [imdbid-tt1375666}. It does
// bracketedBareImdbPattern matches a bare IMDb id wrapped in brackets without a
// provider prefix, e.g. [tt10011226] or {tt0095016} (Plex/Kodi-style tags). A
// tt-prefixed number is unambiguously IMDb.
var bracketedBareImdbPattern = regexp.MustCompile(`(?i)[{\[](tt\d{7,8})[}\]]`)
// ParseStructuredFolderIDs extracts only explicit provider IDs from a folder or
// file name, such as {tmdb-27205}, [imdbid-tt1375666], or [tt1375666]. It does
// not consider trailing bare IDs or folderType-based heuristics.
func ParseStructuredFolderIDs(name string) *FolderIDHints {
matches := folderIDPattern.FindAllStringSubmatch(name, -1)
if len(matches) == 0 {
return nil
}
hints := &FolderIDHints{}
for _, m := range matches {
provider := strings.ToLower(m[1])
@@ -36,6 +38,10 @@ func ParseStructuredFolderIDs(name string) *FolderIDHints {
}
}
if m := bracketedBareImdbPattern.FindStringSubmatch(name); m != nil && hints.ImdbID == "" {
hints.ImdbID = strings.ToLower(m[1])
}
if hints.TmdbID == "" && hints.ImdbID == "" && hints.TvdbID == "" {
return nil
}
@@ -66,12 +72,30 @@ func ParseFolderIDs(folderName string, folderType string) *FolderIDHints {
return nil
}
// A bare trailing number is only an ID when appended to a real title. If the
// name has no letters (e.g. "86", "22 7"), it's a numeric title, not an ID.
// Trade-off: a folder named ONLY a bare provider id (e.g. "81189") with no
// title text now returns nil rather than that id — acceptable because
// Sonarr/Radarr never produce bare-number folders.
if !containsLetter(trimmed) {
return nil
}
if strings.EqualFold(strings.TrimSpace(folderType), "series") {
return &FolderIDHints{TvdbID: id}
}
return &FolderIDHints{TmdbID: id}
}
func containsLetter(s string) bool {
for _, r := range s {
if unicode.IsLetter(r) {
return true
}
}
return false
}
func looksLikeYear(value string) bool {
return len(value) == 4 && value >= "1800" && value <= "2100"
}
+37
View File
@@ -0,0 +1,37 @@
package naming
import "testing"
func TestParseFolderIDs_NumericTitleIsNotAnID(t *testing.T) {
// Numeric-only anime titles must NOT be parsed as a bare trailing tvdb/tmdb id.
if got := ParseFolderIDs("86", "series"); got != nil {
t.Errorf(`ParseFolderIDs("86","series") = %+v, want nil`, got)
}
if got := ParseFolderIDs("22 7", "series"); got != nil {
t.Errorf(`ParseFolderIDs("22 7","series") = %+v, want nil`, got)
}
// A real bare trailing id with title text must still be parsed.
got := ParseFolderIDs("Some Show 81189", "series")
if got == nil || got.TvdbID != "81189" {
t.Errorf(`ParseFolderIDs("Some Show 81189","series") = %+v, want TvdbID="81189"`, got)
}
// Structured tags must still win regardless of letters.
got = ParseFolderIDs("{tmdb-27205}", "movies")
if got == nil || got.TmdbID != "27205" {
t.Errorf(`ParseFolderIDs("{tmdb-27205}","movies") = %+v, want TmdbID="27205"`, got)
}
// Non-Latin (CJK) title with a trailing real id must still parse —
// unicode.IsLetter covers kana/kanji, so this is treated as title + id.
got = ParseFolderIDs("進撃の巨人 73743", "series")
if got == nil || got.TvdbID != "73743" {
t.Errorf(`ParseFolderIDs("進撃の巨人 73743","series") = %+v, want TvdbID="73743"`, got)
}
// Numeric-only title for a movies library is also not an id.
if got := ParseFolderIDs("86", "movies"); got != nil {
t.Errorf(`ParseFolderIDs("86","movies") = %+v, want nil`, got)
}
}
+46
View File
@@ -0,0 +1,46 @@
package naming
import "testing"
func TestParseFolderIDs_BracketedBareImdb(t *testing.T) {
cases := []struct {
name, folder, wantImdb string
}{
{"square brackets", "17 Blocks (2021) [tt10011226]", "tt10011226"},
{"curly braces", "Some Show {tt1234567}", "tt1234567"},
{"8-digit tt", "Movie (2024) [tt12345678]", "tt12345678"},
}
for _, c := range cases {
got := ParseFolderIDs(c.folder, "movie")
if got == nil || got.ImdbID != c.wantImdb {
t.Errorf("%s: ParseFolderIDs(%q) = %+v, want ImdbID=%q", c.name, c.folder, got, c.wantImdb)
}
}
// must NOT change existing behavior:
if got := ParseFolderIDs("Movie [imdb-tt1375666]", "movie"); got == nil || got.ImdbID != "tt1375666" {
t.Errorf("structured imdb regressed: %+v", got)
}
if got := ParseFolderIDs("Some Movie (2020) [BB]", "movie"); got != nil {
t.Errorf("non-tt bracket tag must not parse as id, got %+v", got)
}
if got := ParseFolderIDs("17 Blocks (2021)", "movie"); got != nil {
t.Errorf("no-id folder must return nil, got %+v", got)
}
}
func TestParseStructuredFolderIDs_BracketedBareImdb(t *testing.T) {
got := ParseStructuredFolderIDs("17 Blocks (2021) [tt10011226]")
if got == nil || got.ImdbID != "tt10011226" {
t.Fatalf("ParseStructuredFolderIDs bare imdb = %+v, want ImdbID tt10011226", got)
}
got = ParseStructuredFolderIDs("Some Movie {tmdb-12345} [tt7654321]")
if got == nil || got.TmdbID != "12345" || got.ImdbID != "tt7654321" {
t.Fatalf("ParseStructuredFolderIDs mixed ids = %+v, want tmdb+imdb", got)
}
got = ParseStructuredFolderIDs("Some Movie [imdb-tt1375666] [tt7654321]")
if got == nil || got.ImdbID != "tt1375666" {
t.Fatalf("structured imdb should win over bare imdb, got %+v", got)
}
}
+25
View File
@@ -0,0 +1,25 @@
package naming
import "testing"
func TestStripInferProviderTags_HandlesBrokenSonarrTokens(t *testing.T) {
cases := []struct {
in string
want string
}{
{"A Girl & Her Guard Dog [tvdb-{TvdbId}]", "A Girl & Her Guard Dog"},
{"A Raven in the Harem [tvdb-{TvdbId}]", "A Raven in the Harem"},
{"A Salad Bowl of Eccentrics {imdb-}", "A Salad Bowl of Eccentrics"},
{"Coronation Street {imdb-}", "Coronation Street"},
// well-formed tags must still be stripped:
{"Some Show [tvdb-81189]", "Some Show"},
{"Some Show {tmdb-27205}", "Some Show"},
// no tag: unchanged:
{"Cowboy Bebop", "Cowboy Bebop"},
}
for _, c := range cases {
if got := stripInferProviderTags(c.in); got != c.want {
t.Errorf("stripInferProviderTags(%q) = %q, want %q", c.in, got, c.want)
}
}
}
+4 -1
View File
@@ -18,7 +18,10 @@ var (
inferSeasonDirRe = regexp.MustCompile(`(?i)^Season\s+(\d{1,4})(?:\s.*)?$`)
inferNumericSeasonRe = regexp.MustCompile(`^\d{1,4}$`)
inferSpecialsDirRe = regexp.MustCompile(`(?i)^(?:specials?|extras?)$`)
inferProviderTagRe = regexp.MustCompile(`\s*[{\[](?:tmdb|tmdbid|imdb|imdbid|tvdb|tvdbid)-[\w]+[}\]]`)
// Matches a well-formed tag ([tvdb-81189]), an unsubstituted Sonarr token
// ([tvdb-{TvdbId}]), or an empty token ({imdb-}). The id part is either a
// {...} placeholder or zero-or-more word chars.
inferProviderTagRe = regexp.MustCompile(`\s*[{\[](?:tmdb|tmdbid|imdb|imdbid|tvdb|tvdbid)-(?:\{[^}]*\}|[\w]*)[}\]]`)
)
type RootAssignment struct {