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) + } +}