Files
silo-server/internal/metadata/worker_test.go
T
Quick ee54942b01 fix(metadata): break duplicate provider candidate ties
- Score candidate metadata completeness and auto-match the richer duplicate when title/year/type tie
- Enrich near-duplicate candidates via the provider chain before initial match selection
- Seed both movie and series match queues for mixed-type libraries and wait for TV queue settle
- Add taskmanager worker test coverage and a plan doc for the tie-breaker work
2026-05-26 20:07:43 -04:00

1203 lines
37 KiB
Go

package metadata
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/models"
)
type fakeWorkerFolderRepo struct {
folders map[int]*models.MediaFolder
}
func (r *fakeWorkerFolderRepo) GetByID(_ context.Context, id int) (*models.MediaFolder, error) {
if folder, ok := r.folders[id]; ok {
cp := *folder
return &cp, nil
}
return nil, fmt.Errorf("folder not found: %d", id)
}
type fakeSeriesQueueRepo struct {
jobs []models.SeriesRootMatchJob
errors map[string]string
deleted map[string]struct{}
lastAttemptedAt map[string]time.Time
claimCalls int
scopedClaimCalls int
claimErr error
}
func newFakeSeriesQueueRepo(jobs ...models.SeriesRootMatchJob) *fakeSeriesQueueRepo {
return &fakeSeriesQueueRepo{
jobs: append([]models.SeriesRootMatchJob(nil), jobs...),
errors: make(map[string]string),
deleted: make(map[string]struct{}),
lastAttemptedAt: make(map[string]time.Time),
}
}
func (r *fakeSeriesQueueRepo) Claim(_ context.Context, limit int) ([]models.SeriesRootMatchJob, error) {
r.claimCalls++
if r.claimErr != nil {
return nil, r.claimErr
}
return r.claim(limit, time.Time{})
}
func (r *fakeSeriesQueueRepo) ClaimByFolderAndPathPrefix(_ context.Context, folderID int, pathPrefix string, limit int, attemptBefore time.Time) ([]models.SeriesRootMatchJob, error) {
r.scopedClaimCalls++
out := make([]models.SeriesRootMatchJob, 0, len(r.jobs))
for _, job := range r.jobs {
if job.MediaFolderID != folderID {
continue
}
if pathPrefix != "" && job.SampleFilePath != pathPrefix && !strings.HasPrefix(job.SampleFilePath, pathPrefix+"/") &&
job.ObservedRootPath != pathPrefix && !strings.HasPrefix(job.ObservedRootPath, pathPrefix+"/") {
continue
}
key := fmt.Sprintf("%d:%s", job.MediaFolderID, job.ObservedRootPath)
if claimedAt := r.lastAttemptedAt[key]; !attemptBefore.IsZero() && !claimedAt.IsZero() && !claimedAt.Before(attemptBefore) {
continue
}
out = append(out, job)
r.lastAttemptedAt[key] = time.Now().UTC()
if limit > 0 && len(out) >= limit {
break
}
}
return out, nil
}
func (r *fakeSeriesQueueRepo) claim(limit int, _ time.Time) ([]models.SeriesRootMatchJob, error) {
if limit <= 0 || limit > len(r.jobs) {
limit = len(r.jobs)
}
out := append([]models.SeriesRootMatchJob(nil), r.jobs[:limit]...)
for _, job := range out {
key := fmt.Sprintf("%d:%s", job.MediaFolderID, job.ObservedRootPath)
r.lastAttemptedAt[key] = time.Now().UTC()
}
return out, nil
}
func (r *fakeSeriesQueueRepo) Delete(_ context.Context, folderID int, observedRootPath string) error {
r.deleted[fmt.Sprintf("%d:%s", folderID, observedRootPath)] = struct{}{}
filtered := r.jobs[:0]
for _, job := range r.jobs {
if job.MediaFolderID == folderID && job.ObservedRootPath == observedRootPath {
continue
}
filtered = append(filtered, job)
}
r.jobs = filtered
return nil
}
func (r *fakeSeriesQueueRepo) UpdateError(_ context.Context, folderID int, observedRootPath string, errText string) error {
r.errors[fmt.Sprintf("%d:%s", folderID, observedRootPath)] = errText
return nil
}
func (r *fakeSeriesQueueRepo) ListByFolder(_ context.Context, folderID int, limit int, offset int) ([]models.SeriesRootMatchQueueEntry, int, error) {
out := make([]models.SeriesRootMatchQueueEntry, 0)
for _, job := range r.jobs {
if job.MediaFolderID != folderID {
continue
}
out = append(out, models.SeriesRootMatchQueueEntry{
MediaFolderID: job.MediaFolderID,
ObservedRootPath: job.ObservedRootPath,
})
}
total := len(out)
if offset > len(out) {
return nil, total, nil
}
out = out[offset:]
if limit > 0 && limit < len(out) {
out = out[:limit]
}
return out, total, nil
}
func (r *fakeSeriesQueueRepo) CountByFolder(_ context.Context, folderID int) (int, error) {
count := 0
for _, job := range r.jobs {
if job.MediaFolderID == folderID {
count++
}
}
return count, nil
}
type fakeMovieQueueRepo struct {
files []*models.MediaFile
errors map[int]string
deleted map[int]struct{}
lastAttemptedAt map[int]time.Time
claimCalls int
scopedClaimCalls int
claimErr error
}
func newFakeMovieQueueRepo(files ...*models.MediaFile) *fakeMovieQueueRepo {
cp := make([]*models.MediaFile, 0, len(files))
for _, file := range files {
if file == nil {
continue
}
fileCopy := *file
cp = append(cp, &fileCopy)
}
return &fakeMovieQueueRepo{
files: cp,
errors: make(map[int]string),
deleted: make(map[int]struct{}),
lastAttemptedAt: make(map[int]time.Time),
}
}
func (r *fakeMovieQueueRepo) Claim(_ context.Context, limit int) ([]*models.MediaFile, error) {
r.claimCalls++
if r.claimErr != nil {
return nil, r.claimErr
}
return r.claim(limit, 0, "", time.Time{})
}
func (r *fakeMovieQueueRepo) ClaimByFolderAndPathPrefix(_ context.Context, folderID int, pathPrefix string, limit int, attemptBefore time.Time) ([]*models.MediaFile, error) {
r.scopedClaimCalls++
return r.claim(limit, folderID, pathPrefix, attemptBefore)
}
func (r *fakeMovieQueueRepo) claim(limit int, folderID int, pathPrefix string, attemptBefore time.Time) ([]*models.MediaFile, error) {
out := make([]*models.MediaFile, 0, len(r.files))
for _, file := range r.files {
if file == nil {
continue
}
if folderID > 0 && file.MediaFolderID != folderID {
continue
}
if pathPrefix != "" && file.FilePath != pathPrefix && !strings.HasPrefix(file.FilePath, pathPrefix+"/") {
continue
}
if claimedAt := r.lastAttemptedAt[file.ID]; !attemptBefore.IsZero() && !claimedAt.IsZero() && !claimedAt.Before(attemptBefore) {
continue
}
fileCopy := *file
out = append(out, &fileCopy)
r.lastAttemptedAt[file.ID] = time.Now().UTC()
if limit > 0 && len(out) >= limit {
break
}
}
return out, nil
}
func (r *fakeMovieQueueRepo) Delete(_ context.Context, mediaFileID int) error {
r.deleted[mediaFileID] = struct{}{}
filtered := r.files[:0]
for _, file := range r.files {
if file != nil && file.ID == mediaFileID {
continue
}
filtered = append(filtered, file)
}
r.files = filtered
return nil
}
func (r *fakeMovieQueueRepo) UpdateError(_ context.Context, mediaFileID int, errText string) error {
r.errors[mediaFileID] = errText
return nil
}
// TestWorkerProcessFile_SkeletonCreatedForNoFolderIDs verifies that the worker
// processes files under roots without folder IDs and creates skeleton items
// (no longer skipping them).
func TestWorkerProcessFile_SkeletonCreatedForNoFolderIDs(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010)/Inception.mkv",
}
// Use the process hook to avoid needing a real provider chain.
// We simulate a failed enrichment (no providers configured).
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
return &ProcessResult{Updated: false}, nil
}
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.ProcessFile(ctx, file)
// The skeleton should have been created.
if cid, ok := h.fileRepo.contentIDs[file.ID]; !ok || cid == "" {
t.Fatal("expected file to be linked to a skeleton item")
}
// The item should exist and be marked "unmatched" (since enrichment returned Updated=false).
cid := h.fileRepo.contentIDs[file.ID]
item, err := h.itemRepo.GetByID(ctx, cid)
if err != nil {
t.Fatalf("item not found: %v", err)
}
if item.Status != "unmatched" {
t.Errorf("expected status=unmatched after failed enrichment, got %q", item.Status)
}
}
// TestWorkerProcessFile_EnrichmentFailureTransitionsToUnmatched verifies that
// when Process returns an error, the item status is set to "unmatched".
func TestWorkerProcessFile_EnrichmentFailureTransitionsToUnmatched(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/The Matrix (1999) {tmdb-603}/The.Matrix.mkv",
}
// Simulate enrichment error.
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
return nil, ErrMetadataNotFound
}
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.ProcessFile(ctx, file)
cid := h.fileRepo.contentIDs[file.ID]
if cid == "" {
t.Fatal("expected file to be linked to a skeleton item")
}
item, err := h.itemRepo.GetByID(ctx, cid)
if err != nil {
t.Fatalf("item not found: %v", err)
}
if item.Status != "unmatched" {
t.Errorf("expected status=unmatched, got %q", item.Status)
}
}
func TestProcessUnmatched_ContinuesAfterSeriesClaimError(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
seriesRepo := newFakeSeriesQueueRepo()
seriesRepo.claimErr = errors.New("series queue unavailable")
movieRepo := newFakeMovieQueueRepo()
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.enableTVSeriesRootQueue = true
worker.seriesClaimer = seriesRepo
worker.movieClaimer = movieRepo
worker.processUnmatched(ctx)
if seriesRepo.claimCalls != 1 {
t.Fatalf("series claim calls = %d, want 1", seriesRepo.claimCalls)
}
if movieRepo.claimCalls != 1 {
t.Fatalf("movie claim calls = %d, want 1", movieRepo.claimCalls)
}
if h.fileRepo.claimMixedCalls != 1 {
t.Fatalf("mixed background claim calls = %d, want 1", h.fileRepo.claimMixedCalls)
}
}
func TestProcessUnmatched_ContinuesAfterSeriesProcessingError(t *testing.T) {
ctx := context.Background()
fileLister := newFakeFileRepo()
seriesRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{
MediaFolderID: 10,
ObservedRootPath: "/media/shows/Example Show",
SampleFilePath: "/media/shows/Example Show/Season 01/Episode.mkv",
})
movieRepo := newFakeMovieQueueRepo()
worker := NewMatchWorker(nil, fileLister, 1, 10, 0)
worker.enableTVSeriesRootQueue = true
worker.seriesClaimer = seriesRepo
worker.movieClaimer = movieRepo
worker.processUnmatched(ctx)
if seriesRepo.claimCalls != 1 {
t.Fatalf("series claim calls = %d, want 1", seriesRepo.claimCalls)
}
if movieRepo.claimCalls != 1 {
t.Fatalf("movie claim calls = %d, want 1", movieRepo.claimCalls)
}
if fileLister.claimMixedCalls != 1 {
t.Fatalf("mixed background claim calls = %d, want 1", fileLister.claimMixedCalls)
}
}
func TestWorkerProcessFile_TrustedExplicitIDBypassesAmbiguousSkeleton(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/Predator (1987)/Predator Ultimate Hunter Edition (1987) {tmdb-106}.mkv",
GroupKeyVersion: 1,
ContentGroupKey: "v1|movie|predator|1987",
}
h.scannedGroupRepo.setGroup(&models.ScannedMediaGroup{
MediaFolderID: 10,
GroupKeyVersion: 1,
ContentGroupKey: "v1|movie|predator|1987",
BaseTitle: "Predator",
BaseYear: 1987,
InferredType: "movie",
State: "ambiguous",
})
called := false
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
called = true
if got, want := req.Hints.TmdbID, "106"; got != want {
t.Fatalf("Hints.TmdbID = %q, want %q", got, want)
}
return &ProcessResult{Updated: false}, nil
}
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.ProcessFile(ctx, file)
if !called {
t.Fatal("expected process hook to run for trusted explicit IDs")
}
}
// TestWorkerProcessFile_LibraryMembershipForPending verifies that when a
// skeleton is created for a file (no folder IDs), a library membership
// exists immediately — before enrichment runs.
func TestWorkerProcessFile_LibraryMembershipForPending(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010)/Inception.mkv",
}
var capturedContentID string
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
capturedContentID = req.ContentID
// At this point the skeleton is created; verify membership exists.
if !h.libraryRepo.hasMembership(req.ContentID, 10) {
t.Error("expected library membership to exist before enrichment runs")
}
return &ProcessResult{Updated: false}, nil
}
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.ProcessFile(ctx, file)
// Also verify membership after enrichment.
if capturedContentID == "" {
t.Fatal("expected process hook to be called with a content_id")
}
if !h.libraryRepo.hasMembership(capturedContentID, 10) {
t.Error("expected library membership to persist after enrichment")
}
}
func TestWorkerProcessFile_SkipsDisabledLibrary(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Enabled: false},
},
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010)/Inception.mkv",
}
called := false
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
called = true
return &ProcessResult{Updated: false}, nil
}
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.ProcessFile(ctx, file)
if called {
t.Fatal("expected matcher to skip files in disabled libraries")
}
if cid := h.fileRepo.contentIDs[file.ID]; cid != "" {
t.Fatalf("expected file to remain unmatched, got content_id %q", cid)
}
}
func TestWorkerProcessFile_MoviesLibraryEpisodeShapedMovieUsesMovieHints(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "movies", Enabled: true},
},
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/s01e03 (2020) {imdb-tt12261772} {tmdb-588077}/s01e03 (2020).mkv",
}
var capturedType string
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
capturedType = req.Hints.Type
return &ProcessResult{Updated: false}, nil
}
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.ProcessFile(ctx, file)
if capturedType != "movie" {
t.Fatalf("Hints.Type = %q, want movie", capturedType)
}
}
func TestWorkerProcessFile_MixedLibraryEpisodeShapedMovieUsesMovieHints(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "mixed", Enabled: true},
},
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/mixed/s01e03 (2020) {imdb-tt12261772} {tmdb-588077}/s01e03 (2020).mkv",
}
var capturedType string
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
capturedType = req.Hints.Type
return &ProcessResult{Updated: false}, nil
}
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.ProcessFile(ctx, file)
if capturedType != "movie" {
t.Fatalf("Hints.Type = %q, want movie", capturedType)
}
}
func TestWorkerProcessFile_MovieDoesNotExpandScannerGroupPaths(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "movies", Enabled: true},
},
}
representative := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010)/Inception.1080p.mkv",
GroupKeyVersion: 1,
ContentGroupKey: "v1|movie|inception|2010",
}
otherVariant := &models.MediaFile{
ID: 2,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010)/Inception.2160p.mkv",
GroupKeyVersion: 1,
ContentGroupKey: "v1|movie|inception|2010",
}
h.fileRepo.setGroupFiles(10, 1, "v1|movie|inception|2010", representative, otherVariant)
var capturedPaths []string
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
capturedPaths = append([]string(nil), req.Hints.AllGroupFilePaths...)
return &ProcessResult{Updated: false}, nil
}
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.ProcessFile(ctx, representative)
if len(capturedPaths) != 1 {
t.Fatalf("AllGroupFilePaths len = %d, want 1", len(capturedPaths))
}
if capturedPaths[0] != representative.FilePath {
t.Fatalf("AllGroupFilePaths[0] = %q, want %q", capturedPaths[0], representative.FilePath)
}
}
func TestWorkerProcessFiles_SeriesBatchProcessesOneRepresentativePerGroup(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "series", Enabled: true},
},
}
files := []*models.MediaFile{
{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E01.mkv",
GroupKeyVersion: 1,
ContentGroupKey: "v1|series|example_show|2024",
},
{
ID: 2,
MediaFolderID: 10,
FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E02.mkv",
GroupKeyVersion: 1,
ContentGroupKey: "v1|series|example_show|2024",
},
}
processCalls := 0
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
processCalls++
return &ProcessResult{Updated: true}, nil
}
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
processed := worker.processFiles(ctx, files)
if processed != 2 {
t.Fatalf("processed = %d, want original claimed file count 2", processed)
}
if processCalls != 1 {
t.Fatalf("processCalls = %d, want 1", processCalls)
}
}
func TestWorkerProcessAllByFolderAndPathPrefix_SeriesFolderUsesRootQueue(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "series", Enabled: true},
},
}
files := []*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",
},
{
ID: 2,
MediaFolderID: 10,
FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E02.mkv",
ObservedRootPath: "/media/shows/Example Show",
GroupKeyVersion: 1,
ContentGroupKey: "v1|series|example_show|2024",
},
}
h.fileRepo.setGroupFiles(10, 1, "v1|series|example_show|2024", files...)
processCalls := 0
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
processCalls++
return &ProcessResult{Updated: true}, nil
}
queueRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{
MediaFolderID: 10,
ObservedRootPath: "/media/shows/Example Show",
SampleFilePath: files[0].FilePath,
ObservedFileCount: 2,
})
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("ProcessAllByFolderAndPathPrefix error = %v", err)
}
if processed != 2 {
t.Fatalf("processed = %d, want 2", processed)
}
if processCalls != 1 {
t.Fatalf("processCalls = %d, want 1", processCalls)
}
if h.fileRepo.claimUnmatchedCalls != 0 {
t.Fatalf("claimUnmatchedCalls = %d, want 0", h.fileRepo.claimUnmatchedCalls)
}
if _, ok := queueRepo.deleted["10:/media/shows/Example Show"]; !ok {
t.Fatal("expected queue row to be deleted")
}
}
func TestWorkerProcessAllByFolderAndPathPrefix_MovieFolderUsesMovieQueue(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "movies", Enabled: true},
},
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010)/Inception.mkv",
}
h.fileRepo.setGroupFiles(10, 1, "movie", file)
processCalls := 0
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
processCalls++
return &ProcessResult{Updated: true}, nil
}
seriesQueueRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{
MediaFolderID: 10,
ObservedRootPath: "/media/movies/Inception (2010)",
SampleFilePath: file.FilePath,
ObservedFileCount: 1,
})
movieQueueRepo := newFakeMovieQueueRepo(file)
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.SetSeriesRootClaimer(seriesQueueRepo, true)
worker.SetMovieFileClaimer(movieQueueRepo)
processed, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/movies/Inception (2010)", time.Time{})
if err != nil {
t.Fatalf("ProcessAllByFolderAndPathPrefix error = %v", err)
}
if processed != 1 {
t.Fatalf("processed = %d, want 1", processed)
}
if processCalls != 1 {
t.Fatalf("processCalls = %d, want 1", processCalls)
}
if seriesQueueRepo.scopedClaimCalls != 0 {
t.Fatalf("scopedClaimCalls = %d, want 0", seriesQueueRepo.scopedClaimCalls)
}
if movieQueueRepo.scopedClaimCalls == 0 {
t.Fatal("expected movie queue to be used")
}
if _, ok := movieQueueRepo.deleted[file.ID]; !ok {
t.Fatal("expected movie queue row to be deleted")
}
if h.fileRepo.claimNonSeriesCalls != 0 {
t.Fatalf("claimNonSeriesCalls = %d, want 0", h.fileRepo.claimNonSeriesCalls)
}
}
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()
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",
}
h.fileRepo.setGroupFiles(10, 1, "v1|series|example_show|2024", file)
h.service.hooks.createOrFindSkeleton = func(_ context.Context, _ *models.MediaFile, _ int) (*skeletonResult, error) {
return nil, fmt.Errorf("boom")
}
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("ProcessAllByFolderAndPathPrefix error = %v", err)
}
if processed != 0 {
t.Fatalf("processed = %d, want 0", processed)
}
if _, ok := queueRepo.deleted["10:/media/shows/Example Show"]; ok {
t.Fatal("did not expect queue row to be deleted")
}
if queueRepo.errors["10:/media/shows/Example Show"] == "" {
t.Fatal("expected queue error to be recorded")
}
}
func TestWorkerProcessAllByFolderAndPathPrefix_SeriesRootEnrichmentFailureKeepsQueueRow(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)
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
return nil, ErrMetadataNotFound
}
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("ProcessAllByFolderAndPathPrefix error = %v", err)
}
if processed != 0 {
t.Fatalf("processed = %d, want 0", processed)
}
if _, ok := queueRepo.deleted["10:/media/shows/Example Show"]; ok {
t.Fatal("did not expect queue row to be deleted")
}
if queueRepo.errors["10:/media/shows/Example Show"] == "" {
t.Fatal("expected queue error to be recorded")
}
contentID := h.fileRepo.rootContent["10:/media/shows/Example Show"]
item, err := h.itemRepo.GetByID(ctx, contentID)
if err != nil {
t.Fatalf("item not found: %v", err)
}
if item.Status != "unmatched" {
t.Fatalf("item.Status = %q, want unmatched", item.Status)
}
}
func TestWorkerProcessAllByFolderAndPathPrefix_MovieQueueFailureDoesNotStopLaterClaims(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "movies", Enabled: true},
},
}
first := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010)/Inception.mkv",
}
second := &models.MediaFile{
ID: 2,
MediaFolderID: 10,
FilePath: "/media/movies/Interstellar (2014)/Interstellar.mkv",
}
movieQueueRepo := newFakeMovieQueueRepo(first, second)
processCalls := 0
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
processCalls++
if req.ContentID == h.fileRepo.contentIDs[first.ID] {
return nil, ErrMetadataNotFound
}
return &ProcessResult{Updated: true}, nil
}
worker := NewMatchWorker(h.service, h.fileRepo, 1, 1, 0)
worker.SetMovieFileClaimer(movieQueueRepo)
processed, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/movies", time.Time{})
if err != nil {
t.Fatalf("ProcessAllByFolderAndPathPrefix error = %v", err)
}
if processed != 1 {
t.Fatalf("processed = %d, want 1 successful item", processed)
}
if processCalls != 2 {
t.Fatalf("processCalls = %d, want 2", processCalls)
}
if _, ok := movieQueueRepo.deleted[first.ID]; ok {
t.Fatal("did not expect failed movie queue row to be deleted")
}
if _, ok := movieQueueRepo.deleted[second.ID]; !ok {
t.Fatal("expected successful movie queue row to be deleted")
}
if movieQueueRepo.errors[first.ID] == "" {
t.Fatal("expected failed movie queue row to record an error")
}
}
func TestWorkerProcessAllByFolderAndPathPrefix_MovieQueueFailureKeepsQueueRow(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "movies", Enabled: true},
},
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010)/Inception.mkv",
}
movieQueueRepo := newFakeMovieQueueRepo(file)
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
return nil, ErrMetadataNotFound
}
worker := NewMatchWorker(h.service, h.fileRepo, 1, 1, 0)
worker.SetMovieFileClaimer(movieQueueRepo)
processed, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/movies/Inception (2010)", time.Time{})
if err != nil {
t.Fatalf("ProcessAllByFolderAndPathPrefix error = %v", err)
}
if processed != 0 {
t.Fatalf("processed = %d, want 0", processed)
}
if _, ok := movieQueueRepo.deleted[file.ID]; ok {
t.Fatal("did not expect movie queue row to be deleted")
}
if movieQueueRepo.errors[file.ID] == "" {
t.Fatal("expected queue error to be recorded")
}
contentID := h.fileRepo.contentIDs[file.ID]
item, err := h.itemRepo.GetByID(ctx, contentID)
if err != nil {
t.Fatalf("item not found: %v", err)
}
if item.Status != "unmatched" {
t.Fatalf("item.Status = %q, want unmatched", item.Status)
}
}
func TestWorkerClaimBackgroundFiles_WithMovieQueueAndTVQueueDisabledUsesGenericClaims(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E01.mkv",
}
h.fileRepo.setGroupFiles(10, 0, "", file)
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.SetMovieFileClaimer(newFakeMovieQueueRepo())
files, err := worker.claimBackgroundFiles(ctx)
if err != nil {
t.Fatalf("claimBackgroundFiles error = %v", err)
}
if len(files) != 1 || files[0].ID != file.ID {
t.Fatalf("files = %#v, want [%d]", files, file.ID)
}
if h.fileRepo.claimMixedCalls != 0 {
t.Fatalf("claimMixedCalls = %d, want 0", h.fileRepo.claimMixedCalls)
}
if h.fileRepo.claimUnmatchedCalls == 0 {
t.Fatal("expected generic unmatched claims to be used")
}
}
func TestWorkerClaimBackgroundFiles_WithMovieQueueAndTVQueueEnabledUsesMixedClaims(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/mixed/Movie/Example.mkv",
}
h.fileRepo.setGroupFiles(10, 0, "", file)
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.SetMovieFileClaimer(newFakeMovieQueueRepo())
worker.SetSeriesRootClaimer(newFakeSeriesQueueRepo(), true)
files, err := worker.claimBackgroundFiles(ctx)
if err != nil {
t.Fatalf("claimBackgroundFiles error = %v", err)
}
if len(files) != 1 || files[0].ID != file.ID {
t.Fatalf("files = %#v, want [%d]", files, file.ID)
}
if h.fileRepo.claimMixedCalls == 0 {
t.Fatal("expected mixed unmatched claims to be used")
}
if h.fileRepo.claimNonSeriesCalls != 0 {
t.Fatalf("claimNonSeriesCalls = %d, want 0", h.fileRepo.claimNonSeriesCalls)
}
if h.fileRepo.claimUnmatchedCalls != 0 {
t.Fatalf("claimUnmatchedCalls = %d, want 0", h.fileRepo.claimUnmatchedCalls)
}
}
func TestWorkerClaimScopedFiles_WithMovieQueueAndTVQueueDisabledUsesScopedGenericClaims(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E01.mkv",
}
h.fileRepo.setGroupFiles(10, 0, "", file)
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.SetMovieFileClaimer(newFakeMovieQueueRepo())
files, err := worker.claimScopedFiles(ctx, 10, "/media/shows/Example Show", time.Time{}, scopedFallbackGeneric)
if err != nil {
t.Fatalf("claimScopedFiles error = %v", err)
}
if len(files) != 1 || files[0].ID != file.ID {
t.Fatalf("files = %#v, want [%d]", files, file.ID)
}
if h.fileRepo.claimMixedCalls != 0 {
t.Fatalf("claimMixedCalls = %d, want 0", h.fileRepo.claimMixedCalls)
}
if h.fileRepo.claimUnmatchedCalls == 0 {
t.Fatal("expected scoped generic unmatched claims to be used")
}
}
func TestWorkerProcessAllByFolderAndPathPrefix_MovieQueueRetryReusesLinkedSkeleton(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "movies", Enabled: true},
},
}
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: "pending-movie",
Status: "pending",
Title: "Inception",
Year: 2010,
Type: "movie",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert pending movie: %v", err)
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010)/Inception.mkv",
ContentID: "pending-movie",
BaseTitle: "Inception",
BaseYear: 2010,
BaseType: "movie",
}
h.fileRepo.contentIDs[file.ID] = "pending-movie"
movieQueueRepo := newFakeMovieQueueRepo(file)
createOrFindCalls := 0
h.service.hooks.createOrFindSkeleton = func(_ context.Context, _ *models.MediaFile, _ int) (*skeletonResult, error) {
createOrFindCalls++
return nil, fmt.Errorf("unexpected skeleton creation")
}
processCalls := 0
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
processCalls++
if req.ContentID != "pending-movie" {
t.Fatalf("req.ContentID = %q, want pending-movie", req.ContentID)
}
return nil, ErrMetadataNotFound
}
worker := NewMatchWorker(h.service, h.fileRepo, 1, 1, 0)
worker.SetMovieFileClaimer(movieQueueRepo)
processed, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/movies/Inception (2010)", time.Time{})
if err != nil {
t.Fatalf("ProcessAllByFolderAndPathPrefix error = %v", err)
}
if processed != 0 {
t.Fatalf("processed = %d, want 0", processed)
}
if createOrFindCalls != 0 {
t.Fatalf("createOrFindCalls = %d, want 0", createOrFindCalls)
}
if processCalls != 1 {
t.Fatalf("processCalls = %d, want 1", processCalls)
}
if got := h.fileRepo.contentIDs[file.ID]; got != "pending-movie" {
t.Fatalf("contentIDs[%d] = %q, want pending-movie", file.ID, got)
}
if _, ok := movieQueueRepo.deleted[file.ID]; ok {
t.Fatal("did not expect movie queue row to be deleted")
}
}
func TestWorkerProcessBatchByFolderAndPathPrefix_MovieQueueClaimsOnlyOncePerScan(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "movies", Enabled: true},
},
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010)/Inception.mkv",
}
movieQueueRepo := newFakeMovieQueueRepo(file)
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
return nil, ErrMetadataNotFound
}
worker := NewMatchWorker(h.service, h.fileRepo, 1, 1, 0)
worker.SetMovieFileClaimer(movieQueueRepo)
attemptBefore := time.Now().UTC()
firstProcessed, err := worker.ProcessBatchByFolderAndPathPrefix(ctx, 10, "/media/movies/Inception (2010)", attemptBefore)
if err != nil {
t.Fatalf("first ProcessBatchByFolderAndPathPrefix error = %v", err)
}
secondProcessed, err := worker.ProcessBatchByFolderAndPathPrefix(ctx, 10, "/media/movies/Inception (2010)", attemptBefore)
if err != nil {
t.Fatalf("second ProcessBatchByFolderAndPathPrefix error = %v", err)
}
if firstProcessed != 0 {
t.Fatalf("firstProcessed = %d, want 0", firstProcessed)
}
if secondProcessed != 0 {
t.Fatalf("secondProcessed = %d, want 0", secondProcessed)
}
if movieQueueRepo.errors[file.ID] == "" {
t.Fatal("expected queue error to be recorded")
}
}