Merge branch 'main' of https://github.com/Silo-Server/silo-server
This commit is contained in:
@@ -2,6 +2,8 @@ package metadata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -342,7 +344,117 @@ type scoredMatchCandidate struct {
|
||||
score float64
|
||||
}
|
||||
|
||||
func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) (*MatchCandidate, bool) {
|
||||
// candidatesAreSingleDistinctShow reports whether every scored candidate refers
|
||||
// to the same show as best — same year, an exact normalized title match, and no
|
||||
// conflicting provider IDs. This is true when the search effectively returned
|
||||
// one distinct title, possibly as separate per-source rows (e.g. a TVDB row and
|
||||
// a TMDB row that weren't merged because each carries only its own provider's
|
||||
// ID). Candidates that share a canonical provider key but carry different values
|
||||
// are considered distinct shows and cause the function to return false.
|
||||
// Candidates with Year == 0 are treated as year-mismatched (a provider that
|
||||
// omitted the year yields false here) — conservative by design.
|
||||
func candidatesAreSingleDistinctShow(best MatchCandidate, scored []scoredMatchCandidate) bool {
|
||||
// Year==0 means the provider didn't supply a release year, so we cannot
|
||||
// claim the candidates refer to the *same* show via year-equality. Without
|
||||
// this guard, two no-year candidates from different providers would satisfy
|
||||
// the multi-source corroboration arm of the lone-result rule and get
|
||||
// auto-accepted, which over-accepts ambiguous matches.
|
||||
if best.Year == 0 {
|
||||
return false
|
||||
}
|
||||
// Track the first non-empty value seen per canonical provider key across
|
||||
// best AND every scored candidate. If any key ends up with more than one
|
||||
// distinct value, the tie group spans multiple shows — including the case
|
||||
// where `best` lacks a key but two non-best candidates carry conflicting
|
||||
// values for it.
|
||||
seenIDs := make(map[string]string, len(canonicalCandidateIDKeys))
|
||||
for _, key := range canonicalCandidateIDKeys {
|
||||
if v := strings.TrimSpace(best.ProviderIDs[key]); v != "" {
|
||||
seenIDs[key] = v
|
||||
}
|
||||
}
|
||||
for _, c := range scored {
|
||||
if c.candidate.Year == 0 || c.candidate.Year != best.Year {
|
||||
return false
|
||||
}
|
||||
if inferTitleSimilarity(best.Title, c.candidate.Title, best.Year) != 1 {
|
||||
return false
|
||||
}
|
||||
for _, key := range canonicalCandidateIDKeys {
|
||||
cv := strings.TrimSpace(c.candidate.ProviderIDs[key])
|
||||
if cv == "" {
|
||||
continue
|
||||
}
|
||||
if existing, ok := seenIDs[key]; ok {
|
||||
if existing != cv {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
seenIDs[key] = cv
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// topTieGroup returns the highest-scored candidate plus every candidate within
|
||||
// the 15-point tie window of it — i.e. the set of candidates that are not
|
||||
// clearly beaten. Assumes scored is sorted descending by score.
|
||||
func topTieGroup(scored []scoredMatchCandidate) []scoredMatchCandidate {
|
||||
if len(scored) == 0 {
|
||||
return nil
|
||||
}
|
||||
group := []scoredMatchCandidate{scored[0]}
|
||||
for _, c := range scored[1:] {
|
||||
if scored[0].score-c.score < 15 {
|
||||
group = append(group, c)
|
||||
}
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
// pickByProviderPriority returns the group candidate whose Sources include the
|
||||
// highest-priority provider (providerPriority is ordered highest-first, e.g. the
|
||||
// library's chain order). Falls back to the top-scored candidate when there is no
|
||||
// priority info or no source matches.
|
||||
func pickByProviderPriority(group []scoredMatchCandidate, providerPriority []string) *MatchCandidate {
|
||||
for _, prov := range providerPriority {
|
||||
for i := range group {
|
||||
for _, s := range group[i].candidate.Sources {
|
||||
if strings.EqualFold(s, prov) {
|
||||
return &group[i].candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return &group[0].candidate
|
||||
}
|
||||
|
||||
// distinctSourceCount returns how many distinct providers (case-insensitive
|
||||
// Sources values) appear across the candidate group — i.e. how many independent
|
||||
// providers returned this show.
|
||||
func distinctSourceCount(group []scoredMatchCandidate) int {
|
||||
seen := make(map[string]struct{})
|
||||
for _, c := range group {
|
||||
for _, s := range c.candidate.Sources {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
if s != "" {
|
||||
seen[s] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
// absYearDelta returns the absolute difference between two release years.
|
||||
func absYearDelta(a, b int) int {
|
||||
if a > b {
|
||||
return a - b
|
||||
}
|
||||
return b - a
|
||||
}
|
||||
|
||||
func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate, providerPriority []string) (*MatchCandidate, bool) {
|
||||
if len(candidates) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
@@ -358,6 +470,32 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate)
|
||||
return scoredCandidates[i].score > scoredCandidates[j].score
|
||||
})
|
||||
|
||||
if slog.Default().Enabled(context.Background(), slog.LevelDebug) {
|
||||
// hints can be nil on some callers (scoreMatchCandidate tolerates it).
|
||||
// Guard the debug log so DEBUG-enabled runs don't panic on a nil deref.
|
||||
hintTitle, hintType := "", ""
|
||||
hintYear := 0
|
||||
if hints != nil {
|
||||
hintTitle = hints.Title
|
||||
hintYear = hints.Year
|
||||
hintType = hints.Type
|
||||
}
|
||||
for rank, sc := range scoredCandidates {
|
||||
slog.Debug("match candidate scoring",
|
||||
"hint_title", hintTitle,
|
||||
"hint_year", hintYear,
|
||||
"hint_type", hintType,
|
||||
"rank", rank,
|
||||
"candidate_title", sc.candidate.Title,
|
||||
"candidate_year", sc.candidate.Year,
|
||||
"candidate_type", sc.candidate.ContentType,
|
||||
"sources", strings.Join(sc.candidate.Sources, ","),
|
||||
"provider_ids", fmt.Sprintf("%v", sc.candidate.ProviderIDs),
|
||||
"score", sc.score,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
best := scoredCandidates[0]
|
||||
if trustedHintIDsPresent(hints) {
|
||||
if candidateMatchesTrustedIDs(hints, best.candidate) {
|
||||
@@ -369,6 +507,47 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate)
|
||||
if best.score < 55 {
|
||||
return nil, false
|
||||
}
|
||||
// A search that resolves to a single distinct show (one candidate, or the
|
||||
// same title+year returned once per source) whose year matches the parsed
|
||||
// year is high-confidence even when the fuzzy title score sits in the 55-69
|
||||
// band (short/numeric/alternate titles). Accept the top-ranked candidate
|
||||
// without lowering the score thresholds.
|
||||
// We check only the TOP tie-group (candidates within 15 pts of best) so that
|
||||
// low-score noise from unrelated shows below the group does not veto a clear
|
||||
// cross-source agreement. When the top group is one distinct show, pick the
|
||||
// winner by the library's metadata-provider priority (falls back to top-scored).
|
||||
// Residual risk: two different shows with an identical title+year and no
|
||||
// provider IDs would both pass; accepted as low-risk given the title+year+type
|
||||
// corroboration.
|
||||
if candidateTypeMatchesHint(hints.Type, best.candidate.ContentType) {
|
||||
topGroup := topTieGroup(scoredCandidates)
|
||||
if candidatesAreSingleDistinctShow(best.candidate, topGroup) {
|
||||
yearCorroborated := hints.Year != 0 && best.candidate.Year == hints.Year
|
||||
// Cross-source agreement (the same title+year returned by 2+ distinct
|
||||
// providers, which candidatesAreSingleDistinctShow already verified) is
|
||||
// strong independent corroboration — it stands in for a missing hint year
|
||||
// (folders without a "(YYYY)"). A lone single-source no-year result is NOT
|
||||
// accepted here and stays subject to the single-candidate >=70 gate.
|
||||
multiSourceCorroborated := distinctSourceCount(topGroup) >= 2
|
||||
// An exact normalized-title match on a sole distinct show is strong
|
||||
// corroboration on its own, even when the folder year is off by a year
|
||||
// or two (festival vs wide-release date, regional release) — e.g.
|
||||
// "Dead Reckoning (1947)" vs TMDB's 1946, "17 Blocks (2021)" vs 2019.
|
||||
// Bounded to ±2 years so same-title remakes decades apart still require
|
||||
// a year or multi-source match. Uses the same normalizer as title scoring
|
||||
// so "exact" here means a perfect title-similarity component.
|
||||
titleCorroborated := hints.Year != 0 && best.candidate.Year != 0 &&
|
||||
absYearDelta(best.candidate.Year, hints.Year) <= 2 &&
|
||||
normalizeTitleForScoring(best.candidate.Title) == normalizeTitleForScoring(hints.Title)
|
||||
// Year or source-count corroboration is only meaningful when the winning
|
||||
// candidate is at least title-coherent with the scanner hint. Otherwise a
|
||||
// high source/provider score can auto-accept an unrelated same-year result.
|
||||
hintTitleCoherent := inferTitleSimilarity(hints.Title, best.candidate.Title, hints.Year) > 0
|
||||
if (hintTitleCoherent && (yearCorroborated || multiSourceCorroborated)) || titleCorroborated {
|
||||
return pickByProviderPriority(topGroup, providerPriority), true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(scoredCandidates) == 1 {
|
||||
if best.score < 70 {
|
||||
return nil, false
|
||||
@@ -459,7 +638,7 @@ func selectRefreshMatchCandidate(existing *models.MediaItem, candidates []MatchC
|
||||
TvdbID: existing.TvdbID,
|
||||
ImdbID: existing.ImdbID,
|
||||
}
|
||||
return selectInitialMatchCandidate(hints, candidates)
|
||||
return selectInitialMatchCandidate(hints, candidates, nil)
|
||||
}
|
||||
|
||||
func trustedHintIDsPresent(hints *MatchHints) bool {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSelectInitialMatchCandidate_LoneResultYearMatchBelow70(t *testing.T) {
|
||||
// Exact title, matching year, NO sources, NO provider IDs => score 45+20 = 65 (<70).
|
||||
// Old behavior rejected this (single candidate <70); the new rule accepts it.
|
||||
hints := &MatchHints{Title: "1201", Year: 1993, Type: "movie"}
|
||||
cands := []MatchCandidate{{Title: "1201", Year: 1993, ContentType: "movie"}}
|
||||
got, ok := selectInitialMatchCandidate(hints, cands, nil)
|
||||
if !ok || got == nil || got.Title != "1201" {
|
||||
t.Fatalf("expected lone year-matching result to be accepted, got ok=%v cand=%+v", ok, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_SameShowAcrossTwoSources(t *testing.T) {
|
||||
// Same title+year returned once per source (TVDB-only and TMDB-only, no shared ID
|
||||
// so they were NOT merged). Old behavior: tie-break bails -> nil. New: accept best.
|
||||
hints := &MatchHints{Title: "Blue Lock", Year: 2022, Type: "series"}
|
||||
cands := []MatchCandidate{
|
||||
{Title: "Blue Lock", Year: 2022, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "404404"}},
|
||||
{Title: "Blue Lock", Year: 2022, ContentType: "series", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "120089"}},
|
||||
}
|
||||
got, ok := selectInitialMatchCandidate(hints, cands, nil)
|
||||
if !ok || got == nil {
|
||||
t.Fatalf("expected same-show-across-sources to be accepted, got ok=%v cand=%+v", ok, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_LoneResultYearMismatchStillRejected(t *testing.T) {
|
||||
// Exact title but year mismatch, one source => score 45+12 = 57 (in [55,70), no year bonus).
|
||||
// Year does NOT corroborate, so the new rule must NOT fire; single candidate <70 => reject.
|
||||
hints := &MatchHints{Title: "1201", Year: 1993, Type: "movie"}
|
||||
cands := []MatchCandidate{{Title: "1201", Year: 1990, ContentType: "movie", Sources: []string{"tmdb"}}}
|
||||
if got, ok := selectInitialMatchCandidate(hints, cands, nil); ok {
|
||||
t.Fatalf("expected year-mismatch lone result to be rejected, got cand=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_CorroborationRequiresHintTitleCoherence(t *testing.T) {
|
||||
// Same-year, multi-source candidate scores above the 55 floor via provider
|
||||
// evidence, but the title is not coherent with the scanner hint. The lone
|
||||
// result rule must not rescue it just because the year/source evidence is
|
||||
// strong enough to reach the corroboration branch.
|
||||
hints := &MatchHints{Title: "Hotel Transylvania Puppy!", Year: 2017, Type: "movie"}
|
||||
cands := []MatchCandidate{
|
||||
{
|
||||
Title: "Puppy!",
|
||||
Year: 2017,
|
||||
ContentType: "movie",
|
||||
Sources: []string{"tmdb", "tvdb", "imdb"},
|
||||
ProviderIDs: map[string]string{"tmdb": "222"},
|
||||
},
|
||||
}
|
||||
if got, ok := selectInitialMatchCandidate(hints, cands, []string{"tmdb", "tvdb", "imdb"}); ok {
|
||||
t.Fatalf("unrelated same-year candidate must not be auto-accepted, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_TwoDifferentShowsUnchanged(t *testing.T) {
|
||||
// Two genuinely different shows that BOTH score >=55 (so the new rule IS evaluated,
|
||||
// not short-circuited by the <55 floor): candidatesAreSingleDistinctShow must return
|
||||
// false (titles differ), so the new rule does NOT fire and behavior falls through to
|
||||
// the existing tie-break (which returns nil here because DetailScore is 0). Guards
|
||||
// against over-accepting distinct results.
|
||||
//
|
||||
// Each candidate shares 7 tokens with the hint plus one distinct trailing word, so
|
||||
// each is coherent with the hint (Jaccard 7/8 = 0.875 >= 0.85 => sim 0.8 => +28) and
|
||||
// scores 28 + 20(year) + 24(2 sources) + 5 + 1(1 id) = 78 (>=55, reaches the guard).
|
||||
// The two candidates differ from each other (Jaccard 7/9 = 0.78 < 0.85 => sim 0), so
|
||||
// candidatesAreSingleDistinctShow returns false. Equal scores => gap 0 < 15 => tie-break.
|
||||
hints := &MatchHints{Title: "The Real History of the World War", Year: 2010, Type: "series"}
|
||||
cands := []MatchCandidate{
|
||||
{Title: "The Real History of the World War Europe", Year: 2010, ContentType: "series", Sources: []string{"tvdb", "tmdb"}, ProviderIDs: map[string]string{"tvdb": "1"}},
|
||||
{Title: "The Real History of the World War Pacific", Year: 2010, ContentType: "series", Sources: []string{"tvdb", "tmdb"}, ProviderIDs: map[string]string{"tvdb": "2"}},
|
||||
}
|
||||
if _, ok := selectInitialMatchCandidate(hints, cands, nil); ok {
|
||||
t.Fatalf("two distinct shows must not be auto-accepted by the lone-result rule")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_ConflictingProviderIDsNotAccepted(t *testing.T) {
|
||||
// Same title+year but different tmdb IDs => two distinct shows; must NOT auto-accept.
|
||||
// Each scores 65 (45 exact title + 20 year) + 5 + 1(richness) = ... actually
|
||||
// 45+20+5+1 = 71 (no sources, one provider ID), well above the 55 floor, so the new
|
||||
// branch is reached. candidatesAreSingleDistinctShow must return false because the two
|
||||
// candidates carry the same canonical provider key (tmdb) with conflicting values.
|
||||
hints := &MatchHints{Title: "Alpha", Year: 2022, Type: "movie"}
|
||||
cands := []MatchCandidate{
|
||||
{Title: "Alpha", Year: 2022, ContentType: "movie", ProviderIDs: map[string]string{"tmdb": "111"}},
|
||||
{Title: "Alpha", Year: 2022, ContentType: "movie", ProviderIDs: map[string]string{"tmdb": "222"}},
|
||||
}
|
||||
if _, ok := selectInitialMatchCandidate(hints, cands, nil); ok {
|
||||
t.Fatal("conflicting tmdb IDs must not be auto-accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_CrossSourceNoHintYearAcceptedByMultiSource(t *testing.T) {
|
||||
// Hint has NO year (0). Both providers return the same show (year 1999), tied.
|
||||
// Multi-source agreement substitutes for the missing hint year.
|
||||
hints := &MatchHints{Title: "100 Deeds for Eddie McDowd", Year: 0, Type: "series"}
|
||||
cands := []MatchCandidate{
|
||||
{Title: "100 Deeds for Eddie McDowd", Year: 1999, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "72450"}},
|
||||
{Title: "100 Deeds for Eddie McDowd", Year: 1999, ContentType: "series", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "6518"}},
|
||||
}
|
||||
got, ok := selectInitialMatchCandidate(hints, cands, []string{"tvdb", "tmdb"})
|
||||
if !ok || got == nil || got.ProviderIDs["tvdb"] != "72450" {
|
||||
t.Fatalf("expected tvdb winner via multi-source corroboration, got ok=%v cand=%+v", ok, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_CrossSourceNoCandidateYearNotAccepted(t *testing.T) {
|
||||
// Both providers return the same title but neither carries a release year.
|
||||
// Year-equality between two 0-years is meaningless, so
|
||||
// candidatesAreSingleDistinctShow must reject and the multi-source
|
||||
// corroboration arm must NOT fire — otherwise two no-year cross-source
|
||||
// results would auto-accept, over-accepting ambiguous matches.
|
||||
hints := &MatchHints{Title: "Untitled Show", Year: 0, Type: "series"}
|
||||
cands := []MatchCandidate{
|
||||
{Title: "Untitled Show", Year: 0, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "111"}},
|
||||
{Title: "Untitled Show", Year: 0, ContentType: "series", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "222"}},
|
||||
}
|
||||
if got, ok := selectInitialMatchCandidate(hints, cands, []string{"tvdb", "tmdb"}); ok {
|
||||
t.Fatalf("no-year cross-source candidates must not be auto-accepted, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_LoneNoYearSingleSourceNotAccepted(t *testing.T) {
|
||||
// Single candidate, no hint year, single source: no year corroboration AND only
|
||||
// one source -> must NOT auto-accept (falls to the single-candidate >=70 gate).
|
||||
hints := &MatchHints{Title: "Some Obscure Show", Year: 0, Type: "series"}
|
||||
cands := []MatchCandidate{
|
||||
{Title: "Some Obscure Show", Year: 1999, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "999"}},
|
||||
}
|
||||
if got, ok := selectInitialMatchCandidate(hints, cands, []string{"tvdb"}); ok {
|
||||
t.Fatalf("lone no-year single-source result must not be auto-accepted, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_CrossSourceTieResolvedByProviderPriority(t *testing.T) {
|
||||
// "100 Days Wild" (2020, series, no shared IDs): TVDB and TMDB each return the correct
|
||||
// show (score 83 each: 45 exact title + 20 year + 12 source + 5 has IDs + 1 richness).
|
||||
// Two noise candidates (unrelated title/year) score 18 — well outside the 15-pt tie
|
||||
// window of 83, so topTieGroup contains only the two correct candidates.
|
||||
// candidatesAreSingleDistinctShow passes (same title/year, no conflicting IDs),
|
||||
// and pickByProviderPriority selects the winner by the library's chain order.
|
||||
hints := &MatchHints{Title: "100 Days Wild", Year: 2020, Type: "series"}
|
||||
cands := []MatchCandidate{
|
||||
{Title: "100 Days Wild", Year: 2020, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "386908"}},
|
||||
{Title: "100 Days Wild", Year: 2020, ContentType: "series", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "109476"}},
|
||||
{Title: "Some Other Show", Year: 2026, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "476741"}},
|
||||
{Title: "Live to 100", Year: 2023, ContentType: "series", Sources: []string{"tvdb"}, ProviderIDs: map[string]string{"tvdb": "437829"}},
|
||||
}
|
||||
|
||||
// tvdb ranked first in provider chain -> tvdb candidate wins
|
||||
got, ok := selectInitialMatchCandidate(hints, cands, []string{"tvdb", "tmdb"})
|
||||
if !ok || got == nil || got.ProviderIDs["tvdb"] != "386908" {
|
||||
t.Fatalf("expected tvdb winner (386908), got ok=%v cand=%+v", ok, got)
|
||||
}
|
||||
|
||||
// tmdb ranked first in provider chain -> tmdb candidate wins
|
||||
got, ok = selectInitialMatchCandidate(hints, cands, []string{"tmdb", "tvdb"})
|
||||
if !ok || got == nil || got.ProviderIDs["tmdb"] != "109476" {
|
||||
t.Fatalf("expected tmdb winner (109476), got ok=%v cand=%+v", ok, got)
|
||||
}
|
||||
|
||||
// nil priority -> still accepts (fallback to top-scored, i.e. first in sorted order)
|
||||
got, ok = selectInitialMatchCandidate(hints, cands, nil)
|
||||
if !ok || got == nil {
|
||||
t.Fatalf("expected nil-priority to still accept a match, got ok=%v cand=%+v", ok, got)
|
||||
}
|
||||
}
|
||||
@@ -25,12 +25,85 @@ func TestSelectInitialMatchCandidate_IgnoresLocalContentIDForTrustedSelection(t
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if !ok || winner == nil {
|
||||
t.Fatal("expected local content_id not to force trusted-ID matching")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_SoleExactTitleYearOffByTwoMatches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Sole distinct candidate, exact title, year off by 2 (e.g. "Stasi FC (2023)"
|
||||
// vs TMDB's 2025). Scores in the 55-69 band — below the single-candidate >=70
|
||||
// gate — but the exact title on a lone result should now match via title
|
||||
// corroboration without lowering any threshold.
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{Title: "Stasi FC", Year: 2023, Type: "movie"},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "Stasi FC",
|
||||
Year: 2025,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "111"},
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if !ok || winner == nil || winner.ProviderIDs["tmdb"] != "111" {
|
||||
t.Fatalf("expected sole exact-title year-off-by-2 candidate to match, got ok=%v winner=%+v", ok, winner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_SoleExactTitleYearOffByThreeRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A 3-year gap exceeds the ±2 bound: a same-title film three years apart is
|
||||
// not corroborated and stays subject to the single-candidate >=70 gate.
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{Title: "Stasi FC", Year: 2023, Type: "movie"},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "Stasi FC",
|
||||
Year: 2026,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "111"},
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatalf("expected year-off-by-3 sole candidate to be rejected, got ok=%v winner=%+v", ok, winner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_SoleDifferentTitleExactYearStillFloored(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// "Hotel Transylvania Puppy!" vs TMDB's "Puppy!" (same year) scores below the
|
||||
// 55 floor on title similarity, so it must stay rejected — title corroboration
|
||||
// must not rescue a low-similarity title just because the year matches.
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{Title: "Hotel Transylvania Puppy!", Year: 2017, Type: "movie"},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "Puppy!",
|
||||
Year: 2017,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "222"},
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatalf("expected low-similarity sole candidate to stay rejected, got ok=%v winner=%+v", ok, winner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuppressTitleYearFallbackForTrustedIDs_IgnoresMetadb(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -377,6 +450,7 @@ func TestSelectInitialMatchCandidate_AcceptsSinglePunctuationEquivalentCandidate
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if !ok || winner == nil {
|
||||
t.Fatalf("expected lone punctuation-equivalent candidate to be accepted")
|
||||
@@ -411,6 +485,7 @@ func TestSelectInitialMatchCandidate_AcceptsProviderTitleWithRepeatedYear(t *tes
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if !ok || winner == nil {
|
||||
t.Fatal("expected provider title with repeated release year to be accepted")
|
||||
@@ -445,6 +520,7 @@ func TestSelectInitialMatchCandidate_UsesDetailScoreForDuplicateProviderTie(t *t
|
||||
DetailScore: 46,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if !ok || winner == nil {
|
||||
t.Fatal("expected richer duplicate TMDB candidate to be accepted")
|
||||
@@ -479,6 +555,7 @@ func TestSelectInitialMatchCandidate_RejectsDuplicateTieWithoutClearDetailGap(t
|
||||
DetailScore: 34,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatal("expected duplicate tie without clear detail gap to remain unmatched")
|
||||
@@ -508,6 +585,7 @@ func TestSelectInitialMatchCandidate_UsesProviderOrderForExactCrossProviderTie(t
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if !ok || winner == nil {
|
||||
t.Fatal("expected exact cross-provider tie to use provider order")
|
||||
@@ -540,6 +618,7 @@ func TestSelectInitialMatchCandidate_ProviderOrderTieRequiresExactTitleYear(t *t
|
||||
Sources: []string{"imdb", "metadb", "tmdb", "xattr"},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatal("expected non-equivalent cross-provider tie to remain unmatched")
|
||||
@@ -571,6 +650,7 @@ func TestSelectInitialMatchCandidate_DetailScoreDoesNotOverrideDifferentTitleTie
|
||||
DetailScore: 80,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatal("expected richer different-title candidate to be rejected")
|
||||
@@ -600,6 +680,7 @@ func TestSelectInitialMatchCandidate_DetailScoreRequiresDatedDuplicateCandidates
|
||||
DetailScore: 46,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatal("expected duplicate detail tie-breaker to reject candidates without matching years")
|
||||
@@ -631,6 +712,7 @@ func TestSelectInitialMatchCandidate_DetailScoreRequiresHintCompatibleType(t *te
|
||||
DetailScore: 46,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatal("expected duplicate detail tie-breaker to reject candidates with hint-incompatible type")
|
||||
@@ -653,6 +735,7 @@ func TestSelectInitialMatchCandidate_RejectsWeakSingleCandidate(t *testing.T) {
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatalf("expected weak lone candidate to be rejected")
|
||||
|
||||
@@ -886,6 +886,12 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques
|
||||
"provider", p.Slug(), "error", err)
|
||||
continue
|
||||
}
|
||||
slog.Debug("metadata: provider search result",
|
||||
"provider", p.Slug(),
|
||||
"query_title", searchQuery.Title,
|
||||
"query_year", searchQuery.Year,
|
||||
"result_count", len(results),
|
||||
)
|
||||
for _, result := range results {
|
||||
if searchResultConflictsWithTrustedIDs(accumulatedIDs, result.ProviderIDs) {
|
||||
slog.Warn("metadata: skipping conflicting search result",
|
||||
@@ -902,7 +908,17 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques
|
||||
}
|
||||
|
||||
candidates := NormalizeCandidates(allResults, contentType)
|
||||
if winner, ok := selectInitialMatchCandidate(req.Hints, candidates); ok && winner != nil {
|
||||
slog.Debug("metadata: search candidates assembled",
|
||||
"query_title", searchQuery.Title,
|
||||
"query_year", searchQuery.Year,
|
||||
"raw_results", len(allResults),
|
||||
"candidates", len(candidates),
|
||||
)
|
||||
providerPriority := make([]string, 0, len(itemChain))
|
||||
for _, p := range itemChain {
|
||||
providerPriority = append(providerPriority, p.Slug())
|
||||
}
|
||||
if winner, ok := selectInitialMatchCandidate(req.Hints, candidates, providerPriority); ok && winner != nil {
|
||||
for k, v := range winner.ProviderIDs {
|
||||
if v != "" {
|
||||
accumulatedIDs[k] = v
|
||||
|
||||
@@ -2,6 +2,7 @@ package metadata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
@@ -837,10 +839,21 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo
|
||||
}
|
||||
}
|
||||
if err := w.service.ensureSeriesEpisodeLinks(ctx, representative.ContentID); err != nil {
|
||||
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil {
|
||||
return 0, updateErr
|
||||
if errors.Is(err, catalog.ErrItemNotFound) {
|
||||
// The series item was concurrently merged into another (provider-ID
|
||||
// dedup moves its seasons+episodes to the survivor, then deletes the
|
||||
// source row). The episodes are already reattached, so there is nothing
|
||||
// to link here — benign; finish normally instead of failing the batch.
|
||||
slog.Info("metadata: series item gone during episode-link ensure (likely concurrent merge); skipping",
|
||||
"content_id", representative.ContentID,
|
||||
"folder_id", job.MediaFolderID,
|
||||
"observed_root_path", job.ObservedRootPath)
|
||||
} else {
|
||||
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil {
|
||||
return 0, updateErr
|
||||
}
|
||||
return 0, fmt.Errorf("ensuring series episode links for %s: %w", representative.ContentID, err)
|
||||
}
|
||||
return 0, fmt.Errorf("ensuring series episode links for %s: %w", representative.ContentID, err)
|
||||
}
|
||||
}
|
||||
if err := w.seriesClaimer.Delete(ctx, job.MediaFolderID, job.ObservedRootPath); err != nil {
|
||||
@@ -933,10 +946,17 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo
|
||||
}
|
||||
if strings.TrimSpace(finalContentID) != "" {
|
||||
if err := w.service.ensureSeriesEpisodeLinks(ctx, finalContentID); err != nil {
|
||||
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil {
|
||||
return 0, updateErr
|
||||
if errors.Is(err, catalog.ErrItemNotFound) {
|
||||
slog.Info("metadata: series item gone during episode-link ensure (likely concurrent merge); skipping",
|
||||
"content_id", finalContentID,
|
||||
"folder_id", job.MediaFolderID,
|
||||
"observed_root_path", job.ObservedRootPath)
|
||||
} else {
|
||||
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil {
|
||||
return 0, updateErr
|
||||
}
|
||||
return 0, fmt.Errorf("ensuring series episode links for %s: %w", finalContentID, err)
|
||||
}
|
||||
return 0, fmt.Errorf("ensuring series episode links for %s: %w", finalContentID, err)
|
||||
}
|
||||
if _, ok := w.service.confirmedOwnershipItem(ctx, finalContentID); ok {
|
||||
w.service.claimConfirmedSeriesRootOwnership(ctx, job.MediaFolderID, job.ObservedRootPath, finalContentID, groupFiles)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
@@ -1200,3 +1201,276 @@ func TestWorkerProcessBatchByFolderAndPathPrefix_MovieQueueClaimsOnlyOncePerScan
|
||||
t.Fatal("expected queue error to be recorded")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests for concurrent-merge ErrItemNotFound tolerance (hotfix 2026-05-27)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestProcessSeriesRoot_Site1_ErrItemNotFoundIsToleratedNotFailed verifies that
|
||||
// when ensureSeriesEpisodeLinks returns catalog.ErrItemNotFound at Site 1
|
||||
// (the "all files already linked" fast path), the batch succeeds instead of
|
||||
// failing. This covers the concurrent-merge case where the source series row
|
||||
// was deleted after its episodes were moved to the survivor.
|
||||
func TestProcessSeriesRoot_Site1_ErrItemNotFoundIsToleratedNotFailed(t *testing.T) {
|
||||
h := newTestHarness()
|
||||
ctx := context.Background()
|
||||
|
||||
// Need a series-type folder so queueUsageForFolder enables the series queue.
|
||||
h.service.folderRepo = &fakeWorkerFolderRepo{
|
||||
folders: map[int]*models.MediaFolder{
|
||||
10: {ID: 10, Type: "series", Enabled: true},
|
||||
},
|
||||
}
|
||||
|
||||
// Pre-populate the item as "matched" so reusableQueuedMovieSkeleton
|
||||
// returns false (matched is not skeleton-like), bypassing the re-process
|
||||
// block and falling through directly to ensureSeriesEpisodeLinks.
|
||||
const contentID = "series-gone-after-merge"
|
||||
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
|
||||
ContentID: contentID,
|
||||
Status: "matched",
|
||||
Title: "Example Show",
|
||||
Type: "series",
|
||||
Studios: []string{},
|
||||
Networks: []string{},
|
||||
Countries: []string{},
|
||||
Genres: []string{},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert item: %v", err)
|
||||
}
|
||||
|
||||
// All files are already linked — hasUnlinkedGroupFile returns false.
|
||||
file := &models.MediaFile{
|
||||
ID: 1,
|
||||
MediaFolderID: 10,
|
||||
FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E01.mkv",
|
||||
ObservedRootPath: "/media/shows/Example Show",
|
||||
GroupKeyVersion: 1,
|
||||
ContentGroupKey: "v1|series|example_show|2024",
|
||||
ContentID: contentID,
|
||||
}
|
||||
h.fileRepo.setGroupFiles(10, 1, "v1|series|example_show|2024", file)
|
||||
h.fileRepo.contentIDs[file.ID] = contentID
|
||||
|
||||
// Hook ensureSeriesEpisodeLinks to simulate the source row being gone.
|
||||
h.service.hooks.ensureSeriesEpisodeLinks = func(_ context.Context, _ string) error {
|
||||
return fmt.Errorf("loading series item: %w", catalog.ErrItemNotFound)
|
||||
}
|
||||
|
||||
queueRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{
|
||||
MediaFolderID: 10,
|
||||
ObservedRootPath: "/media/shows/Example Show",
|
||||
SampleFilePath: file.FilePath,
|
||||
ObservedFileCount: 1,
|
||||
})
|
||||
|
||||
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
|
||||
worker.SetSeriesRootClaimer(queueRepo, true)
|
||||
processed, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/shows/Example Show", time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for ErrItemNotFound (benign concurrent merge), got: %v", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("processed = %d, want 1", processed)
|
||||
}
|
||||
// Queue row must be deleted on normal completion.
|
||||
if _, ok := queueRepo.deleted["10:/media/shows/Example Show"]; !ok {
|
||||
t.Fatal("expected queue row to be deleted on normal completion")
|
||||
}
|
||||
// No error must have been recorded in the queue.
|
||||
if queueRepo.errors["10:/media/shows/Example Show"] != "" {
|
||||
t.Fatalf("expected no queue error, got %q", queueRepo.errors["10:/media/shows/Example Show"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessSeriesRoot_Site1_NonNotFoundErrorStillFails verifies that a
|
||||
// genuine (non-ErrItemNotFound) error from ensureSeriesEpisodeLinks at Site 1
|
||||
// still fails the batch — the fix must not swallow real errors.
|
||||
func TestProcessSeriesRoot_Site1_NonNotFoundErrorStillFails(t *testing.T) {
|
||||
h := newTestHarness()
|
||||
ctx := context.Background()
|
||||
|
||||
h.service.folderRepo = &fakeWorkerFolderRepo{
|
||||
folders: map[int]*models.MediaFolder{
|
||||
10: {ID: 10, Type: "series", Enabled: true},
|
||||
},
|
||||
}
|
||||
|
||||
const contentID = "series-link-failure"
|
||||
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
|
||||
ContentID: contentID,
|
||||
Status: "matched",
|
||||
Title: "Example Show",
|
||||
Type: "series",
|
||||
Studios: []string{},
|
||||
Networks: []string{},
|
||||
Countries: []string{},
|
||||
Genres: []string{},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert item: %v", err)
|
||||
}
|
||||
|
||||
file := &models.MediaFile{
|
||||
ID: 1,
|
||||
MediaFolderID: 10,
|
||||
FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E01.mkv",
|
||||
ObservedRootPath: "/media/shows/Example Show",
|
||||
GroupKeyVersion: 1,
|
||||
ContentGroupKey: "v1|series|example_show|2024",
|
||||
ContentID: contentID,
|
||||
}
|
||||
h.fileRepo.setGroupFiles(10, 1, "v1|series|example_show|2024", file)
|
||||
h.fileRepo.contentIDs[file.ID] = contentID
|
||||
|
||||
// Return a genuine (non-not-found) error.
|
||||
linkErr := errors.New("database connection reset")
|
||||
h.service.hooks.ensureSeriesEpisodeLinks = func(_ context.Context, _ string) error {
|
||||
return linkErr
|
||||
}
|
||||
|
||||
queueRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{
|
||||
MediaFolderID: 10,
|
||||
ObservedRootPath: "/media/shows/Example Show",
|
||||
SampleFilePath: file.FilePath,
|
||||
ObservedFileCount: 1,
|
||||
})
|
||||
|
||||
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
|
||||
worker.SetSeriesRootClaimer(queueRepo, true)
|
||||
_, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/shows/Example Show", time.Time{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for genuine link failure, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ensuring series episode links") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessSeriesRoot_Site2_ErrItemNotFoundIsToleratedNotFailed verifies that
|
||||
// when ensureSeriesEpisodeLinks returns catalog.ErrItemNotFound at Site 2
|
||||
// (the new-skeleton / enrichment path), the batch succeeds.
|
||||
func TestProcessSeriesRoot_Site2_ErrItemNotFoundIsToleratedNotFailed(t *testing.T) {
|
||||
h := newTestHarness()
|
||||
ctx := context.Background()
|
||||
|
||||
h.service.folderRepo = &fakeWorkerFolderRepo{
|
||||
folders: map[int]*models.MediaFolder{
|
||||
10: {ID: 10, Type: "series", Enabled: true},
|
||||
},
|
||||
}
|
||||
|
||||
// File has no ContentID yet → hasUnlinkedGroupFile returns true →
|
||||
// proceeds through createOrFindSkeleton + enrichment path (Site 2).
|
||||
file := &models.MediaFile{
|
||||
ID: 1,
|
||||
MediaFolderID: 10,
|
||||
FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E01.mkv",
|
||||
ObservedRootPath: "/media/shows/Example Show",
|
||||
GroupKeyVersion: 1,
|
||||
ContentGroupKey: "v1|series|example_show|2024",
|
||||
BaseTitle: "Example Show",
|
||||
BaseType: "series",
|
||||
}
|
||||
h.fileRepo.setGroupFiles(10, 1, "v1|series|example_show|2024", file)
|
||||
|
||||
const skeletonID = "skeleton-series-id"
|
||||
|
||||
h.service.hooks.createOrFindSkeleton = func(_ context.Context, _ *models.MediaFile, _ int) (*skeletonResult, error) {
|
||||
return &skeletonResult{
|
||||
ContentID: skeletonID,
|
||||
IsNew: true,
|
||||
ItemStatus: "pending",
|
||||
Type: "series",
|
||||
}, nil
|
||||
}
|
||||
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
|
||||
return &ProcessResult{Updated: true}, nil
|
||||
}
|
||||
// Simulate the concurrent-merge: ensureSeriesEpisodeLinks finds the source gone.
|
||||
h.service.hooks.ensureSeriesEpisodeLinks = func(_ context.Context, _ string) error {
|
||||
return fmt.Errorf("loading series item: %w", catalog.ErrItemNotFound)
|
||||
}
|
||||
|
||||
queueRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{
|
||||
MediaFolderID: 10,
|
||||
ObservedRootPath: "/media/shows/Example Show",
|
||||
SampleFilePath: file.FilePath,
|
||||
ObservedFileCount: 1,
|
||||
})
|
||||
|
||||
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
|
||||
worker.SetSeriesRootClaimer(queueRepo, true)
|
||||
processed, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/shows/Example Show", time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for ErrItemNotFound (benign concurrent merge), got: %v", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("processed = %d, want 1", processed)
|
||||
}
|
||||
if _, ok := queueRepo.deleted["10:/media/shows/Example Show"]; !ok {
|
||||
t.Fatal("expected queue row to be deleted on normal completion")
|
||||
}
|
||||
if queueRepo.errors["10:/media/shows/Example Show"] != "" {
|
||||
t.Fatalf("expected no queue error, got %q", queueRepo.errors["10:/media/shows/Example Show"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessSeriesRoot_Site2_NonNotFoundErrorStillFails verifies that a genuine
|
||||
// error from ensureSeriesEpisodeLinks at Site 2 still fails the batch.
|
||||
func TestProcessSeriesRoot_Site2_NonNotFoundErrorStillFails(t *testing.T) {
|
||||
h := newTestHarness()
|
||||
ctx := context.Background()
|
||||
|
||||
h.service.folderRepo = &fakeWorkerFolderRepo{
|
||||
folders: map[int]*models.MediaFolder{
|
||||
10: {ID: 10, Type: "series", Enabled: true},
|
||||
},
|
||||
}
|
||||
|
||||
file := &models.MediaFile{
|
||||
ID: 1,
|
||||
MediaFolderID: 10,
|
||||
FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E01.mkv",
|
||||
ObservedRootPath: "/media/shows/Example Show",
|
||||
GroupKeyVersion: 1,
|
||||
ContentGroupKey: "v1|series|example_show|2024",
|
||||
BaseTitle: "Example Show",
|
||||
BaseType: "series",
|
||||
}
|
||||
h.fileRepo.setGroupFiles(10, 1, "v1|series|example_show|2024", file)
|
||||
|
||||
const skeletonID = "skeleton-series-id-2"
|
||||
|
||||
h.service.hooks.createOrFindSkeleton = func(_ context.Context, _ *models.MediaFile, _ int) (*skeletonResult, error) {
|
||||
return &skeletonResult{
|
||||
ContentID: skeletonID,
|
||||
IsNew: true,
|
||||
ItemStatus: "pending",
|
||||
Type: "series",
|
||||
}, nil
|
||||
}
|
||||
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
|
||||
return &ProcessResult{Updated: true}, nil
|
||||
}
|
||||
linkErr := errors.New("db timeout")
|
||||
h.service.hooks.ensureSeriesEpisodeLinks = func(_ context.Context, _ string) error {
|
||||
return linkErr
|
||||
}
|
||||
|
||||
queueRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{
|
||||
MediaFolderID: 10,
|
||||
ObservedRootPath: "/media/shows/Example Show",
|
||||
SampleFilePath: file.FilePath,
|
||||
ObservedFileCount: 1,
|
||||
})
|
||||
|
||||
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
|
||||
worker.SetSeriesRootClaimer(queueRepo, true)
|
||||
_, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/shows/Example Show", time.Time{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for genuine link failure at site 2, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ensuring series episode links") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package naming
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// folderIDPattern matches patterns like [tmdbid-27205], {tmdb-27205},
|
||||
@@ -12,15 +13,16 @@ var folderIDPattern = regexp.MustCompile(`[{\[](tmdb|tmdbid|imdb|imdbid|tvdb|tvd
|
||||
var trailingImdbIDPattern = regexp.MustCompile(`(?i)(?:^|\s)(tt\d{7,8})$`)
|
||||
var trailingNumericIDPattern = regexp.MustCompile(`(?:^|\s)(\d+)$`)
|
||||
|
||||
// ParseStructuredFolderIDs extracts only explicit structured provider IDs from
|
||||
// a folder or file name, such as {tmdb-27205} or [imdbid-tt1375666}. It does
|
||||
// bracketedBareImdbPattern matches a bare IMDb id wrapped in brackets without a
|
||||
// provider prefix, e.g. [tt10011226] or {tt0095016} (Plex/Kodi-style tags). A
|
||||
// tt-prefixed number is unambiguously IMDb.
|
||||
var bracketedBareImdbPattern = regexp.MustCompile(`(?i)[{\[](tt\d{7,8})[}\]]`)
|
||||
|
||||
// ParseStructuredFolderIDs extracts only explicit provider IDs from a folder or
|
||||
// file name, such as {tmdb-27205}, [imdbid-tt1375666], or [tt1375666]. It does
|
||||
// not consider trailing bare IDs or folderType-based heuristics.
|
||||
func ParseStructuredFolderIDs(name string) *FolderIDHints {
|
||||
matches := folderIDPattern.FindAllStringSubmatch(name, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
hints := &FolderIDHints{}
|
||||
for _, m := range matches {
|
||||
provider := strings.ToLower(m[1])
|
||||
@@ -36,6 +38,10 @@ func ParseStructuredFolderIDs(name string) *FolderIDHints {
|
||||
}
|
||||
}
|
||||
|
||||
if m := bracketedBareImdbPattern.FindStringSubmatch(name); m != nil && hints.ImdbID == "" {
|
||||
hints.ImdbID = strings.ToLower(m[1])
|
||||
}
|
||||
|
||||
if hints.TmdbID == "" && hints.ImdbID == "" && hints.TvdbID == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -66,12 +72,30 @@ func ParseFolderIDs(folderName string, folderType string) *FolderIDHints {
|
||||
return nil
|
||||
}
|
||||
|
||||
// A bare trailing number is only an ID when appended to a real title. If the
|
||||
// name has no letters (e.g. "86", "22 7"), it's a numeric title, not an ID.
|
||||
// Trade-off: a folder named ONLY a bare provider id (e.g. "81189") with no
|
||||
// title text now returns nil rather than that id — acceptable because
|
||||
// Sonarr/Radarr never produce bare-number folders.
|
||||
if !containsLetter(trimmed) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.EqualFold(strings.TrimSpace(folderType), "series") {
|
||||
return &FolderIDHints{TvdbID: id}
|
||||
}
|
||||
return &FolderIDHints{TmdbID: id}
|
||||
}
|
||||
|
||||
func containsLetter(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func looksLikeYear(value string) bool {
|
||||
return len(value) == 4 && value >= "1800" && value <= "2100"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package naming
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseFolderIDs_NumericTitleIsNotAnID(t *testing.T) {
|
||||
// Numeric-only anime titles must NOT be parsed as a bare trailing tvdb/tmdb id.
|
||||
if got := ParseFolderIDs("86", "series"); got != nil {
|
||||
t.Errorf(`ParseFolderIDs("86","series") = %+v, want nil`, got)
|
||||
}
|
||||
if got := ParseFolderIDs("22 7", "series"); got != nil {
|
||||
t.Errorf(`ParseFolderIDs("22 7","series") = %+v, want nil`, got)
|
||||
}
|
||||
|
||||
// A real bare trailing id with title text must still be parsed.
|
||||
got := ParseFolderIDs("Some Show 81189", "series")
|
||||
if got == nil || got.TvdbID != "81189" {
|
||||
t.Errorf(`ParseFolderIDs("Some Show 81189","series") = %+v, want TvdbID="81189"`, got)
|
||||
}
|
||||
|
||||
// Structured tags must still win regardless of letters.
|
||||
got = ParseFolderIDs("{tmdb-27205}", "movies")
|
||||
if got == nil || got.TmdbID != "27205" {
|
||||
t.Errorf(`ParseFolderIDs("{tmdb-27205}","movies") = %+v, want TmdbID="27205"`, got)
|
||||
}
|
||||
|
||||
// Non-Latin (CJK) title with a trailing real id must still parse —
|
||||
// unicode.IsLetter covers kana/kanji, so this is treated as title + id.
|
||||
got = ParseFolderIDs("進撃の巨人 73743", "series")
|
||||
if got == nil || got.TvdbID != "73743" {
|
||||
t.Errorf(`ParseFolderIDs("進撃の巨人 73743","series") = %+v, want TvdbID="73743"`, got)
|
||||
}
|
||||
|
||||
// Numeric-only title for a movies library is also not an id.
|
||||
if got := ParseFolderIDs("86", "movies"); got != nil {
|
||||
t.Errorf(`ParseFolderIDs("86","movies") = %+v, want nil`, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package naming
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseFolderIDs_BracketedBareImdb(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, folder, wantImdb string
|
||||
}{
|
||||
{"square brackets", "17 Blocks (2021) [tt10011226]", "tt10011226"},
|
||||
{"curly braces", "Some Show {tt1234567}", "tt1234567"},
|
||||
{"8-digit tt", "Movie (2024) [tt12345678]", "tt12345678"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := ParseFolderIDs(c.folder, "movie")
|
||||
if got == nil || got.ImdbID != c.wantImdb {
|
||||
t.Errorf("%s: ParseFolderIDs(%q) = %+v, want ImdbID=%q", c.name, c.folder, got, c.wantImdb)
|
||||
}
|
||||
}
|
||||
// must NOT change existing behavior:
|
||||
if got := ParseFolderIDs("Movie [imdb-tt1375666]", "movie"); got == nil || got.ImdbID != "tt1375666" {
|
||||
t.Errorf("structured imdb regressed: %+v", got)
|
||||
}
|
||||
if got := ParseFolderIDs("Some Movie (2020) [BB]", "movie"); got != nil {
|
||||
t.Errorf("non-tt bracket tag must not parse as id, got %+v", got)
|
||||
}
|
||||
if got := ParseFolderIDs("17 Blocks (2021)", "movie"); got != nil {
|
||||
t.Errorf("no-id folder must return nil, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStructuredFolderIDs_BracketedBareImdb(t *testing.T) {
|
||||
got := ParseStructuredFolderIDs("17 Blocks (2021) [tt10011226]")
|
||||
if got == nil || got.ImdbID != "tt10011226" {
|
||||
t.Fatalf("ParseStructuredFolderIDs bare imdb = %+v, want ImdbID tt10011226", got)
|
||||
}
|
||||
|
||||
got = ParseStructuredFolderIDs("Some Movie {tmdb-12345} [tt7654321]")
|
||||
if got == nil || got.TmdbID != "12345" || got.ImdbID != "tt7654321" {
|
||||
t.Fatalf("ParseStructuredFolderIDs mixed ids = %+v, want tmdb+imdb", got)
|
||||
}
|
||||
|
||||
got = ParseStructuredFolderIDs("Some Movie [imdb-tt1375666] [tt7654321]")
|
||||
if got == nil || got.ImdbID != "tt1375666" {
|
||||
t.Fatalf("structured imdb should win over bare imdb, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package naming
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStripInferProviderTags_HandlesBrokenSonarrTokens(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"A Girl & Her Guard Dog [tvdb-{TvdbId}]", "A Girl & Her Guard Dog"},
|
||||
{"A Raven in the Harem [tvdb-{TvdbId}]", "A Raven in the Harem"},
|
||||
{"A Salad Bowl of Eccentrics {imdb-}", "A Salad Bowl of Eccentrics"},
|
||||
{"Coronation Street {imdb-}", "Coronation Street"},
|
||||
// well-formed tags must still be stripped:
|
||||
{"Some Show [tvdb-81189]", "Some Show"},
|
||||
{"Some Show {tmdb-27205}", "Some Show"},
|
||||
// no tag: unchanged:
|
||||
{"Cowboy Bebop", "Cowboy Bebop"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := stripInferProviderTags(c.in); got != c.want {
|
||||
t.Errorf("stripInferProviderTags(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,10 @@ var (
|
||||
inferSeasonDirRe = regexp.MustCompile(`(?i)^Season\s+(\d{1,4})(?:\s.*)?$`)
|
||||
inferNumericSeasonRe = regexp.MustCompile(`^\d{1,4}$`)
|
||||
inferSpecialsDirRe = regexp.MustCompile(`(?i)^(?:specials?|extras?)$`)
|
||||
inferProviderTagRe = regexp.MustCompile(`\s*[{\[](?:tmdb|tmdbid|imdb|imdbid|tvdb|tvdbid)-[\w]+[}\]]`)
|
||||
// Matches a well-formed tag ([tvdb-81189]), an unsubstituted Sonarr token
|
||||
// ([tvdb-{TvdbId}]), or an empty token ({imdb-}). The id part is either a
|
||||
// {...} placeholder or zero-or-more word chars.
|
||||
inferProviderTagRe = regexp.MustCompile(`\s*[{\[](?:tmdb|tmdbid|imdb|imdbid|tvdb|tvdbid)-(?:\{[^}]*\}|[\w]*)[}\]]`)
|
||||
)
|
||||
|
||||
type RootAssignment struct {
|
||||
|
||||
Reference in New Issue
Block a user