Files
silo-server/internal/metadata/worker_test.go
22fec4ed2d feat(metadata): add resilient match queue diagnostics (#463)
* feat(metadata): add resilient match queue diagnostics

* fix(metadata): harden match queue lifecycle

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-07-24 12:18:49 -04:00

1779 lines
57 KiB
Go

package metadata
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
const queuedShowName = "Show Name"
func TestQueueClaimSizeNeverExceedsAvailableWorkers(t *testing.T) {
worker := NewMatchWorker(nil, nil, 8, 500, time.Second)
if got := worker.queueClaimSize(); got != 8 {
t.Fatalf("queue claim size = %d, want 8 workers", got)
}
worker.SetConcurrency(32, 4)
if got := worker.queueClaimSize(); got != 4 {
t.Fatalf("queue claim size = %d, want batch cap 4", got)
}
}
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
releasedLeases []string
}
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
}
job.LeaseToken = "fake-series-lease"
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 i, job := range out {
out[i].LeaseToken = "fake-series-lease"
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) UpdateFailure(ctx context.Context, folderID int, observedRootPath, leaseToken string, failure MatchFailure) error {
return r.UpdateError(ctx, folderID, observedRootPath, leaseToken, failure.Message)
}
func (r *fakeSeriesQueueRepo) ReleaseLease(_ context.Context, leaseToken string) (int, error) {
r.releasedLeases = append(r.releasedLeases, leaseToken)
return 1, 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
releasedLeases []string
}
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.MovieMatchJob, 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.MovieMatchJob, error) {
r.scopedClaimCalls++
return r.claim(limit, folderID, pathPrefix, attemptBefore)
}
func (r *fakeMovieQueueRepo) claim(limit int, folderID int, pathPrefix string, attemptBefore time.Time) ([]models.MovieMatchJob, error) {
out := make([]models.MovieMatchJob, 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, models.MovieMatchJob{File: &fileCopy, LeaseToken: "fake-movie-lease"})
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, _ string) 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, _ string, errText string) error {
r.errors[mediaFileID] = errText
return nil
}
func (r *fakeMovieQueueRepo) UpdateFailure(ctx context.Context, mediaFileID int, leaseToken string, failure MatchFailure) error {
return r.UpdateError(ctx, mediaFileID, leaseToken, failure.Message)
}
func (r *fakeMovieQueueRepo) ReleaseLease(_ context.Context, leaseToken string) (int, error) {
r.releasedLeases = append(r.releasedLeases, leaseToken)
return 1, nil
}
func TestProcessQueuedMovieFiles_ReleasesUnfinishedBatchOnCancellation(t *testing.T) {
const leaseToken = "movie-batch"
repo := newFakeMovieQueueRepo()
worker := NewMatchWorker(nil, nil, 2, 10, 0)
worker.movieClaimer = repo
ctx, cancel := context.WithCancel(context.Background())
cancel()
processed := worker.processQueuedMovieFiles(ctx, []models.MovieMatchJob{
{File: &models.MediaFile{ID: 1}, LeaseToken: leaseToken},
{File: &models.MediaFile{ID: 2}, LeaseToken: leaseToken},
})
if processed != 0 {
t.Fatalf("processed = %d, want 0", processed)
}
if got := repo.releasedLeases; len(got) != 1 || got[0] != leaseToken {
t.Fatalf("released leases = %v, want [%s]", got, leaseToken)
}
}
func TestProcessSeriesRoots_ReleasesUnfinishedBatchOnCancellation(t *testing.T) {
const leaseToken = "series-batch"
repo := newFakeSeriesQueueRepo()
worker := NewMatchWorker(nil, nil, 2, 10, 0)
worker.seriesClaimer = repo
ctx, cancel := context.WithCancel(context.Background())
cancel()
processed, err := worker.processSeriesRoots(ctx, []models.SeriesRootMatchJob{
{MediaFolderID: 1, ObservedRootPath: "/shows/One", LeaseToken: leaseToken},
{MediaFolderID: 1, ObservedRootPath: "/shows/Two", LeaseToken: leaseToken},
})
if err != nil {
t.Fatalf("process series roots: %v", err)
}
if processed != 0 {
t.Fatalf("processed = %d, want 0", processed)
}
if got := repo.releasedLeases; len(got) != 1 || got[0] != leaseToken {
t.Fatalf("released leases = %v, want [%s]", got, leaseToken)
}
}
// 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")
}
}
func TestWorkerProcessBatchByFolderAndPathPrefix_ZeroResultSeriesClaimFallsThroughToMovie(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "mixed", Enabled: true},
},
}
pathPrefix := "/media/mixed/Example"
seriesQueue := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{
MediaFolderID: 10,
ObservedRootPath: pathPrefix + "/Missing Show",
SampleFilePath: pathPrefix + "/Missing Show/Show S01E01.mkv",
})
movieFile := &models.MediaFile{
ID: 2,
MediaFolderID: 10,
FilePath: pathPrefix + "/Movie (2026)/Movie.mkv",
BaseType: "movie",
}
movieQueue := newFakeMovieQueueRepo(movieFile)
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
return &ProcessResult{Updated: true}, nil
}
worker := NewMatchWorker(h.service, h.fileRepo, 1, 1, 0)
worker.SetSeriesRootClaimer(seriesQueue, true)
worker.SetMovieFileClaimer(movieQueue)
_, err := worker.ProcessBatchByFolderAndPathPrefix(ctx, 10, pathPrefix, time.Now().UTC())
if err != nil {
t.Fatalf("ProcessBatchByFolderAndPathPrefix error = %v", err)
}
if movieQueue.scopedClaimCalls == 0 {
t.Fatal("zero-result series claim suppressed the movie queue")
}
if movieQueue.lastAttemptedAt[movieFile.ID].IsZero() {
t.Fatal("movie fallback job was not claimed")
}
}
func TestWorkerProcessBatchByFolderAndPathPrefix_ZeroResultMovieClaimFallsThroughToRaw(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "mixed", Enabled: true},
},
}
pathPrefix := "/media/mixed/Example"
queuedFile := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: pathPrefix + "/Queued Movie (2026)/Queued.mkv",
BaseType: "movie",
}
rawFile := &models.MediaFile{
ID: 2,
MediaFolderID: 10,
FilePath: pathPrefix + "/Raw Movie (2025)/Raw.mkv",
BaseType: "movie",
GroupKeyVersion: 1,
ContentGroupKey: "v1|movie|raw_movie|2025",
}
h.fileRepo.setGroupFiles(10, rawFile.GroupKeyVersion, rawFile.ContentGroupKey, rawFile)
processCalls := 0
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
processCalls++
if processCalls == 1 {
return nil, ErrMetadataNotFound
}
return &ProcessResult{Updated: true}, nil
}
worker := NewMatchWorker(h.service, h.fileRepo, 1, 1, 0)
worker.SetSeriesRootClaimer(newFakeSeriesQueueRepo(), true)
worker.SetMovieFileClaimer(newFakeMovieQueueRepo(queuedFile))
processed, err := worker.ProcessBatchByFolderAndPathPrefix(ctx, 10, pathPrefix, time.Now().UTC())
if err != nil {
t.Fatalf("ProcessBatchByFolderAndPathPrefix error = %v", err)
}
if processed != 1 {
t.Fatalf("processed = %d, want raw fallback result", processed)
}
if h.fileRepo.claimMixedCalls == 0 {
t.Fatal("zero-result movie claim suppressed the raw mixed fallback")
}
if processCalls != 2 {
t.Fatalf("process calls = %d, want queued attempt plus raw fallback", processCalls)
}
}
// ---------------------------------------------------------------------------
// Tests for concurrent-merge ErrItemNotFound tolerance (hotfix 2026-05-27)
// ---------------------------------------------------------------------------
// TestProcessSeriesRoot_Site1_ErrItemNotFoundIsToleratedNotFailed verifies that
// when ensureSeriesEpisodeLinks returns catalog.ErrItemNotFound at Site 1
// (the "all files already linked" fast path), the batch succeeds instead of
// failing. This covers the concurrent-merge case where the source series row
// was deleted after its episodes were moved to the survivor.
func TestProcessSeriesRoot_Site1_ErrItemNotFoundIsToleratedNotFailed(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
// Need a series-type folder so queueUsageForFolder enables the series queue.
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "series", Enabled: true},
},
}
// Pre-populate the item as "matched" so reusableQueuedMovieSkeleton
// returns false (matched is not skeleton-like), bypassing the re-process
// block and falling through directly to ensureSeriesEpisodeLinks.
const contentID = "series-gone-after-merge"
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: contentID,
Status: "matched",
Title: "Example Show",
Type: "series",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert item: %v", err)
}
// All files are already linked — hasUnlinkedGroupFile returns false.
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E01.mkv",
ObservedRootPath: "/media/shows/Example Show",
GroupKeyVersion: 1,
ContentGroupKey: "v1|series|example_show|2024",
ContentID: contentID,
}
h.fileRepo.setGroupFiles(10, 1, "v1|series|example_show|2024", file)
h.fileRepo.contentIDs[file.ID] = contentID
// Hook ensureSeriesEpisodeLinks to simulate the source row being gone.
h.service.hooks.ensureSeriesEpisodeLinks = func(_ context.Context, _ string) error {
return fmt.Errorf("loading series item: %w", catalog.ErrItemNotFound)
}
queueRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{
MediaFolderID: 10,
ObservedRootPath: "/media/shows/Example Show",
SampleFilePath: file.FilePath,
ObservedFileCount: 1,
})
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.SetSeriesRootClaimer(queueRepo, true)
processed, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/shows/Example Show", time.Time{})
if err != nil {
t.Fatalf("expected no error for ErrItemNotFound (benign concurrent merge), got: %v", err)
}
if processed != 1 {
t.Fatalf("processed = %d, want 1", processed)
}
// Queue row must be deleted on normal completion.
if _, ok := queueRepo.deleted["10:/media/shows/Example Show"]; !ok {
t.Fatal("expected queue row to be deleted on normal completion")
}
// No error must have been recorded in the queue.
if queueRepo.errors["10:/media/shows/Example Show"] != "" {
t.Fatalf("expected no queue error, got %q", queueRepo.errors["10:/media/shows/Example Show"])
}
}
// TestProcessSeriesRoot_Site1_NonNotFoundErrorStillFails verifies that a
// genuine (non-ErrItemNotFound) error from ensureSeriesEpisodeLinks at Site 1
// still fails the batch — the fix must not swallow real errors.
func TestProcessSeriesRoot_Site1_NonNotFoundErrorStillFails(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "series", Enabled: true},
},
}
const contentID = "series-link-failure"
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: contentID,
Status: "matched",
Title: "Example Show",
Type: "series",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert item: %v", err)
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E01.mkv",
ObservedRootPath: "/media/shows/Example Show",
GroupKeyVersion: 1,
ContentGroupKey: "v1|series|example_show|2024",
ContentID: contentID,
}
h.fileRepo.setGroupFiles(10, 1, "v1|series|example_show|2024", file)
h.fileRepo.contentIDs[file.ID] = contentID
// Return a genuine (non-not-found) error.
linkErr := errors.New("database connection reset")
h.service.hooks.ensureSeriesEpisodeLinks = func(_ context.Context, _ string) error {
return linkErr
}
queueRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{
MediaFolderID: 10,
ObservedRootPath: "/media/shows/Example Show",
SampleFilePath: file.FilePath,
ObservedFileCount: 1,
})
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.SetSeriesRootClaimer(queueRepo, true)
_, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/shows/Example Show", time.Time{})
if err == nil {
t.Fatal("expected error for genuine link failure, got nil")
}
if !strings.Contains(err.Error(), "ensuring series episode links") {
t.Fatalf("unexpected error message: %v", err)
}
}
// TestProcessSeriesRoot_Site2_ErrItemNotFoundIsToleratedNotFailed verifies that
// when ensureSeriesEpisodeLinks returns catalog.ErrItemNotFound at Site 2
// (the new-skeleton / enrichment path), the batch succeeds.
func TestProcessSeriesRoot_Site2_ErrItemNotFoundIsToleratedNotFailed(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "series", Enabled: true},
},
}
// File has no ContentID yet → hasUnlinkedGroupFile returns true →
// proceeds through createOrFindSkeleton + enrichment path (Site 2).
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E01.mkv",
ObservedRootPath: "/media/shows/Example Show",
GroupKeyVersion: 1,
ContentGroupKey: "v1|series|example_show|2024",
BaseTitle: "Example Show",
BaseType: "series",
}
h.fileRepo.setGroupFiles(10, 1, "v1|series|example_show|2024", file)
const skeletonID = "skeleton-series-id"
h.service.hooks.createOrFindSkeleton = func(_ context.Context, _ *models.MediaFile, _ int) (*skeletonResult, error) {
return &skeletonResult{
ContentID: skeletonID,
IsNew: true,
ItemStatus: "pending",
Type: "series",
}, nil
}
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
return &ProcessResult{Updated: true}, nil
}
// Simulate the concurrent-merge: ensureSeriesEpisodeLinks finds the source gone.
h.service.hooks.ensureSeriesEpisodeLinks = func(_ context.Context, _ string) error {
return fmt.Errorf("loading series item: %w", catalog.ErrItemNotFound)
}
queueRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{
MediaFolderID: 10,
ObservedRootPath: "/media/shows/Example Show",
SampleFilePath: file.FilePath,
ObservedFileCount: 1,
})
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.SetSeriesRootClaimer(queueRepo, true)
processed, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/shows/Example Show", time.Time{})
if err != nil {
t.Fatalf("expected no error for ErrItemNotFound (benign concurrent merge), got: %v", err)
}
if processed != 1 {
t.Fatalf("processed = %d, want 1", processed)
}
if _, ok := queueRepo.deleted["10:/media/shows/Example Show"]; !ok {
t.Fatal("expected queue row to be deleted on normal completion")
}
if queueRepo.errors["10:/media/shows/Example Show"] != "" {
t.Fatalf("expected no queue error, got %q", queueRepo.errors["10:/media/shows/Example Show"])
}
}
// TestProcessSeriesRoot_Site2_NonNotFoundErrorStillFails verifies that a genuine
// error from ensureSeriesEpisodeLinks at Site 2 still fails the batch.
func TestProcessSeriesRoot_Site2_NonNotFoundErrorStillFails(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "series", Enabled: true},
},
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E01.mkv",
ObservedRootPath: "/media/shows/Example Show",
GroupKeyVersion: 1,
ContentGroupKey: "v1|series|example_show|2024",
BaseTitle: "Example Show",
BaseType: "series",
}
h.fileRepo.setGroupFiles(10, 1, "v1|series|example_show|2024", file)
const skeletonID = "skeleton-series-id-2"
h.service.hooks.createOrFindSkeleton = func(_ context.Context, _ *models.MediaFile, _ int) (*skeletonResult, error) {
return &skeletonResult{
ContentID: skeletonID,
IsNew: true,
ItemStatus: "pending",
Type: "series",
}, nil
}
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
return &ProcessResult{Updated: true}, nil
}
linkErr := errors.New("db timeout")
h.service.hooks.ensureSeriesEpisodeLinks = func(_ context.Context, _ string) error {
return linkErr
}
queueRepo := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{
MediaFolderID: 10,
ObservedRootPath: "/media/shows/Example Show",
SampleFilePath: file.FilePath,
ObservedFileCount: 1,
})
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.SetSeriesRootClaimer(queueRepo, true)
_, err := worker.ProcessAllByFolderAndPathPrefix(ctx, 10, "/media/shows/Example Show", time.Time{})
if err == nil {
t.Fatal("expected error for genuine link failure at site 2, got nil")
}
if !strings.Contains(err.Error(), "ensuring series episode links") {
t.Fatalf("unexpected error message: %v", err)
}
}
func TestReparseQueuedFileIdentityUsesCurrentPathWithoutMutatingScanRow(t *testing.T) {
t.Parallel()
const expectedTitle = "10 Tricks"
file := &models.MediaFile{
FilePath: "/movies/10 Tricks (2022)/10 Tricks (2022) (tt0473100) [WEBDL-1080p].mp4",
BaseTitle: "stale scanner title",
BaseYear: 0,
BaseType: "movie",
}
current := reparseQueuedFileIdentity(file, "movie")
if current == file {
t.Fatal("reparse returned the persisted model pointer")
}
if current.BaseTitle != expectedTitle || current.BaseYear != 2022 || current.BaseType != matchContentTypeMovie {
t.Fatalf("reparsed identity = %q/%d/%q", current.BaseTitle, current.BaseYear, current.BaseType)
}
if file.BaseTitle != "stale scanner title" || file.BaseYear != 0 {
t.Fatalf("persisted scan model was mutated: %#v", file)
}
}
func TestReparseQueuedFileIdentityClearsStaleYearAndUsesQueueType(t *testing.T) {
t.Parallel()
file := &models.MediaFile{
FilePath: "/movies/Show Name/Show Name.mkv",
BaseTitle: "stale",
BaseYear: 1999,
BaseType: "series",
}
current := reparseQueuedFileIdentity(file, "movie")
if current.BaseTitle != queuedShowName || current.BaseYear != 0 || current.BaseType != "movie" {
t.Fatalf("reparsed identity = %q/%d/%q, want Show Name/0/movie", current.BaseTitle, current.BaseYear, current.BaseType)
}
if file.BaseYear != 1999 || file.BaseType != "series" {
t.Fatalf("persisted scan model was mutated: %#v", file)
}
}
func TestReusableQueuedMovieSkeletonPreservesKnownYearWhenCurrentPathHasNone(t *testing.T) {
t.Parallel()
h := newTestHarness()
ctx := context.Background()
const contentID = "local-year-fallback"
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: contentID,
Status: "pending",
Title: queuedShowName,
Year: 1999,
Type: "movie",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("seed item: %v", err)
}
file := &models.MediaFile{
ContentID: contentID,
FilePath: "/movies/Show Name/Show Name.mkv",
BaseTitle: queuedShowName,
BaseYear: 1987,
BaseType: "movie",
}
worker := NewMatchWorker(h.service, h.fileRepo, 1, 1, 0)
skeleton, ok := worker.reusableQueuedMovieSkeleton(ctx, file, false)
if !ok || skeleton == nil {
t.Fatal("expected reusable skeleton")
}
if skeleton.Year != 1999 {
t.Fatalf("skeleton year = %d, want preserved item year 1999", skeleton.Year)
}
}
func TestQueuedMatchIdentityAlternatesRecoversSeriesParentShow(t *testing.T) {
file := &models.MediaFile{
FilePath: "/shows/Becoming You/Becoming.You.S01.HDR.2160p.WEB-DL-GROUP/Becoming.You.S01E01.2160p.WEB-DL.mkv",
}
skeleton := &skeletonResult{
Title: "Becoming.You.S01.HDR.2160p.WEB-DL-GROUP",
Type: "series",
ObservedRootPath: "/shows/Becoming You/Becoming.You.S01.HDR.2160p.WEB-DL-GROUP",
}
got := queuedMatchIdentityAlternates(file, skeleton)
if len(got) == 0 || got[0].Title != "Becoming You" || got[0].Source != "current_path" {
t.Fatalf("alternate identities = %#v, want current-path Becoming You", got)
}
}
func TestQueuedMatchIdentityAlternatesRecoversMovieFilenameAndReleaseFolder(t *testing.T) {
const alienInvasionTitle = "Alien Invasion"
tests := []struct {
name string
file *models.MediaFile
skeleton *skeletonResult
wantTitle string
wantYear int
wantSource string
}{
{
name: "filename beats divergent parent",
file: &models.MediaFile{FilePath: "/movies/After the Lethargy (2018)/Alien Invasion (2018).mkv"},
skeleton: &skeletonResult{Title: "After the Lethargy", Year: 2018, Type: "movie",
ObservedRootPath: "/movies/After the Lethargy (2018)"},
wantTitle: alienInvasionTitle, wantYear: 2018, wantSource: "filename",
},
{
name: "hash filename uses release folder",
file: &models.MediaFile{FilePath: "/movies/Jurassic.World.2015.1080p.WEB-DL-GROUP/291684abcdef012345.mkv"},
skeleton: &skeletonResult{Title: "291684abcdef012345", Type: "movie",
ObservedRootPath: "/movies/Jurassic.World.2015.1080p.WEB-DL-GROUP"},
wantTitle: "Jurassic World", wantYear: 2015, wantSource: "release_folder",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := queuedMatchIdentityAlternates(tt.file, tt.skeleton)
for _, identity := range got {
if identity.Title == tt.wantTitle && identity.Year == tt.wantYear && identity.Source == tt.wantSource {
return
}
}
t.Fatalf("alternate identities = %#v, want %q/%d from %s", got, tt.wantTitle, tt.wantYear, tt.wantSource)
})
}
}