diff --git a/internal/metadata/episode_match_validation.go b/internal/metadata/episode_match_validation.go index ef500414..300c5591 100644 --- a/internal/metadata/episode_match_validation.go +++ b/internal/metadata/episode_match_validation.go @@ -10,11 +10,9 @@ import ( "strings" "sync" "time" - "unicode" "unicode/utf8" "github.com/Silo-Server/silo-server/internal/naming" - "golang.org/x/text/unicode/norm" ) const ( @@ -552,8 +550,8 @@ func isDistinctiveSingleEpisodeTitle(title string) bool { } func episodeTitleSimilarity(left, right string) float64 { - leftComparable := foldEpisodeTitleDiacritics(normalizeTitleForScoring(left)) - rightComparable := foldEpisodeTitleDiacritics(normalizeTitleForScoring(right)) + leftComparable := normalizeTitleForScoring(left) + rightComparable := normalizeTitleForScoring(right) if leftComparable == "" || rightComparable == "" { return 0 } @@ -565,13 +563,3 @@ func episodeTitleSimilarity(left, right string) float64 { } return 0 } - -func foldEpisodeTitleDiacritics(value string) string { - var builder strings.Builder - for _, r := range norm.NFD.String(value) { - if !unicode.Is(unicode.Mn, r) { - builder.WriteRune(r) - } - } - return norm.NFC.String(builder.String()) -} diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index 1759e938..0b59c65e 100644 --- a/internal/metadata/match_candidates.go +++ b/internal/metadata/match_candidates.go @@ -10,6 +10,9 @@ import ( "strconv" "strings" "unicode" + "unicode/utf8" + + "golang.org/x/text/unicode/norm" "github.com/Silo-Server/silo-server/internal/lang" "github.com/Silo-Server/silo-server/internal/models" @@ -639,6 +642,13 @@ func candidateTitles(candidate MatchCandidate) []string { } func bestCandidateTitleSimilarity(hint string, candidate MatchCandidate, year int) (float64, string) { + // A no-year local folder still has enough information to remove a provider's + // own trailing year decoration. Without this fallback, a corroborated result + // such as "Adventure Time (2010)" scores below an undecorated same-title + // competitor before the no-year consensus tie-break can run. + if year == 0 { + year = candidate.Year + } bestScore := 0.0 bestTitle := "" for _, title := range candidateTitles(candidate) { @@ -986,6 +996,9 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate, return &best.candidate, true } if best.score-scoredCandidates[1].score < 15 { + if winner, ok := tmdbTVDBTitleConsensusWinner(hints, scoredCandidates); ok { + return winner, true + } if winner, ok := duplicateTieBreakWinner(hints, scoredCandidates); ok { return winner, true } @@ -994,6 +1007,67 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate, return &best.candidate, true } +// tmdbTVDBTitleConsensusWinner resolves a narrow series-only ambiguity when +// the local folder omits a year. It accepts only a candidate tied for the +// existing highest score, and only when exactly one candidate has an exact +// title match, a provider year, and independently retained TMDB and TVDB +// identities. This deliberately does not merge any metadata or IDs from +// competing candidates. +func tmdbTVDBTitleConsensusWinner(hints *MatchHints, scoredCandidates []scoredMatchCandidate) (*MatchCandidate, bool) { + if hints == nil || !strings.EqualFold(strings.TrimSpace(hints.Type), "series") || hints.Year != 0 || len(scoredCandidates) < 2 { + return nil, false + } + + qualifyingIndex := -1 + for i := range scoredCandidates { + candidate := scoredCandidates[i].candidate + if !strings.EqualFold(strings.TrimSpace(candidate.ContentType), "series") || candidate.Year == 0 { + continue + } + if similarity, _ := bestCandidateTitleSimilarity(hints.Title, candidate, 0); similarity != 1 { + continue + } + if !candidateHasSource(candidate, "tmdb") || !candidateHasSource(candidate, "tvdb") { + continue + } + if !candidateRetainsCanonicalProviderID(candidate, "tmdb") || !candidateRetainsCanonicalProviderID(candidate, "tvdb") { + continue + } + if qualifyingIndex != -1 { + return nil, false + } + qualifyingIndex = i + } + + if qualifyingIndex == -1 || scoredCandidates[qualifyingIndex].score != scoredCandidates[0].score { + return nil, false + } + winner := scoredCandidates[qualifyingIndex].candidate + if !slices.Contains(winner.MatchReasons, "tmdb_tvdb_title_consensus") { + winner.MatchReasons = append(winner.MatchReasons, "tmdb_tvdb_title_consensus") + } + return &winner, true +} + +func candidateRetainsCanonicalProviderID(candidate MatchCandidate, provider string) bool { + provider = strings.ToLower(strings.TrimSpace(provider)) + for _, conflictingKey := range candidate.ConflictingProviderIDKeys { + if strings.EqualFold(strings.TrimSpace(conflictingKey), provider) { + return strings.TrimSpace(candidate.ConfirmedProviderIDs[provider]) != "" + } + } + return strings.TrimSpace(candidate.ProviderIDs[provider]) != "" +} + +func candidateHasSource(candidate MatchCandidate, provider string) bool { + for _, source := range candidate.Sources { + if strings.EqualFold(strings.TrimSpace(source), provider) { + return true + } + } + return false +} + func providerOrderExactTieBreakWinner(hints *MatchHints, scoredCandidates []scoredMatchCandidate) (*MatchCandidate, bool) { if hints == nil || len(scoredCandidates) < 2 { return nil, false @@ -1183,14 +1257,97 @@ func normalizeCandidateTitleForYear(title string, year int) string { } yearText := strconv.Itoa(year) fields := strings.Fields(normalized) - if len(fields) <= 1 || fields[len(fields)-1] != yearText { + if len(fields) <= 1 { + return normalized + } + // Providers frequently disambiguate a reused title by appending the release + // year to the title string itself ("24 stjerners julikalender (DK) (2026)"). + // The scanner strips "(YYYY)" from the folder name into a separate year, so + // without this the two sides can never compare equal. Only the known year is + // stripped, so an unrelated trailing number stays part of the title. + last := fields[len(fields)-1] + if last != yearText && trimYearDecoration(last) != yearText { return normalized } return strings.Join(fields[:len(fields)-1], " ") } +// trimYearDecoration unwraps a bracketed year token — "(2026)", "[2026]" — to +// its bare digits. Returns the input unchanged when it is not so wrapped. +func trimYearDecoration(token string) string { + for _, pair := range [][2]string{{"(", ")"}, {"[", "]"}} { + if inner, ok := strings.CutPrefix(token, pair[0]); ok { + if inner, ok := strings.CutSuffix(inner, pair[1]); ok { + return inner + } + } + } + return token +} + +// nonCombiningLetterFolds covers letters that are not decomposable combining +// forms, so NFD alone leaves them untouched. European library folders routinely +// carry the ASCII spelling while providers return these. +var nonCombiningLetterFolds = strings.NewReplacer( + "ø", "o", "Ø", "o", + "æ", "ae", "Æ", "ae", + "œ", "oe", "Œ", "oe", + "ß", "ss", "ẞ", "ss", + "đ", "d", "Đ", "d", + "ł", "l", "Ł", "l", + "ı", "i", +) + +// foldTitleDiacritics strips Latin combining marks so provider spellings that +// differ only by accents compare equal ("Yeşilçam" vs "Yesilcam"). Combining +// marks remain significant in other scripts, where they can change lexical +// identity rather than decorate an otherwise equivalent Latin letter. +// Deliberately avoids a shared chained transformer: transformers carry per-call +// state, and this runs concurrently across episode-validation workers. +func foldTitleDiacritics(title string) string { + if isASCIIOnly(title) { + return title + } + title = nonCombiningLetterFolds.Replace(title) + if isASCIIOnly(title) { + return title + } + decomposed := norm.NFD.String(title) + var builder strings.Builder + builder.Grow(len(decomposed)) + var lastStarter rune + for _, r := range decomposed { + // Spacing kana marks otherwise fall through punctuation cleanup; convert + // them so NFC can recompose the voiced kana before title tokenization. + switch r { + case '\u309b': + r = '\u3099' + case '\u309c': + r = '\u309a' + } + if unicode.Is(unicode.Mn, r) && unicode.Is(unicode.Latin, lastStarter) { + continue + } + builder.WriteRune(r) + if !unicode.Is(unicode.Mn, r) { + lastStarter = r + } + } + return norm.NFC.String(builder.String()) +} + +func isASCIIOnly(value string) bool { + for i := range len(value) { + if value[i] >= utf8.RuneSelf { + return false + } + } + return true +} + func normalizeTitleForScoring(title string) string { title = naming.StripComparisonSafeEditionSuffix(title) + title = foldTitleDiacritics(title) title = strings.ToLower(strings.TrimSpace(title)) if title == "" { return "" diff --git a/internal/metadata/match_candidates_select_test.go b/internal/metadata/match_candidates_select_test.go index 699c94e4..2ee876e1 100644 --- a/internal/metadata/match_candidates_select_test.go +++ b/internal/metadata/match_candidates_select_test.go @@ -1,9 +1,329 @@ package metadata import ( + "slices" "testing" ) +const ( + adventureTimeTestTitle = "Adventure Time" + exampleSeriesTestTitle = "Example Series" + localizedEnglishTitle = "Jubei-chan: The Ninja Girl" + localizedOriginalTitle = "十兵衛ちゃん" + localizedTVDBID = "tvdb-series-1" + localizedTMDBID = "tmdb-series-1" + testAlternateAliasKind = "alternate" + testNFOProvider = "nfo" +) + +//nolint:goconst // Keep the production-shaped provider fixtures readable in place. +func TestSelectInitialMatchCandidate_LocalizedPrimaryTitleMatchesEnglishAlias(t *testing.T) { + hints := &MatchHints{Title: localizedEnglishTitle, Year: 1999, Type: anchoredItemTypeSeries} + candidates := []MatchCandidate{ + { + Title: localizedOriginalTitle, + OriginalTitle: localizedOriginalTitle, + TitleAliases: []TitleAlias{ + {Title: localizedEnglishTitle, Language: "en", Kind: testAlternateAliasKind, Provider: testTVDBProvider}, + }, + Year: 1999, + ContentType: anchoredItemTypeSeries, + Sources: []string{testTVDBProvider}, + ProviderIDs: map[string]string{testTVDBProvider: localizedTVDBID, testTMDBProvider: localizedTMDBID}, + }, + } + + got, ok := selectInitialMatchCandidate(hints, candidates, nil) + if !ok || got == nil { + t.Fatal("localized primary title with an exact English alias was not matched") + } + if got.MatchedTitle != localizedEnglishTitle { + t.Fatalf("MatchedTitle = %q, want English alias", got.MatchedTitle) + } +} + +//nolint:goconst // Keep the production-shaped provider fixtures readable in place. +func TestSelectInitialMatchCandidate_MissingYearTMDBTVDBTitleConsensus(t *testing.T) { + hints := &MatchHints{Title: adventureTimeTestTitle, Type: anchoredItemTypeSeries} + consensus := MatchCandidate{ + Title: adventureTimeTestTitle, + Year: 2010, + ContentType: anchoredItemTypeSeries, + Sources: []string{testTVDBProvider, testTMDBProvider}, + ProviderIDs: map[string]string{testTMDBProvider: "15260", testTVDBProvider: "152831", testIMDBProvider: "tt1305826"}, + } + competitor := MatchCandidate{ + Title: adventureTimeTestTitle, + Year: 1967, + ContentType: "series", + Sources: []string{"tmdb"}, + ProviderIDs: map[string]string{"tmdb": "245745"}, + } + + for _, candidates := range [][]MatchCandidate{{consensus, competitor}, {competitor, consensus}} { + got, ok := selectInitialMatchCandidate(hints, candidates, nil) + if !ok || got == nil || got.ProviderIDs["tvdb"] != "152831" { + t.Fatalf("ordered candidates %+v returned ok=%v candidate=%+v", candidates, ok, got) + } + if !slices.Contains(got.MatchReasons, "tmdb_tvdb_title_consensus") { + t.Fatalf("MatchReasons = %v, want tmdb_tvdb_title_consensus", got.MatchReasons) + } + } +} + +//nolint:goconst // Keep the production-shaped provider fixtures readable in place. +func TestSelectInitialMatchCandidate_MissingYearConsensusStripsCandidateYearDecoration(t *testing.T) { + hints := &MatchHints{Title: adventureTimeTestTitle, Type: anchoredItemTypeSeries} + candidates := []MatchCandidate{ + { + Title: adventureTimeTestTitle + " (2010)", + Year: 2010, + ContentType: anchoredItemTypeSeries, + Sources: []string{testTMDBProvider, testTVDBProvider}, + ProviderIDs: map[string]string{testTMDBProvider: "15260", testTVDBProvider: "152831"}, + }, + { + Title: adventureTimeTestTitle, + Year: 1967, + ContentType: anchoredItemTypeSeries, + Sources: []string{testTMDBProvider}, + ProviderIDs: map[string]string{testTMDBProvider: "245745"}, + }, + } + + got, ok := selectInitialMatchCandidate(hints, candidates, nil) + if !ok || got == nil || got.ProviderIDs[testTVDBProvider] != "152831" { + t.Fatalf("decorated corroborated candidate lost to competitor: ok=%v candidate=%+v", ok, got) + } + if !slices.Contains(got.MatchReasons, "tmdb_tvdb_title_consensus") { + t.Fatalf("MatchReasons = %v, want tmdb_tvdb_title_consensus", got.MatchReasons) + } +} + +//nolint:goconst // Keep the production-shaped provider fixtures readable in place. +func TestSelectInitialMatchCandidate_MissingYearConsensusIsOrderIndependentAtEqualScore(t *testing.T) { + hints := &MatchHints{Title: adventureTimeTestTitle, Type: anchoredItemTypeSeries} + consensus := MatchCandidate{ + Title: adventureTimeTestTitle, + Year: 2010, + ContentType: anchoredItemTypeSeries, + Sources: []string{testTMDBProvider, testTVDBProvider}, + ProviderIDs: map[string]string{testTMDBProvider: "15260", testTVDBProvider: "152831"}, + } + equalScoreCompetitor := MatchCandidate{ + Title: adventureTimeTestTitle, + Year: 1967, + ContentType: anchoredItemTypeSeries, + Sources: []string{testTMDBProvider, testTVDBProvider}, + ProviderIDs: map[string]string{testTMDBProvider: "245745", testIMDBProvider: "tt0000001"}, + } + if consensusScore, competitorScore := scoreMatchCandidate(hints, consensus), scoreMatchCandidate(hints, equalScoreCompetitor); consensusScore != competitorScore { + t.Fatalf("fixture scores differ: consensus=%v competitor=%v", consensusScore, competitorScore) + } + + for _, candidates := range [][]MatchCandidate{{consensus, equalScoreCompetitor}, {equalScoreCompetitor, consensus}} { + got, ok := selectInitialMatchCandidate(hints, candidates, nil) + if !ok || got == nil || got.ProviderIDs[testTVDBProvider] != "152831" { + t.Fatalf("ordered candidates %+v returned ok=%v candidate=%+v", candidates, ok, got) + } + } +} + +//nolint:goconst // Keep the production-shaped provider fixtures readable in place. +func TestSelectInitialMatchCandidate_MissingYearConsensusBeatsSameYearSingleProvider(t *testing.T) { + hints := &MatchHints{Title: exampleSeriesTestTitle, Type: "series"} + candidates := []MatchCandidate{ + { + Title: exampleSeriesTestTitle, + Year: 2021, + ContentType: "series", + Sources: []string{"tmdb", "tvdb"}, + ProviderIDs: map[string]string{"tmdb": "101", "tvdb": "201"}, + }, + { + Title: exampleSeriesTestTitle, + Year: 2021, + ContentType: "series", + Sources: []string{"tmdb"}, + ProviderIDs: map[string]string{"tmdb": "102"}, + }, + } + + got, ok := selectInitialMatchCandidate(hints, candidates, nil) + if !ok || got == nil || got.ProviderIDs["tvdb"] != "201" { + t.Fatalf("same-year single-provider competitor displaced consensus: ok=%v candidate=%+v", ok, got) + } +} + +//nolint:goconst // Keep the production-shaped provider fixtures readable in place. +func TestSelectInitialMatchCandidate_MissingYearConsensusAllowsResolvedCanonicalIDConflict(t *testing.T) { + hints := &MatchHints{Title: exampleSeriesTestTitle, Type: anchoredItemTypeSeries} + candidates := NormalizeCandidatesForLanguage([]SearchResult{ + { + Name: exampleSeriesTestTitle, + Year: 2021, + Provider: testTVDBProvider, + ProviderIDs: map[string]string{ + testIMDBProvider: "tt0000101", + testTMDBProvider: "101", + testTVDBProvider: "201", + }, + }, + { + Name: exampleSeriesTestTitle, + Year: 2021, + Provider: testTMDBProvider, + ProviderIDs: map[string]string{ + testIMDBProvider: "tt0000101", + testTMDBProvider: "101", + testTVDBProvider: "999", + }, + }, + { + Name: exampleSeriesTestTitle, + Year: 1991, + Provider: testTMDBProvider, + ProviderIDs: map[string]string{testTMDBProvider: "102"}, + }, + }, anchoredItemTypeSeries, "en") + if len(candidates) != 2 { + t.Fatalf("NormalizeCandidatesForLanguage() returned %d candidates, want 2", len(candidates)) + } + if candidates[0].ProviderIDs[testTVDBProvider] != "" || candidates[0].ConfirmedProviderIDs[testTVDBProvider] != "201" { + t.Fatalf("resolved TVDB identity = provider:%q confirmed:%q", candidates[0].ProviderIDs[testTVDBProvider], candidates[0].ConfirmedProviderIDs[testTVDBProvider]) + } + + got, ok := selectInitialMatchCandidate(hints, candidates, nil) + if !ok || got == nil || got.ConfirmedProviderIDs[testTVDBProvider] != "201" { + t.Fatalf("resolved canonical ID conflict was not retained: ok=%v candidate=%+v", ok, got) + } +} + +//nolint:goconst // Keep the production-shaped provider fixtures readable in place. +func TestSelectInitialMatchCandidate_MissingYearConsensusNegativeControls(t *testing.T) { + baseConsensus := MatchCandidate{ + Title: adventureTimeTestTitle, + Year: 2010, + ContentType: "series", + Sources: []string{"tmdb", "tvdb"}, + ProviderIDs: map[string]string{"tmdb": "15260", "tvdb": "152831"}, + } + baseCompetitor := MatchCandidate{ + Title: adventureTimeTestTitle, + Year: 1967, + ContentType: "series", + Sources: []string{"tmdb"}, + ProviderIDs: map[string]string{"tmdb": "245745"}, + } + + tests := []struct { + name string + hints *MatchHints + candidates []MatchCandidate + }{ + { + name: "movie content type", + hints: &MatchHints{Title: adventureTimeTestTitle, Type: anchoredItemTypeMovie}, + candidates: []MatchCandidate{ + {Title: adventureTimeTestTitle, Year: 2010, ContentType: anchoredItemTypeMovie, Sources: []string{testTMDBProvider, testTVDBProvider}, ProviderIDs: map[string]string{testTMDBProvider: "1", testTVDBProvider: "2"}}, + {Title: adventureTimeTestTitle, Year: 1967, ContentType: anchoredItemTypeMovie, Sources: []string{testTMDBProvider}, ProviderIDs: map[string]string{testTMDBProvider: "3"}}, + }, + }, + { + name: "one remote provider even with cross references", + hints: &MatchHints{Title: adventureTimeTestTitle, Type: "series"}, + candidates: []MatchCandidate{ + {Title: adventureTimeTestTitle, Year: 2010, ContentType: "series", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "1", "tvdb": "2"}}, + baseCompetitor, + }, + }, + { + name: "two corroborated identities", + hints: &MatchHints{Title: adventureTimeTestTitle, Type: "series"}, + candidates: []MatchCandidate{ + baseConsensus, + {Title: adventureTimeTestTitle, Year: 1967, ContentType: "series", Sources: []string{"tmdb", "tvdb"}, ProviderIDs: map[string]string{"tmdb": "3", "tvdb": "4"}}, + }, + }, + { + name: "coherent title is not exact", + hints: &MatchHints{Title: "The Real History of the World War", Type: "series"}, + candidates: []MatchCandidate{ + {Title: "The Real History of the World War Europe", Year: 2010, ContentType: "series", Sources: []string{"tmdb", "tvdb"}, ProviderIDs: map[string]string{"tmdb": "1", "tvdb": "2"}}, + {Title: "The Real History of the World War Pacific", Year: 2010, ContentType: "series", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "3"}}, + }, + }, + { + name: "missing retained tvdb id", + hints: &MatchHints{Title: adventureTimeTestTitle, Type: "series"}, + candidates: []MatchCandidate{ + {Title: adventureTimeTestTitle, Year: 2010, ContentType: "series", Sources: []string{"tmdb", "tvdb"}, ProviderIDs: map[string]string{"tmdb": "1"}}, + baseCompetitor, + }, + }, + { + name: "quarantined tvdb id", + hints: &MatchHints{Title: adventureTimeTestTitle, Type: "series"}, + candidates: []MatchCandidate{ + {Title: adventureTimeTestTitle, Year: 2010, ContentType: "series", Sources: []string{"tmdb", "tvdb"}, ProviderIDs: map[string]string{"tmdb": "1", "tvdb": "2"}, ConflictingProviderIDKeys: []string{"tvdb"}}, + baseCompetitor, + }, + }, + { + name: "nfo is not a second provider", + hints: &MatchHints{Title: adventureTimeTestTitle, Type: "series"}, + candidates: []MatchCandidate{ + {Title: adventureTimeTestTitle, Year: 2010, ContentType: anchoredItemTypeSeries, Sources: []string{testTMDBProvider, testNFOProvider}, ProviderIDs: map[string]string{testTMDBProvider: "1", testTVDBProvider: "2"}}, + baseCompetitor, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got, ok := selectInitialMatchCandidate(tt.hints, tt.candidates, nil); ok { + t.Fatalf("negative control matched candidate %+v", got) + } + }) + } +} + +//nolint:goconst // Keep the production-shaped provider fixtures readable in place. +func TestTMDBTVDBTitleConsensusWinner_NegativeControls(t *testing.T) { + consensus := MatchCandidate{Title: adventureTimeTestTitle, Year: 2010, ContentType: "series", Sources: []string{"tmdb", "tvdb"}, ProviderIDs: map[string]string{"tmdb": "2", "tvdb": "3"}} + competitor := MatchCandidate{Title: adventureTimeTestTitle, Year: 1967, ContentType: "series", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "1"}} + tests := []struct { + name string + hints *MatchHints + scored []scoredMatchCandidate + }{ + { + name: "hint year present", + hints: &MatchHints{Title: adventureTimeTestTitle, Year: 1967, Type: "series"}, + scored: []scoredMatchCandidate{ + {candidate: consensus, score: 80}, + {candidate: competitor, score: 80}, + }, + }, + { + name: "corroborated candidate is lower ranked", + hints: &MatchHints{Title: adventureTimeTestTitle, Type: "series"}, + scored: []scoredMatchCandidate{ + {candidate: competitor, score: 80}, + {candidate: consensus, score: 76}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got, ok := tmdbTVDBTitleConsensusWinner(tt.hints, tt.scored); ok { + t.Fatalf("negative control returned candidate %+v", got) + } + }) + } +} + 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. diff --git a/internal/metadata/match_queue_policy.go b/internal/metadata/match_queue_policy.go index 8d903b7a..1341082e 100644 --- a/internal/metadata/match_queue_policy.go +++ b/internal/metadata/match_queue_policy.go @@ -15,7 +15,8 @@ var ( ) const ( - matcherRevision = 9 + movieMatcherRevision = 10 + seriesMatcherRevision = 10 movieQueueRetryDelay = 15 * time.Second seriesRootQueueQuietWindow = 10 * time.Second seriesRootQueueRetryDelay = 30 * time.Second @@ -50,7 +51,7 @@ func matchQueueBackoffExpr(basePlaceholder, maxPlaceholder string) string { // matchQueueInputFingerprintSQL returns a deterministic SQL expression for // inputs that can change a result without changing the queue key. Arguments // are internal SQL expressions selected by repository code, never user input. -func matchQueueInputFingerprintSQL(pathExpression, typeExpression, folderIDExpression, languageExpression string) string { +func matchQueueInputFingerprintSQL(pathExpression, typeExpression, folderIDExpression, languageExpression string, matcherRevision int) string { return fmt.Sprintf(`md5(%s || '|' || COALESCE(%s, '') || '|' || COALESCE(%s, '') || '|%d|' || COALESCE(( SELECT string_agg( chain.priority::text || ':' || installation.id::text || ':' || installation.plugin_id || ':' || @@ -84,7 +85,7 @@ func seriesMatchQueueInputFingerprintSQL(rootExpression, folderIDExpression, lan AND shape_file.missing_since IS NULL AND shape_file.extra_id IS NULL ), ''))`, rootExpression, folderIDExpression, rootExpression) - return matchQueueInputFingerprintSQL(shapeExpression, "'series'", folderIDExpression, languageExpression) + return matchQueueInputFingerprintSQL(shapeExpression, "'series'", folderIDExpression, languageExpression, seriesMatcherRevision) } func boundedMatchFailureMessage(message string) string { diff --git a/internal/metadata/match_queue_state_db_test.go b/internal/metadata/match_queue_state_db_test.go index 32e889b3..566726b9 100644 --- a/internal/metadata/match_queue_state_db_test.go +++ b/internal/metadata/match_queue_state_db_test.go @@ -77,11 +77,11 @@ func TestNormalizeMatchFailureKindTreatsUnknownAsTransient(t *testing.T) { func TestMatchQueueFingerprintIncludesMatcherAndProviderConfiguration(t *testing.T) { t.Parallel() - expression := matchQueueInputFingerprintSQL("mf.file_path", "'movie'", "mf.media_folder_id", "folders.metadata_language") + expression := matchQueueInputFingerprintSQL("mf.file_path", "'movie'", "mf.media_folder_id", "folders.metadata_language", movieMatcherRevision) for _, required := range []string{ "mf.file_path", "'movie'", "folders.metadata_language", "installation.version", "chain.priority", "chain.capability_id", "plugin_runtime_configs", "config.updated_at::text", - fmt.Sprintf("|%d|", matcherRevision), + fmt.Sprintf("|%d|", movieMatcherRevision), } { if !strings.Contains(expression, required) { t.Fatalf("fingerprint expression %q does not contain %q", expression, required) @@ -92,11 +92,97 @@ func TestMatchQueueFingerprintIncludesMatcherAndProviderConfiguration(t *testing func TestSeriesMatchQueueFingerprintIncludesEpisodePathShape(t *testing.T) { t.Parallel() expression := seriesMatchQueueInputFingerprintSQL("q.observed_root_path", "q.media_folder_id", "folders.metadata_language") - for _, required := range []string{"shape_file.file_path", "shape_file.observed_root_path", "shape_file.missing_since", "shape_file.extra_id"} { + for _, required := range []string{"shape_file.file_path", "shape_file.observed_root_path", "shape_file.missing_since", "shape_file.extra_id", fmt.Sprintf("|%d|", seriesMatcherRevision)} { if !strings.Contains(expression, required) { t.Fatalf("series fingerprint expression %q does not contain %q", expression, required) } } + if movieMatcherRevision != 10 { + t.Fatalf("movie matcher revision = %d, want shared title-normalization revision 10", movieMatcherRevision) + } + if seriesMatcherRevision != 10 { + t.Fatalf("series matcher revision = %d, want consensus revision 10", seriesMatcherRevision) + } +} + +func TestMatchQueueSharedTitleRevisionWakesMoviesAndSeries(t *testing.T) { + pool := chainBuiltinTestPool(t) + ctx := context.Background() + previousMatcherRevision := movieMatcherRevision - 1 + + movieFolderID := insertTestFolder(t, pool, "movie") + moviePath := fmt.Sprintf("/test/revision-isolation-%d/Movie.mkv", time.Now().UnixNano()) + var movieFileID int + if err := pool.QueryRow(ctx, ` + INSERT INTO media_files (media_folder_id, file_path, base_type, file_size) + VALUES ($1, $2, 'movie', 0) RETURNING id + `, movieFolderID, moviePath).Scan(&movieFileID); err != nil { + t.Fatalf("seed movie file: %v", err) + } + movieRepo := NewMovieMatchQueueRepository(pool, scannerrepo.NewFileRepository(pool)) + if err := movieRepo.EnqueueMovieFile(ctx, movieFileID); err != nil { + t.Fatalf("EnqueueMovieFile(): %v", err) + } + if _, err := pool.Exec(ctx, ` + UPDATE movie_match_queue + SET state = 'parked', available_at = NOW() + interval '24 hours', parked_at = NOW(), + matcher_revision = $2 + WHERE media_file_id = $1 + `, movieFileID, previousMatcherRevision); err != nil { + t.Fatalf("park movie row: %v", err) + } + + seriesFolderID := insertTestFolder(t, pool, "series") + seriesRoot := fmt.Sprintf("/test/revision-isolation-%d/Series", time.Now().UnixNano()) + if _, err := pool.Exec(ctx, ` + INSERT INTO media_files ( + media_folder_id, file_path, observed_root_path, base_type, + season_number, episode_number, file_size + ) VALUES ($1, $2, $3, 'series', 1, 1, 0) + `, seriesFolderID, seriesRoot+"/Season 01/Series S01E01.mkv", seriesRoot); err != nil { + t.Fatalf("seed series file: %v", err) + } + seriesRepo := NewSeriesRootMatchQueueRepository(pool) + if err := seriesRepo.EnqueueSeriesRoot(ctx, seriesFolderID, seriesRoot); err != nil { + t.Fatalf("EnqueueSeriesRoot(): %v", err) + } + if _, err := pool.Exec(ctx, ` + UPDATE series_root_match_queue + SET state = 'parked', available_at = NOW() + interval '24 hours', parked_at = NOW(), + matcher_revision = $3 + WHERE media_folder_id = $1 AND observed_root_path = $2 + `, seriesFolderID, seriesRoot, previousMatcherRevision); err != nil { + t.Fatalf("seed pre-change series revision: %v", err) + } + + if _, err := movieRepo.WakeForChangedInputs(ctx); err != nil { + t.Fatalf("movie WakeForChangedInputs(): %v", err) + } + var movieState string + var movieRevision int + if err := pool.QueryRow(ctx, ` + SELECT state, matcher_revision FROM movie_match_queue WHERE media_file_id = $1 + `, movieFileID).Scan(&movieState, &movieRevision); err != nil { + t.Fatalf("load movie queue row: %v", err) + } + if movieState != queueStatePending || movieRevision != movieMatcherRevision { + t.Fatalf("movie row was not awakened: state=%q revision=%d", movieState, movieRevision) + } + + if _, err := seriesRepo.WakeForChangedInputs(ctx); err != nil { + t.Fatalf("series WakeForChangedInputs(): %v", err) + } + var seriesState string + var seriesRevision int + if err := pool.QueryRow(ctx, ` + SELECT state, matcher_revision FROM series_root_match_queue + WHERE media_folder_id = $1 AND observed_root_path = $2 + `, seriesFolderID, seriesRoot).Scan(&seriesState, &seriesRevision); err != nil { + t.Fatalf("load series queue row: %v", err) + } + if seriesState != queueStatePending || seriesRevision != seriesMatcherRevision { + t.Fatalf("series row was not awakened: state=%q revision=%d", seriesState, seriesRevision) + } } func TestSeriesMatchQueueDeterministicFailuresParkAndRetryNowResets(t *testing.T) { @@ -249,7 +335,7 @@ func TestSeriesMatchQueueWakeForChangedInputsResetsOnlyChangedRows(t *testing.T) `, folderID, root).Scan(&state, &failureKind, &lastError, &deterministicCount, &fingerprint, &revision, &availableAt, &parkedAt, &leaseToken, &rerunRequested); err != nil { t.Fatalf("load woken row: %v", err) } - if state != queueStatePending || failureKind != "" || lastError != "" || deterministicCount != 0 || fingerprint == "" || fingerprint == "old-fingerprint" || revision != matcherRevision || parkedAt != nil { + if state != queueStatePending || failureKind != "" || lastError != "" || deterministicCount != 0 || fingerprint == "" || fingerprint == "old-fingerprint" || revision != seriesMatcherRevision || parkedAt != nil { t.Fatalf("woken row = state:%q failure:%q last:%q deterministic:%d fingerprint:%q revision:%d available:%v parked:%v", state, failureKind, lastError, deterministicCount, fingerprint, revision, availableAt, parkedAt) } if leaseToken != staleLeaseToken || !rerunRequested || availableAt.Before(time.Now().Add(time.Hour)) { diff --git a/internal/metadata/match_title_normalization_test.go b/internal/metadata/match_title_normalization_test.go new file mode 100644 index 00000000..71ef66df --- /dev/null +++ b/internal/metadata/match_title_normalization_test.go @@ -0,0 +1,81 @@ +package metadata + +import "testing" + +const normalizedSomeShowTitle = "Some Show" + +func TestNormalizeCandidateTitleForYear_TrailingYearVariants(t *testing.T) { + cases := []struct { + name string + title string + year int + want string + }{ + {"bare year stripped", "Some Show 2026", 2026, normalizedSomeShowTitle}, + {"parenthesised year stripped", "24 stjerners julikalender (DK) (2026)", 2026, "24 stjerners julikalender (DK)"}, + {"bracketed year stripped", "Some Show [2026]", 2026, normalizedSomeShowTitle}, + {"different year kept", "Some Show (1999)", 2026, "Some Show (1999)"}, + {"no year hint keeps title", "Some Show (2026)", 0, "Some Show (2026)"}, + {"single token never stripped", "2026", 2026, "2026"}, + {"mid-title year kept", "2001 A Space Odyssey", 1968, "2001 A Space Odyssey"}, + } + for _, tc := range cases { + if got := normalizeCandidateTitleForYear(tc.title, tc.year); got != tc.want { + t.Errorf("%s: normalizeCandidateTitleForYear(%q, %d) = %q, want %q", + tc.name, tc.title, tc.year, got, tc.want) + } + } +} + +func TestInferTitleSimilarity_FoldsDiacritics(t *testing.T) { + cases := []struct { + name string + left string + right string + year int + want float64 + }{ + {"combining marks", "Ze do Caixao", "Zé do Caixão", 2015, 1}, + {"turkish letters", "Konusanlar", "Konuşanlar", 2020, 1}, + {"capital eszett", "STRASSE", "STRAẞE", 0, 1}, + {"eszett", "Strasse", "Straße", 0, 1}, + {"o slash", "Forbrydelsen Kobenhavn", "Forbrydelsen København", 0, 1}, + {"ae ligature", "Baerbar", "Bærbar", 0, 1}, + {"oe ligature", "Coeur", "Cœur", 0, 1}, + {"unrelated titles stay distinct", "Miniverse", "Universe", 0, 0}, + } + for _, tc := range cases { + if got := inferTitleSimilarity(tc.left, tc.right, tc.year); got != tc.want { + t.Errorf("%s: inferTitleSimilarity(%q, %q, %d) = %v, want %v", + tc.name, tc.left, tc.right, tc.year, got, tc.want) + } + } +} + +func TestInferTitleSimilarity_PreservesKanaVoicing(t *testing.T) { + if got := inferTitleSimilarity("ガキの使い", "カキの使い", 0); got != 0 { + t.Fatalf("kana titles differing by voicing must stay distinct, got %v", got) + } + if got := inferTitleSimilarity("ガキの使い", "カ゛キの使い", 0); got != 1 { + t.Fatalf("spacing dakuten must normalize to the voiced kana, got %v", got) + } + if got := inferTitleSimilarity("カ゛キの使い", "カキの使い", 0); got != 0 { + t.Fatalf("spacing dakuten must remain distinct from unvoiced kana, got %v", got) + } +} + +func TestInferTitleSimilarity_PreservesNonLatinCombiningMarks(t *testing.T) { + // Thai mai ek changes the word from ปา (throw) to ป่า (forest). It is a + // combining mark, but unlike a Latin accent it must remain match-significant. + if got := inferTitleSimilarity("ปา", "ป่า", 0); got != 0 { + t.Fatalf("Thai titles differing by a lexical combining mark must stay distinct, got %v", got) + } +} + +// The dedicated episode-title fold was removed in favor of the shared +// normalizer; episode comparison must keep folding diacritics. +func TestEpisodeTitleSimilarity_StillFoldsDiacritics(t *testing.T) { + if got := episodeTitleSimilarity("Cafe con Leche", "Café con Leche"); got != 1 { + t.Fatalf("episodeTitleSimilarity should fold diacritics, got %v", got) + } +} diff --git a/internal/metadata/movie_match_queue_repo.go b/internal/metadata/movie_match_queue_repo.go index 17f49a70..b06bbdc3 100644 --- a/internal/metadata/movie_match_queue_repo.go +++ b/internal/metadata/movie_match_queue_repo.go @@ -97,8 +97,8 @@ func (r *MovieMatchQueueRepository) EnqueueMovieFile(ctx context.Context, fileID SELECT mf.id, mf.media_folder_id, - `+matchQueueInputFingerprintSQL("mf.file_path", "'movie'", "mf.media_folder_id", "folders.metadata_language")+`, - `+fmt.Sprintf("%d", matcherRevision)+`, + `+matchQueueInputFingerprintSQL("mf.file_path", "'movie'", "mf.media_folder_id", "folders.metadata_language", movieMatcherRevision)+`, + `+fmt.Sprintf("%d", movieMatcherRevision)+`, NOW(), NOW() FROM media_files mf @@ -174,8 +174,8 @@ func (r *MovieMatchQueueRepository) SyncForFolder(ctx context.Context, folderID SELECT mf.id, mf.media_folder_id, - `+matchQueueInputFingerprintSQL("mf.file_path", "'movie'", "mf.media_folder_id", "folders.metadata_language")+`, - `+fmt.Sprintf("%d", matcherRevision)+`, + `+matchQueueInputFingerprintSQL("mf.file_path", "'movie'", "mf.media_folder_id", "folders.metadata_language", movieMatcherRevision)+`, + `+fmt.Sprintf("%d", movieMatcherRevision)+`, NOW(), NOW() FROM media_files mf @@ -256,8 +256,8 @@ func (r *MovieMatchQueueRepository) SyncInScope(ctx context.Context, folderID in SELECT mf.id, mf.media_folder_id, - `+matchQueueInputFingerprintSQL("mf.file_path", "'movie'", "mf.media_folder_id", "folders.metadata_language")+`, - `+fmt.Sprintf("%d", matcherRevision)+`, + `+matchQueueInputFingerprintSQL("mf.file_path", "'movie'", "mf.media_folder_id", "folders.metadata_language", movieMatcherRevision)+`, + `+fmt.Sprintf("%d", movieMatcherRevision)+`, NOW(), NOW() FROM media_files mf @@ -588,7 +588,7 @@ func (r *MovieMatchQueueRepository) RetryNowByFolder(ctx context.Context, folder tag, err := r.pool.Exec(ctx, ` WITH current_inputs AS ( SELECT q.media_file_id, mf.media_folder_id, - `+matchQueueInputFingerprintSQL("mf.file_path", "'movie'", "mf.media_folder_id", "folders.metadata_language")+` AS input_fingerprint + `+matchQueueInputFingerprintSQL("mf.file_path", "'movie'", "mf.media_folder_id", "folders.metadata_language", movieMatcherRevision)+` AS input_fingerprint FROM movie_match_queue q JOIN media_files mf ON mf.id = q.media_file_id JOIN media_folders folders ON folders.id = mf.media_folder_id @@ -601,7 +601,7 @@ func (r *MovieMatchQueueRepository) RetryNowByFolder(ctx context.Context, folder failure_kind = '', failure_detail = '{}'::jsonb, last_error = '', parked_at = NULL, rerun_requested = true, media_folder_id = current_inputs.media_folder_id, - input_fingerprint = current_inputs.input_fingerprint, matcher_revision = `+fmt.Sprintf("%d", matcherRevision)+`, updated_at = NOW() + input_fingerprint = current_inputs.input_fingerprint, matcher_revision = `+fmt.Sprintf("%d", movieMatcherRevision)+`, updated_at = NOW() FROM current_inputs WHERE movie_match_queue.media_file_id = current_inputs.media_file_id `, folderID) @@ -618,7 +618,7 @@ func (r *MovieMatchQueueRepository) WakeForChangedInputs(ctx context.Context) (i tag, err := r.pool.Exec(ctx, ` WITH changed AS ( SELECT q.media_file_id, mf.media_folder_id, - `+matchQueueInputFingerprintSQL("mf.file_path", "'movie'", "mf.media_folder_id", "folders.metadata_language")+` AS input_fingerprint + `+matchQueueInputFingerprintSQL("mf.file_path", "'movie'", "mf.media_folder_id", "folders.metadata_language", movieMatcherRevision)+` AS input_fingerprint FROM movie_match_queue q JOIN media_files mf ON mf.id = q.media_file_id JOIN media_folders folders ON folders.id = mf.media_folder_id @@ -630,10 +630,10 @@ func (r *MovieMatchQueueRepository) WakeForChangedInputs(ctx context.Context) (i failure_kind = '', failure_detail = '{}'::jsonb, last_error = '', parked_at = NULL, rerun_requested = true, media_folder_id = changed.media_folder_id, - input_fingerprint = changed.input_fingerprint, matcher_revision = `+fmt.Sprintf("%d", matcherRevision)+`, updated_at = NOW() + input_fingerprint = changed.input_fingerprint, matcher_revision = `+fmt.Sprintf("%d", movieMatcherRevision)+`, updated_at = NOW() FROM changed WHERE changed.media_file_id = q.media_file_id - AND (q.input_fingerprint <> changed.input_fingerprint OR q.matcher_revision <> `+fmt.Sprintf("%d", matcherRevision)+` OR q.media_folder_id <> changed.media_folder_id) + AND (q.input_fingerprint <> changed.input_fingerprint OR q.matcher_revision <> `+fmt.Sprintf("%d", movieMatcherRevision)+` OR q.media_folder_id <> changed.media_folder_id) `) if err != nil { return 0, fmt.Errorf("waking movie matches with changed inputs: %w", err) diff --git a/internal/metadata/plugin_provider_test.go b/internal/metadata/plugin_provider_test.go index 0e0c98a7..051dbbd3 100644 --- a/internal/metadata/plugin_provider_test.go +++ b/internal/metadata/plugin_provider_test.go @@ -11,20 +11,28 @@ import ( pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" ) +const ( + pluginInstallationIDSetting = "plugin_installation_id" + capabilityIDSetting = "capability_id" +) + type fakePluginMetadataClient struct { + searchResponse *pluginv1.SearchMetadataResponse response *pluginv1.GetMetadataResponse imagesResponse *pluginv1.GetImagesResponse seasonsResp *pluginv1.GetSeasonsResponse episodesResp *pluginv1.GetEpisodesResponse + searchReq *pluginv1.SearchMetadataRequest getMetadataReq *pluginv1.GetMetadataRequest getImagesReq *pluginv1.GetImagesRequest getSeasonsReq *pluginv1.GetSeasonsRequest getEpisodesReq *pluginv1.GetEpisodesRequest } -func (f *fakePluginMetadataClient) Search(context.Context, *pluginv1.SearchMetadataRequest) (*pluginv1.SearchMetadataResponse, error) { - return nil, nil +func (f *fakePluginMetadataClient) Search(_ context.Context, req *pluginv1.SearchMetadataRequest) (*pluginv1.SearchMetadataResponse, error) { + f.searchReq = req + return f.searchResponse, nil } func (f *fakePluginMetadataClient) GetMetadata(_ context.Context, req *pluginv1.GetMetadataRequest) (*pluginv1.GetMetadataResponse, error) { @@ -70,6 +78,75 @@ func setPluginMetadataItemReleaseDate(t *testing.T, item *pluginv1.MetadataItem, item.ProtoReflect().Set(field, protoreflect.ValueOfString(value)) } +//nolint:goconst // Keep the production-shaped protobuf fixture readable in place. +func TestPluginProviderSearch_MapsLocalizedTitlesAndCrossReferences(t *testing.T) { + const localizedJapanesePrimaryTitle = "十兵衛ちゃん -ラブリー眼帯の秘密-" + client := &fakePluginMetadataClient{ + searchResponse: &pluginv1.SearchMetadataResponse{Results: []*pluginv1.ProviderSearchResult{ + { + ProviderId: localizedTVDBID, + ItemType: anchoredItemTypeSeries, + Title: localizedJapanesePrimaryTitle, + OriginalTitle: localizedOriginalTitle, + Year: 1999, + TitleLanguage: "ja", + TitleIsFallback: true, + OriginalLanguage: "ja", + TitleAliases: []*pluginv1.TitleAlias{ + {Title: localizedEnglishTitle, Language: "en", Kind: testAlternateAliasKind}, + }, + ProviderIds: mustStructFromStringMap(t, map[string]string{ + testTMDBProvider: localizedTMDBID, + testIMDBProvider: "tt0213338", + }), + }, + }}, + } + + provider, err := NewPluginProviderWithClientFactory(map[string]string{ + pluginInstallationIDSetting: "1", + capabilityIDSetting: testTVDBProvider, + }, func(context.Context, int, string) (pluginMetadataClient, error) { + return client, nil + }) + if err != nil { + t.Fatalf("NewPluginProviderWithClientFactory() error = %v", err) + } + + results, err := provider.Search(context.Background(), SearchQuery{ + Title: localizedEnglishTitle, + ContentType: anchoredItemTypeSeries, + Language: "en", + }) + if err != nil { + t.Fatalf("Search() error = %v", err) + } + if client.searchReq == nil || client.searchReq.GetQuery() != localizedEnglishTitle || client.searchReq.GetItemType() != anchoredItemTypeSeries || client.searchReq.GetLanguage() != "en" { + t.Fatalf("captured search request = %+v", client.searchReq) + } + if len(results) != 1 { + t.Fatalf("Search() returned %d results, want 1", len(results)) + } + got := results[0] + if got.Name != localizedJapanesePrimaryTitle || got.OriginalTitle != localizedOriginalTitle || got.Year != 1999 { + t.Fatalf("localized identity fields = %+v", got) + } + if got.TitleLanguage != "ja" || !got.TitleIsFallback || got.OriginalLanguage != "ja" { + t.Fatalf("language/fallback fields = language:%q fallback:%v original:%q", got.TitleLanguage, got.TitleIsFallback, got.OriginalLanguage) + } + wantAliases := []TitleAlias{{Title: localizedEnglishTitle, Language: "en", Kind: testAlternateAliasKind, Provider: testTVDBProvider}} + if !reflect.DeepEqual(got.TitleAliases, wantAliases) { + t.Fatalf("TitleAliases = %#v, want %#v", got.TitleAliases, wantAliases) + } + if !reflect.DeepEqual(got.ProviderIDs, map[string]string{ + testTVDBProvider: localizedTVDBID, + testTMDBProvider: localizedTMDBID, + testIMDBProvider: "tt0213338", + }) { + t.Fatalf("ProviderIDs = %#v", got.ProviderIDs) + } +} + func TestPluginProviderGetMetadata_MapsReleaseDate(t *testing.T) { item := &pluginv1.MetadataItem{ ProviderId: "provider-1", diff --git a/internal/metadata/series_root_match_queue_repo.go b/internal/metadata/series_root_match_queue_repo.go index b9f8f89e..346048ab 100644 --- a/internal/metadata/series_root_match_queue_repo.go +++ b/internal/metadata/series_root_match_queue_repo.go @@ -107,7 +107,7 @@ func (r *SeriesRootMatchQueueRepository) EnqueueSeriesRoot(ctx context.Context, roots.media_folder_id, roots.observed_root_path, `+seriesMatchQueueInputFingerprintSQL("roots.observed_root_path", "roots.media_folder_id", "roots.metadata_language")+`, - `+fmt.Sprintf("%d", matcherRevision)+`, + `+fmt.Sprintf("%d", seriesMatcherRevision)+`, NOW() + $3::interval, NOW() FROM eligible_roots roots @@ -214,7 +214,7 @@ func (r *SeriesRootMatchQueueRepository) SyncForFolder(ctx context.Context, fold roots.media_folder_id, roots.observed_root_path, `+seriesMatchQueueInputFingerprintSQL("roots.observed_root_path", "roots.media_folder_id", "roots.metadata_language")+`, - `+fmt.Sprintf("%d", matcherRevision)+`, + `+fmt.Sprintf("%d", seriesMatcherRevision)+`, NOW() + $2::interval, NOW() FROM eligible_roots roots @@ -327,7 +327,7 @@ func (r *SeriesRootMatchQueueRepository) SyncInScope(ctx context.Context, folder ) SELECT roots.media_folder_id, roots.observed_root_path, `+seriesMatchQueueInputFingerprintSQL("roots.observed_root_path", "roots.media_folder_id", "roots.metadata_language")+`, - `+fmt.Sprintf("%d", matcherRevision)+`, NOW() + $4::interval, NOW() + `+fmt.Sprintf("%d", seriesMatcherRevision)+`, NOW() + $4::interval, NOW() FROM in_scope_roots roots ON CONFLICT (media_folder_id, observed_root_path) DO UPDATE SET @@ -745,7 +745,7 @@ func (r *SeriesRootMatchQueueRepository) RetryNowByFolder(ctx context.Context, f deterministic_attempt_count = 0, failure_kind = '', failure_detail = '{}'::jsonb, last_error = '', parked_at = NULL, rerun_requested = true, - input_fingerprint = current_inputs.input_fingerprint, matcher_revision = `+fmt.Sprintf("%d", matcherRevision)+`, updated_at = NOW() + input_fingerprint = current_inputs.input_fingerprint, matcher_revision = `+fmt.Sprintf("%d", seriesMatcherRevision)+`, updated_at = NOW() FROM current_inputs WHERE series_root_match_queue.media_folder_id = current_inputs.media_folder_id AND series_root_match_queue.observed_root_path = current_inputs.observed_root_path @@ -773,11 +773,11 @@ func (r *SeriesRootMatchQueueRepository) WakeForChangedInputs(ctx context.Contex deterministic_attempt_count = 0, failure_kind = '', failure_detail = '{}'::jsonb, last_error = '', parked_at = NULL, rerun_requested = true, - input_fingerprint = changed.input_fingerprint, matcher_revision = `+fmt.Sprintf("%d", matcherRevision)+`, updated_at = NOW() + input_fingerprint = changed.input_fingerprint, matcher_revision = `+fmt.Sprintf("%d", seriesMatcherRevision)+`, updated_at = NOW() FROM changed WHERE changed.media_folder_id = q.media_folder_id AND changed.observed_root_path = q.observed_root_path - AND (q.input_fingerprint <> changed.input_fingerprint OR q.matcher_revision <> `+fmt.Sprintf("%d", matcherRevision)+`) + AND (q.input_fingerprint <> changed.input_fingerprint OR q.matcher_revision <> `+fmt.Sprintf("%d", seriesMatcherRevision)+`) `) if err != nil { return 0, fmt.Errorf("waking series matches with changed inputs: %w", err)