Merge remote-tracking branch 'origin/main' into feat/audiobooks

# Conflicts:
#	go.sum
This commit is contained in:
RXWatcher
2026-05-27 16:27:25 +02:00
146 changed files with 15999 additions and 1363 deletions
+197 -17
View File
@@ -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))
+284 -4
View File
@@ -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{
+32 -8
View File
@@ -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 (
+4 -7
View File
@@ -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
View File
@@ -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) {
+69 -1
View File
@@ -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)
}