git/intake/main

fix: journal updates can lose records and the reports can be wrong

- Every update was an unlocked open-modify-save and every save used a fixed
  "<journal>.tmp" name. Two runs on one root each read the journal, each
  filed an album, and the second save wrote back a snapshot taken before the
  first, so an album could be on disk with no record: invisible to verify and
  audit, and free to be filed a second time. Saves now use a unique temporary
  file, and each read-modify-write runs under a lock beside the journal.
- forget() ran on any execute failure, including one that happened before a
  byte was copied, and deleted the record of an album still in the archive.
  It now clears a record only once the album it points at has gone.
- record() asked whether a library had already been built after writing the
  record, so it always saw false and downgraded the flag; verify then
  reported an album whose library copy is on disk as one without a copy.
- Transcode rebuilt a library without telling the journal.
- library audit skipped a record whose archive was missing or held no audio
  and then reported "0 needing attention"; verify accepted an existing but
  empty archive directory as present.
- library audit hard-coded ".ogg", so every album in an opus library was
  reported incomplete for ever.
- --json printed the human-readable problems before the JSON, so the output
  was unparseable exactly when it had something to say.
- findRecord folded a punctuation-only target to the empty string, and a
  substring search for "" matches every album.
- Resume stopped at the first ready album whatever state it was in, so one
  mistagged mp3 meant no album behind it was reached, and the refusal was
  reported as success.
- library re-cover replaced the archive art but never rebuilt the library.

