fix: the library copy can lose its cover, or be destroyed by a failed rebuild
A cover that lives in the staging folder is deleted along with the other staging leftovers, and the library is built after that from the same path. library.Transcode skipped a cover it could not stat without a word, so the album got a library copy with no artwork at all while the archive had the right cover and the summary still named it. FilePlan now keeps a copy of the cover before the archive runs, and library.Transcode reports a cover it cannot read. The library swap was RemoveAll(old) followed by Rename(new): a failure part-way through the removal left the old library half deleted, the new one was discarded by the deferred cleanup because the rename never happened, and there was a window in which neither copy existed. The old library is moved aside first and moved back if the new one cannot land. A failed cover optimisation no longer promotes whatever ImageMagick had half-written. This file also carries the wiring that follows the album when collision "both" files it somewhere else -- the reported directories, the library this run builds and the journal record -- and the enforcement of --dry-run in the filing path (see c8ea074), which the TUI reached without the flag being passed on. Closes 20d9413
diff --git a/internal/cli/file.go b/internal/cli/file.go
@@ -34,16 +34,27 @@ func (a *App) FilePlan(ctx context.Context, p plan.Plan, cover coverChoice, coll
res.LibraryDir = p.LibraryDir
// Checked first, so an unwritable journal refuses the album rather than
- // filing it and then discovering it cannot be recorded.
- if err := a.journalReady(); err != nil {
- return res, fmt.Errorf("refusing to file: %w", err)
+ // filing it and then discovering it cannot be recorded. A dry run changes
+ // nothing, and that includes proving the journal writable by writing it.
+ if !a.DryRun {
+ if err := a.journalReady(); err != nil {
+ return res, fmt.Errorf("refusing to file: %w", err)
+ }
}
+ // A cover chosen out of the staging folder is deleted with the rest of the
+ // staging leftovers by the call below, and the library is built after that,
+ // so it would quietly be built with no artwork at all. Keep a copy first.
+ libraryCover, dropCover := a.keepCover(p, cover)
+ defer dropCover()
+
out, err := execute.Run(ctx, p, execute.Options{
+ DryRun: a.DryRun,
KeepStaging: a.Cfg.KeepStaging,
VerifyDecode: a.Cfg.VerifyDecode,
Collision: collision,
CoverPath: cover.Path,
+ StagingRoot: a.Cfg.Staging(),
Logf: a.logf,
})
if err != nil {
@@ -53,10 +64,37 @@ func (a *App) FilePlan(ctx context.Context, p plan.Plan, cover coverChoice, coll
res.Files = len(out.Files)
res.Bytes = out.Bytes
res.Removed = out.RemovedStaging
+ for _, name := range out.StagingStuck {
+ res.Warnings = append(res.Warnings, fmt.Sprintf("%s was archived but could not be removed from staging", name))
+ }
+
+ if a.DryRun {
+ // Nothing was written and there is nothing to record, so say what would
+ // have happened and stop before the journal learns about an album that
+ // is not on disk.
+ res.Files = len(p.Tracks)
+ res.Placeholder = cover.Placeholder
+ res.Cover = cover.Label
+ res.Library = !a.Cfg.NoLibrary
+ a.logf("dry run: nothing was written")
+ return res, nil
+ }
+
+ // "both" files the album next to the one that was already there rather than
+ // over it, so every later step -- the summary, the journal, and the library
+ // this same run builds -- has to use where it actually went, not where the
+ // plan asked for. Otherwise the library is transcoded from the album that was
+ // already there and the copy just filed is invisible to verify and audit.
+ res.ArchiveDir = out.ArchiveDir
+ p.ArchiveDir = out.ArchiveDir
+ if p.LibraryDir != "" && !strings.HasSuffix(p.LibraryDir, filepath.Base(out.ArchiveDir)) {
+ p.LibraryDir = filepath.Join(filepath.Dir(p.LibraryDir), filepath.Base(out.ArchiveDir))
+ }
+ res.LibraryDir = p.LibraryDir
libraryBuilt := false
if !a.Cfg.NoLibrary {
- if err := a.buildLibrary(ctx, p, cover); err != nil {
+ if err := a.buildLibrary(ctx, p, libraryCover, cover); err != nil {
// Record what did land, so the archive is not left untracked, and
// report both problems if that fails too.
recErr := a.record(p, state.PhaseArchived, nil, cover.Placeholder, false)
@@ -78,7 +116,61 @@ func (a *App) FilePlan(ctx context.Context, p plan.Plan, cover coverChoice, coll
return res, nil
}
-func (a *App) buildLibrary(ctx context.Context, p plan.Plan, cover coverChoice) error {
+// keepCover returns a path to the chosen cover that will still exist once the
+// staging folder has been cleared, plus a function to remove the copy again.
+//
+// Only a cover that lives inside the staging album needs this: embedded art is
+// extracted to a temporary file by the artwork package, and a folder cover is
+// the real file in staging, which execute.Run deletes as a leftover. The library
+// copy used to be built from that deleted path, and library.Transcode skipped a
+// cover it could not stat without a word.
+func (a *App) keepCover(p plan.Plan, cover coverChoice) (string, func()) {
+ none := func() {}
+ if cover.Path == "" || p.Album.Dir == "" {
+ return cover.Path, none
+ }
+ root, err := filepath.Abs(p.Album.Dir)
+ if err != nil {
+ return cover.Path, none
+ }
+ abs, err := filepath.Abs(cover.Path)
+ if err != nil {
+ return cover.Path, none
+ }
+ rel, err := filepath.Rel(root, abs)
+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+ return cover.Path, none
+ }
+
+ dir := ""
+ if a.Cfg.StateDir != "" {
+ dir = filepath.Join(a.Cfg.StateDir, "art-cache")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ a.logf("warning: the library will have no cover: %v", err)
+ return cover.Path, none
+ }
+ }
+ f, err := os.CreateTemp(dir, "library-cover-*"+filepath.Ext(cover.Path))
+ if err != nil {
+ a.logf("warning: the library will have no cover: %v", err)
+ return cover.Path, none
+ }
+ defer f.Close()
+ data, err := os.ReadFile(cover.Path)
+ if err != nil {
+ os.Remove(f.Name())
+ a.logf("warning: the library will have no cover: %v", err)
+ return cover.Path, none
+ }
+ if _, err := f.Write(data); err != nil {
+ os.Remove(f.Name())
+ a.logf("warning: the library will have no cover: %v", err)
+ return cover.Path, none
+ }
+ return f.Name(), func() { os.Remove(f.Name()) }
+}
+
+func (a *App) buildLibrary(ctx context.Context, p plan.Plan, coverPath string, cover coverChoice) error {
masters := masterFiles(p)
names := make([]string, 0, len(p.Tracks))
for _, t := range p.Tracks {
@@ -91,7 +183,6 @@ func (a *App) buildLibrary(ctx context.Context, p plan.Plan, cover coverChoice)
a.logf("dry run: would transcode %d master(s) into the library", len(masters))
return nil
}
- coverPath := cover.Path
res, err := library.Transcode(ctx, masters, names, p.LibraryDir, library.Options{
Codec: a.Cfg.Codec,
Quality: a.Cfg.Quality,
diff --git a/internal/cli/file_test.go b/internal/cli/file_test.go
@@ -0,0 +1,268 @@
+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")
+ }
+}
diff --git a/internal/library/library.go b/internal/library/library.go
@@ -37,6 +37,16 @@ type Result struct {
const DefaultQuality = 9
+// Ext is the file extension a codec's library files are written with. It is
+// exported because anything comparing a library copy against its archive has to
+// agree with this, and hard-coding ".ogg" made every opus library look broken.
+func Ext(codec string) string {
+ if strings.EqualFold(strings.TrimSpace(codec), "opus") {
+ return ".opus"
+ }
+ return ".ogg"
+}
+
func Transcode(ctx context.Context, masterPaths []string, targetNames []string, outDir string, opts Options) (Result, error) {
logf := opts.Logf
if logf == nil {
@@ -91,19 +101,26 @@ func Transcode(ctx context.Context, masterPaths []string, targetNames []string,
libCover := filepath.Join(tmp, "cover.jpg")
size, err := artwork.Optimize(ctx, opts.CoverPath, artMax, artQuality, libCover)
if err != nil {
+ // magick writes to libCover as it goes, so a failure can leave a
+ // half-written file there. It would then travel with the rest of
+ // the album into the library, where nothing ever decodes it and
+ // every check only asks whether a cover.jpg exists.
+ os.Remove(libCover)
res.Warnings = append(res.Warnings, fmt.Sprintf("could not optimise the cover: %v", err))
} else {
embedCover = libCover
res.Cover = filepath.Join(outDir, "cover.jpg")
logf("cover: optimised to %dx%d max (%s)", artMax, artMax, humanBytes(size))
}
+ } else {
+ // Worth saying out loud: the album is about to be given a library
+ // copy with no artwork at all, and the usual cause is a cover that
+ // lived in the staging folder and has just been cleared away.
+ res.Warnings = append(res.Warnings, fmt.Sprintf("no library cover: %s cannot be read (%v)", opts.CoverPath, err))
}
}
- outExt := ".ogg"
- if codec == "opus" {
- outExt = ".opus"
- }
+ outExt := Ext(codec)
written := make([]string, 0, len(masterPaths))
for i, master := range masterPaths {
@@ -135,12 +152,27 @@ func Transcode(ctx context.Context, masterPaths []string, targetNames []string,
logf("encoded %s", name)
}
+ // Swap the finished library into place without ever leaving neither copy on
+ // disk. Removing the old directory first -- and RemoveAll unlinks as it goes,
+ // so a failure part-way through leaves it half deleted -- destroyed the old
+ // library before the new one had landed, and the new one was thrown away by
+ // the deferred cleanup because the rename never happened.
if _, err := os.Stat(outDir); err == nil {
- if err := os.RemoveAll(outDir); err != nil {
- return res, err
+ stale := outDir + ".intake-stale"
+ os.RemoveAll(stale)
+ if err := os.Rename(outDir, stale); err != nil {
+ return res, fmt.Errorf("moving the existing library aside: %w", err)
}
- }
- if err := os.Rename(tmp, outDir); err != nil {
+ if err := os.Rename(tmp, outDir); err != nil {
+ if back := os.Rename(stale, outDir); back != nil {
+ return res, fmt.Errorf("promoting library directory: %w (the previous library is now at %s)", err, stale)
+ }
+ return res, fmt.Errorf("promoting library directory: %w", err)
+ }
+ if err := os.RemoveAll(stale); err != nil {
+ logf("note: the replaced library is still at %s: %v", filepath.Base(stale), err)
+ }
+ } else if err := os.Rename(tmp, outDir); err != nil {
return res, fmt.Errorf("promoting library directory: %w", err)
}
diff --git a/internal/library/library_test.go b/internal/library/library_test.go
@@ -5,6 +5,7 @@ import (
"encoding/base64"
"encoding/binary"
"os"
+ "os/exec"
"path/filepath"
"strings"
"testing"
@@ -72,3 +73,94 @@ func TestPictureCommentStructure(t *testing.T) {
t.Error("image bytes were not preserved")
}
}
+
+// A cover that cannot be read used to be skipped in silence. The usual reason is
+// a cover that lived in the staging folder and has just been cleared away by the
+// filing run, so the album gets a library copy with no artwork at all and every
+// summary still names the cover it thought it used.
+func TestAMissingCoverIsReported(t *testing.T) {
+ needTools(t, "ffmpeg", "metaflac", "ffprobe")
+ dir := t.TempDir()
+ master := filepath.Join(dir, "master.flac")
+ if out, 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, out)
+ }
+ if out, err := exec.Command("metaflac", "--set-tag=TITLE=One",
+ "--set-tag=ALBUM=An Album",
+ "--set-tag=ALBUMARTIST=Band",
+ "--set-tag=TRACKNUMBER=1", master).CombinedOutput(); err != nil {
+ t.Fatalf("tagging the master: %v %s", err, out)
+ }
+
+ out := filepath.Join(dir, "library", "album")
+ res, err := Transcode(context.Background(), []string{master}, []string{"1-01_one.flac"}, out, Options{
+ CoverPath: filepath.Join(dir, "staging", "cover.jpg"),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, w := range res.Warnings {
+ if strings.Contains(w, "cover") {
+ found = true
+ }
+ }
+ if !found {
+ t.Errorf("a cover that could not be read was not reported: %v", res.Warnings)
+ }
+}
+
+// Rebuilding a library used to remove the old directory first and rename the new
+// one in afterwards, so a failure in between destroyed the old copy and threw
+// away the new one. Nothing should be left behind when it succeeds, either.
+func TestRebuildingALibraryLeavesNoLitter(t *testing.T) {
+ needTools(t, "ffmpeg", "metaflac", "ffprobe")
+ dir := t.TempDir()
+ master := filepath.Join(dir, "master.flac")
+ if out, 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, out)
+ }
+ if out, err := exec.Command("metaflac", "--set-tag=TITLE=One",
+ "--set-tag=ALBUM=An Album",
+ "--set-tag=ALBUMARTIST=Band",
+ "--set-tag=TRACKNUMBER=1", master).CombinedOutput(); err != nil {
+ t.Fatalf("tagging the master: %v %s", err, out)
+ }
+ out := filepath.Join(dir, "library", "album")
+ for i := 0; i < 2; i++ {
+ if _, err := Transcode(context.Background(), []string{master}, []string{"1-01_one.flac"}, out, Options{}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ entries, err := os.ReadDir(filepath.Dir(out))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range entries {
+ if e.Name() != "album" {
+ t.Errorf("rebuilding left %s behind", e.Name())
+ }
+ }
+ tracks, err := os.ReadDir(out)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(tracks) != 1 || !strings.HasSuffix(tracks[0].Name(), ".ogg") {
+ var names []string
+ for _, e := range tracks {
+ names = append(names, e.Name())
+ }
+ t.Errorf("library contents after a rebuild: %v", names)
+ }
+}
+
+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)
+ }
+ }
+}