diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index 4b9acaa3..345b2c0c 100644 --- a/internal/metadata/match_candidates.go +++ b/internal/metadata/match_candidates.go @@ -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 { diff --git a/internal/metadata/match_candidates_select_test.go b/internal/metadata/match_candidates_select_test.go new file mode 100644 index 00000000..699c94e4 --- /dev/null +++ b/internal/metadata/match_candidates_select_test.go @@ -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) + } +} diff --git a/internal/metadata/match_candidates_test.go b/internal/metadata/match_candidates_test.go index acb5c37e..2e206dfa 100644 --- a/internal/metadata/match_candidates_test.go +++ b/internal/metadata/match_candidates_test.go @@ -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") diff --git a/internal/metadata/service.go b/internal/metadata/service.go index f90fb81a..3879ea8d 100644 --- a/internal/metadata/service.go +++ b/internal/metadata/service.go @@ -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 diff --git a/internal/metadata/worker.go b/internal/metadata/worker.go index 72785f9b..bec12554 100644 --- a/internal/metadata/worker.go +++ b/internal/metadata/worker.go @@ -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) diff --git a/internal/metadata/worker_test.go b/internal/metadata/worker_test.go index 867d3f44..0ecb4d15 100644 --- a/internal/metadata/worker_test.go +++ b/internal/metadata/worker_test.go @@ -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) + } +} diff --git a/internal/naming/folderid.go b/internal/naming/folderid.go index af5f7a3f..d977648a 100644 --- a/internal/naming/folderid.go +++ b/internal/naming/folderid.go @@ -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" } diff --git a/internal/naming/folderid_numeric_test.go b/internal/naming/folderid_numeric_test.go new file mode 100644 index 00000000..5e0e61be --- /dev/null +++ b/internal/naming/folderid_numeric_test.go @@ -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) + } +} diff --git a/internal/naming/folderid_test.go b/internal/naming/folderid_test.go new file mode 100644 index 00000000..45960787 --- /dev/null +++ b/internal/naming/folderid_test.go @@ -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) + } +} diff --git a/internal/naming/provider_token_test.go b/internal/naming/provider_token_test.go new file mode 100644 index 00000000..9dde9de8 --- /dev/null +++ b/internal/naming/provider_token_test.go @@ -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) + } + } +} diff --git a/internal/naming/root_inference.go b/internal/naming/root_inference.go index e0095cdc..f5db7672 100644 --- a/internal/naming/root_inference.go +++ b/internal/naming/root_inference.go @@ -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 {