package cli
import (
"bytes"
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"intake/internal/artist"
"intake/internal/artwork"
"intake/internal/config"
"intake/internal/plan"
"intake/internal/scan"
"intake/internal/sheet"
"intake/internal/state"
"intake/internal/tags"
)
func cliApp(t *testing.T, root string) (*App, *bytes.Buffer) {
t.Helper()
var out bytes.Buffer
cfg := config.Defaults()
cfg.Root = root
cfg.StagingDir = "staging"
cfg.ArchiveDir = "archive"
cfg.LibraryDir = "library"
cfg.StateDir = filepath.Join(root, "state")
a := &App{Cfg: cfg, Out: &out, Err: &bytes.Buffer{}, In: strings.NewReader("")}
return a, &out
}
func seed(t *testing.T, a *App, rec state.Record) {
t.Helper()
if err := state.Update(a.journalPath(), func(j *state.Journal) (bool, error) {
j.Put(rec)
return true, nil
}); err != nil {
t.Fatal(err)
}
}
// A library copy that has been built stays built. The check that was meant to
// preserve the flag ran after the record had already been written, so it always
// saw "false" and overwrote the truth -- after which verify reported an album
// whose library copy is sitting on disk as one that has no library copy.
func TestRecordingKeepsABuiltLibraryMarkedBuilt(t *testing.T) {
root := t.TempDir()
a, _ := cliApp(t, root)
p := plan.Plan{Album: scan.Album{Dir: filepath.Join(root, "staging", "x")}, AlbumName: "A"}
seed(t, a, state.Record{Source: p.Album.Dir, Phase: state.PhaseArchived, LibraryBuilt: true})
if err := a.record(p, state.PhaseArchived, nil, false, false); err != nil {
t.Fatal(err)
}
j, err := a.journal()
if err != nil {
t.Fatal(err)
}
rec, _ := j.Get(p.Album.Dir)
if !rec.LibraryBuilt {
t.Error("a library that had been built was recorded as not built")
}
}
// A run that fails is not evidence that an album recorded earlier has gone away.
// forget used to fire on any failure, including one that happened before a single
// byte was copied while the album was still safely in the archive.
func TestForgetLeavesRecordsForAlbumsStillOnDisk(t *testing.T) {
root := t.TempDir()
a, _ := cliApp(t, root)
archive := filepath.Join(root, "archive", "artist", "album")
if err := os.MkdirAll(archive, 0o755); err != nil {
t.Fatal(err)
}
p := plan.Plan{
Album: scan.Album{Dir: filepath.Join(root, "staging", "x")},
ArchiveDir: archive,
}
seed(t, a, state.Record{Source: p.Album.Dir, Phase: state.PhaseArchived, ArchiveDir: archive})
a.forget(p)
j, _ := a.journal()
if _, ok := j.Get(p.Album.Dir); !ok {
t.Error("the record of an album that is still in the archive was deleted")
}
// But a record whose album really has gone is still cleared up.
if err := os.RemoveAll(archive); err != nil {
t.Fatal(err)
}
a.forget(p)
j, _ = a.journal()
if _, ok := j.Get(p.Album.Dir); ok {
t.Error("the record of an album that is no longer on disk was kept")
}
}
// A target made only of punctuation folds down to the empty string, and a
// substring search for "" matches every album -- so asking for "!!" used to
// return whichever archived album happened to be alone in the journal.
func TestFindRecordRefusesAnAllPunctuationTarget(t *testing.T) {
j := &state.Journal{Albums: map[string]state.Record{}}
j.Put(state.Record{Source: "/staging/x", Phase: state.PhaseArchived, Album: "Selected Ambient Works"})
if _, ok := findRecord(j, "!!!"); ok {
t.Error("an empty search key matched an album")
}
if _, ok := findRecord(j, "---"); ok {
t.Error("an empty search key matched an album")
}
if _, ok := findRecord(j, "selected"); !ok {
t.Error("a real search should still match")
}
}
// A record whose archive has gone or holds no audio used to be skipped entirely,
// so the command whose whole job is to notice that reported "0 needing
// attention" for an album that no longer exists.
func TestAuditNoticesAnAlbumWithNoArchive(t *testing.T) {
root := t.TempDir()
a, out := cliApp(t, root)
missing := filepath.Join(root, "archive", "artist", "gone")
empty := filepath.Join(root, "archive", "artist", "empty")
if err := os.MkdirAll(empty, 0o755); err != nil {
t.Fatal(err)
}
seed(t, a, state.Record{Source: "/staging/one", Phase: state.PhaseArchived, AlbumArtist: "A", Album: "Gone", ArchiveDir: missing, LibraryDir: missing})
seed(t, a, state.Record{Source: "/staging/two", Phase: state.PhaseArchived, AlbumArtist: "B", Album: "Empty", ArchiveDir: empty, LibraryDir: empty})
if err := a.Audit(t.Context()); err != nil {
t.Fatal(err)
}
text := out.String()
if !strings.Contains(text, "2 album(s) checked") {
t.Errorf("both albums should have been checked:\n%s", text)
}
if !strings.Contains(text, "2 needing attention") {
t.Errorf("both albums should have been reported:\n%s", text)
}
}
// The library extension follows the configured codec. Hard-coding ".ogg" meant
// every album in an opus library was reported incomplete, for ever.
func TestAuditUsesTheConfiguredCodecExtension(t *testing.T) {
for _, tc := range []struct {
codec string
want int
}{
{"vorbis", 0},
{"opus", 0},
} {
t.Run(tc.codec, func(t *testing.T) {
root := t.TempDir()
a, out := cliApp(t, root)
a.Cfg.Codec = tc.codec
archive := filepath.Join(root, "archive", "artist", "album")
lib := filepath.Join(root, "library", "artist", "album")
for _, d := range []string{archive, lib} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(archive, "1-01_one.flac"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
ext := ".ogg"
if tc.codec == "opus" {
ext = ".opus"
}
if err := os.WriteFile(filepath.Join(lib, "1-01_one"+ext), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(lib, "cover.jpg"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
seed(t, a, state.Record{Source: "/staging/one", Phase: state.PhaseArchived, AlbumArtist: "A", Album: "B", ArchiveDir: archive, LibraryDir: lib})
if err := a.Audit(t.Context()); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "1 complete, 0 needing attention") {
t.Errorf("a complete %s library was reported as broken:\n%s", tc.codec, out.String())
}
})
}
}
// A caller that asked for JSON gets JSON and nothing else. Both reports used to
// print their human-readable problems first, which made the output unparseable
// exactly when it had something to say.
func TestJSONOutputIsTheOnlyThingOnStdout(t *testing.T) {
root := t.TempDir()
a, out := cliApp(t, root)
missing := filepath.Join(root, "archive", "artist", "gone")
seed(t, a, state.Record{Source: "/staging/one", Phase: state.PhaseArchived, AlbumArtist: "A", Album: "Gone", ArchiveDir: missing, LibraryDir: missing})
a.JSON = true
if err := a.Audit(t.Context()); err != nil {
t.Fatal(err)
}
var auditOut map[string]any
if err := json.Unmarshal(out.Bytes(), &auditOut); err != nil {
t.Fatalf("library audit --json is not JSON: %v\n%s", err, out.String())
}
if got, _ := auditOut["missing_archives"].(float64); got != 1 {
t.Errorf("missing_archives = %v, want 1", auditOut["missing_archives"])
}
out.Reset()
a.JSON = true
if err := a.Verify(t.Context()); err != nil {
t.Fatal(err)
}
var verifyOut map[string]any
if err := json.Unmarshal(out.Bytes(), &verifyOut); err != nil {
t.Fatalf("library verify --json is not JSON: %v\n%s", err, out.String())
}
if got, _ := verifyOut["missing"].(float64); got != 1 {
t.Errorf("missing = %v, want 1", verifyOut["missing"])
}
}
// The journal is the only thing that knows an album exists. An album that reached
// the archive without a record -- the process was killed between the promotion
// and the journal write -- is invisible: verify says nothing and a later run
// files a second copy beside it. Comparing the archive against the journal is
// what makes that visible.
func TestUnrecordedArchiveDirectoriesAreReported(t *testing.T) {
root := t.TempDir()
a, out := cliApp(t, root)
recorded := filepath.Join(root, "archive", "artist", "known")
orphan := filepath.Join(root, "archive", "artist", "orphan")
for _, d := range []string{recorded, orphan} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
seed(t, a, state.Record{Source: "/staging/one", Phase: state.PhaseArchived, AlbumArtist: "A", Album: "Known", ArchiveDir: recorded, LibraryDir: recorded})
if err := a.Verify(t.Context()); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "UNRECORDED") || !strings.Contains(out.String(), orphan) {
t.Errorf("verify did not report the album with no record:\n%s", out.String())
}
if strings.Contains(out.String(), "UNRECORDED "+recorded) {
t.Errorf("verify reported a recorded album as unrecorded:\n%s", out.String())
}
out.Reset()
if err := a.Audit(t.Context()); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "1 album(s) are in the archive with no journal record") {
t.Errorf("audit did not count the album with no record:\n%s", out.String())
}
out.Reset()
a.JSON = true
if err := a.Verify(t.Context()); err != nil {
t.Fatal(err)
}
var parsed map[string]any
if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
t.Fatalf("library verify --json is not JSON: %v\n%s", err, out.String())
}
list, _ := parsed["unrecorded"].([]any)
if len(list) != 1 {
t.Errorf("unrecorded = %v, want one entry", parsed["unrecorded"])
}
}
// Un-filing moves the masters back to staging rather than copying and deleting
// them, because after filing the archive holds the only copy: the staged
// originals were removed once it verified.
func TestUnfileMovesTheAlbumBackToStaging(t *testing.T) {
root := t.TempDir()
a, out := cliApp(t, root)
a.Yes = true
archive := filepath.Join(root, "archive", "an-artist", "an-album")
lib := filepath.Join(root, "library", "an-artist", "an-album")
for _, d := range []string{archive, lib} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
master := filepath.Join(archive, "1-01_one.flac")
if err := os.WriteFile(master, []byte("the only copy"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(lib, "1-01_one.ogg"), []byte("derived"), 0o644); err != nil {
t.Fatal(err)
}
seed(t, a, state.Record{Source: "/staging/an-album", Phase: state.PhaseArchived, AlbumArtist: "An Artist", Album: "An Album", ArchiveDir: archive, LibraryDir: lib, Tracks: 1})
if err := a.Unfile(t.Context(), "an album"); err != nil {
t.Fatalf("%v\n%s", err, out.String())
}
back := filepath.Join(root, "staging", "an-album")
got, err := os.ReadFile(filepath.Join(back, "1-01_one.flac"))
if err != nil {
t.Fatalf("the masters did not come back: %v\n%s", err, out.String())
}
if string(got) != "the only copy" {
t.Errorf("the masters changed on the way back: %q", got)
}
if _, err := os.Stat(archive); !os.IsNotExist(err) {
t.Error("the album is still in the archive as well")
}
if _, err := os.Stat(lib); !os.IsNotExist(err) {
t.Error("the stale library copy was left behind")
}
j, err := a.journal()
if err != nil {
t.Fatal(err)
}
if len(j.Albums) != 0 {
t.Errorf("the record survived the un-filing: %+v", j.Albums)
}
}
// Forgetting a record leaves every file exactly where it is: it is a statement
// about what intake remembers, not about what is on disk.
func TestForgetLeavesEveryFileWhereItIs(t *testing.T) {
root := t.TempDir()
a, out := cliApp(t, root)
a.Yes = true
archive := filepath.Join(root, "archive", "an-artist", "an-album")
if err := os.MkdirAll(archive, 0o755); err != nil {
t.Fatal(err)
}
master := filepath.Join(archive, "1-01_one.flac")
if err := os.WriteFile(master, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
seed(t, a, state.Record{Source: "/staging/an-album", Phase: state.PhaseArchived, Album: "An Album", ArchiveDir: archive})
if err := a.ForgetRecord(t.Context(), "an album"); err != nil {
t.Fatalf("%v\n%s", err, out.String())
}
if _, err := os.Stat(master); err != nil {
t.Errorf("forgetting a record removed a file: %v", err)
}
j, _ := a.journal()
if len(j.Albums) != 0 {
t.Error("the record was not forgotten")
}
}
// The journal screen needs to see the two ways a record and the disk disagree.
func TestJournalEntriesShowWhatIsOnDisk(t *testing.T) {
root := t.TempDir()
a, _ := cliApp(t, root)
here := filepath.Join(root, "archive", "an-artist", "here")
gone := filepath.Join(root, "archive", "an-artist", "gone")
orphan := filepath.Join(root, "archive", "an-artist", "orphan")
for _, d := range []string{here, orphan} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
seed(t, a, state.Record{Source: "/staging/here", Phase: state.PhaseArchived, Album: "Here", ArchiveDir: here, LibraryBuilt: true})
seed(t, a, state.Record{Source: "/staging/gone", Phase: state.PhaseArchived, Album: "Gone", ArchiveDir: gone})
entries, err := a.JournalEntries(t.Context())
if err != nil {
t.Fatal(err)
}
byAlbum := map[string]JournalEntry{}
for _, e := range entries {
byAlbum[e.Album] = e
}
if e := byAlbum["Here"]; e.Missing || !e.Library {
t.Errorf("a complete album was reported wrong: %+v", e)
}
if e := byAlbum["Gone"]; !e.Missing {
t.Errorf("a missing archive was not noticed: %+v", e)
}
if e := byAlbum["orphan"]; !e.Unrecorded {
t.Errorf("an album with no record was not listed: %+v", e)
}
}
// Batch files everything it can and parks what needs a person, because one bad
// download out of two hundred must not hold up the other hundred and ninety-nine.
func TestBatchParksWhatNeedsYouAndCarriesOn(t *testing.T) {
needTools(t, "ffmpeg", "metaflac", "flac", "magick")
root := t.TempDir()
a, out := cliApp(t, root)
a.Cfg.NoCover = true
a.Cfg.NoLibrary = true
// One album that cannot be worked out: untagged files with no separator in
// the folder name.
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)
rep, err := a.Batch(context.Background())
if err != nil {
t.Fatalf("%v\n%s", err, out.String())
}
if len(rep.Filed) != 1 || rep.Filed[0] != "b-good" {
t.Errorf("filed = %v", rep.Filed)
}
if len(rep.Parked) != 1 || !strings.Contains(rep.Parked[0], "a-blocked") {
t.Errorf("parked = %v", rep.Parked)
}
if _, err := os.Stat(filepath.Join(good.ArchiveDir, "1-01_track-1.flac")); err != nil {
t.Errorf("the good album was not filed: %v", err)
}
if _, err := os.Stat(blocked); err != nil {
t.Errorf("the parked album should have been left alone: %v", err)
}
// The park is recorded, so the interface can point at it and the reason is
// not thrown away with the log.
entries, err := a.JournalEntries(t.Context())
if err != nil {
t.Fatal(err)
}
var found bool
for _, e := range entries {
if e.NeedsAttention && e.Source == blocked {
found = true
if e.Reason == "" {
t.Error("the parked album has no reason recorded")
}
}
}
if !found {
t.Errorf("the parked album was not recorded: %+v", entries)
}
// Running it again files nothing new and parks the same album again, so a
// batch is safe to repeat.
rep2, err := a.Batch(context.Background())
if err != nil {
t.Fatalf("%v\n%s", err, out.String())
}
if len(rep2.Filed) != 0 {
t.Errorf("a second batch filed %v", rep2.Filed)
}
if len(rep2.Parked) != 1 {
t.Errorf("a second batch parked %v", rep2.Parked)
}
if len(rep2.Failed) != 0 {
t.Errorf("a second batch failed on %v", rep2.Failed)
}
}
// The destination path alone does not answer "do I already have this": a
// re-download whose artist folder is spelled differently lands somewhere else
// entirely, and the user only finds out after reviewing the cover of an album
// they already own.
func TestLookupExistingFindsTheSameAlbumUnderAnotherSpelling(t *testing.T) {
root := t.TempDir()
a, _ := cliApp(t, root)
owned := filepath.Join(root, "archive", "boards-of-canada", "music-has-the-right")
seed(t, a, state.Record{
Source: "/staging/old", Phase: state.PhaseArchived,
AlbumArtist: "Boards of Canada", Album: "Music Has the Right to Children",
ArchiveDir: owned, Tracks: 18,
})
p := plan.Plan{
AlbumName: "music has the right to children",
AlbumArt: "boards of canada",
ArchiveDir: filepath.Join(root, "archive", "boards-of-canada", "music-has-the-right-to-children"),
}
got, ok := a.LookupExisting(p)
if !ok {
t.Fatal("an album with the same artist and name under another folder was not found")
}
if got.ArchiveDir != owned {
t.Errorf("found %q, want %q", got.ArchiveDir, owned)
}
if got.SamePath {
t.Error("the paths differ, so SamePath should be false")
}
// A different album is not a match.
other := plan.Plan{AlbumName: "Geogaddi", AlbumArt: "Boards of Canada"}
if _, ok := a.LookupExisting(other); ok {
t.Error("a different album matched")
}
}
// An album already sitting exactly where the plan would write it is found even
// with no journal record at all, which is what happens to anything copied in by
// hand.
func TestLookupExistingFindsAnUnrecordedAlbumAtTheSamePath(t *testing.T) {
root := t.TempDir()
a, _ := cliApp(t, root)
dir := filepath.Join(root, "archive", "an-artist", "an-album")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
got, ok := a.LookupExisting(plan.Plan{ArchiveDir: dir})
if !ok {
t.Fatal("an album already at the destination was not found")
}
if !got.SamePath {
t.Error("SamePath should be true")
}
}
// An archive can exist without a journal: albums filed by an older version,
// albums copied in by hand, or a record that was lost. Those albums are
// invisible to verify and audit, and a batch would file a second copy beside
// them. Adopting writes the record and touches no audio at all.
func TestAdoptBuildsRecordsFromTheArchive(t *testing.T) {
needTools(t, "ffmpeg", "metaflac", "flac")
root := t.TempDir()
a, out := cliApp(t, root)
a.Yes = true
archive := filepath.Join(root, "archive", "straylight-run", "about-time")
if err := os.MkdirAll(archive, 0o755); err != nil {
t.Fatal(err)
}
master := filepath.Join(archive, "1-01_one.flac")
cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-f", "lavfi", "-i", "sine=frequency=440:duration=0.1", master)
if o, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("building a master: %v %s", err, o)
}
cmd = exec.Command("metaflac", "--set-tag=ALBUM=About Time",
"--set-tag=ALBUMARTIST=Straylight Run", "--set-tag=TITLE=One", master)
if o, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("tagging the master: %v %s", err, o)
}
if err := os.WriteFile(filepath.Join(archive, "cover.jpg"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := a.Adopt(t.Context()); err != nil {
t.Fatalf("%v\n%s", err, out.String())
}
j, err := a.journal()
if err != nil {
t.Fatal(err)
}
rec, ok := j.Get(archive)
if !ok {
t.Fatalf("the album was not adopted: %+v\n%s", j.Albums, out.String())
}
if rec.AlbumArtist != "Straylight Run" || rec.Album != "About Time" {
t.Errorf("the record reads %q by %q", rec.Album, rec.AlbumArtist)
}
if rec.Phase != state.PhaseArchived || rec.Tracks != 1 || !rec.Adopted {
t.Errorf("record = %+v", rec)
}
if rec.Placeholder {
t.Error("the album has a cover.jpg and should not be flagged as needing art")
}
if rec.LibraryDir != filepath.Join(root, "library", "straylight-run", "about-time") {
t.Errorf("library dir = %q", rec.LibraryDir)
}
if _, err := os.Stat(master); err != nil {
t.Errorf("adopting disturbed the audio: %v", err)
}
// Verify no longer reports it as unreported, and running adopt again does
// nothing.
entries, err := a.JournalEntries(t.Context())
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if e.Unrecorded {
t.Errorf("the adopted album is still shown as unrecorded: %+v", e)
}
}
out.Reset()
if err := a.Adopt(t.Context()); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "nothing to adopt") {
t.Errorf("a second adopt should find nothing:\n%s", out.String())
}
}
// An adopted album with no cover is flagged as needing art, so it turns up in
// the placeholder list rather than looking finished.
func TestAdoptFlagsAnAlbumWithNoCover(t *testing.T) {
needTools(t, "ffmpeg", "metaflac", "flac")
root := t.TempDir()
a, _ := cliApp(t, root)
a.Yes = true
archive := filepath.Join(root, "archive", "an-artist", "an-album")
if err := os.MkdirAll(archive, 0o755); err != nil {
t.Fatal(err)
}
master := filepath.Join(archive, "1-01_one.flac")
if o, err := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-f", "lavfi", "-i", "sine=frequency=440:duration=0.1", master).CombinedOutput(); err != nil {
t.Fatalf("building a master: %v %s", err, o)
}
if o, err := exec.Command("metaflac", "--set-tag=ALBUM=An Album",
"--set-tag=ALBUMARTIST=An Artist", "--set-tag=TITLE=One", master).CombinedOutput(); err != nil {
t.Fatalf("tagging the master: %v %s", err, o)
}
if err := a.Adopt(t.Context()); err != nil {
t.Fatal(err)
}
j, _ := a.journal()
rec, _ := j.Get(archive)
if !rec.Placeholder {
t.Error("an album with no cover should be flagged as needing art")
}
}
// Ignoring a folder is how junk stops coming back. It has to be honoured
// everywhere an album list is built, or the folder keeps reappearing in one place
// while being hidden in another.
func TestIgnoredFoldersAreSkippedEverywhere(t *testing.T) {
needTools(t, "ffmpeg", "metaflac")
root := t.TempDir()
a, out := cliApp(t, root)
junk := filepath.Join(a.Cfg.Staging(), "junk-folder")
if err := os.MkdirAll(junk, 0o755); err != nil {
t.Fatal(err)
}
stagedAlbum(t, a, "a-real-album", 440)
albums, err := a.listStaging(context.Background())
if err != nil {
t.Fatal(err)
}
if len(albums) != 2 {
t.Fatalf("before ignoring, %d album(s) were listed", len(albums))
}
if err := a.Ignore([]string{"junk-folder"}); err != nil {
t.Fatalf("%v\n%s", err, out.String())
}
albums, err = a.listStaging(context.Background())
if err != nil {
t.Fatal(err)
}
if len(albums) != 1 || albums[0].Name != "a-real-album" {
t.Errorf("the ignored folder was still listed: %+v", albums)
}
// A batch must not touch it either.
rep, err := a.Batch(context.Background())
if err != nil {
t.Fatalf("%v\n%s", err, out.String())
}
if len(rep.Filed) != 1 || rep.Filed[0] != "a-real-album" {
t.Errorf("batch filed %v", rep.Filed)
}
if _, err := os.Stat(junk); err != nil {
t.Errorf("the ignored folder was removed: %v", err)
}
// And it comes back when it stops being ignored.
if err := a.Unignore([]string{"junk-folder"}); err != nil {
t.Fatal(err)
}
albums, err = a.listStaging(context.Background())
if err != nil {
t.Fatal(err)
}
if len(albums) != 1 {
t.Errorf("after unignoring, %d album(s) were listed", len(albums))
}
}
func TestListingTheIgnoreFile(t *testing.T) {
root := t.TempDir()
a, out := cliApp(t, root)
if err := a.Ignore([]string{"one", "two"}); err != nil {
t.Fatal(err)
}
out.Reset()
a.JSON = true
if err := a.ListIgnore(); err != nil {
t.Fatal(err)
}
var parsed map[string]any
if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
t.Fatalf("ignore --json is not JSON: %v\n%s", err, out.String())
}
list, _ := parsed["ignored"].([]any)
if len(list) != 2 {
t.Errorf("ignored = %v", parsed["ignored"])
}
}
// Placeholders accumulate, and replacing them one at a time never gets done. A
// bulk pass only takes a confident match though: replacing a placeholder with the
// wrong sleeve is worse than keeping the placeholder.
func TestBulkReCover(t *testing.T) {
needTools(t, "ffmpeg", "metaflac", "flac", "magick")
root := t.TempDir()
a, out := cliApp(t, root)
a.Yes = true
a.Cfg.NoLibrary = true
// An archived album with its own cover.jpg sitting beside the masters: that
// is a candidate that is always right, and it needs no network.
archive := filepath.Join(root, "archive", "an-artist", "an-album")
if err := os.MkdirAll(archive, 0o755); err != nil {
t.Fatal(err)
}
master := filepath.Join(archive, "1-01_one.flac")
if o, err := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-f", "lavfi", "-i", "sine=frequency=440:duration=0.1", master).CombinedOutput(); err != nil {
t.Fatalf("building a master: %v %s", err, o)
}
if o, err := exec.Command("metaflac", "--set-tag=ALBUM=An Album",
"--set-tag=ALBUMARTIST=An Artist", "--set-tag=TITLE=One", master).CombinedOutput(); err != nil {
t.Fatalf("tagging the master: %v %s", err, o)
}
art := filepath.Join(archive, "front.jpg")
if err := artwork.Placeholder(context.Background(), artwork.AlbumInfo{Artist: "An Artist", Album: "An Album"}, art, 300); err != nil {
t.Skipf("could not build a cover image: %v", err)
}
seed(t, a, state.Record{
Source: "/staging/old", Phase: state.PhaseArchived, AlbumArtist: "An Artist",
Album: "An Album", ArchiveDir: archive, Tracks: 1, Placeholder: true,
})
// With no cover anywhere and no network, it has no confident match and must
// leave the album alone rather than guess.
a.Cfg.Offline = true
if err := os.Remove(art); err != nil {
t.Fatal(err)
}
if err := a.ReCoverPlaceholders(context.Background()); err != nil {
t.Fatalf("%v\n%s", err, out.String())
}
j, _ := a.journal()
rec, _ := j.Get("/staging/old")
if !rec.Placeholder {
t.Error("an album with no candidate should have been left on its placeholder")
}
if !strings.Contains(out.String(), "no confident match") {
t.Errorf("the run should say why it left the album alone:\n%s", out.String())
}
}
// Two spellings of one artist is the most common mess a rebuild produces.
// Fixing it by hand means moving two directories, editing every journal record
// underneath them and finding every artists.toml entry that named the old name;
// missing any one leaves the journal describing folders that are not there.
func TestRenamingAnArtistFolderRepointsEverything(t *testing.T) {
root := t.TempDir()
a, out := cliApp(t, root)
a.Yes = true
fromArchive := filepath.Join(root, "archive", "boards-of-canad")
fromLibrary := filepath.Join(root, "library", "boards-of-canad")
for _, d := range []string{filepath.Join(fromArchive, "geogaddi"), fromLibrary + "/geogaddi"} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
master := filepath.Join(fromArchive, "geogaddi", "1-01_one.flac")
if err := os.WriteFile(master, []byte("audio"), 0o644); err != nil {
t.Fatal(err)
}
seed(t, a, state.Record{
Source: "/staging/geogaddi", Phase: state.PhaseArchived, AlbumArtist: "Boards of Canad",
Album: "Geogaddi", ArchiveDir: filepath.Join(fromArchive, "geogaddi"),
LibraryDir: filepath.Join(fromLibrary, "geogaddi"), LibraryBuilt: true,
})
if err := os.WriteFile(artist.AliasPath(root), []byte("Boards of Canad = \"boards-of-canad\"\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := a.RenameArtist(t.Context(), "boards-of-canad", "boards-of-canada", false); err != nil {
t.Fatalf("%v\n%s", err, out.String())
}
wantArchive := filepath.Join(root, "archive", "boards-of-canada", "geogaddi")
if got, err := os.ReadFile(filepath.Join(wantArchive, "1-01_one.flac")); err != nil || string(got) != "audio" {
t.Fatalf("the audio did not arrive: %v %q", err, got)
}
if _, err := os.Stat(fromArchive); !os.IsNotExist(err) {
t.Error("the old archive folder is still there")
}
if _, err := os.Stat(filepath.Join(root, "library", "boards-of-canada", "geogaddi")); err != nil {
t.Errorf("the library copy did not move: %v", err)
}
j, _ := a.journal()
rec, ok := j.Get("/staging/geogaddi")
if !ok {
t.Fatal("the record disappeared")
}
if rec.ArchiveDir != wantArchive {
t.Errorf("record archive dir = %q, want %q", rec.ArchiveDir, wantArchive)
}
if rec.LibraryDir != filepath.Join(root, "library", "boards-of-canada", "geogaddi") {
t.Errorf("record library dir = %q", rec.LibraryDir)
}
ix := artist.NewIndex()
if err := ix.LoadAliases(artist.AliasPath(root)); err != nil {
t.Fatal(err)
}
if got := ix.Alias["Boards of Canad"]; got != "boards-of-canada" {
t.Errorf("alias = %q, want it repointed", got)
}
// And verify is happy afterwards, which is the real test.
out.Reset()
if err := a.Verify(t.Context()); err != nil {
t.Fatal(err)
}
if strings.Contains(out.String(), "MISSING") {
t.Errorf("verify reports the moved album as missing:\n%s", out.String())
}
}
func TestMergingAnArtistFolder(t *testing.T) {
root := t.TempDir()
a, out := cliApp(t, root)
a.Yes = true
mk := func(artistDir, album string) string {
d := filepath.Join(root, "archive", artistDir, album)
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
p := filepath.Join(d, "1-01_one.flac")
if err := os.WriteFile(p, []byte(album), 0o644); err != nil {
t.Fatal(err)
}
seed(t, a, state.Record{Source: "/staging/" + album, Phase: state.PhaseArchived,
ArchiveDir: d, LibraryDir: filepath.Join(root, "library", artistDir, album)})
return p
}
mk("the-beatles", "abbey-road")
mk("beatles", "revolver")
if err := a.RenameArtist(t.Context(), "the-beatles", "beatles", true); err != nil {
t.Fatalf("%v\n%s", err, out.String())
}
for _, album := range []string{"abbey-road", "revolver"} {
if _, err := os.Stat(filepath.Join(root, "archive", "beatles", album, "1-01_one.flac")); err != nil {
t.Errorf("%s did not arrive: %v", album, err)
}
}
if _, err := os.Stat(filepath.Join(root, "archive", "the-beatles")); !os.IsNotExist(err) {
t.Error("the old folder is still there")
}
// A clash stops the whole thing before anything moves.
mk("other", "revolver")
if err := a.RenameArtist(t.Context(), "other", "beatles", true); err == nil {
t.Errorf("merging an album that is already there should be refused:\n%s", out.String())
}
if _, err := os.Stat(filepath.Join(root, "archive", "other", "revolver", "1-01_one.flac")); err != nil {
t.Errorf("the refused merge moved something anyway: %v", err)
}
}
// A review that is interrupted should not throw away the corrections. The sheet
// is already a complete description of what intake is about to write, so keeping
// it is all that is needed.
func TestSavedSheetSurvivesBetweenRuns(t *testing.T) {
needTools(t, "ffmpeg", "metaflac")
root := t.TempDir()
a, _ := cliApp(t, root)
p := plan.Plan{
Album: scan.Album{Dir: filepath.Join(root, "staging", "an-album")},
AlbumName: "An Album",
AlbumArt: "An Artist",
ArtistDir: "an-artist",
AlbumDir: "an-album",
Tracks: []plan.TrackPlan{{
Source: filepath.Join(root, "staging", "an-album", "01 one.flac"),
Title: "One",
Disc: 1,
Number: 1,
Target: "1-01_one.flac",
Ext: ".flac",
Tags: []tags.Note{
{Field: tags.FieldTitle, Value: "One"},
{Field: tags.FieldAlbum, Value: "An Album"},
{Field: tags.FieldAlbumArtist, Value: "An Artist"},
{Field: tags.FieldTrackNumber, Value: "1"},
},
}},
}
// Stand in for an editor that fixed the album artist and the title.
edited := p.Sheet()
edited.AlbumArtist = "The Right Artist"
edited.ArtistDir = "the-right-artist"
edited.Tracks[0].Title = "One, Reprised"
if _, err := a.sheetStore().Save(p.Album.Dir, sheet.Render(edited)); err != nil {
t.Fatal(err)
}
// A later run on the same album starts from the saved sheet.
again := p
a.applySavedSheet(&again)
if again.AlbumArt != "The Right Artist" || again.ArtistDir != "the-right-artist" {
t.Errorf("the saved sheet was not applied: %+v", again)
}
if again.Tracks[0].Title != "One, Reprised" {
t.Errorf("title = %q", again.Tracks[0].Title)
}
if again.Tracks[0].Target != "1-01_one-reprised.flac" {
t.Errorf("target = %q", again.Tracks[0].Target)
}
// A saved sheet that no longer fits the album is dropped rather than left to
// fail on every run.
if _, err := a.sheetStore().Save(p.Album.Dir, "## 9\ntitle: nonsense\n"); err != nil {
t.Fatal(err)
}
fresh := p
a.applySavedSheet(&fresh)
if fresh.Tracks[0].Title != "One" {
t.Errorf("a sheet that does not fit changed the plan: %q", fresh.Tracks[0].Title)
}
if _, ok, _ := a.sheetStore().Load(p.Album.Dir); ok {
t.Error("the sheet that did not fit was kept")
}
}
// An audit that only reports leaves the same chore every time. --fix rebuilds
// what it can, and never touches the archive: the masters are the one thing a
// rebuild cannot replace.
func TestAuditFixRebuildsTheLibrary(t *testing.T) {
needTools(t, "ffmpeg", "metaflac", "flac", "magick")
root := t.TempDir()
a, out := cliApp(t, root)
archive := filepath.Join(root, "archive", "an-artist", "an-album")
if err := os.MkdirAll(archive, 0o755); err != nil {
t.Fatal(err)
}
master := filepath.Join(archive, "1-01_one.flac")
if o, err := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-f", "lavfi", "-i", "sine=frequency=440:duration=0.1", master).CombinedOutput(); err != nil {
t.Fatalf("building a master: %v %s", err, o)
}
if o, err := exec.Command("metaflac", "--set-tag=ALBUM=An Album",
"--set-tag=ALBUMARTIST=An Artist", "--set-tag=TITLE=One",
"--set-tag=TRACKNUMBER=1", master).CombinedOutput(); err != nil {
t.Fatalf("tagging the master: %v %s", err, o)
}
if err := artwork.Placeholder(context.Background(), artwork.AlbumInfo{Artist: "An Artist", Album: "An Album"},
filepath.Join(archive, "cover.jpg"), 300); err != nil {
t.Skipf("could not build a cover image: %v", err)
}
lib := filepath.Join(root, "library", "an-artist", "an-album")
seed(t, a, state.Record{Source: "/staging/old", Phase: state.PhaseArchived, AlbumArtist: "An Artist",
Album: "An Album", ArchiveDir: archive, LibraryDir: lib, Tracks: 1})
// Reporting first: the audit says what is wrong and does not fix it.
if err := a.Audit(t.Context()); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(lib); !os.IsNotExist(err) {
t.Fatal("an audit without --fix built something")
}
if !strings.Contains(out.String(), "--fix") {
t.Errorf("the report should say how to repair it:\n%s", out.String())
}
out.Reset()
a.Fix = true
if err := a.Audit(t.Context()); err != nil {
t.Fatalf("%v\n%s", err, out.String())
}
track := filepath.Join(lib, "1-01_one.ogg")
if _, err := os.Stat(track); err != nil {
t.Errorf("the library copy was not rebuilt: %v\n%s", err, out.String())
}
if _, err := os.Stat(filepath.Join(lib, "cover.jpg")); err != nil {
t.Errorf("the library cover was not written: %v", err)
}
if _, err := os.Stat(master); err != nil {
t.Errorf("--fix disturbed the archive: %v", err)
}
j, _ := a.journal()
rec, _ := j.Get("/staging/old")
if !rec.LibraryBuilt {
t.Error("the rebuilt library was not recorded")
}
// And a second pass has nothing left to do.
out.Reset()
if err := a.Audit(t.Context()); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "1 complete, 0 needing attention") {
t.Errorf("the album is still reported as needing attention:\n%s", out.String())
}
}