package execute import ( "context" "os" "os/exec" "path/filepath" "strings" "testing" "intake/internal/plan" "intake/internal/scan" "intake/internal/tags" ) // Every test here needs a real flac: execute copies it, tags it with metaflac, // verifies it with ffprobe and flac --test, and the whole point is that the file // that comes out is checked the same way a real one is. The tones are a tenth of // a second long, so the cost is milliseconds. type fixture struct { root string staging string album string } func newFixture(t *testing.T) *fixture { t.Helper() for _, tool := range []string{"ffmpeg", "ffprobe", "metaflac", "flac"} { if _, err := exec.LookPath(tool); err != nil { t.Skipf("%s is needed to build a fixture", tool) } } root := t.TempDir() f := &fixture{ root: root, staging: filepath.Join(root, "staging"), album: filepath.Join(root, "staging", "Artist - Album"), } for _, d := range []string{f.album, filepath.Join(root, "archive"), filepath.Join(root, "library")} { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } } return f } // tone writes a flac and gives it just enough tags that a filed copy verifies. func (f *fixture) tone(t *testing.T, name, title string, freq int) string { t.Helper() path := filepath.Join(f.album, name) cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", sine(freq), path) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("building %s: %v %s", name, err, out) } f.tag(t, path, title) return path } func sine(freq int) string { return "sine=frequency=" + itoa(freq) + ":duration=0.1" } func itoa(n int) string { if n == 0 { return "0" } var b []byte for n > 0 { b = append([]byte{byte('0' + n%10)}, b...) n /= 10 } return string(b) } func (f *fixture) tag(t *testing.T, path, title string) { t.Helper() lines := "TITLE=" + title + "\nALBUM=Album\nARTIST=Artist\nALBUMARTIST=Artist\nTRACKNUMBER=1\n" cmd := exec.Command("metaflac", "--remove-all-tags", "--import-tags-from=-", path) cmd.Stdin = strings.NewReader(lines) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("tagging %s: %v %s", filepath.Base(path), err, out) } } func (f *fixture) plan(tracks ...plan.TrackPlan) plan.Plan { return plan.Plan{ Album: scan.Album{Dir: f.album}, AlbumName: "Album", AlbumArt: "Artist", ArtistDir: "artist", AlbumDir: "album", ArchiveRoot: filepath.Join(f.root, "archive"), LibraryRoot: filepath.Join(f.root, "library"), ArchiveDir: filepath.Join(f.root, "archive", "artist", "album"), LibraryDir: filepath.Join(f.root, "library", "artist", "album"), Tracks: tracks, } } func trackPlan(src, target string) plan.TrackPlan { return plan.TrackPlan{ Source: src, Target: target, Ext: ".flac", Tags: []tags.Note{ {Field: tags.FieldTitle, Value: strings.TrimSuffix(target, ".flac")}, {Field: tags.FieldAlbumArtist, Value: "Artist"}, }, } } func (f *fixture) options() Options { return Options{VerifyDecode: "sample", StagingRoot: f.staging} } // Two tracks that share a number and a title produce the same target. copyFile // truncates, so without a guard the second write replaces the first, the album // is filed a track short, verify passes -- it compares each written file with the // plan entry in the same position -- and nothing anywhere reports a problem. func TestRunRefusesTwoTracksWithTheSameTarget(t *testing.T) { f := newFixture(t) a := f.tone(t, "01 intro.flac", "Intro", 440) b := f.tone(t, "01 intro (1).flac", "Intro", 660) p := f.plan(trackPlan(a, "1-01_intro.flac"), trackPlan(b, "1-01_intro.flac")) res, err := Run(context.Background(), p, f.options()) if err == nil { t.Fatal("running a plan with two identical targets should be refused") } if !strings.Contains(err.Error(), "1-01_intro.flac") { t.Errorf("the refusal should name the target: %v", err) } if len(res.Files) != 0 { t.Errorf("nothing should have been written, got %v", res.Files) } if _, err := os.Stat(p.ArchiveDir); !os.IsNotExist(err) { t.Error("the archive directory was created before the plan was refused") } for _, src := range []string{a, b} { if _, err := os.Stat(src); err != nil { t.Errorf("staged file %s was touched: %v", filepath.Base(src), err) } } } func TestRunRefusesAnEmptyPlan(t *testing.T) { f := newFixture(t) if _, err := Run(context.Background(), f.plan(), f.options()); err == nil { t.Fatal("a plan with no tracks should be refused rather than panic") } } // "both" deliberately puts the album somewhere other than the path that was // asked for. Everything downstream has to follow it there: the reported archive // directory, the file list, the byte count, and the directory a caller goes on to // read the masters from. func TestBothCollisionReportsWhereTheAlbumActuallyWent(t *testing.T) { f := newFixture(t) existing := f.plan() if err := os.MkdirAll(existing.ArchiveDir, 0o755); err != nil { t.Fatal(err) } old := filepath.Join(existing.ArchiveDir, "1-01_old.flac") if err := os.WriteFile(old, []byte("the album that was already there"), 0o644); err != nil { t.Fatal(err) } src := f.tone(t, "01 new.flac", "New", 880) p := f.plan(trackPlan(src, "1-01_new.flac")) opts := f.options() opts.Collision = "both" res, err := Run(context.Background(), p, opts) if err != nil { t.Fatal(err) } want := p.ArchiveDir + " (2)" if res.ArchiveDir != want { t.Errorf("reported archive dir = %q, want %q", res.ArchiveDir, want) } if len(res.Files) != 1 || filepath.Dir(res.Files[0]) != want { t.Errorf("reported files = %v, want one file inside %q", res.Files, want) } if fi, err := os.Stat(filepath.Join(want, "1-01_new.flac")); err != nil { t.Fatalf("the new album did not land where it was reported: %v", err) } else if res.Bytes != fi.Size() { t.Errorf("reported %d bytes, the new album is %d", res.Bytes, fi.Size()) } if _, err := os.Stat(old); err != nil { t.Errorf("the album that was already there was disturbed: %v", err) } if _, err := os.Stat(filepath.Join(p.ArchiveDir, "1-01_new.flac")); !os.IsNotExist(err) { t.Error("the new album was written over the old one") } } // Replacing an album used to remove the old one first and rename the new one in // afterwards. A failure in between -- and a failed RemoveAll leaves the same // state -- meant the album was simply gone. The old copy now survives until the // new one is in place. func TestReplaceKeepsTheOldAlbumUntilTheNewOneIsInPlace(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "album") if err := os.MkdirAll(target, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(target, "keep.flac"), []byte("irreplaceable"), 0o644); err != nil { t.Fatal(err) } // A source that cannot be renamed into place. missing := filepath.Join(dir, "gone") if _, err := promote(target, missing, "replace", func(string, ...any) {}); err == nil { t.Fatal("promoting a directory that does not exist should fail") } got, err := os.ReadFile(filepath.Join(target, "keep.flac")) if err != nil { t.Fatalf("the old album was destroyed by a failed replace: %v", err) } if string(got) != "irreplaceable" { t.Errorf("the old album came back as %q", got) } } // A merge is all-or-nothing. Checking for conflicts as the files move left the // destination holding part of one album and part of another, which is worse than // either album on its own and cannot be put right by running again. func TestMergeMovesNothingWhenAnythingClashes(t *testing.T) { dir := t.TempDir() dst := filepath.Join(dir, "dst") src := filepath.Join(dir, "src") for _, d := range []string{dst, src} { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } } for _, f := range []string{"01_new.flac", "02_old.flac"} { if err := os.WriteFile(filepath.Join(src, f), []byte("incoming"), 0o644); err != nil { t.Fatal(err) } } if err := os.WriteFile(filepath.Join(dst, "02_old.flac"), []byte("already here"), 0o644); err != nil { t.Fatal(err) } if err := mergeInto(src, dst); err == nil { t.Fatal("a clash should stop the merge") } entries, err := os.ReadDir(dst) if err != nil { t.Fatal(err) } if len(entries) != 1 || entries[0].Name() != "02_old.flac" { var names []string for _, e := range entries { names = append(names, e.Name()) } t.Errorf("the destination gained files from a merge that failed: %v", names) } left, err := os.ReadDir(src) if err != nil { t.Fatal(err) } if len(left) != 2 { t.Errorf("the incoming album was consumed by a merge that failed: %d file(s) left", len(left)) } } // Staging cleanup used to walk up to four empty directories, which reached past // the album, past staging, and out into the library root. An empty staging // directory is the normal state after the last album is filed, and deleting it // makes the next scan fail. func TestStagingCleanupNeverClimbsPastStaging(t *testing.T) { root := t.TempDir() staging := filepath.Join(root, "staging") album := filepath.Join(staging, "album") if err := os.MkdirAll(album, 0o755); err != nil { t.Fatal(err) } if _, err := cleanStaging(album, staging); err != nil { t.Fatal(err) } for _, keep := range []string{root, staging} { if _, err := os.Stat(keep); err != nil { t.Errorf("%s was removed by staging cleanup: %v", filepath.Base(keep), err) } } if _, err := os.Stat(album); !os.IsNotExist(err) { t.Error("the empty album folder should have been removed") } // With no staging root it must not climb at all. album2 := filepath.Join(staging, "album2") if err := os.MkdirAll(album2, 0o755); err != nil { t.Fatal(err) } if _, err := cleanStaging(album2, ""); err != nil { t.Fatal(err) } if _, err := os.Stat(staging); err != nil { t.Errorf("cleanup climbed out of the album with no staging root: %v", err) } } // A CD1 / CD2 rip has the album one level up from the tracks. Clearing only the // disc folder left the empty second disc, the album's own sidecars and the album // folder itself in staging, so the finished album stayed in the list for ever. func TestStagingCleanupClearsWholeMultiDiscAlbum(t *testing.T) { root := t.TempDir() staging := filepath.Join(root, "staging") album := filepath.Join(staging, "Artist - Album") for _, d := range []string{"CD1", "CD2"} { if err := os.MkdirAll(filepath.Join(album, d), 0o755); err != nil { t.Fatal(err) } } if err := os.WriteFile(filepath.Join(album, "album.cue"), []byte("PERFORMER\n"), 0o644); err != nil { t.Fatal(err) } removed, err := cleanStaging(album, staging) if err != nil { t.Fatal(err) } if len(removed) != 1 || removed[0] != "album.cue" { t.Errorf("leftovers reported as %v, want [album.cue]", removed) } if _, err := os.Stat(album); !os.IsNotExist(err) { t.Error("the album folder was left in staging") } if _, err := os.Stat(staging); err != nil { t.Errorf("staging itself was removed: %v", err) } } // Once the album is promoted it is archived, and failing to delete the staged // originals afterwards does not change that. Reporting it as a failure made the // caller drop the journal record for an album that is sitting on disk, so verify // and audit could no longer see it and a later run would file it again. func TestStagedFilesThatCannotBeRemovedDoNotFailTheRun(t *testing.T) { f := newFixture(t) src := f.tone(t, "01 one.flac", "One", 440) p := f.plan(trackPlan(src, "1-01_one.flac")) // Read-only album folder: the copy reads from it, the removal from it fails. if err := os.Chmod(f.album, 0o555); err != nil { t.Fatal(err) } t.Cleanup(func() { os.Chmod(f.album, 0o755) }) res, err := Run(context.Background(), p, f.options()) if err != nil { t.Fatalf("a complete archive was reported as a failure: %v", err) } if _, err := os.Stat(filepath.Join(p.ArchiveDir, "1-01_one.flac")); err != nil { t.Fatalf("the album was not archived: %v", err) } if len(res.StagingStuck) != 1 { t.Errorf("StagingStuck = %v, want the one file that could not be removed", res.StagingStuck) } if res.RemovedStaging { t.Error("the run claimed staging was cleared when it was not") } } // An artist folder is created so the album can go inside it. A run that fails -- // a truncated download, a decode that will not verify -- used to leave the empty // folder behind, where it reads as an artist whose albums have gone missing. func TestFailedRunLeavesNoEmptyArtistFolder(t *testing.T) { f := newFixture(t) p := f.plan(trackPlan(filepath.Join(f.album, "not-here.flac"), "1-01_one.flac")) if _, err := Run(context.Background(), p, f.options()); err == nil { t.Fatal("a plan whose source file is missing should fail") } if _, err := os.Stat(filepath.Dir(p.ArchiveDir)); !os.IsNotExist(err) { t.Errorf("an empty artist folder was left in the archive: %v", err) } } // A plan that is refused before anything is copied must not disturb an artist // folder that was already there with albums in it. func TestFailedRunKeepsAnArtistFolderThatWasAlreadyThere(t *testing.T) { f := newFixture(t) artistDir := filepath.Dir(f.plan().ArchiveDir) if err := os.MkdirAll(filepath.Join(artistDir, "older-album"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(artistDir, "older-album", "track.flac"), []byte("x"), 0o644); err != nil { t.Fatal(err) } p := f.plan(trackPlan(filepath.Join(f.album, "not-here.flac"), "1-01_one.flac")) if _, err := Run(context.Background(), p, f.options()); err == nil { t.Fatal("a plan whose source file is missing should fail") } if _, err := os.Stat(filepath.Join(artistDir, "older-album", "track.flac")); err != nil { t.Errorf("an existing artist folder was disturbed: %v", err) } }