Closes c8ea074
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index 0400809..52a12b4 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -164,6 +164,12 @@ func (a *App) Resume(ctx context.Context) error {
if err != nil {
return err
}
+ // An album that cannot be filed without a decision must not be allowed to
+ // block the ones behind it. Resume used to stop at the first ready album
+ // whatever state it was in, so one mistagged mp3 meant no later album was
+ // ever reached -- and because process reported the refusal as success, the
+ // command exited zero and looked like it had simply finished.
+ var blocked []string
for _, al := range albums {
if al.Status != scan.StatusReady {
continue
@@ -171,17 +177,43 @@ func (a *App) Resume(ctx context.Context) error {
if rec, ok := j.Get(al.Dir); ok && rec.Phase == state.PhaseArchived {
continue
}
- a.logf("resuming %s", al.Name)
full, err := a.probeAlbum(ctx, al.Dir)
if err != nil {
return err
}
+ if reason := a.blockedReason(full); reason != "" {
+ blocked = append(blocked, fmt.Sprintf("%s: %s", full.Name, reason))
+ continue
+ }
+ a.logf("resuming %s", al.Name)
return a.process(ctx, full)
}
+ if len(blocked) > 0 {
+ a.logf("")
+ a.logf("nothing could be resumed: %d ready album(s) need attention first", len(blocked))
+ for _, b := range blocked {
+ a.logf(" ! %s", b)
+ }
+ return errors.New("every ready album needs attention first")
+ }
a.logf("nothing to resume: every ready album is archived or absent")
return nil
}
+// blockedReason says why an album cannot be filed without a decision, using only
+// what can be worked out from the tags. It deliberately does not resolve the
+// artist, because that can ask a question, and this runs before anybody has
+// agreed to be asked.
+func (a *App) blockedReason(al scan.Album) string {
+ p := plan.Build(al, a.Cfg.Archive(), a.Cfg.Library(), plan.DirKey{}, a.Cfg.DiscPrefix)
+ for _, i := range p.Issues {
+ if i.Hard {
+ return i.Text
+ }
+ }
+ return ""
+}
+
func (a *App) process(ctx context.Context, al scan.Album) error {
if al.Status != scan.StatusReady {
return fmt.Errorf("%s: %s (%s)", al.Name, al.Status, strings.Join(al.Notes, "; "))
@@ -293,7 +325,9 @@ func (a *App) process(ctx context.Context, al scan.Album) error {
if a.Yes {
return errors.New("hard issues present; refusing --yes")
}
- return nil
+ // Not an error, but not a success either: a caller that cannot see the
+ // output has to be able to tell that the album was left alone.
+ return fmt.Errorf("left alone: %s", firstHardIssue(p))
}
if a.DryRun {
a.logf("")
@@ -368,7 +402,7 @@ func (a *App) Transcode(ctx context.Context, target string) error {
return fmt.Errorf("album directory is missing: %s", rec.ArchiveDir)
}
- masters, names, err := archiveMasters(rec.ArchiveDir)
+ masters, _, err := archiveMasters(rec.ArchiveDir)
if err != nil {
return err
}
@@ -391,29 +425,21 @@ func (a *App) Transcode(ctx context.Context, target string) error {
}
}
- coverPath := filepath.Join(rec.ArchiveDir, "cover.jpg")
- if _, err := os.Stat(coverPath); err != nil {
- coverPath = ""
- }
- res, err := library.Transcode(ctx, masters, names, rec.LibraryDir, library.Options{
- Codec: a.Cfg.Codec,
- Quality: a.Cfg.Quality,
- ArtMaxPx: a.Cfg.LibraryArtPx,
- CoverPath: coverPath,
- VerifyDecode: true,
- Logf: a.logf,
- })
+ res, err := a.rebuildLibrary(ctx, rec)
if err != nil {
return err
}
- for _, w := range res.Warnings {
- a.logf(" warning: %s", w)
- }
a.logf("")
a.logf("done: %d file(s) -> %s (%s)", len(res.Files), res.Dir, humanBytes(res.Bytes))
return nil
}
+type auditFinding struct {
+ Album string `json:"album"`
+ Dir string `json:"dir"`
+ Problem string `json:"problem"`
+}
+
func (a *App) Audit(ctx context.Context) error {
j, err := a.journal()
if err != nil {
@@ -425,28 +451,44 @@ func (a *App) Audit(ctx context.Context) error {
}
sort.Strings(keys)
- var checked, ok, problems int
+ // The extension follows the configured codec. Hard-coding ".ogg" meant every
+ // album in an opus library was reported INCOMPLETE, every time, with a
+ // suggestion to rebuild it -- which rebuilds it identically.
+ ext := library.Ext(a.Cfg.Codec)
+
+ var checked, ok, problems, missingArchives int
+ var findings []auditFinding
for _, k := range keys {
rec := j.Albums[k]
if rec.Phase != state.PhaseArchived {
continue
}
+ checked++
+ label := rec.AlbumArtist + " - " + rec.Album
+
+ // A record whose archive is gone or holds no audio is the worst thing to
+ // be quiet about: this command exists to notice exactly that, and it used
+ // to skip it and then report "0 needing attention".
masters, _, err := archiveMasters(rec.ArchiveDir)
if err != nil || len(masters) == 0 {
+ problems++
+ missingArchives++
+ why := "the archive directory holds no audio"
+ if err != nil {
+ why = "the archive directory cannot be read: " + err.Error()
+ }
+ findings = append(findings, auditFinding{label, rec.ArchiveDir, "no archive: " + why})
continue
}
- checked++
- label := rec.AlbumArtist + " - " + rec.Album
if _, err := os.Stat(rec.LibraryDir); err != nil {
problems++
- a.logf("NO LIBRARY %s", label)
- a.logf(" %s", rec.LibraryDir)
+ findings = append(findings, auditFinding{label, rec.LibraryDir, "no library copy"})
continue
}
missing := 0
for _, m := range masters {
- want := filepath.Join(rec.LibraryDir, strings.TrimSuffix(filepath.Base(m), filepath.Ext(m))+".ogg")
+ want := filepath.Join(rec.LibraryDir, strings.TrimSuffix(filepath.Base(m), filepath.Ext(m))+ext)
if _, err := os.Stat(want); err != nil {
missing++
}
@@ -458,24 +500,45 @@ func (a *App) Audit(ctx context.Context) error {
switch {
case missing > 0:
problems++
- a.logf("INCOMPLETE %s (%d of %d tracks missing)", label, missing, len(masters))
+ findings = append(findings, auditFinding{label, rec.LibraryDir,
+ fmt.Sprintf("%d of %d tracks missing from the library copy", missing, len(masters))})
case coverMissing:
problems++
- a.logf("NO COVER %s (library copy has no cover.jpg)", label)
+ findings = append(findings, auditFinding{label, rec.LibraryDir, "the library copy has no cover.jpg"})
default:
ok++
}
}
+ // Everything a person reads is printed only when there is no JSON to print,
+ // so a caller that asked for JSON gets JSON and nothing else. It used to
+ // print the problems first, which made the output unparseable exactly when it
+ // had something to say.
if a.JSON {
enc := json.NewEncoder(a.Out)
enc.SetIndent("", " ")
- return enc.Encode(map[string]any{
+ out := map[string]any{
"checked": checked, "ok": ok, "problems": problems,
- })
+ "missing_archives": missingArchives,
+ }
+ if findings != nil {
+ out["albums"] = findings
+ }
+ return enc.Encode(out)
+ }
+
+ for _, f := range findings {
+ a.logf("%s", f.Album)
+ a.logf(" %s", f.Dir)
+ a.logf(" %s", f.Problem)
+ }
+ if len(findings) > 0 {
+ a.logf("")
}
- a.logf("")
a.logf("library audit: %d album(s) checked, %d complete, %d needing attention", checked, ok, problems)
+ if missingArchives > 0 {
+ a.logf("%d archived album(s) have no audio in their archive directory.", missingArchives)
+ }
if problems > 0 {
a.logf("Rebuild one with: intake library transcode <album>")
}
@@ -500,8 +563,10 @@ func archiveMasters(dir string) ([]string, []string, error) {
return masters, names, nil
}
+func (a *App) journalPath() string { return filepath.Join(a.Cfg.StateDir, "state.json") }
+
func (a *App) journal() (*state.Journal, error) {
- path := filepath.Join(a.Cfg.StateDir, "state.json")
+ path := a.journalPath()
j, err := state.Open(path)
if err != nil {
return nil, fmt.Errorf("reading journal %s: %w", path, err)
@@ -510,30 +575,30 @@ func (a *App) journal() (*state.Journal, error) {
}
func (a *App) record(p plan.Plan, phase state.Phase, issues []string, placeholder, libraryBuilt bool) error {
- j, err := a.journal()
- if err != nil {
- return fmt.Errorf("opening the journal to record %q: %w", p.AlbumName, err)
- }
- j.Put(state.Record{
- Source: p.Album.Dir,
- AlbumArtist: p.AlbumArt,
- Album: p.AlbumName,
- Phase: phase,
- ArchiveDir: p.ArchiveDir,
- LibraryDir: p.LibraryDir,
- Tracks: len(p.Tracks),
- Issues: issues,
- Placeholder: placeholder,
- LibraryBuilt: libraryBuilt,
- })
- if !libraryBuilt {
- if existing, ok := j.Get(p.Album.Dir); ok && existing.LibraryBuilt {
- rec := j.Albums[p.Album.Dir]
- rec.LibraryBuilt = true
- j.Put(rec)
+ err := state.Update(a.journalPath(), func(j *state.Journal) (bool, error) {
+ // Read the previous record before writing the new one. Asking afterwards
+ // always sees the value that was just written, so the check that was meant
+ // to keep a built library marked as built never ran, and every later write
+ // downgraded it to false -- after which verify reported an album whose
+ // library copy is on disk as "without a library copy".
+ if prev, ok := j.Get(p.Album.Dir); ok && prev.LibraryBuilt && !libraryBuilt {
+ libraryBuilt = true
}
- }
- if err := j.Save(); err != nil {
+ j.Put(state.Record{
+ Source: p.Album.Dir,
+ AlbumArtist: p.AlbumArt,
+ Album: p.AlbumName,
+ Phase: phase,
+ ArchiveDir: p.ArchiveDir,
+ LibraryDir: p.LibraryDir,
+ Tracks: len(p.Tracks),
+ Issues: issues,
+ Placeholder: placeholder,
+ LibraryBuilt: libraryBuilt,
+ })
+ return true, nil
+ })
+ if err != nil {
return fmt.Errorf("recording %q in the journal: %w", p.AlbumName, err)
}
return nil
@@ -547,27 +612,38 @@ func (a *App) record(p plan.Plan, phase state.Phase, issues []string, placeholde
// journal unchanged is the cheapest way to exercise the exact write that will
// matter later, and it happens before the first file is copied.
func (a *App) journalReady() error {
- j, err := a.journal()
+ // The lock is taken as well as the write: proving the journal writable only
+ // helps if the same lock will still be available when the real record is
+ // written a minute later.
+ err := state.Update(a.journalPath(), func(*state.Journal) (bool, error) { return true, nil })
if err != nil {
- return err
- }
- if err := j.Save(); err != nil {
- return fmt.Errorf("journal at %s cannot be written: %w", filepath.Join(a.Cfg.StateDir, "state.json"), err)
+ return fmt.Errorf("journal at %s cannot be written: %w", a.journalPath(), err)
}
return nil
}
func (a *App) forget(p plan.Plan) {
- j, err := a.journal()
+ err := state.Update(a.journalPath(), func(j *state.Journal) (bool, error) {
+ rec, ok := j.Get(p.Album.Dir)
+ if !ok {
+ return false, nil
+ }
+ // A run that failed proves nothing about an album recorded earlier.
+ // forget used to be called on every failure -- including one that happened
+ // before a single byte was copied, when the album it is about is still
+ // sitting safely in the archive -- and deleting its record left the archive
+ // untracked, invisible to verify and audit, and free to be filed a second
+ // time. The record only goes when the album it points at has gone too.
+ if rec.ArchiveDir != "" {
+ if _, err := os.Stat(rec.ArchiveDir); err == nil {
+ return false, nil
+ }
+ }
+ j.Delete(p.Album.Dir)
+ return true, nil
+ })
if err != nil {
- return
- }
- if _, ok := j.Get(p.Album.Dir); !ok {
- return
- }
- j.Delete(p.Album.Dir)
- if err := j.Save(); err != nil {
- fmt.Fprintf(a.Err, "warning: could not save journal: %v\n", err)
+ fmt.Fprintf(a.Err, "warning: could not update journal: %v\n", err)
}
}
@@ -588,6 +664,7 @@ func (a *App) Verify(ctx context.Context) error {
var archived, missing, placeholders, noLibrary int
var placeholderRows []state.Record
+ var missingRows []string
for _, k := range keys {
rec := j.Albums[k]
if rec.Phase != state.PhaseArchived {
@@ -596,7 +673,7 @@ func (a *App) Verify(ctx context.Context) error {
archived++
if _, err := os.Stat(rec.ArchiveDir); err != nil {
missing++
- a.logf("MISSING %s (journaled but not on disk)", rec.ArchiveDir)
+ missingRows = append(missingRows, rec.ArchiveDir)
}
if !rec.LibraryBuilt && !a.Cfg.NoLibrary {
noLibrary++
@@ -607,6 +684,42 @@ func (a *App) Verify(ctx context.Context) error {
}
}
+ // JSON mode prints JSON and nothing else. The human-readable report used to
+ // come out first, which left a script with "MISSING ...{...}" -- unparseable
+ // precisely when there was something wrong to report.
+ if a.JSON {
+ type row struct {
+ Path string `json:"archive_dir"`
+ Artist string `json:"album_artist"`
+ Album string `json:"album"`
+ Placeholder bool `json:"placeholder"`
+ Library bool `json:"library_built"`
+ Missing bool `json:"archive_missing"`
+ }
+ rows := []row{}
+ for _, k := range keys {
+ rec := j.Albums[k]
+ if rec.Phase != state.PhaseArchived {
+ continue
+ }
+ _, statErr := os.Stat(rec.ArchiveDir)
+ rows = append(rows, row{rec.ArchiveDir, rec.AlbumArtist, rec.Album, rec.Placeholder, rec.LibraryBuilt, statErr != nil})
+ }
+ enc := json.NewEncoder(a.Out)
+ enc.SetIndent("", " ")
+ return enc.Encode(map[string]any{
+ "albums": rows,
+ "archived": archived,
+ "missing": missing,
+ "placeholders": placeholders,
+ "no_library": noLibrary,
+ })
+ }
+
+ for _, dir := range missingRows {
+ a.logf("MISSING %s (journaled but not on disk)", dir)
+ }
+
if len(placeholderRows) > 0 {
a.logf("")
a.logf("ALBUMS WITH PLACEHOLDER ART (%d) -- these need real covers", placeholders)
@@ -625,26 +738,6 @@ func (a *App) Verify(ctx context.Context) error {
a.logf("Replace one with: intake library re-cover <path>")
}
- if a.JSON {
- type row struct {
- Path string `json:"archive_dir"`
- Artist string `json:"album_artist"`
- Album string `json:"album"`
- Placeholder bool `json:"placeholder"`
- }
- var rows []row
- for _, k := range keys {
- rec := j.Albums[k]
- if rec.Phase != state.PhaseArchived {
- continue
- }
- rows = append(rows, row{rec.ArchiveDir, rec.AlbumArtist, rec.Album, rec.Placeholder})
- }
- enc := json.NewEncoder(a.Out)
- enc.SetIndent("", " ")
- return enc.Encode(map[string]any{"albums": rows, "placeholders": placeholders})
- }
-
a.logf("")
a.logf("journal: %d archived, %d missing on disk, %d with placeholder covers, %d without a library copy",
archived, missing, placeholders, noLibrary)
@@ -725,10 +818,12 @@ func (a *App) ReCover(ctx context.Context, target string) error {
return err
}
}
- rec.Placeholder = choice.Placeholder
- j.Put(rec)
- if err := j.Save(); err != nil {
- return err
+ if err := state.Update(a.journalPath(), func(j *state.Journal) (bool, error) {
+ rec.Placeholder = choice.Placeholder
+ j.Put(rec)
+ return true, nil
+ }); err != nil {
+ return fmt.Errorf("recording the new cover for %q: %w", rec.Album, err)
}
a.logf("")
@@ -738,9 +833,66 @@ func (a *App) ReCover(ctx context.Context, target string) error {
} else {
a.logf("this album no longer appears in the placeholder list")
}
+
+ // The library copy has its own cover.jpg and its own embedded picture, so
+ // replacing the archive's art leaves the two disagreeing: the placeholder
+ // list stops mentioning the album while the copy people actually stream still
+ // carries the placeholder. Rebuild it now, from the archive that now has the
+ // right cover.
+ if !a.Cfg.NoLibrary && rec.LibraryBuilt && rec.LibraryDir != "" {
+ a.logf("")
+ a.logf("rebuilding the library copy so it carries the new cover ...")
+ _, err := a.rebuildLibrary(ctx, rec)
+ return err
+ }
return nil
}
+// rebuildLibrary transcodes an archived album into its library directory and
+// records that the copy exists.
+//
+// Recording it is the point. The library copy used to be written without the
+// journal hearing about it, so `library verify` went on reporting the album as
+// one without a library copy however many times it was rebuilt.
+func (a *App) rebuildLibrary(ctx context.Context, rec state.Record) (library.Result, error) {
+ var res library.Result
+ masters, names, err := archiveMasters(rec.ArchiveDir)
+ if err != nil {
+ return res, err
+ }
+ if len(masters) == 0 {
+ return res, fmt.Errorf("no audio files found in %s", rec.ArchiveDir)
+ }
+ coverPath := filepath.Join(rec.ArchiveDir, "cover.jpg")
+ if _, err := os.Stat(coverPath); err != nil {
+ coverPath = ""
+ }
+ res, err = library.Transcode(ctx, masters, names, rec.LibraryDir, library.Options{
+ Codec: a.Cfg.Codec,
+ Quality: a.Cfg.Quality,
+ ArtMaxPx: a.Cfg.LibraryArtPx,
+ CoverPath: coverPath,
+ VerifyDecode: true,
+ Logf: a.logf,
+ })
+ if err != nil {
+ return res, err
+ }
+ for _, w := range res.Warnings {
+ a.logf(" warning: %s", w)
+ }
+ rec.LibraryBuilt = true
+ rec.LibraryDir = res.Dir
+ if err := state.Update(a.journalPath(), func(j *state.Journal) (bool, error) {
+ j.Put(rec)
+ return true, nil
+ }); err != nil {
+ return res, fmt.Errorf("recording the rebuilt library for %q: %w", rec.Album, err)
+ }
+ a.logf("library: %d file(s) -> %s (%s)", len(res.Files), res.Dir, humanBytes(res.Bytes))
+ return res, nil
+}
+
func findRecord(j *state.Journal, target string) (state.Record, bool) {
target = strings.TrimSpace(target)
if target == "" {
@@ -748,6 +900,13 @@ func findRecord(j *state.Journal, target string) (state.Record, bool) {
}
clean := filepath.Clean(target)
want := looseKey(target)
+ if want == "" {
+ // A target made only of punctuation folds down to nothing, and
+ // strings.Contains against "" matches every album, so searching for "!!"
+ // used to return whichever archived album happened to be alone in the
+ // journal.
+ return state.Record{}, false
+ }
var matches []state.Record
for _, rec := range j.Albums {
diff --git a/internal/cli/journal_test.go b/internal/cli/journal_test.go
new file mode 100644
index 0000000..792e04f
--- /dev/null
+++ b/internal/cli/journal_test.go
@@ -0,0 +1,221 @@
+package cli
+
+import (
+ "bytes"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "intake/internal/config"
+ "intake/internal/plan"
+ "intake/internal/scan"
+ "intake/internal/state"
+)
+
+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"])
+ }
+}
diff --git a/internal/state/state.go b/internal/state/state.go
index 6a89e5c..b3cf079 100644
--- a/internal/state/state.go
+++ b/internal/state/state.go
@@ -3,6 +3,7 @@ package state
import (
"encoding/json"
"errors"
+ "fmt"
"os"
"path/filepath"
"time"
@@ -86,9 +87,106 @@ func (j *Journal) Save() error {
if err != nil {
return err
}
- tmp := j.path + ".tmp"
- if err := os.WriteFile(tmp, data, 0o644); err != nil {
+ // A temp name of its own, so that two writers cannot rename each other's
+ // half-written file into place. The fixed ".tmp" name meant the loser of a
+ // race got "no such file or directory" from its own rename and lost the
+ // record it was trying to write.
+ tmp, err := os.CreateTemp(filepath.Dir(j.path), filepath.Base(j.path)+".tmp-*")
+ if err != nil {
+ return err
+ }
+ name := tmp.Name()
+ if _, err := tmp.Write(data); err != nil {
+ tmp.Close()
+ os.Remove(name)
+ return err
+ }
+ if err := tmp.Sync(); err != nil {
+ tmp.Close()
+ os.Remove(name)
return err
}
- return os.Rename(tmp, j.path)
+ if err := tmp.Close(); err != nil {
+ os.Remove(name)
+ return err
+ }
+ if err := os.Rename(name, j.path); err != nil {
+ os.Remove(name)
+ return err
+ }
+ return nil
+}
+
+// Update runs fn against the journal with the journal locked, and saves the
+// result when fn asks for that. The lock is what makes a read, a change and a
+// write one indivisible step.
+//
+// Every update used to be an unlocked open-modify-save. Two intake processes on
+// one root both read the journal, each filed its own album, and the second save
+// wrote back a snapshot taken before the first one, so the first album's record
+// vanished while the album itself stayed on disk. Nothing downstream could tell:
+// verify and audit work from the journal, so the album stopped existing as far as
+// intake was concerned, and the next resume filed it a second time.
+func Update(path string, fn func(*Journal) (bool, error)) error {
+ if path == "" {
+ return nil
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ unlock, err := lockJournal(path)
+ if err != nil {
+ return err
+ }
+ defer unlock()
+
+ j, err := Open(path)
+ if err != nil {
+ return err
+ }
+ save, err := fn(j)
+ if err != nil {
+ return err
+ }
+ if !save {
+ return nil
+ }
+ return j.Save()
+}
+
+const (
+ lockPoll = 25 * time.Millisecond
+ lockWait = 15 * time.Second
+ lockStale = 5 * time.Minute
+)
+
+// lockJournal takes a cooperative lock beside the journal. It is a lock file
+// created with O_EXCL rather than flock, because flock is not available
+// everywhere intake builds and the window it protects is a few milliseconds of
+// file writing rather than a long-running operation.
+//
+// A lock older than lockStale is broken rather than waited on for ever: the
+// alternative is that a run killed while holding one stops every later run.
+func lockJournal(path string) (func(), error) {
+ lock := path + ".lock"
+ deadline := time.Now().Add(lockWait)
+ for {
+ f, err := os.OpenFile(lock, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
+ if err == nil {
+ fmt.Fprintf(f, "%d\n", os.Getpid())
+ f.Close()
+ return func() { os.Remove(lock) }, nil
+ }
+ if !os.IsExist(err) {
+ return nil, err
+ }
+ if fi, statErr := os.Stat(lock); statErr == nil && time.Since(fi.ModTime()) > lockStale {
+ os.Remove(lock)
+ continue
+ }
+ if time.Now().After(deadline) {
+ return nil, fmt.Errorf("the journal at %s is locked by another intake run (%s); wait for it to finish, or delete that file if no run is active", path, lock)
+ }
+ time.Sleep(lockPoll)
+ }
}
diff --git a/internal/state/state_test.go b/internal/state/state_test.go
new file mode 100644
index 0000000..4eb93ff
--- /dev/null
+++ b/internal/state/state_test.go
@@ -0,0 +1,133 @@
+package state
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+)
+
+// Every journal update used to be an unlocked open-modify-save. Two intake runs
+// on one root both read the journal, each filed its own album, and the second
+// save wrote back a snapshot taken before the first, so the first album's record
+// vanished while the album itself stayed on disk -- invisible to verify and audit,
+// and free to be filed a second time.
+func TestConcurrentUpdatesKeepEveryRecord(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state.json")
+ const n = 32
+
+ var wg sync.WaitGroup
+ for i := 0; i < n; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ src := fmt.Sprintf("/staging/album-%02d", i)
+ err := Update(path, func(j *Journal) (bool, error) {
+ j.Put(Record{Source: src, Phase: PhaseArchived, Album: src})
+ return true, nil
+ })
+ if err != nil {
+ t.Errorf("update %d: %v", i, err)
+ }
+ }(i)
+ }
+ wg.Wait()
+
+ j, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(j.Albums) != n {
+ t.Errorf("the journal holds %d records after %d concurrent updates", len(j.Albums), n)
+ }
+ for i := 0; i < n; i++ {
+ src := fmt.Sprintf("/staging/album-%02d", i)
+ if _, ok := j.Get(src); !ok {
+ t.Errorf("the record for %s was lost", src)
+ }
+ }
+}
+
+func TestUpdateReleasesTheLock(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state.json")
+ if err := Update(path, func(j *Journal) (bool, error) { return true, nil }); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(path + ".lock"); !os.IsNotExist(err) {
+ t.Error("the lock file was left behind")
+ }
+ // So a second update is not blocked by the first.
+ if err := Update(path, func(j *Journal) (bool, error) { return true, nil }); err != nil {
+ t.Fatalf("a later update could not take the lock: %v", err)
+ }
+}
+
+// A lock that is never released -- a run killed while holding it -- must not stop
+// every later run for ever.
+func TestAStaleLockIsBroken(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state.json")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path+".lock", []byte("99999\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ old := time.Now().Add(-2 * lockStale)
+ if err := os.Chtimes(path+".lock", old, old); err != nil {
+ t.Fatal(err)
+ }
+
+ done := make(chan error, 1)
+ go func() {
+ done <- Update(path, func(j *Journal) (bool, error) {
+ j.Put(Record{Source: "/staging/x", Phase: PhaseArchived})
+ return true, nil
+ })
+ }()
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatalf("a stale lock stopped the update: %v", err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("a stale lock was waited on instead of being broken")
+ }
+}
+
+func TestSaveWritesAtomically(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "state.json")
+ j, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ j.Put(Record{Source: "/staging/x", Phase: PhaseArchived, Album: "An Album"})
+ if err := j.Save(); err != nil {
+ t.Fatal(err)
+ }
+
+ // No temporary files left behind, and a second save does not collide with a
+ // fixed name.
+ if err := j.Save(); err != nil {
+ t.Fatal(err)
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range entries {
+ if e.Name() != "state.json" {
+ t.Errorf("unexpected leftover %s", e.Name())
+ }
+ }
+
+ again, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rec, ok := again.Get("/staging/x"); !ok || rec.Album != "An Album" {
+ t.Errorf("the saved record did not come back: %+v", rec)
+ }
+}