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