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