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