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)
}
}