git/intake/main
internal/cli/file_test.go
269 lines9 KB last change raw · history
package cli

import (
	"context"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"intake/internal/artwork"
	"intake/internal/plan"
	"intake/internal/scan"
	"intake/internal/state"
)

func needTools(t *testing.T, tools ...string) {
	t.Helper()
	for _, tool := range tools {
		if _, err := exec.LookPath(tool); err != nil {
			t.Skipf("%s is needed to build a fixture", tool)
		}
	}
}

// makeFLAC writes a short tone with the tags a filing run needs.
func makeFLAC(t *testing.T, path, title string, freq int) {
	t.Helper()
	cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
		"-f", "lavfi", "-i", "sine=frequency="+itoa(freq)+":duration=0.1", path)
	if out, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("building %s: %v %s", path, err, out)
	}
	lines := "TITLE=" + title + "\nALBUM=An Album\nARTIST=An Artist\nALBUMARTIST=An Artist\nTRACKNUMBER=1\nDISCNUMBER=1\nDATE=2001\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", path, err, out)
	}
}

func stagedAlbum(t *testing.T, a *App, name string, freqs ...int) plan.Plan {
	t.Helper()
	dir := filepath.Join(a.Cfg.Staging(), name)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatal(err)
	}
	for i, f := range freqs {
		makeFLAC(t, filepath.Join(dir, itoa(i+1)+" track.flac"), "Track "+itoa(i+1), f)
	}
	al, err := scan.One(context.Background(), dir)
	if err != nil {
		t.Fatal(err)
	}
	p := plan.Build(al, a.Cfg.Archive(), a.Cfg.Library(), plan.DirKey{}, a.Cfg.DiscPrefix)
	if p.Blocked() {
		t.Fatalf("the fixture album is blocked: %+v", p.Issues)
	}
	return p
}

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

// --dry-run promises to change nothing. The TUI path used to reach
// execute.Run without the flag being passed on, so previewing an album with the
// full-screen interface wrote it to the archive and cleared staging.
func TestDryRunInTheFilingPathWritesNothing(t *testing.T) {
	needTools(t, "ffmpeg", "metaflac")
	root := t.TempDir()
	a, out := cliApp(t, root)
	a.DryRun = true
	p := stagedAlbum(t, a, "an album", 440)

	res, err := a.FilePlan(context.Background(), p, coverChoice{}, "")
	if err != nil {
		t.Fatalf("a dry run should not fail: %v", err)
	}
	if _, err := os.Stat(p.ArchiveDir); !os.IsNotExist(err) {
		t.Error("a dry run created the archive directory")
	}
	if _, err := os.Stat(a.journalPath()); !os.IsNotExist(err) {
		t.Error("a dry run wrote the journal")
	}
	if res.Files != len(p.Tracks) {
		t.Errorf("a dry run should report the %d track(s) it would write, got %d", len(p.Tracks), res.Files)
	}
	if !strings.Contains(out.String(), "dry run") {
		t.Errorf("a dry run should say so:\n%s", out.String())
	}
	if _, err := os.Stat(filepath.Join(a.Cfg.Staging(), "an album", "1 track.flac")); err != nil {
		t.Errorf("a dry run disturbed staging: %v", err)
	}
}

// "both" files the new album beside the one already there. Everything after the
// promotion has to follow it: the journal used to record the album that was
// already there, so the copy just filed was invisible to verify and audit, and
// the library was transcoded from the old masters.
func TestBothCollisionJournalsWhereTheAlbumActuallyWent(t *testing.T) {
	needTools(t, "ffmpeg", "metaflac", "flac", "magick")
	root := t.TempDir()
	a, _ := cliApp(t, root)
	a.Cfg.NoLibrary = true
	p := stagedAlbum(t, a, "an album", 440)

	if _, err := a.FilePlan(context.Background(), p, coverChoice{}, ""); err != nil {
		t.Fatal(err)
	}

	// File the same album a second time; the destination now exists.
	p2 := stagedAlbum(t, a, "an album", 660)
	res, err := a.FilePlan(context.Background(), p2, coverChoice{}, "both")
	if err != nil {
		t.Fatal(err)
	}
	want := p2.ArchiveDir + " (2)"
	if res.ArchiveDir != want {
		t.Errorf("reported archive dir = %q, want %q", res.ArchiveDir, want)
	}
	if res.LibraryDir != strings.TrimSuffix(p2.LibraryDir, "")+" (2)" {
		t.Errorf("reported library dir = %q, want it to follow the archive", res.LibraryDir)
	}
	if _, err := os.Stat(filepath.Join(want, "1-01_track-1.flac")); err != nil {
		t.Errorf("the second copy did not land where it was reported: %v", err)
	}

	j, err := a.journal()
	if err != nil {
		t.Fatal(err)
	}
	rec, ok := j.Get(p2.Album.Dir)
	if !ok {
		t.Fatal("the album was not recorded at all")
	}
	if rec.ArchiveDir != want {
		t.Errorf("the journal records %q, but the album is at %q", rec.ArchiveDir, want)
	}
}

