474 lines
11 KiB
Go
474 lines
11 KiB
Go
package worker
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"coda/internal/config"
|
|
"coda/internal/jobs"
|
|
"coda/internal/models"
|
|
"coda/internal/queue"
|
|
"coda/internal/scheduler"
|
|
"coda/internal/store"
|
|
"coda/internal/transcoder"
|
|
"coda/internal/validator"
|
|
)
|
|
|
|
type mockStore struct {
|
|
data map[string]string
|
|
}
|
|
|
|
func (m *mockStore) GetAll(_ context.Context) (map[string]string, error) {
|
|
result := make(map[string]string, len(m.data))
|
|
for k, v := range m.data {
|
|
result[k] = v
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (m *mockStore) Set(_ context.Context, key, value string) error {
|
|
if m.data == nil {
|
|
m.data = map[string]string{}
|
|
}
|
|
m.data[key] = value
|
|
return nil
|
|
}
|
|
|
|
func (m *mockStore) Delete(_ context.Context, key string) error {
|
|
delete(m.data, key)
|
|
return nil
|
|
}
|
|
|
|
type countingRescanner struct {
|
|
mu sync.Mutex
|
|
calls int
|
|
paths []string
|
|
}
|
|
|
|
func (r *countingRescanner) Rescan(_ context.Context, jobPath string) error {
|
|
r.mu.Lock()
|
|
r.calls++
|
|
r.paths = append(r.paths, jobPath)
|
|
r.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (r *countingRescanner) Calls() int {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.calls
|
|
}
|
|
|
|
type countingNotifier struct {
|
|
mu sync.Mutex
|
|
calls int
|
|
statuses []models.JobStatus
|
|
storedStatus []models.JobStatus
|
|
store *store.Store
|
|
}
|
|
|
|
func (n *countingNotifier) Notify(ctx context.Context, job *models.Job) error {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
|
|
n.calls++
|
|
n.statuses = append(n.statuses, job.Status)
|
|
if n.store != nil {
|
|
stored, err := n.store.Jobs.Get(ctx, job.ID)
|
|
if err == nil {
|
|
n.storedStatus = append(n.storedStatus, stored.Status)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (n *countingNotifier) Calls() int {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
return n.calls
|
|
}
|
|
|
|
func setupPool(t *testing.T, overrides map[string]string) (*store.Store, *queue.Queue, *jobs.Service, *Pool, *countingRescanner) {
|
|
t.Helper()
|
|
|
|
st := openTestStore(t)
|
|
q := queue.New(10)
|
|
jobSvc := jobs.New(st, q)
|
|
|
|
svc, err := config.NewService(context.Background(), &mockStore{data: overrides})
|
|
if err != nil {
|
|
t.Fatalf("NewService: %v", err)
|
|
}
|
|
|
|
sched := scheduler.New(svc)
|
|
tr := transcoder.New(svc)
|
|
val := validator.New(svc)
|
|
rs := &countingRescanner{}
|
|
pool := New(svc, st, q, sched, tr, val, rs, NoopNotifier{})
|
|
|
|
return st, q, jobSvc, pool, rs
|
|
}
|
|
|
|
func TestNotifierCalledBeforeFinishOnSuccess(t *testing.T) {
|
|
dir := t.TempDir()
|
|
albumDir := filepath.Join(dir, "Test Artist", "Notify Album")
|
|
if err := os.MkdirAll(albumDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(albumDir, "01 track.flac"), []byte("fake flac"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
st, _, jobSvc, pool, _ := setupPool(t, map[string]string{
|
|
"DRY_RUN": "true",
|
|
"WORK_WINDOW_ENABLED": "false",
|
|
"MUSIC_PATH": dir,
|
|
})
|
|
notifier := &countingNotifier{store: st}
|
|
pool.Notifier = notifier
|
|
|
|
ctx := context.Background()
|
|
id, err := jobSvc.Enqueue(ctx, jobs.EnqueueInput{
|
|
Artist: "Test Artist",
|
|
Album: "Notify Album",
|
|
Path: albumDir,
|
|
Source: models.SourceManual,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Enqueue: %v", err)
|
|
}
|
|
|
|
pool.process(ctx, id)
|
|
|
|
if notifier.Calls() != 1 {
|
|
t.Fatalf("notifier calls = %d, want 1", notifier.Calls())
|
|
}
|
|
if notifier.statuses[0] != models.StatusDone {
|
|
t.Errorf("notified status = %q, want done", notifier.statuses[0])
|
|
}
|
|
if notifier.storedStatus[0] != models.StatusProcessing {
|
|
t.Errorf("stored status during notify = %q, want processing", notifier.storedStatus[0])
|
|
}
|
|
|
|
job, err := st.Jobs.Get(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if job.Status != models.StatusDone {
|
|
t.Errorf("final status = %q, want done", job.Status)
|
|
}
|
|
}
|
|
|
|
func TestNotifierCalledBeforeFinishOnError(t *testing.T) {
|
|
dir := t.TempDir()
|
|
albumDir := filepath.Join(dir, "Test Artist", "Empty Album")
|
|
if err := os.MkdirAll(albumDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
|
|
st, _, jobSvc, pool, _ := setupPool(t, map[string]string{
|
|
"DRY_RUN": "true",
|
|
"WORK_WINDOW_ENABLED": "false",
|
|
"MUSIC_PATH": dir,
|
|
})
|
|
notifier := &countingNotifier{store: st}
|
|
pool.Notifier = notifier
|
|
|
|
ctx := context.Background()
|
|
id, err := jobSvc.Enqueue(ctx, jobs.EnqueueInput{
|
|
Artist: "Test Artist",
|
|
Album: "Empty Album",
|
|
Path: albumDir,
|
|
Source: models.SourceManual,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Enqueue: %v", err)
|
|
}
|
|
|
|
pool.process(ctx, id)
|
|
|
|
if notifier.Calls() != 1 {
|
|
t.Fatalf("notifier calls = %d, want 1", notifier.Calls())
|
|
}
|
|
if notifier.statuses[0] != models.StatusError {
|
|
t.Errorf("notified status = %q, want error", notifier.statuses[0])
|
|
}
|
|
if notifier.storedStatus[0] != models.StatusProcessing {
|
|
t.Errorf("stored status during notify = %q, want processing", notifier.storedStatus[0])
|
|
}
|
|
|
|
job, err := st.Jobs.Get(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if job.Status != models.StatusError {
|
|
t.Errorf("final status = %q, want error", job.Status)
|
|
}
|
|
}
|
|
|
|
func TestNoopNotifier(t *testing.T) {
|
|
if err := (NoopNotifier{}).Notify(context.Background(), &models.Job{}); err != nil {
|
|
t.Errorf("NoopNotifier.Notify returned error: %v", err)
|
|
}
|
|
}
|
|
|
|
func openTestStore(t *testing.T) *store.Store {
|
|
t.Helper()
|
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
|
st, err := store.New(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("store.New: %v", err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
return st
|
|
}
|
|
|
|
func TestPoolDryRun(t *testing.T) {
|
|
dir := t.TempDir()
|
|
albumDir := filepath.Join(dir, "Test Artist", "Test Album")
|
|
if err := os.MkdirAll(albumDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
|
|
for _, name := range []string{"01 track.flac", "02 track.flac"} {
|
|
if err := os.WriteFile(filepath.Join(albumDir, name), []byte("fake flac"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
}
|
|
|
|
st, q, jobSvc, pool, rs := setupPool(t, map[string]string{
|
|
"DRY_RUN": "true",
|
|
"WORK_WINDOW_ENABLED": "false",
|
|
"MUSIC_PATH": dir,
|
|
})
|
|
_ = st
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
id, err := jobSvc.Enqueue(ctx, jobs.EnqueueInput{
|
|
Artist: "Test Artist",
|
|
Album: "Test Album",
|
|
Path: albumDir,
|
|
Source: models.SourceManual,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Enqueue: %v", err)
|
|
}
|
|
|
|
pool.Start(ctx)
|
|
time.Sleep(200 * time.Millisecond)
|
|
q.Close()
|
|
pool.Wait()
|
|
|
|
job, err := st.Jobs.Get(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if job.Status != models.StatusDone {
|
|
t.Errorf("Status = %q, want done", job.Status)
|
|
}
|
|
|
|
for _, name := range []string{"01 track.flac", "02 track.flac"} {
|
|
if _, err := os.Stat(filepath.Join(albumDir, name)); os.IsNotExist(err) {
|
|
t.Errorf("%s should not be removed in dry run", name)
|
|
}
|
|
}
|
|
|
|
if rs.Calls() > 0 {
|
|
t.Errorf("rescanner should not be called in dry run, got %d calls", rs.Calls())
|
|
}
|
|
}
|
|
|
|
func requireFFmpegFFprobe(t *testing.T) (string, string) {
|
|
t.Helper()
|
|
ffmpeg, err := exec.LookPath("ffmpeg")
|
|
if err != nil {
|
|
t.Skip("ffmpeg not found")
|
|
}
|
|
ffprobe, err := exec.LookPath("ffprobe")
|
|
if err != nil {
|
|
t.Skip("ffprobe not found")
|
|
}
|
|
return ffmpeg, ffprobe
|
|
}
|
|
|
|
func generateTestFLAC(t *testing.T, dir, name, ffmpegPath string) string {
|
|
t.Helper()
|
|
flacPath := filepath.Join(dir, name)
|
|
cmd := exec.Command(ffmpegPath,
|
|
"-f", "lavfi", "-i", "sine=frequency=440:duration=1",
|
|
"-c:a", "flac",
|
|
"-y",
|
|
flacPath,
|
|
)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("generate flac: %v\n%s", err, out)
|
|
}
|
|
return flacPath
|
|
}
|
|
|
|
func TestPoolRealConversion(t *testing.T) {
|
|
ffmpegPath, ffprobePath := requireFFmpegFFprobe(t)
|
|
t.Setenv("FFMPEG_BIN", ffmpegPath)
|
|
t.Setenv("FFPROBE_BIN", ffprobePath)
|
|
|
|
dir := t.TempDir()
|
|
albumDir := filepath.Join(dir, "Test Artist", "Real Album")
|
|
if err := os.MkdirAll(albumDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
|
|
flacPath := generateTestFLAC(t, albumDir, "01 real.flac", ffmpegPath)
|
|
|
|
st, q, jobSvc, pool, rs := setupPool(t, map[string]string{
|
|
"DRY_RUN": "false",
|
|
"WORK_WINDOW_ENABLED": "false",
|
|
"MUSIC_PATH": dir,
|
|
})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
id, err := jobSvc.Enqueue(ctx, jobs.EnqueueInput{
|
|
Artist: "Test Artist",
|
|
Album: "Real Album",
|
|
Path: albumDir,
|
|
Source: models.SourceManual,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Enqueue: %v", err)
|
|
}
|
|
|
|
pool.Start(ctx)
|
|
time.Sleep(1 * time.Second)
|
|
q.Close()
|
|
pool.Wait()
|
|
|
|
job, err := st.Jobs.Get(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if job.Status != models.StatusDone {
|
|
t.Errorf("Status = %q, want done (error: %q)", job.Status, job.ErrorMsg)
|
|
}
|
|
|
|
if _, err := os.Stat(flacPath); !os.IsNotExist(err) {
|
|
t.Error("flac should be removed after successful conversion")
|
|
}
|
|
|
|
mp3Path := flacPath[:len(flacPath)-len(".flac")] + ".mp3"
|
|
info, err := os.Stat(mp3Path)
|
|
if err != nil {
|
|
t.Fatalf("stat mp3: %v", err)
|
|
}
|
|
if info.Size() == 0 {
|
|
t.Error("mp3 is empty")
|
|
}
|
|
|
|
if rs.Calls() != 1 {
|
|
t.Errorf("rescanner should be called once, got %d", rs.Calls())
|
|
}
|
|
}
|
|
|
|
func TestPoolValidationFails(t *testing.T) {
|
|
ffmpegPath, _ := requireFFmpegFFprobe(t)
|
|
t.Setenv("FFMPEG_BIN", ffmpegPath)
|
|
t.Setenv("FFPROBE_BIN", "/nonexistent/ffprobe")
|
|
|
|
dir := t.TempDir()
|
|
albumDir := filepath.Join(dir, "Test Artist", "Fail Album")
|
|
if err := os.MkdirAll(albumDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
|
|
flacPath := generateTestFLAC(t, albumDir, "01 fail.flac", ffmpegPath)
|
|
|
|
st, q, jobSvc, pool, _ := setupPool(t, map[string]string{
|
|
"DRY_RUN": "false",
|
|
"WORK_WINDOW_ENABLED": "false",
|
|
})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
id, err := jobSvc.Enqueue(ctx, jobs.EnqueueInput{
|
|
Artist: "Test Artist",
|
|
Album: "Fail Album",
|
|
Path: albumDir,
|
|
Source: models.SourceManual,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Enqueue: %v", err)
|
|
}
|
|
|
|
pool.Start(ctx)
|
|
time.Sleep(1 * time.Second)
|
|
q.Close()
|
|
pool.Wait()
|
|
|
|
job, err := st.Jobs.Get(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if job.Status != models.StatusError {
|
|
t.Errorf("Status = %q, want error", job.Status)
|
|
}
|
|
|
|
if _, err := os.Stat(flacPath); os.IsNotExist(err) {
|
|
t.Error("flac should be preserved when validation fails")
|
|
}
|
|
}
|
|
|
|
func TestWorkerRecordsProgress(t *testing.T) {
|
|
dir := t.TempDir()
|
|
albumDir := filepath.Join(dir, "Test Artist", "Progress Album")
|
|
if err := os.MkdirAll(albumDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
|
|
for _, name := range []string{"01 track.flac", "02 track.flac", "03 track.flac"} {
|
|
if err := os.WriteFile(filepath.Join(albumDir, name), []byte("fake flac"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
}
|
|
|
|
st, _, jobSvc, pool, _ := setupPool(t, map[string]string{
|
|
"DRY_RUN": "true",
|
|
"WORK_WINDOW_ENABLED": "false",
|
|
"MUSIC_PATH": dir,
|
|
})
|
|
|
|
ctx := context.Background()
|
|
id, err := jobSvc.Enqueue(ctx, jobs.EnqueueInput{
|
|
Artist: "Test Artist",
|
|
Album: "Progress Album",
|
|
Path: albumDir,
|
|
Source: models.SourceManual,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Enqueue: %v", err)
|
|
}
|
|
|
|
pool.process(ctx, id)
|
|
|
|
job, err := st.Jobs.Get(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if job.FilesTotal != 3 {
|
|
t.Errorf("FilesTotal = %d, want 3", job.FilesTotal)
|
|
}
|
|
if job.FilesProcessed != 3 {
|
|
t.Errorf("FilesProcessed = %d, want 3", job.FilesProcessed)
|
|
}
|
|
if job.Status != models.StatusDone {
|
|
t.Errorf("Status = %q, want done", job.Status)
|
|
}
|
|
}
|