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:
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user