From e07492532725b2e5b512ed0ed4f741a9230ce1bd Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Wed, 27 May 2026 18:36:58 +0200 Subject: [PATCH 01/14] fix(naming): strip unsubstituted Sonarr tokens ({TvdbId}/{imdb-}) from titles These tokens survived the provider-tag regex ([\w]+ doesn't match braces), polluting parsed titles (e.g. 'A Girl & Her Guard Dog [tvdb-{TvdbId}]') so they could not score-match. Broaden the regex to drop {...} and empty tokens. --- internal/naming/provider_token_test.go | 25 +++++++++++++++++++++++++ internal/naming/root_inference.go | 5 ++++- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 internal/naming/provider_token_test.go 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..f383d725 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 { From cc7cd5cd39e8f422cbc76adf41740f8c634e1551 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Wed, 27 May 2026 18:43:18 +0200 Subject: [PATCH 02/14] fix(naming): numeric-only titles are not bare provider IDs '86' / '22 7' were parsed as trailing tvdb ids, tripping the trusted-ID gate so the correct title match was rejected. Require a letter in the name before treating a trailing number as a bare id. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/naming/folderid.go | 16 +++++++++++++++ internal/naming/folderid_numeric_test.go | 25 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 internal/naming/folderid_numeric_test.go diff --git a/internal/naming/folderid.go b/internal/naming/folderid.go index af5f7a3f..ade90a1c 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}, @@ -66,12 +67,27 @@ 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. + 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..a50a71a9 --- /dev/null +++ b/internal/naming/folderid_numeric_test.go @@ -0,0 +1,25 @@ +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) + } +} From a6da4a4a989b47a1e36c22dbf2a4445a381714d1 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Wed, 27 May 2026 18:47:02 +0200 Subject: [PATCH 03/14] test(naming): document bare-id trade-off; cover CJK title + movies numeric Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/naming/folderid.go | 3 +++ internal/naming/folderid_numeric_test.go | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/internal/naming/folderid.go b/internal/naming/folderid.go index ade90a1c..e9cc064c 100644 --- a/internal/naming/folderid.go +++ b/internal/naming/folderid.go @@ -69,6 +69,9 @@ func ParseFolderIDs(folderName string, folderType string) *FolderIDHints { // 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 } diff --git a/internal/naming/folderid_numeric_test.go b/internal/naming/folderid_numeric_test.go index a50a71a9..5e0e61be 100644 --- a/internal/naming/folderid_numeric_test.go +++ b/internal/naming/folderid_numeric_test.go @@ -22,4 +22,16 @@ func TestParseFolderIDs_NumericTitleIsNotAnID(t *testing.T) { 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) + } } From 8ff06ed008402cd64591f5eab82cd3a125298aa4 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Wed, 27 May 2026 19:59:00 +0200 Subject: [PATCH 04/14] feat(matcher): auto-accept a year-corroborated single distinct show A search that resolves to one distinct show (one candidate, or the same title+year returned once per source as unmerged TVDB/TMDB rows) whose year matches the parsed year is now auto-accepted via the existing top-ranked candidate, even when the fuzzy title score is in the 55-69 band. The 55/70/15 thresholds are unchanged; this only adds a year-gated acceptance for effectively-unique results (recovers lone-correct-result items like 1201 (1993)). Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/metadata/match_candidates.go | 46 +++++++++++++++ .../metadata/match_candidates_select_test.go | 57 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 internal/metadata/match_candidates_select_test.go diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index 4b9acaa3..e13306c9 100644 --- a/internal/metadata/match_candidates.go +++ b/internal/metadata/match_candidates.go @@ -342,6 +342,42 @@ type scoredMatchCandidate struct { score float64 } +// 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. +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 + } + 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 + } + // If both candidates carry the same canonical provider key (e.g. both + // have a tmdb ID) but different values, they are different shows. + for _, key := range canonicalCandidateIDKeys { + bv := strings.TrimSpace(best.ProviderIDs[key]) + cv := strings.TrimSpace(c.candidate.ProviderIDs[key]) + if bv != "" && cv != "" && bv != cv { + return false + } + } + } + return true +} + func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) (*MatchCandidate, bool) { if len(candidates) == 0 { return nil, false @@ -369,6 +405,16 @@ 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. + if hints.Year != 0 && best.candidate.Year == hints.Year && + candidateTypeMatchesHint(hints.Type, best.candidate.ContentType) && + candidatesAreSingleDistinctShow(best.candidate, scoredCandidates) { + return &best.candidate, true + } if len(scoredCandidates) == 1 { if best.score < 70 { return nil, false diff --git a/internal/metadata/match_candidates_select_test.go b/internal/metadata/match_candidates_select_test.go new file mode 100644 index 00000000..df7685e8 --- /dev/null +++ b/internal/metadata/match_candidates_select_test.go @@ -0,0 +1,57 @@ +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) + 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) + 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); ok { + t.Fatalf("expected year-mismatch lone result to be rejected, got cand=%+v", got) + } +} + +func TestSelectInitialMatchCandidate_TwoDifferentShowsUnchanged(t *testing.T) { + // Two DIFFERENT shows (different titles, similar scores => gap=0 => tie-break path). + // candidatesAreSingleDistinctShow returns false (titles differ), so the new rule + // must NOT fire; falls through to the existing tie-break which returns nil because + // DetailScore is 0. Guards against over-accepting distinct results. + // + // "The Show Special" and "The Show Extra" both score 38 against hint "The Show" / + // 2010 (0-similarity title match, 1 source, 1 provider ID), gap = 0 < 15 => + // tie-break; inferTitleSimilarity between the two candidates is 0 => not same show. + hints := &MatchHints{Title: "The Show", Year: 2010, Type: "movie"} + cands := []MatchCandidate{ + {Title: "The Show Special", Year: 2010, ContentType: "movie", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "1"}}, + {Title: "The Show Extra", Year: 2010, ContentType: "movie", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "2"}}, + } + if _, ok := selectInitialMatchCandidate(hints, cands); ok { + t.Fatalf("two distinct shows must not be auto-accepted by the lone-result rule") + } +} From aff528358ed8ae870871e3300239b3de7742ebf1 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Wed, 27 May 2026 20:10:55 +0200 Subject: [PATCH 05/14] test(matcher): exercise the single-distinct-show guard properly + conflicting-ID case; doc notes Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/metadata/match_candidates.go | 5 +++ .../metadata/match_candidates_select_test.go | 39 ++++++++++++++----- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index e13306c9..10650de8 100644 --- a/internal/metadata/match_candidates.go +++ b/internal/metadata/match_candidates.go @@ -349,6 +349,8 @@ type scoredMatchCandidate struct { // 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 @@ -410,6 +412,9 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) // year is high-confidence even when the fuzzy title score sits in the 55-69 // band (short/numeric/alternate titles). Accept the top-ranked candidate // without lowering the score thresholds. + // Residual risk: two different shows with an identical title+year and no + // provider IDs would both pass; accepted as low-risk given the title+year+type + // corroboration. if hints.Year != 0 && best.candidate.Year == hints.Year && candidateTypeMatchesHint(hints.Type, best.candidate.ContentType) && candidatesAreSingleDistinctShow(best.candidate, scoredCandidates) { diff --git a/internal/metadata/match_candidates_select_test.go b/internal/metadata/match_candidates_select_test.go index df7685e8..ed2af379 100644 --- a/internal/metadata/match_candidates_select_test.go +++ b/internal/metadata/match_candidates_select_test.go @@ -38,20 +38,39 @@ func TestSelectInitialMatchCandidate_LoneResultYearMismatchStillRejected(t *test } func TestSelectInitialMatchCandidate_TwoDifferentShowsUnchanged(t *testing.T) { - // Two DIFFERENT shows (different titles, similar scores => gap=0 => tie-break path). - // candidatesAreSingleDistinctShow returns false (titles differ), so the new rule - // must NOT fire; falls through to the existing tie-break which returns nil because - // DetailScore is 0. Guards against over-accepting distinct results. + // 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. // - // "The Show Special" and "The Show Extra" both score 38 against hint "The Show" / - // 2010 (0-similarity title match, 1 source, 1 provider ID), gap = 0 < 15 => - // tie-break; inferTitleSimilarity between the two candidates is 0 => not same show. - hints := &MatchHints{Title: "The Show", Year: 2010, Type: "movie"} + // 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 Show Special", Year: 2010, ContentType: "movie", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "1"}}, - {Title: "The Show Extra", Year: 2010, ContentType: "movie", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "2"}}, + {Title: "The Real History of the World War Europe", Year: 2010, ContentType: "series", Sources: []string{"tvdb", "tmdb"}, ProviderIDs: map[string]string{"tvdb": "1"}}, + {Title: "The Real History of the World War Pacific", Year: 2010, ContentType: "series", Sources: []string{"tvdb", "tmdb"}, ProviderIDs: map[string]string{"tvdb": "2"}}, } if _, ok := selectInitialMatchCandidate(hints, cands); ok { 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); ok { + t.Fatal("conflicting tmdb IDs must not be auto-accepted") + } +} From aab0a804b97d77f6d32fef12f0aef3e1a52e0c1e Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Wed, 27 May 2026 21:01:28 +0200 Subject: [PATCH 06/14] fix(matcher): tolerate concurrent-merge ErrItemNotFound in series episode-link ensure A scan drainer and the background MatchWorker can process the same folder concurrently. When a provider-ID merge moves a series' episodes to the survivor and deletes the source, an in-flight ensureSeriesEpisodeLinks(sourceID) hits catalog.ErrItemNotFound and was failing the whole scan. The episodes are already reattached, so this is benign: log and continue (matching the lenient call sites) instead of failing. Genuine errors still abort. --- internal/metadata/worker.go | 32 +++- internal/metadata/worker_test.go | 274 +++++++++++++++++++++++++++++++ 2 files changed, 300 insertions(+), 6 deletions(-) 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) + } +} From f4ba5faddcd1edaadb51df6b1f36841221d09f18 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Wed, 27 May 2026 22:47:08 +0200 Subject: [PATCH 07/14] diag(matcher): debug-log per-candidate match scores Adds a DEBUG-gated log in selectInitialMatchCandidate printing each scored candidate (title/year/type/sources/provider_ids/score) against the hint, so operators can see why an item did or didn't auto-match. Zero-cost when debug logging is off; no change to matching behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/metadata/match_candidates.go | 53 ++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index 10650de8..e6ad3646 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" @@ -360,6 +362,17 @@ func candidatesAreSingleDistinctShow(best MatchCandidate, scored []scoredMatchCa 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 @@ -367,13 +380,17 @@ func candidatesAreSingleDistinctShow(best MatchCandidate, scored []scoredMatchCa if inferTitleSimilarity(best.Title, c.candidate.Title, best.Year) != 1 { return false } - // If both candidates carry the same canonical provider key (e.g. both - // have a tmdb ID) but different values, they are different shows. for _, key := range canonicalCandidateIDKeys { - bv := strings.TrimSpace(best.ProviderIDs[key]) cv := strings.TrimSpace(c.candidate.ProviderIDs[key]) - if bv != "" && cv != "" && bv != cv { - return false + if cv == "" { + continue + } + if existing, ok := seenIDs[key]; ok { + if existing != cv { + return false + } + } else { + seenIDs[key] = cv } } } @@ -396,6 +413,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) { From db34df0a35ec6be76d3c49aa815831dd23ab10b9 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Wed, 27 May 2026 23:14:33 +0200 Subject: [PATCH 08/14] feat(matcher): resolve cross-source ties by library provider priority Accept a year-corroborated single distinct show when the TOP tie-group (within 15 pts of best) is one show across providers, ignoring low-score noise below it, and pick the winner by the library's metadata-provider chain order (providerPriority, highest-first; falls back to top-scored). Recovers items like '100 Days Wild' that are returned identically by TVDB and TMDB. Thresholds (55/70/15) unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/metadata/match_candidates.go | 52 +++++++++++++++++-- .../metadata/match_candidates_select_test.go | 48 ++++++++++++++--- internal/metadata/match_candidates_test.go | 2 + internal/metadata/service.go | 6 ++- 4 files changed, 96 insertions(+), 12 deletions(-) diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index e6ad3646..335f1e4b 100644 --- a/internal/metadata/match_candidates.go +++ b/internal/metadata/match_candidates.go @@ -397,7 +397,40 @@ func candidatesAreSingleDistinctShow(best MatchCandidate, scored []scoredMatchCa return true } -func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) (*MatchCandidate, bool) { +// topTieGroup returns the highest-scored candidate plus every candidate within +// the 15-point tie window of it — i.e. the set of candidates that are not +// clearly beaten. Assumes scored is sorted descending by score. +func topTieGroup(scored []scoredMatchCandidate) []scoredMatchCandidate { + if len(scored) == 0 { + return nil + } + group := []scoredMatchCandidate{scored[0]} + for _, c := range scored[1:] { + if scored[0].score-c.score < 15 { + group = append(group, c) + } + } + return group +} + +// pickByProviderPriority returns the group candidate whose Sources include the +// highest-priority provider (providerPriority is ordered highest-first, e.g. the +// library's chain order). Falls back to the top-scored candidate when there is no +// priority info or no source matches. +func pickByProviderPriority(group []scoredMatchCandidate, providerPriority []string) *MatchCandidate { + for _, prov := range providerPriority { + for i := range group { + for _, s := range group[i].candidate.Sources { + if strings.EqualFold(s, prov) { + return &group[i].candidate + } + } + } + } + return &group[0].candidate +} + +func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate, providerPriority []string) (*MatchCandidate, bool) { if len(candidates) == 0 { return nil, false } @@ -455,13 +488,22 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) // year is high-confidence even when the fuzzy title score sits in the 55-69 // band (short/numeric/alternate titles). Accept the top-ranked candidate // without lowering the score thresholds. + // We check only the TOP tie-group (candidates within 15 pts of best) so that + // low-score noise from unrelated shows below the group does not veto a clear + // cross-source agreement. When the top group is one distinct show, pick the + // winner by the library's metadata-provider priority (falls back to top-scored). // Residual risk: two different shows with an identical title+year and no // provider IDs would both pass; accepted as low-risk given the title+year+type // corroboration. if hints.Year != 0 && best.candidate.Year == hints.Year && - candidateTypeMatchesHint(hints.Type, best.candidate.ContentType) && - candidatesAreSingleDistinctShow(best.candidate, scoredCandidates) { - return &best.candidate, true + candidateTypeMatchesHint(hints.Type, best.candidate.ContentType) { + topGroup := topTieGroup(scoredCandidates) + if candidatesAreSingleDistinctShow(best.candidate, topGroup) { + // One distinct show, possibly returned by several providers and clearly + // ahead of any different show below. Accept it, choosing the winner by + // the library's metadata-provider priority (falls back to top-scored). + return pickByProviderPriority(topGroup, providerPriority), true + } } if len(scoredCandidates) == 1 { if best.score < 70 { @@ -553,7 +595,7 @@ func selectRefreshMatchCandidate(existing *models.MediaItem, candidates []MatchC TvdbID: existing.TvdbID, ImdbID: existing.ImdbID, } - return selectInitialMatchCandidate(hints, candidates) + return selectInitialMatchCandidate(hints, candidates, nil) } func trustedHintIDsPresent(hints *MatchHints) bool { diff --git a/internal/metadata/match_candidates_select_test.go b/internal/metadata/match_candidates_select_test.go index ed2af379..6af943d6 100644 --- a/internal/metadata/match_candidates_select_test.go +++ b/internal/metadata/match_candidates_select_test.go @@ -1,13 +1,15 @@ package metadata -import "testing" +import ( + "testing" +) func TestSelectInitialMatchCandidate_LoneResultYearMatchBelow70(t *testing.T) { // Exact title, matching year, NO sources, NO provider IDs => score 45+20 = 65 (<70). // Old behavior rejected this (single candidate <70); the new rule accepts it. hints := &MatchHints{Title: "1201", Year: 1993, Type: "movie"} cands := []MatchCandidate{{Title: "1201", Year: 1993, ContentType: "movie"}} - got, ok := selectInitialMatchCandidate(hints, cands) + got, ok := selectInitialMatchCandidate(hints, cands, nil) if !ok || got == nil || got.Title != "1201" { t.Fatalf("expected lone year-matching result to be accepted, got ok=%v cand=%+v", ok, got) } @@ -21,7 +23,7 @@ func TestSelectInitialMatchCandidate_SameShowAcrossTwoSources(t *testing.T) { {Title: "Blue Lock", Year: 2022, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "404404"}}, {Title: "Blue Lock", Year: 2022, ContentType: "series", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "120089"}}, } - got, ok := selectInitialMatchCandidate(hints, cands) + got, ok := selectInitialMatchCandidate(hints, cands, nil) if !ok || got == nil { t.Fatalf("expected same-show-across-sources to be accepted, got ok=%v cand=%+v", ok, got) } @@ -32,7 +34,7 @@ func TestSelectInitialMatchCandidate_LoneResultYearMismatchStillRejected(t *test // Year does NOT corroborate, so the new rule must NOT fire; single candidate <70 => reject. hints := &MatchHints{Title: "1201", Year: 1993, Type: "movie"} cands := []MatchCandidate{{Title: "1201", Year: 1990, ContentType: "movie", Sources: []string{"tmdb"}}} - if got, ok := selectInitialMatchCandidate(hints, cands); ok { + if got, ok := selectInitialMatchCandidate(hints, cands, nil); ok { t.Fatalf("expected year-mismatch lone result to be rejected, got cand=%+v", got) } } @@ -54,7 +56,7 @@ func TestSelectInitialMatchCandidate_TwoDifferentShowsUnchanged(t *testing.T) { {Title: "The Real History of the World War Europe", Year: 2010, ContentType: "series", Sources: []string{"tvdb", "tmdb"}, ProviderIDs: map[string]string{"tvdb": "1"}}, {Title: "The Real History of the World War Pacific", Year: 2010, ContentType: "series", Sources: []string{"tvdb", "tmdb"}, ProviderIDs: map[string]string{"tvdb": "2"}}, } - if _, ok := selectInitialMatchCandidate(hints, cands); ok { + if _, ok := selectInitialMatchCandidate(hints, cands, nil); ok { t.Fatalf("two distinct shows must not be auto-accepted by the lone-result rule") } } @@ -70,7 +72,41 @@ func TestSelectInitialMatchCandidate_ConflictingProviderIDsNotAccepted(t *testin {Title: "Alpha", Year: 2022, ContentType: "movie", ProviderIDs: map[string]string{"tmdb": "111"}}, {Title: "Alpha", Year: 2022, ContentType: "movie", ProviderIDs: map[string]string{"tmdb": "222"}}, } - if _, ok := selectInitialMatchCandidate(hints, cands); ok { + if _, ok := selectInitialMatchCandidate(hints, cands, nil); ok { t.Fatal("conflicting tmdb IDs must not be auto-accepted") } } + +func TestSelectInitialMatchCandidate_CrossSourceTieResolvedByProviderPriority(t *testing.T) { + // "100 Days Wild" (2020, series, no shared IDs): TVDB and TMDB each return the correct + // show (score 83 each: 45 exact title + 20 year + 12 source + 5 has IDs + 1 richness). + // Two noise candidates (unrelated title/year) score 18 — well outside the 15-pt tie + // window of 83, so topTieGroup contains only the two correct candidates. + // candidatesAreSingleDistinctShow passes (same title/year, no conflicting IDs), + // and pickByProviderPriority selects the winner by the library's chain order. + hints := &MatchHints{Title: "100 Days Wild", Year: 2020, Type: "series"} + cands := []MatchCandidate{ + {Title: "100 Days Wild", Year: 2020, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "386908"}}, + {Title: "100 Days Wild", Year: 2020, ContentType: "series", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "109476"}}, + {Title: "Some Other Show", Year: 2026, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "476741"}}, + {Title: "Live to 100", Year: 2023, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "437829"}}, + } + + // tvdb ranked first in provider chain -> tvdb candidate wins + got, ok := selectInitialMatchCandidate(hints, cands, []string{"tvdb", "tmdb"}) + if !ok || got == nil || got.ProviderIDs["tvdb"] != "386908" { + t.Fatalf("expected tvdb winner (386908), got ok=%v cand=%+v", ok, got) + } + + // tmdb ranked first in provider chain -> tmdb candidate wins + got, ok = selectInitialMatchCandidate(hints, cands, []string{"tmdb", "tvdb"}) + if !ok || got == nil || got.ProviderIDs["tmdb"] != "109476" { + t.Fatalf("expected tmdb winner (109476), got ok=%v cand=%+v", ok, got) + } + + // nil priority -> still accepts (fallback to top-scored, i.e. first in sorted order) + got, ok = selectInitialMatchCandidate(hints, cands, nil) + if !ok || got == nil { + t.Fatalf("expected nil-priority to still accept a match, got ok=%v cand=%+v", ok, got) + } +} diff --git a/internal/metadata/match_candidates_test.go b/internal/metadata/match_candidates_test.go index acb5c37e..ce4a1300 100644 --- a/internal/metadata/match_candidates_test.go +++ b/internal/metadata/match_candidates_test.go @@ -508,6 +508,7 @@ func TestSelectInitialMatchCandidate_UsesProviderOrderForExactCrossProviderTie(t Sources: []string{"tmdb"}, }, }, + nil, ) if !ok || winner == nil { t.Fatal("expected exact cross-provider tie to use provider order") @@ -540,6 +541,7 @@ func TestSelectInitialMatchCandidate_ProviderOrderTieRequiresExactTitleYear(t *t Sources: []string{"imdb", "metadb", "tmdb", "xattr"}, }, }, + nil, ) if ok || winner != nil { t.Fatal("expected non-equivalent cross-provider tie to remain unmatched") diff --git a/internal/metadata/service.go b/internal/metadata/service.go index 33c963a7..db4d5f0a 100644 --- a/internal/metadata/service.go +++ b/internal/metadata/service.go @@ -902,7 +902,11 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques } candidates := NormalizeCandidates(allResults, contentType) - if winner, ok := selectInitialMatchCandidate(req.Hints, candidates); ok && winner != nil { + providerPriority := make([]string, 0, len(itemChain)) + for _, p := range itemChain { + providerPriority = append(providerPriority, p.Slug()) + } + if winner, ok := selectInitialMatchCandidate(req.Hints, candidates, providerPriority); ok && winner != nil { for k, v := range winner.ProviderIDs { if v != "" { accumulatedIDs[k] = v From f4ea8a6d75f72a7ae5c2de203de6ad5418f3bce8 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Thu, 28 May 2026 01:59:35 +0200 Subject: [PATCH 09/14] feat(matcher): accept cross-source-corroborated ties without a hint year When the top tie-group is one distinct show returned by 2+ distinct providers (candidatesAreSingleDistinctShow already verifies matching title+year), accept it even if the hint has no parsed year (year-less folders like '100 Deeds for Eddie McDowd'). Multi-source agreement substitutes for the year guard; lone single-source no-year results stay subject to the single-candidate >=70 gate. Thresholds unchanged. --- internal/metadata/match_candidates.go | 33 ++++++++++++--- .../metadata/match_candidates_select_test.go | 42 +++++++++++++++++++ 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index 335f1e4b..2b0d68fd 100644 --- a/internal/metadata/match_candidates.go +++ b/internal/metadata/match_candidates.go @@ -430,6 +430,22 @@ func pickByProviderPriority(group []scoredMatchCandidate, providerPriority []str 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) +} + func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate, providerPriority []string) (*MatchCandidate, bool) { if len(candidates) == 0 { return nil, false @@ -495,14 +511,19 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate, // Residual risk: two different shows with an identical title+year and no // provider IDs would both pass; accepted as low-risk given the title+year+type // corroboration. - if hints.Year != 0 && best.candidate.Year == hints.Year && - candidateTypeMatchesHint(hints.Type, best.candidate.ContentType) { + if candidateTypeMatchesHint(hints.Type, best.candidate.ContentType) { topGroup := topTieGroup(scoredCandidates) if candidatesAreSingleDistinctShow(best.candidate, topGroup) { - // One distinct show, possibly returned by several providers and clearly - // ahead of any different show below. Accept it, choosing the winner by - // the library's metadata-provider priority (falls back to top-scored). - return pickByProviderPriority(topGroup, providerPriority), true + 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 + if yearCorroborated || multiSourceCorroborated { + return pickByProviderPriority(topGroup, providerPriority), true + } } } if len(scoredCandidates) == 1 { diff --git a/internal/metadata/match_candidates_select_test.go b/internal/metadata/match_candidates_select_test.go index 6af943d6..49e23858 100644 --- a/internal/metadata/match_candidates_select_test.go +++ b/internal/metadata/match_candidates_select_test.go @@ -77,6 +77,48 @@ func TestSelectInitialMatchCandidate_ConflictingProviderIDsNotAccepted(t *testin } } +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). From 7406023c937b4f3fa2fcac09141dcfab28f59622 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Thu, 28 May 2026 02:51:13 +0200 Subject: [PATCH 10/14] diag(matcher): debug-log provider search query + per-provider result counts Adds DEBUG logs in the ModeInitialMatch search path: each provider's result count for the query, and the assembled raw/candidate totals. Lets us see when a provider search returns zero ('no metadata found') vs a scoring/tie issue. Zero behavior change. --- internal/metadata/service.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/metadata/service.go b/internal/metadata/service.go index db4d5f0a..4b699f87 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,6 +908,12 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques } candidates := NormalizeCandidates(allResults, contentType) + 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()) From edb7051508fb425a452e78b66748373f9ed17e1e Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Thu, 28 May 2026 03:18:03 +0200 Subject: [PATCH 11/14] fix(naming): parse bare bracketed IMDb IDs ([tt10011226]/{tt...}) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folders tagged with a bare IMDb id in brackets (Plex/Kodi style, e.g. '17 Blocks (2021) [tt10011226]') had the id silently dropped — folderIDPattern needs an 'imdb-' prefix and trailingImdbIDPattern needs an un-bracketed trailing tt-id. Recognize bracketed bare tt-ids so these items get the trusted-ID match path instead of falling to title+year scoring. --- internal/naming/folderid.go | 9 +++++++++ internal/naming/folderid_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 internal/naming/folderid_test.go diff --git a/internal/naming/folderid.go b/internal/naming/folderid.go index e9cc064c..473308ba 100644 --- a/internal/naming/folderid.go +++ b/internal/naming/folderid.go @@ -13,6 +13,11 @@ 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+)$`) +// 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 structured provider IDs from // a folder or file name, such as {tmdb-27205} or [imdbid-tt1375666}. It does // not consider trailing bare IDs or folderType-based heuristics. @@ -52,6 +57,10 @@ func ParseFolderIDs(folderName string, folderType string) *FolderIDHints { return hints } + if m := bracketedBareImdbPattern.FindStringSubmatch(folderName); m != nil { + return &FolderIDHints{ImdbID: strings.ToLower(m[1])} + } + trimmed := strings.TrimSpace(folderName) if m := trailingImdbIDPattern.FindStringSubmatch(trimmed); m != nil { return &FolderIDHints{ImdbID: strings.ToLower(m[1])} diff --git a/internal/naming/folderid_test.go b/internal/naming/folderid_test.go new file mode 100644 index 00000000..471e9417 --- /dev/null +++ b/internal/naming/folderid_test.go @@ -0,0 +1,29 @@ +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) + } +} From fa839b6e37970cd039b5e1fcc3db2c500a2c9025 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Thu, 28 May 2026 04:11:17 +0200 Subject: [PATCH 12/14] feat(metadata): match sole exact-title candidate despite year off by <=2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folder years routinely differ from provider release years by a year or two (festival vs wide release, regional dates), zeroing the year bonus and leaving a lone exact-title candidate at 63-68 — just under the single-candidate >=70 gate (e.g. Dead Reckoning 1947 vs 1946, 17 Blocks 2021 vs 2019, Stasi FC). Add title corroboration to the existing lone-result rule: a sole distinct show whose normalized title exactly matches and whose year is within +/-2 is accepted. The 55 floor still rejects low-similarity titles (e.g. Hotel Transylvania Puppy! vs Puppy!). No 55/70/15 threshold change. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/metadata/match_candidates.go | 20 +++++- internal/metadata/match_candidates_test.go | 81 ++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index 2b0d68fd..d6cec77d 100644 --- a/internal/metadata/match_candidates.go +++ b/internal/metadata/match_candidates.go @@ -446,6 +446,14 @@ func distinctSourceCount(group []scoredMatchCandidate) int { 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 @@ -521,7 +529,17 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate, // (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 - if yearCorroborated || multiSourceCorroborated { + // 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) + if yearCorroborated || multiSourceCorroborated || titleCorroborated { return pickByProviderPriority(topGroup, providerPriority), true } } diff --git a/internal/metadata/match_candidates_test.go b/internal/metadata/match_candidates_test.go index ce4a1300..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") @@ -573,6 +650,7 @@ func TestSelectInitialMatchCandidate_DetailScoreDoesNotOverrideDifferentTitleTie DetailScore: 80, }, }, + nil, ) if ok || winner != nil { t.Fatal("expected richer different-title candidate to be rejected") @@ -602,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") @@ -633,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") @@ -655,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") From 311fbd05dd106c42955ddccea57c9ca8e8f11ef7 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Thu, 28 May 2026 16:55:39 +0200 Subject: [PATCH 13/14] chore(lint): gofmt single-space alignment in root_inference.go var block When inferProviderTagRe was broadened to handle unsubstituted Sonarr token placeholders ({TvdbId}/{imdb-}), the regex grew long enough that gofmt prefers single-space rather than column-aligned spacing across the var block. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/naming/root_inference.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/naming/root_inference.go b/internal/naming/root_inference.go index f383d725..f5db7672 100644 --- a/internal/naming/root_inference.go +++ b/internal/naming/root_inference.go @@ -21,7 +21,7 @@ var ( // 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]*)[}\]]`) + inferProviderTagRe = regexp.MustCompile(`\s*[{\[](?:tmdb|tmdbid|imdb|imdbid|tvdb|tvdbid)-(?:\{[^}]*\}|[\w]*)[}\]]`) ) type RootAssignment struct { From 9f62c6977c82e46cc4356c3be88dfc8c2f206070 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 28 May 2026 13:09:28 -0400 Subject: [PATCH 14/14] fix(metadata): tighten matcher and bare IMDb parsing --- internal/metadata/match_candidates.go | 6 +++++- .../metadata/match_candidates_select_test.go | 20 +++++++++++++++++++ internal/naming/folderid.go | 16 ++++++--------- internal/naming/folderid_test.go | 17 ++++++++++++++++ 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index d6cec77d..345b2c0c 100644 --- a/internal/metadata/match_candidates.go +++ b/internal/metadata/match_candidates.go @@ -539,7 +539,11 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate, titleCorroborated := hints.Year != 0 && best.candidate.Year != 0 && absYearDelta(best.candidate.Year, hints.Year) <= 2 && normalizeTitleForScoring(best.candidate.Title) == normalizeTitleForScoring(hints.Title) - if yearCorroborated || multiSourceCorroborated || titleCorroborated { + // 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 } } diff --git a/internal/metadata/match_candidates_select_test.go b/internal/metadata/match_candidates_select_test.go index 49e23858..699c94e4 100644 --- a/internal/metadata/match_candidates_select_test.go +++ b/internal/metadata/match_candidates_select_test.go @@ -39,6 +39,26 @@ func TestSelectInitialMatchCandidate_LoneResultYearMismatchStillRejected(t *test } } +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 diff --git a/internal/naming/folderid.go b/internal/naming/folderid.go index 473308ba..d977648a 100644 --- a/internal/naming/folderid.go +++ b/internal/naming/folderid.go @@ -18,15 +18,11 @@ var trailingNumericIDPattern = regexp.MustCompile(`(?:^|\s)(\d+)$`) // tt-prefixed number is unambiguously IMDb. var bracketedBareImdbPattern = regexp.MustCompile(`(?i)[{\[](tt\d{7,8})[}\]]`) -// ParseStructuredFolderIDs extracts only explicit structured provider IDs from -// a folder or file name, such as {tmdb-27205} or [imdbid-tt1375666}. It does +// 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]) @@ -42,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 } @@ -57,10 +57,6 @@ func ParseFolderIDs(folderName string, folderType string) *FolderIDHints { return hints } - if m := bracketedBareImdbPattern.FindStringSubmatch(folderName); m != nil { - return &FolderIDHints{ImdbID: strings.ToLower(m[1])} - } - trimmed := strings.TrimSpace(folderName) if m := trailingImdbIDPattern.FindStringSubmatch(trimmed); m != nil { return &FolderIDHints{ImdbID: strings.ToLower(m[1])} diff --git a/internal/naming/folderid_test.go b/internal/naming/folderid_test.go index 471e9417..45960787 100644 --- a/internal/naming/folderid_test.go +++ b/internal/naming/folderid_test.go @@ -27,3 +27,20 @@ func TestParseFolderIDs_BracketedBareImdb(t *testing.T) { 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) + } +}