Merge remote-tracking branch 'origin/main' into feat/audiobooks
# Conflicts: # go.sum
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
@@ -23,6 +24,7 @@ type MatchCandidate struct {
|
||||
Overview string `json:"overview,omitempty"`
|
||||
Sources []string `json:"sources"`
|
||||
AgreementHints []string `json:"agreement_hints"`
|
||||
DetailScore int `json:"-"`
|
||||
}
|
||||
|
||||
var canonicalCandidateIDKeys = []string{"tmdb", "tvdb", "imdb"}
|
||||
@@ -53,6 +55,106 @@ func providerIDRichness(ids map[string]string) int {
|
||||
return score
|
||||
}
|
||||
|
||||
const (
|
||||
minimumDetailTieBreakScore = 20
|
||||
minimumDetailTieBreakGap = 12
|
||||
)
|
||||
|
||||
func duplicateTieBreakWinner(hints *MatchHints, scoredCandidates []scoredMatchCandidate) (*MatchCandidate, bool) {
|
||||
if hints == nil || len(scoredCandidates) < 2 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
best := scoredCandidates[0]
|
||||
contenders := []scoredMatchCandidate{best}
|
||||
for i := 1; i < len(scoredCandidates); i++ {
|
||||
next := scoredCandidates[i]
|
||||
if best.score-next.score >= 15 {
|
||||
break
|
||||
}
|
||||
if duplicateTieBreakComparable(hints, best.candidate, next.candidate) {
|
||||
contenders = append(contenders, next)
|
||||
}
|
||||
}
|
||||
if len(contenders) < 2 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
sort.SliceStable(contenders, func(i, j int) bool {
|
||||
return contenders[i].candidate.DetailScore > contenders[j].candidate.DetailScore
|
||||
})
|
||||
if contenders[0].candidate.DetailScore < minimumDetailTieBreakScore {
|
||||
return nil, false
|
||||
}
|
||||
if contenders[0].candidate.DetailScore-contenders[1].candidate.DetailScore < minimumDetailTieBreakGap {
|
||||
return nil, false
|
||||
}
|
||||
return &contenders[0].candidate, true
|
||||
}
|
||||
|
||||
func duplicateTieBreakComparable(hints *MatchHints, left, right MatchCandidate) bool {
|
||||
if hints == nil {
|
||||
return false
|
||||
}
|
||||
if hints.Year == 0 || left.Year == 0 || right.Year == 0 {
|
||||
return false
|
||||
}
|
||||
if left.Year != hints.Year || right.Year != hints.Year || left.Year != right.Year {
|
||||
return false
|
||||
}
|
||||
if !candidateTypeMatchesHint(hints.Type, left.ContentType) ||
|
||||
!candidateTypeMatchesHint(hints.Type, right.ContentType) {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(left.ContentType) != "" &&
|
||||
strings.TrimSpace(right.ContentType) != "" &&
|
||||
!strings.EqualFold(left.ContentType, right.ContentType) {
|
||||
return false
|
||||
}
|
||||
if inferTitleSimilarity(left.Title, right.Title, hints.Year) != 1 {
|
||||
return false
|
||||
}
|
||||
if inferTitleSimilarity(hints.Title, left.Title, hints.Year) != 1 {
|
||||
return false
|
||||
}
|
||||
if inferTitleSimilarity(hints.Title, right.Title, hints.Year) != 1 {
|
||||
return false
|
||||
}
|
||||
return samePrimaryProvider(left.ProviderIDs, right.ProviderIDs)
|
||||
}
|
||||
|
||||
func candidateTypeMatchesHint(hintType, candidateType string) bool {
|
||||
hintType = strings.ToLower(strings.TrimSpace(hintType))
|
||||
candidateType = strings.ToLower(strings.TrimSpace(candidateType))
|
||||
if hintType == "" || candidateType == "" {
|
||||
return true
|
||||
}
|
||||
if hintType == candidateType {
|
||||
return true
|
||||
}
|
||||
return isMovieTypeAlias(hintType) && isMovieTypeAlias(candidateType)
|
||||
}
|
||||
|
||||
func isMovieTypeAlias(value string) bool {
|
||||
switch value {
|
||||
case "movie", "movies":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func samePrimaryProvider(left, right map[string]string) bool {
|
||||
for _, key := range canonicalCandidateIDKeys {
|
||||
leftValue := strings.TrimSpace(left[key])
|
||||
rightValue := strings.TrimSpace(right[key])
|
||||
if leftValue != "" && rightValue != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizedKey returns a stable grouping key from provider IDs.
|
||||
// Results with identical provider ID fingerprints (the exact set of
|
||||
// tmdb/tvdb/imdb key=value pairs) are considered the same candidate.
|
||||
@@ -211,13 +313,12 @@ func scoreMatchCandidate(hints *MatchHints, candidate MatchCandidate) float64 {
|
||||
|
||||
score += float64(len(candidate.Sources) * 12)
|
||||
|
||||
hintTitle := normalizeCandidateTitle(hints.Title)
|
||||
candidateTitle := normalizeCandidateTitle(candidate.Title)
|
||||
if hintTitle != "" && candidateTitle != "" {
|
||||
if hintTitle == candidateTitle {
|
||||
if strings.TrimSpace(hints.Title) != "" && strings.TrimSpace(candidate.Title) != "" {
|
||||
titleSimilarity := inferTitleSimilarity(hints.Title, candidate.Title, hints.Year)
|
||||
if titleSimilarity == 1 {
|
||||
score += 45
|
||||
} else {
|
||||
score += inferTitleSimilarity(hints.Title, candidate.Title) * 35
|
||||
score += titleSimilarity * 35
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,18 +337,19 @@ func scoreMatchCandidate(hints *MatchHints, candidate MatchCandidate) float64 {
|
||||
return score
|
||||
}
|
||||
|
||||
type scoredMatchCandidate struct {
|
||||
candidate MatchCandidate
|
||||
score float64
|
||||
}
|
||||
|
||||
func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) (*MatchCandidate, bool) {
|
||||
if len(candidates) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
type scored struct {
|
||||
candidate MatchCandidate
|
||||
score float64
|
||||
}
|
||||
scoredCandidates := make([]scored, 0, len(candidates))
|
||||
scoredCandidates := make([]scoredMatchCandidate, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
scoredCandidates = append(scoredCandidates, scored{
|
||||
scoredCandidates = append(scoredCandidates, scoredMatchCandidate{
|
||||
candidate: candidate,
|
||||
score: scoreMatchCandidate(hints, candidate),
|
||||
})
|
||||
@@ -274,11 +376,76 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate)
|
||||
return &best.candidate, true
|
||||
}
|
||||
if best.score-scoredCandidates[1].score < 15 {
|
||||
return nil, false
|
||||
if winner, ok := duplicateTieBreakWinner(hints, scoredCandidates); ok {
|
||||
return winner, true
|
||||
}
|
||||
return providerOrderExactTieBreakWinner(hints, scoredCandidates)
|
||||
}
|
||||
return &best.candidate, true
|
||||
}
|
||||
|
||||
func providerOrderExactTieBreakWinner(hints *MatchHints, scoredCandidates []scoredMatchCandidate) (*MatchCandidate, bool) {
|
||||
if hints == nil || len(scoredCandidates) < 2 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
best := scoredCandidates[0]
|
||||
contenders := []scoredMatchCandidate{best}
|
||||
for i := 1; i < len(scoredCandidates); i++ {
|
||||
next := scoredCandidates[i]
|
||||
if best.score-next.score >= 15 {
|
||||
break
|
||||
}
|
||||
contenders = append(contenders, next)
|
||||
}
|
||||
if len(contenders) < 2 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
seenPrimaryProviders := make(map[string]struct{}, len(contenders))
|
||||
for _, contender := range contenders {
|
||||
if !exactTitleYearTypeMatch(hints, contender.candidate) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
primaryProvider := candidatePrimaryProvider(contender.candidate)
|
||||
if primaryProvider == "" {
|
||||
return nil, false
|
||||
}
|
||||
if _, exists := seenPrimaryProviders[primaryProvider]; exists {
|
||||
return nil, false
|
||||
}
|
||||
seenPrimaryProviders[primaryProvider] = struct{}{}
|
||||
}
|
||||
|
||||
return &best.candidate, true
|
||||
}
|
||||
|
||||
func exactTitleYearTypeMatch(hints *MatchHints, candidate MatchCandidate) bool {
|
||||
if hints == nil || hints.Year == 0 || candidate.Year == 0 {
|
||||
return false
|
||||
}
|
||||
if candidate.Year != hints.Year {
|
||||
return false
|
||||
}
|
||||
if !candidateTypeMatchesHint(hints.Type, candidate.ContentType) {
|
||||
return false
|
||||
}
|
||||
return inferTitleSimilarity(hints.Title, candidate.Title, hints.Year) == 1
|
||||
}
|
||||
|
||||
func candidatePrimaryProvider(candidate MatchCandidate) string {
|
||||
for _, key := range canonicalCandidateIDKeys {
|
||||
if strings.TrimSpace(candidate.ProviderIDs[key]) != "" {
|
||||
return key
|
||||
}
|
||||
}
|
||||
if len(candidate.Sources) == 1 {
|
||||
return strings.TrimSpace(candidate.Sources[0])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func selectRefreshMatchCandidate(existing *models.MediaItem, candidates []MatchCandidate) (*MatchCandidate, bool) {
|
||||
if existing == nil || len(candidates) == 0 {
|
||||
return nil, false
|
||||
@@ -345,17 +512,17 @@ func normalizeCandidateTitle(title string) string {
|
||||
return strings.Join(strings.Fields(strings.TrimSpace(title)), " ")
|
||||
}
|
||||
|
||||
func inferTitleSimilarity(left, right string) float64 {
|
||||
leftNorm := normalizeCandidateTitle(left)
|
||||
rightNorm := normalizeCandidateTitle(right)
|
||||
func inferTitleSimilarity(left, right string, year int) float64 {
|
||||
leftNorm := normalizeCandidateTitleForYear(left, year)
|
||||
rightNorm := normalizeCandidateTitleForYear(right, year)
|
||||
if leftNorm == "" || rightNorm == "" {
|
||||
return 0
|
||||
}
|
||||
if leftNorm == rightNorm {
|
||||
return 1
|
||||
}
|
||||
leftComparable := strings.Join(strings.Fields(normalizeTitleForScoring(left)), " ")
|
||||
rightComparable := strings.Join(strings.Fields(normalizeTitleForScoring(right)), " ")
|
||||
leftComparable := strings.Join(strings.Fields(normalizeTitleForScoring(leftNorm)), " ")
|
||||
rightComparable := strings.Join(strings.Fields(normalizeTitleForScoring(rightNorm)), " ")
|
||||
if leftComparable == rightComparable {
|
||||
return 1
|
||||
}
|
||||
@@ -365,6 +532,19 @@ func inferTitleSimilarity(left, right string) float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func normalizeCandidateTitleForYear(title string, year int) string {
|
||||
normalized := normalizeCandidateTitle(title)
|
||||
if normalized == "" || year == 0 {
|
||||
return normalized
|
||||
}
|
||||
yearText := strconv.Itoa(year)
|
||||
fields := strings.Fields(normalized)
|
||||
if len(fields) <= 1 || fields[len(fields)-1] != yearText {
|
||||
return normalized
|
||||
}
|
||||
return strings.Join(fields[:len(fields)-1], " ")
|
||||
}
|
||||
|
||||
func normalizeTitleForScoring(title string) string {
|
||||
title = naming.StripComparisonSafeEditionSuffix(title)
|
||||
title = strings.ToLower(strings.TrimSpace(title))
|
||||
|
||||
@@ -6,12 +6,43 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestTrustedIDValue_UsesMetadbContentID(t *testing.T) {
|
||||
func TestSelectInitialMatchCandidate_IgnoresLocalContentIDForTrustedSelection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hints := &MatchHints{ContentID: "meta-123"}
|
||||
if got := trustedIDValue(hints, "metadb"); got != "meta-123" {
|
||||
t.Fatalf("trustedIDValue(metadb) = %q, want meta-123", got)
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{
|
||||
ContentID: "local-skeleton-id",
|
||||
Title: "AEW Worlds End",
|
||||
Year: 2023,
|
||||
Type: "movie",
|
||||
},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "AEW Worlds End",
|
||||
Year: 2023,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "1217341"},
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
},
|
||||
)
|
||||
if !ok || winner == nil {
|
||||
t.Fatal("expected local content_id not to force trusted-ID matching")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuppressTitleYearFallbackForTrustedIDs_IgnoresMetadb(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
query := suppressTitleYearFallbackForTrustedIDs(SearchQuery{
|
||||
Title: "AEW Worlds End",
|
||||
Year: 2023,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"metadb": "local-skeleton-id"},
|
||||
})
|
||||
|
||||
if query.Title != "AEW Worlds End" || query.Year != 2023 {
|
||||
t.Fatalf("title/year were suppressed for metadb: title=%q year=%d", query.Title, query.Year)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,6 +388,255 @@ func TestSelectInitialMatchCandidate_AcceptsSinglePunctuationEquivalentCandidate
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_AcceptsProviderTitleWithRepeatedYear(t *testing.T) {
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{
|
||||
Title: "AEW Worlds End",
|
||||
Year: 2023,
|
||||
Type: "movie",
|
||||
},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "AEW Worlds End 2023",
|
||||
Year: 2023,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "1217341"},
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
{
|
||||
Title: "AEW Worlds End 2023: Zero Hour",
|
||||
Year: 2023,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "1217342"},
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
},
|
||||
)
|
||||
if !ok || winner == nil {
|
||||
t.Fatal("expected provider title with repeated release year to be accepted")
|
||||
}
|
||||
if winner.Title != "AEW Worlds End 2023" {
|
||||
t.Fatalf("winner.Title = %q, want AEW Worlds End 2023", winner.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_UsesDetailScoreForDuplicateProviderTie(t *testing.T) {
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{
|
||||
Title: "UFC 4 Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
Type: "movie",
|
||||
},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "UFC 4: Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "1558410"},
|
||||
Sources: []string{"tmdb"},
|
||||
DetailScore: 18,
|
||||
},
|
||||
{
|
||||
Title: "UFC 4: Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "17508", "imdb": "tt0487980"},
|
||||
Sources: []string{"tmdb"},
|
||||
DetailScore: 46,
|
||||
},
|
||||
},
|
||||
)
|
||||
if !ok || winner == nil {
|
||||
t.Fatal("expected richer duplicate TMDB candidate to be accepted")
|
||||
}
|
||||
if got := winner.ProviderIDs["tmdb"]; got != "17508" {
|
||||
t.Fatalf("winner tmdb = %q, want 17508", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_RejectsDuplicateTieWithoutClearDetailGap(t *testing.T) {
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{
|
||||
Title: "UFC 4 Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
Type: "movie",
|
||||
},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "UFC 4: Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "1558410"},
|
||||
Sources: []string{"tmdb"},
|
||||
DetailScore: 28,
|
||||
},
|
||||
{
|
||||
Title: "UFC 4: Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "17508"},
|
||||
Sources: []string{"tmdb"},
|
||||
DetailScore: 34,
|
||||
},
|
||||
},
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatal("expected duplicate tie without clear detail gap to remain unmatched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_UsesProviderOrderForExactCrossProviderTie(t *testing.T) {
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{
|
||||
Title: "100 Days Wild",
|
||||
Year: 2020,
|
||||
Type: "series",
|
||||
},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "100 Days Wild",
|
||||
Year: 2020,
|
||||
ContentType: "series",
|
||||
ProviderIDs: map[string]string{"tvdb": "383893"},
|
||||
Sources: []string{"tvdb"},
|
||||
},
|
||||
{
|
||||
Title: "100 Days Wild",
|
||||
Year: 2020,
|
||||
ContentType: "series",
|
||||
ProviderIDs: map[string]string{"tmdb": "109792"},
|
||||
Sources: []string{"tmdb"},
|
||||
},
|
||||
},
|
||||
)
|
||||
if !ok || winner == nil {
|
||||
t.Fatal("expected exact cross-provider tie to use provider order")
|
||||
}
|
||||
if got := winner.ProviderIDs["tvdb"]; got != "383893" {
|
||||
t.Fatalf("winner tvdb = %q, want 383893", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_ProviderOrderTieRequiresExactTitleYear(t *testing.T) {
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{
|
||||
Title: "100 Days Wild",
|
||||
Year: 2020,
|
||||
Type: "series",
|
||||
},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "100 Days Wild",
|
||||
Year: 2020,
|
||||
ContentType: "series",
|
||||
ProviderIDs: map[string]string{"tvdb": "383893"},
|
||||
Sources: []string{"tvdb"},
|
||||
},
|
||||
{
|
||||
Title: "Step Brothers",
|
||||
Year: 2020,
|
||||
ContentType: "series",
|
||||
ProviderIDs: map[string]string{"tmdb": "109792", "imdb": "tt1234567"},
|
||||
Sources: []string{"imdb", "metadb", "tmdb", "xattr"},
|
||||
},
|
||||
},
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatal("expected non-equivalent cross-provider tie to remain unmatched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_DetailScoreDoesNotOverrideDifferentTitleTie(t *testing.T) {
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{
|
||||
Title: "UFC 4 Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
Type: "movie",
|
||||
},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "UFC 4: Revenge of the Warriors Event",
|
||||
Year: 1994,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "17508"},
|
||||
Sources: []string{"tmdb"},
|
||||
DetailScore: 22,
|
||||
},
|
||||
{
|
||||
Title: "UFC 4 Revenge of the Warriors Bonus",
|
||||
Year: 1994,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "999999", "imdb": "tt9999999"},
|
||||
Sources: []string{"imdb", "tmdb"},
|
||||
DetailScore: 80,
|
||||
},
|
||||
},
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatal("expected richer different-title candidate to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_DetailScoreRequiresDatedDuplicateCandidates(t *testing.T) {
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{
|
||||
Title: "UFC 4 Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
Type: "movie",
|
||||
},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "UFC 4: Revenge of the Warriors",
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "1558410"},
|
||||
Sources: []string{"tmdb"},
|
||||
DetailScore: 18,
|
||||
},
|
||||
{
|
||||
Title: "UFC 4: Revenge of the Warriors",
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "17508", "imdb": "tt0487980"},
|
||||
Sources: []string{"tmdb"},
|
||||
DetailScore: 46,
|
||||
},
|
||||
},
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatal("expected duplicate detail tie-breaker to reject candidates without matching years")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_DetailScoreRequiresHintCompatibleType(t *testing.T) {
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{
|
||||
Title: "UFC 4 Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
Type: "series",
|
||||
},
|
||||
[]MatchCandidate{
|
||||
{
|
||||
Title: "UFC 4: Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "1558410"},
|
||||
Sources: []string{"tmdb"},
|
||||
DetailScore: 18,
|
||||
},
|
||||
{
|
||||
Title: "UFC 4: Revenge of the Warriors",
|
||||
Year: 1994,
|
||||
ContentType: "movie",
|
||||
ProviderIDs: map[string]string{"tmdb": "17508", "imdb": "tt0487980"},
|
||||
Sources: []string{"tmdb"},
|
||||
DetailScore: 46,
|
||||
},
|
||||
},
|
||||
)
|
||||
if ok || winner != nil {
|
||||
t.Fatal("expected duplicate detail tie-breaker to reject candidates with hint-incompatible type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInitialMatchCandidate_RejectsWeakSingleCandidate(t *testing.T) {
|
||||
winner, ok := selectInitialMatchCandidate(
|
||||
&MatchHints{
|
||||
|
||||
@@ -74,7 +74,10 @@ func (r *MovieMatchQueueRepository) EnqueueMovieFile(ctx context.Context, fileID
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.id = $1
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('movie', 'movies')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('movie', 'movies') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND (
|
||||
mf.content_id IS NULL OR mf.content_id = '' OR
|
||||
@@ -98,7 +101,10 @@ func (r *MovieMatchQueueRepository) EnqueueMovieFile(ctx context.Context, fileID
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.id = q.media_file_id
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('movie', 'movies')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('movie', 'movies') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND (
|
||||
mf.content_id IS NULL OR mf.content_id = '' OR
|
||||
@@ -146,7 +152,10 @@ func (r *MovieMatchQueueRepository) SyncForFolder(ctx context.Context, folderID
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.media_folder_id = $1
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('movie', 'movies')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('movie', 'movies') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND (
|
||||
mf.content_id IS NULL OR mf.content_id = '' OR
|
||||
@@ -170,7 +179,10 @@ func (r *MovieMatchQueueRepository) SyncForFolder(ctx context.Context, folderID
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.id = q.media_file_id
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('movie', 'movies')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('movie', 'movies') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND (
|
||||
mf.content_id IS NULL OR mf.content_id = '' OR
|
||||
@@ -223,7 +235,10 @@ func (r *MovieMatchQueueRepository) SyncInScope(ctx context.Context, folderID in
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.media_folder_id = $1
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('movie', 'movies')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('movie', 'movies') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND (
|
||||
mf.file_path = $2 OR
|
||||
@@ -267,7 +282,10 @@ func (r *MovieMatchQueueRepository) SyncInScope(ctx context.Context, folderID in
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.id = q.media_file_id
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('movie', 'movies')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('movie', 'movies') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND (
|
||||
mf.content_id IS NULL OR mf.content_id = '' OR
|
||||
@@ -301,7 +319,10 @@ func (r *MovieMatchQueueRepository) Claim(ctx context.Context, limit int) ([]*mo
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE q.available_at <= NOW()
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('movie', 'movies')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('movie', 'movies') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND (
|
||||
mf.content_id IS NULL OR mf.content_id = '' OR
|
||||
@@ -369,7 +390,10 @@ func (r *MovieMatchQueueRepository) ClaimByFolderAndPathPrefix(
|
||||
WHERE q.media_folder_id = $1
|
||||
AND q.available_at <= NOW()
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('movie', 'movies')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('movie', 'movies') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND (
|
||||
mf.file_path = $2 OR
|
||||
|
||||
@@ -115,7 +115,10 @@ func (r *SeriesRootMatchQueueRepository) EnqueueSeriesRoot(ctx context.Context,
|
||||
WHERE mf.media_folder_id = q.media_folder_id
|
||||
AND mf.observed_root_path = q.observed_root_path
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.observed_root_path <> ''
|
||||
AND (
|
||||
@@ -164,7 +167,10 @@ func (r *SeriesRootMatchQueueRepository) SyncForFolder(ctx context.Context, fold
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.media_folder_id = $1
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.observed_root_path IS NOT NULL
|
||||
AND mf.observed_root_path <> ''
|
||||
@@ -191,7 +197,10 @@ func (r *SeriesRootMatchQueueRepository) SyncForFolder(ctx context.Context, fold
|
||||
WHERE mf.media_folder_id = q.media_folder_id
|
||||
AND mf.observed_root_path = q.observed_root_path
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.observed_root_path <> ''
|
||||
AND (
|
||||
@@ -238,7 +247,10 @@ func (r *SeriesRootMatchQueueRepository) SyncInScope(ctx context.Context, folder
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.media_folder_id = $1
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.observed_root_path IS NOT NULL
|
||||
AND mf.observed_root_path <> ''
|
||||
@@ -296,7 +308,10 @@ func (r *SeriesRootMatchQueueRepository) SyncInScope(ctx context.Context, folder
|
||||
WHERE mf.media_folder_id = q.media_folder_id
|
||||
AND mf.observed_root_path = q.observed_root_path
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
|
||||
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series')
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.observed_root_path <> ''
|
||||
AND (
|
||||
@@ -329,13 +344,17 @@ func (r *SeriesRootMatchQueueRepository) Claim(ctx context.Context, limit int) (
|
||||
JOIN media_folders folders ON folders.id = q.media_folder_id
|
||||
WHERE q.available_at <= NOW()
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
|
||||
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows', 'mixed')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM media_files mf
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.media_folder_id = q.media_folder_id
|
||||
AND mf.observed_root_path = q.observed_root_path
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
|
||||
lower(trim(mf.base_type)) = 'series'
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.observed_root_path <> ''
|
||||
AND (
|
||||
@@ -412,7 +431,7 @@ func (r *SeriesRootMatchQueueRepository) ClaimByFolderAndPathPrefix(
|
||||
WHERE q.media_folder_id = $1
|
||||
AND q.available_at <= NOW()
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
|
||||
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows', 'mixed')
|
||||
AND (
|
||||
q.observed_root_path = $2 OR q.observed_root_path LIKE $3 ESCAPE '\' OR
|
||||
EXISTS (
|
||||
@@ -430,6 +449,10 @@ func (r *SeriesRootMatchQueueRepository) ClaimByFolderAndPathPrefix(
|
||||
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.media_folder_id = q.media_folder_id
|
||||
AND mf.observed_root_path = q.observed_root_path
|
||||
AND (
|
||||
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
|
||||
lower(trim(mf.base_type)) = 'series'
|
||||
)
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.observed_root_path <> ''
|
||||
AND (
|
||||
|
||||
@@ -183,7 +183,7 @@ type metadataServiceHooks struct {
|
||||
ensureSeriesEpisodeLinks func(ctx context.Context, seriesID string) error
|
||||
}
|
||||
|
||||
var trustedSearchIDKeys = []string{"metadb", "tmdb", "tvdb", "imdb"}
|
||||
var trustedSearchIDKeys = []string{"tmdb", "tvdb", "imdb"}
|
||||
|
||||
var ErrMetadataNotFound = errors.New("no metadata found from any provider")
|
||||
|
||||
@@ -829,10 +829,8 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques
|
||||
if req.Hints == nil {
|
||||
return nil, fmt.Errorf("initial match requires hints")
|
||||
}
|
||||
// Seed IDs from hints.
|
||||
if req.Hints.ContentID != "" {
|
||||
accumulatedIDs["metadb"] = req.Hints.ContentID
|
||||
}
|
||||
// Seed external IDs from hints. Hints.ContentID is Silo's local
|
||||
// skeleton item ID, not a searchable provider ID.
|
||||
if req.Hints.FileHash != "" {
|
||||
accumulatedIDs["oshash"] = req.Hints.FileHash
|
||||
}
|
||||
@@ -962,8 +960,7 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Run search providers to resolve the metadb ID from external IDs.
|
||||
// Without this, GetMetadata has no valid metadb ID to fetch from.
|
||||
// Run search providers to refresh provider IDs before fetching full metadata.
|
||||
searchQuery := SearchQuery{
|
||||
Title: existing.Title,
|
||||
Year: existing.Year,
|
||||
|
||||
+119
-51
@@ -364,7 +364,7 @@ func (w *MatchWorker) ProcessBatch(ctx context.Context) (processed int, err erro
|
||||
// ProcessBatchByFolderAndPathPrefix processes unmatched files within a single
|
||||
// library subtree immediately instead of waiting for the periodic worker loop.
|
||||
func (w *MatchWorker) ProcessBatchByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, attemptBefore time.Time) (processed int, err error) {
|
||||
useSeriesQueue, err := w.useSeriesGroupQueueForFolder(ctx, folderID)
|
||||
useSeriesQueue, useMovieQueue, err := w.queueUsageForFolder(ctx, folderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -373,21 +373,29 @@ func (w *MatchWorker) ProcessBatchByFolderAndPathPrefix(ctx context.Context, fol
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return w.processSeriesRoots(ctx, jobs)
|
||||
processed, err := w.processSeriesRoots(ctx, jobs)
|
||||
if err != nil || processed > 0 {
|
||||
return processed, err
|
||||
}
|
||||
}
|
||||
useMovieQueue, err := w.useMovieQueueForFolder(ctx, folderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
if useSeriesQueue && !useMovieQueue {
|
||||
return processed, nil
|
||||
}
|
||||
if useMovieQueue {
|
||||
files, err := w.movieClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return w.processQueuedMovieFiles(ctx, files), nil
|
||||
processed := w.processQueuedMovieFiles(ctx, files)
|
||||
if processed > 0 {
|
||||
return processed, nil
|
||||
}
|
||||
if !useSeriesQueue {
|
||||
return processed, nil
|
||||
}
|
||||
}
|
||||
|
||||
files, err := w.claimScopedFiles(ctx, folderID, pathPrefix, attemptBefore, false)
|
||||
files, err := w.claimScopedFiles(ctx, folderID, pathPrefix, attemptBefore, scopedFallbackMode(useSeriesQueue, useMovieQueue))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -400,11 +408,7 @@ func (w *MatchWorker) ProcessAllByFolderAndPathPrefix(ctx context.Context, folde
|
||||
if attemptBefore.IsZero() {
|
||||
attemptBefore = time.Now().UTC()
|
||||
}
|
||||
useSeriesQueue, err := w.useSeriesGroupQueueForFolder(ctx, folderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
useMovieQueue, err := w.useMovieQueueForFolder(ctx, folderID)
|
||||
useSeriesQueue, useMovieQueue, err := w.queueUsageForFolder(ctx, folderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -424,30 +428,31 @@ func (w *MatchWorker) ProcessAllByFolderAndPathPrefix(ctx context.Context, folde
|
||||
if err != nil {
|
||||
return processed, err
|
||||
}
|
||||
if len(jobs) == 0 {
|
||||
return processed, nil
|
||||
if len(jobs) > 0 {
|
||||
batchProcessed, err := w.processSeriesRoots(ctx, jobs)
|
||||
if err != nil {
|
||||
return processed, err
|
||||
}
|
||||
processed += batchProcessed
|
||||
continue
|
||||
}
|
||||
batchProcessed, err := w.processSeriesRoots(ctx, jobs)
|
||||
if err != nil {
|
||||
return processed, err
|
||||
}
|
||||
processed += batchProcessed
|
||||
continue
|
||||
}
|
||||
if useMovieQueue {
|
||||
files, err := w.movieClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
|
||||
if err != nil {
|
||||
return processed, err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return processed, nil
|
||||
if len(files) > 0 {
|
||||
batchProcessed := w.processQueuedMovieFiles(ctx, files)
|
||||
processed += batchProcessed
|
||||
continue
|
||||
}
|
||||
batchProcessed := w.processQueuedMovieFiles(ctx, files)
|
||||
processed += batchProcessed
|
||||
continue
|
||||
}
|
||||
if useSeriesQueue != useMovieQueue {
|
||||
return processed, nil
|
||||
}
|
||||
|
||||
files, err := w.claimScopedFiles(ctx, folderID, pathPrefix, attemptBefore, w.enableTVSeriesRootQueue)
|
||||
files, err := w.claimScopedFiles(ctx, folderID, pathPrefix, attemptBefore, scopedFallbackMode(useSeriesQueue, useMovieQueue))
|
||||
if err != nil {
|
||||
return processed, err
|
||||
}
|
||||
@@ -705,6 +710,8 @@ func (w *MatchWorker) reusableQueuedMovieSkeleton(ctx context.Context, file *mod
|
||||
|
||||
func scopedMatcherPath(useSeriesQueue bool, useMovieQueue bool) string {
|
||||
switch {
|
||||
case useSeriesQueue && useMovieQueue:
|
||||
return "series_root_queue+movie_file_queue"
|
||||
case useSeriesQueue:
|
||||
return "series_root_queue"
|
||||
case useMovieQueue:
|
||||
@@ -792,6 +799,43 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo
|
||||
}
|
||||
if !hasUnlinkedGroupFile(groupFiles) {
|
||||
if strings.TrimSpace(representative.ContentID) != "" {
|
||||
if skeleton, ok := w.reusableQueuedMovieSkeleton(ctx, representative); ok && skeleton.ItemStatus != "ambiguous" {
|
||||
req := w.buildProcessRequestForGroup(ctx, representative, skeleton, groupFiles)
|
||||
result, processErr := w.service.Process(ctx, req)
|
||||
if processErr != nil {
|
||||
queueErr := truncateSeriesQueueError(processErr.Error())
|
||||
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, queueErr); updateErr != nil {
|
||||
return 0, updateErr
|
||||
}
|
||||
slog.Warn("metadata: enrichment failed",
|
||||
"file_id", representative.ID,
|
||||
"path", representative.FilePath,
|
||||
"error", processErr)
|
||||
w.logStatusUpdateFailure(ctx, skeleton.ContentID, "unmatched",
|
||||
"content_id", skeleton.ContentID,
|
||||
"file_id", representative.ID,
|
||||
"path", representative.FilePath)
|
||||
if fbErr := w.service.SynthesizeFallbackEpisodes(ctx, skeleton.ContentID); fbErr != nil {
|
||||
slog.Warn("metadata: fallback episode synthesis failed after series root enrichment error",
|
||||
"content_id", skeleton.ContentID, "error", fbErr)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
if result != nil && !result.Updated {
|
||||
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(ErrMetadataNotFound.Error())); updateErr != nil {
|
||||
return 0, updateErr
|
||||
}
|
||||
w.logStatusUpdateFailure(ctx, skeleton.ContentID, "unmatched",
|
||||
"content_id", skeleton.ContentID,
|
||||
"file_id", representative.ID,
|
||||
"path", representative.FilePath)
|
||||
if fbErr := w.service.SynthesizeFallbackEpisodes(ctx, skeleton.ContentID); fbErr != nil {
|
||||
slog.Warn("metadata: fallback episode synthesis failed for unmatched series root",
|
||||
"content_id", skeleton.ContentID, "error", fbErr)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -1016,31 +1060,16 @@ func (w *MatchWorker) folderType(ctx context.Context, folderID int) (string, err
|
||||
return strings.ToLower(strings.TrimSpace(folder.Type)), nil
|
||||
}
|
||||
|
||||
func (w *MatchWorker) useSeriesGroupQueueForFolder(ctx context.Context, folderID int) (bool, error) {
|
||||
if !w.enableTVSeriesRootQueue || w.seriesClaimer == nil {
|
||||
return false, nil
|
||||
}
|
||||
func (w *MatchWorker) queueUsageForFolder(ctx context.Context, folderID int) (useSeriesQueue bool, useMovieQueue bool, err error) {
|
||||
folderType, err := w.folderType(ctx, folderID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return isTVLibraryType(folderType), nil
|
||||
}
|
||||
|
||||
func (w *MatchWorker) useMovieQueueForFolder(ctx context.Context, folderID int) (bool, error) {
|
||||
if w.movieClaimer == nil {
|
||||
return false, nil
|
||||
}
|
||||
folderType, err := w.folderType(ctx, folderID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch folderType {
|
||||
case "movie", "movies":
|
||||
return true, nil
|
||||
default:
|
||||
return false, nil
|
||||
return false, false, err
|
||||
}
|
||||
useSeriesQueue = w.enableTVSeriesRootQueue &&
|
||||
w.seriesClaimer != nil &&
|
||||
(isTVLibraryType(folderType) || isMixedLibraryType(folderType))
|
||||
useMovieQueue = w.movieClaimer != nil && isMovieLibraryType(folderType)
|
||||
return useSeriesQueue, useMovieQueue, nil
|
||||
}
|
||||
|
||||
func (w *MatchWorker) claimBackgroundFiles(ctx context.Context) ([]*models.MediaFile, error) {
|
||||
@@ -1059,14 +1088,40 @@ func (w *MatchWorker) claimBackgroundFiles(ctx context.Context) ([]*models.Media
|
||||
return w.fileLister.ClaimUnmatched(ctx, w.batchSize)
|
||||
}
|
||||
|
||||
func (w *MatchWorker) claimScopedFiles(ctx context.Context, folderID int, pathPrefix string, attemptBefore time.Time, preferNonSeries bool) ([]*models.MediaFile, error) {
|
||||
if preferNonSeries {
|
||||
type scopedFallbackClaimMode int
|
||||
|
||||
const (
|
||||
scopedFallbackGeneric scopedFallbackClaimMode = iota
|
||||
scopedFallbackNonSeries
|
||||
scopedFallbackMixed
|
||||
)
|
||||
|
||||
func scopedFallbackMode(useSeriesQueue bool, useMovieQueue bool) scopedFallbackClaimMode {
|
||||
switch {
|
||||
case useSeriesQueue && useMovieQueue:
|
||||
return scopedFallbackMixed
|
||||
case useSeriesQueue:
|
||||
return scopedFallbackNonSeries
|
||||
default:
|
||||
return scopedFallbackGeneric
|
||||
}
|
||||
}
|
||||
|
||||
func (w *MatchWorker) claimScopedFiles(ctx context.Context, folderID int, pathPrefix string, attemptBefore time.Time, mode scopedFallbackClaimMode) ([]*models.MediaFile, error) {
|
||||
switch mode {
|
||||
case scopedFallbackMixed:
|
||||
if claimer, ok := w.fileLister.(MixedFileClaimer); ok {
|
||||
return claimer.ClaimUnmatchedMixedByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
|
||||
}
|
||||
return nil, fmt.Errorf("mixed-library file claimer is not configured")
|
||||
case scopedFallbackNonSeries:
|
||||
if claimer, ok := w.fileLister.(NonSeriesFileClaimer); ok {
|
||||
return claimer.ClaimUnmatchedNonSeriesByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
|
||||
}
|
||||
return nil, fmt.Errorf("non-series file claimer is not configured")
|
||||
default:
|
||||
return w.fileLister.ClaimUnmatchedByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
|
||||
}
|
||||
return w.fileLister.ClaimUnmatchedByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.batchSize, attemptBefore)
|
||||
}
|
||||
|
||||
func selectRepresentativeGroupFile(groupFiles []*models.MediaFile) *models.MediaFile {
|
||||
@@ -1117,6 +1172,19 @@ func isTVLibraryType(folderType string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func isMixedLibraryType(folderType string) bool {
|
||||
return strings.ToLower(strings.TrimSpace(folderType)) == "mixed"
|
||||
}
|
||||
|
||||
func isMovieLibraryType(folderType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(folderType)) {
|
||||
case "movie", "movies", "mixed":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// RetryUnmatchedItemsByFolderAndPathPrefix revisits linked unmatched items in
|
||||
// scope once. Per-item retry failures are counted as warnings, not fatal.
|
||||
func (w *MatchWorker) RetryUnmatchedItemsByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string) (retried int, stillUnmatched int, err error) {
|
||||
|
||||
@@ -718,6 +718,74 @@ func TestWorkerProcessAllByFolderAndPathPrefix_MovieFolderUsesMovieQueue(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerProcessAllByFolderAndPathPrefix_MixedFolderDrainsSeriesAndMovieQueues(t *testing.T) {
|
||||
h := newTestHarness()
|
||||
ctx := context.Background()
|
||||
h.service.folderRepo = &fakeWorkerFolderRepo{
|
||||
folders: map[int]*models.MediaFolder{
|
||||
10: {ID: 10, Type: "mixed", Enabled: true},
|
||||
},
|
||||
}
|
||||
|
||||
seriesFile := &models.MediaFile{
|
||||
ID: 1,
|
||||
MediaFolderID: 10,
|
||||
FilePath: "/media/mixed/television/Example Show/Season 01/Example.Show.S01E01.mkv",
|
||||
ObservedRootPath: "/media/mixed/television/Example Show",
|
||||
GroupKeyVersion: 1,
|
||||
ContentGroupKey: "v1|series|example_show|2024",
|
||||
BaseTitle: "Example Show",
|
||||
BaseType: "series",
|
||||
}
|
||||
movieFile := &models.MediaFile{
|
||||
ID: 2,
|
||||
MediaFolderID: 10,
|
||||
FilePath: "/media/mixed/movies/Example Movie (2024)/Example.Movie.mkv",
|
||||
BaseTitle: "Example Movie",
|
||||
BaseType: "movie",
|
||||
}
|
||||
h.fileRepo.setGroupFiles(10, 1, "v1|series|example_show|2024", seriesFile)
|
||||
h.fileRepo.setGroupFiles(10, 1, "v1|movie|example_movie|2024", movieFile)
|
||||
|
||||
processedTypes := make([]string, 0, 2)
|
||||
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
||||
processedTypes = append(processedTypes, req.Hints.Type)
|
||||
return &ProcessResult{Updated: true}, nil
|
||||
}
|
||||
|
||||
seriesQueueRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{
|
||||
MediaFolderID: 10,
|
||||
ObservedRootPath: "/media/mixed/television/Example Show",
|
||||
SampleFilePath: seriesFile.FilePath,
|
||||
ObservedFileCount: 1,
|
||||
})
|
||||
movieQueueRepo := newFakeMovieQueueRepo(movieFile)
|
||||
|
||||
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
|
||||
worker.SetSeriesRootClaimer(seriesQueueRepo, true)
|
||||
worker.SetMovieFileClaimer(movieQueueRepo)
|
||||
processed, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/mixed", time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessAllByFolderAndPathPrefix error = %v", err)
|
||||
}
|
||||
|
||||
if processed != 2 {
|
||||
t.Fatalf("processed = %d, want 2", processed)
|
||||
}
|
||||
if got, want := strings.Join(processedTypes, ","), "series,movie"; got != want {
|
||||
t.Fatalf("processed types = %q, want %q", got, want)
|
||||
}
|
||||
if seriesQueueRepo.scopedClaimCalls == 0 {
|
||||
t.Fatal("expected series queue to be claimed")
|
||||
}
|
||||
if movieQueueRepo.scopedClaimCalls == 0 {
|
||||
t.Fatal("expected movie queue to be claimed")
|
||||
}
|
||||
if h.fileRepo.claimMixedCalls != 1 {
|
||||
t.Fatalf("claimMixedCalls = %d, want 1", h.fileRepo.claimMixedCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerProcessAllByFolderAndPathPrefix_SeriesRootSkeletonErrorKeepsQueueRow(t *testing.T) {
|
||||
h := newTestHarness()
|
||||
ctx := context.Background()
|
||||
@@ -1000,7 +1068,7 @@ func TestWorkerClaimScopedFiles_WithMovieQueueAndTVQueueDisabledUsesScopedGeneri
|
||||
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
|
||||
worker.SetMovieFileClaimer(newFakeMovieQueueRepo())
|
||||
|
||||
files, err := worker.claimScopedFiles(ctx, 10, "/media/shows/Example Show", time.Time{}, false)
|
||||
files, err := worker.claimScopedFiles(ctx, 10, "/media/shows/Example Show", time.Time{}, scopedFallbackGeneric)
|
||||
if err != nil {
|
||||
t.Fatalf("claimScopedFiles error = %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user