// A cover that lives in the staging folder is deleted along with the other
// staging leftovers, and the library is built afterwards -- so the library used
// to be built with no artwork at all, silently, while the archive got the right
// cover and the summary still named it.
func TestLibraryKeepsACoverThatCameFromStaging(t *testing.T) {
	needTools(t, "ffmpeg", "metaflac", "flac", "magick")
	root := t.TempDir()
	a, out := cliApp(t, root)
	p := stagedAlbum(t, a, "an album", 440)

	cover := filepath.Join(p.Album.Dir, "cover.jpg")
	if err := artwork.Placeholder(context.Background(), artwork.AlbumInfo{Artist: "An Artist", Album: "An Album"}, cover, 300); err != nil {
		t.Skipf("could not build a cover image: %v", err)
	}

	res, err := a.FilePlan(context.Background(), p, coverChoice{Path: cover, Source: artwork.SourceFolder, Label: "cover.jpg"}, "")
	if err != nil {
		t.Fatal(err)
	}
	if !res.Library {
		t.Fatalf("no library was built:\n%s", out.String())
	}
	if _, err := os.Stat(filepath.Join(p.ArchiveDir, "cover.jpg")); err != nil {
		t.Fatalf("the archive has no cover: %v", err)
	}
	if _, err := os.Stat(filepath.Join(p.LibraryDir, "cover.jpg")); err != nil {
		t.Errorf("the library has no cover although one was chosen: %v\n%s", err, out.String())
	}
	if _, err := os.Stat(cover); !os.IsNotExist(err) {
		t.Error("staging should have been cleared")
	}
}

// Resume used to stop at the first ready album whatever state it was in, so one
// album that needed a decision meant no album behind it was ever reached -- and
// the refusal was reported as success, so the command exited zero.
func TestResumeSkipsAnAlbumThatNeedsAttention(t *testing.T) {
	needTools(t, "ffmpeg", "metaflac", "flac", "magick")
	root := t.TempDir()
	a, out := cliApp(t, root)
	a.Yes = true
	a.Cfg.NoLibrary = true

	// "a-blocked" has no artist anywhere: untagged files in a folder with no
	// separator to infer from.
	blocked := filepath.Join(a.Cfg.Staging(), "a-blocked")
	if err := os.MkdirAll(blocked, 0o755); err != nil {
		t.Fatal(err)
	}
	cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
		"-f", "lavfi", "-i", "sine=frequency=300:duration=0.1", filepath.Join(blocked, "01 one.flac"))
	if o, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("building the blocked fixture: %v %s", err, o)
	}

	good := stagedAlbum(t, a, "b-good", 700)

	if err := a.Resume(context.Background()); err != nil {
		t.Fatalf("resume should have moved past the blocked album: %v\n%s", err, out.String())
	}
	if _, err := os.Stat(filepath.Join(good.ArchiveDir, "1-01_track-1.flac")); err != nil {
		t.Errorf("the album behind the blocked one was never filed: %v\n%s", err, out.String())
	}
	if _, err := os.Stat(blocked); err != nil {
		t.Errorf("the blocked album should have been left alone: %v", err)
	}

	// With nothing but blocked albums left, resume must not claim success.
	a2, out2 := cliApp(t, root)
	a2.Yes = true
	j, err := a2.journal()
	if err != nil {
		t.Fatal(err)
	}
	_ = j
	if err := a2.Resume(context.Background()); err == nil {
		t.Errorf("resume reported success with only a blocked album left:\n%s", out2.String())
	}
	if !strings.Contains(out2.String(), "need attention first") {
		t.Errorf("resume should say what is in the way:\n%s", out2.String())
	}
}

// A record is only dropped when the album it points at has gone. A run that
// fails before copying anything must not erase the record of an album that is
// still sitting in the archive, which is what left a filed album untracked and
// free to be filed a second time.
func TestFilingFailureDoesNotForgetAnArchivedAlbum(t *testing.T) {
	needTools(t, "ffmpeg", "metaflac")
	root := t.TempDir()
	a, _ := cliApp(t, root)
	a.Cfg.NoLibrary = true
	p := stagedAlbum(t, a, "an album", 440)

	// The album is already in the archive and recorded there.
	if err := os.MkdirAll(p.ArchiveDir, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(p.ArchiveDir, "1-01_track-1.flac"), []byte("archived"), 0o644); err != nil {
		t.Fatal(err)
	}
	seed(t, a, state.Record{Source: p.Album.Dir, Phase: state.PhaseArchived, ArchiveDir: p.ArchiveDir})

	// The staged originals have gone, so the run fails before it copies anything.
	if err := os.RemoveAll(p.Album.Dir); err != nil {
		t.Fatal(err)
	}
	if _, err := a.FilePlan(context.Background(), p, coverChoice{}, "replace"); err == nil {
		t.Fatal("filing a plan whose sources have gone should fail")
	}

	j, err := a.journal()
	if err != nil {
		t.Fatal(err)
	}
	if _, ok := j.Get(p.Album.Dir); !ok {
		t.Error("the record of an album still in the archive was deleted by a failed run")
	}
}