fix(metadata): improve localized series matching (#490)

* fix(metadata): improve localized series matching

* test(metadata): strengthen consensus regressions

* fix(metadata): harden localized matching

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
This commit is contained in:
Suspense
2026-07-28 21:02:51 -04:00
committed by GitHub
co-authored by Quick
parent 4eab955ec1
commit 54cfc48ce3
9 changed files with 751 additions and 41 deletions
+2 -14
View File
@@ -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())
}
+158 -1
View File
@@ -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 ""
@@ -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.
+4 -3
View File
@@ -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 {
+90 -4
View File
@@ -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)) {
@@ -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)
}
}
+11 -11
View File
@@ -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)
+79 -2
View File
@@ -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",
@@ -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)