git/intake/main

feat: library audit --fix

An audit that only reports leaves the same chore every time.

'--fix' rebuilds the library copies that are missing or short, and restores
a missing cover.jpg by copying the archive's rather than re-encoding the
album, which would be wasted work for one file. It never writes to the
archive: the masters are the one thing a rebuild cannot replace, so a
missing or empty archive directory is reported and left strictly alone.

Without --fix the report now says that --fix exists, and both modes count
what was repaired in --json.

Closes 7f497cd
diff --git a/README.md b/README.md
index 6088765..2e9fbef 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,7 @@ intake resume # continue the first unarchived album
intake batch # file every ready album, parking the ones that need you
intake library verify # placeholder covers and missing libraries
intake library audit # compare archive and library for drift
+intake library audit --fix # rebuild the library copies that are short
intake library transcode <album> # rebuild a streaming copy from the masters
intake library re-cover <album> # replace an album's cover
intake library re-cover --placeholders # fetch art again for every placeholder
diff --git a/cmd/intake/main.go b/cmd/intake/main.go
index 926d784..3496430 100644
--- a/cmd/intake/main.go
+++ b/cmd/intake/main.go
@@ -304,6 +304,7 @@ func run(ctx context.Context, args []string) error {
case "audit":
fs := flag.NewFlagSet("library audit", flag.ExitOnError)
cf := register(fs)
+ fix := fs.Bool("fix", false, "rebuild the library copies that are missing or short")
if err := fs.Parse(rest[1:]); err != nil {
return err
}
@@ -311,7 +312,9 @@ func run(ctx context.Context, args []string) error {
if err != nil {
return err
}
- return app(cfg, cf).Audit(ctx)
+ a := app(cfg, cf)
+ a.Fix = *fix
+ return a.Audit(ctx)
case "adopt":
fs := flag.NewFlagSet("library adopt", flag.ExitOnError)
cf := register(fs)
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index f38657c..aff69fd 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -271,6 +271,16 @@ Deleting a block drops that file from the album and the totals are counted again
that still holds the name intake derived it from follows a rename of the album or artist;
one that was typed on purpose does not.
+## Repairing drift
+
+`library audit` compares the journal, the archive and the library, and reads the archive
+against the journal so an album that reached the archive without a record is visible rather
+than invisible. `--fix` rebuilds the library copies that are missing or short, and restores
+a missing `cover.jpg` by copying the archive's rather than re-encoding the album.
+
+It never writes to the archive: the masters are the one thing a rebuild cannot replace, so
+a missing or empty archive directory is reported and left strictly alone.
+
## Interface
Bubble Tea v2 (`charm.land/bubbletea/v2`), Bubbles v2 and Lipgloss v2. Screens: browse,
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index 84f322c..c020cc9 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -41,6 +41,8 @@ type App struct {
// Collision overrides what happens when the destination already exists:
// "replace", "merge", "both" or "" to work it out.
Collision string
+ // Fix makes commands that report a problem repair it where they can.
+ Fix bool
}
func New(cfg config.Config, out, errw io.Writer, in io.Reader) *App {
@@ -451,10 +453,44 @@ func (a *App) Transcode(ctx context.Context, target string) error {
// fixLibrary rebuilds an album's library copy from its masters, when the audit
// was asked to repair what it finds. It reports whether it succeeded, and never
// touches the archive: the masters are the one thing a rebuild cannot replace.
+func (a *App) fixLibrary(ctx context.Context, rec state.Record, why string) int {
+ if !a.Fix || a.DryRun {
+ if a.Fix && a.DryRun {
+ a.logf("dry run: would rebuild the library copy of %s (%s)", rec.Album, why)
+ }
+ return 0
+ }
+ a.logf("rebuilding %s: %s", rec.Album, why)
+ if _, err := a.rebuildLibrary(ctx, rec); err != nil {
+ a.logf(" could not rebuild it: %v", err)
+ return 0
+ }
+ return 1
+}
// fixLibraryCover puts a cover.jpg back into a library copy that has lost one,
// without re-encoding the audio: the archive still has the cover it was built
// from, so this is a copy rather than a rebuild.
+func (a *App) fixLibraryCover(ctx context.Context, rec state.Record) bool {
+ src := filepath.Join(rec.ArchiveDir, "cover.jpg")
+ if _, err := os.Stat(src); err != nil {
+ return false
+ }
+ if a.DryRun {
+ a.logf("dry run: would copy the cover into %s", rec.LibraryDir)
+ return true
+ }
+ if err := os.MkdirAll(rec.LibraryDir, 0o755); err != nil {
+ return false
+ }
+ dst := filepath.Join(rec.LibraryDir, "cover.jpg")
+ if err := copyCoverFile(src, dst); err != nil {
+ a.logf(" could not copy the cover: %v", err)
+ return false
+ }
+ a.logf("restored the cover of %s", rec.Album)
+ return true
+}
type auditFinding struct {
Album string `json:"album"`
@@ -484,7 +520,7 @@ func (a *App) Audit(ctx context.Context) error {
}
orphans := a.unrecorded(j, dirs)
- var checked, ok, problems, missingArchives int
+ var checked, ok, problems, missingArchives, fixed int
var findings []auditFinding
for _, dir := range orphans {
problems++
@@ -512,10 +548,14 @@ func (a *App) Audit(ctx context.Context) error {
findings = append(findings, auditFinding{label, rec.ArchiveDir, "no archive: " + why})
continue
}
+ if a.Fix {
+ a.logf("checking %s ...", label)
+ }
if _, err := os.Stat(rec.LibraryDir); err != nil {
problems++
findings = append(findings, auditFinding{label, rec.LibraryDir, "no library copy"})
+ fixed += a.fixLibrary(ctx, rec, "there was no library copy")
continue
}
missing := 0
@@ -534,9 +574,15 @@ func (a *App) Audit(ctx context.Context) error {
problems++
findings = append(findings, auditFinding{label, rec.LibraryDir,
fmt.Sprintf("%d of %d tracks missing from the library copy", missing, len(masters))})
+ fixed += a.fixLibrary(ctx, rec, fmt.Sprintf("%d of %d tracks were missing", missing, len(masters)))
case coverMissing:
problems++
findings = append(findings, auditFinding{label, rec.LibraryDir, "the library copy has no cover.jpg"})
+ if !a.fixLibraryCover(ctx, rec) {
+ a.logf("could not add a cover to %s; the archive has no cover.jpg either, so use `library re-cover`", rec.Album)
+ } else {
+ fixed++
+ }
default:
ok++
}
@@ -553,6 +599,7 @@ func (a *App) Audit(ctx context.Context) error {
"checked": checked, "ok": ok, "problems": problems,
"missing_archives": missingArchives,
"unrecorded": len(orphans),
+ "fixed": fixed,
}
if findings != nil {
out["albums"] = findings
@@ -569,6 +616,12 @@ func (a *App) Audit(ctx context.Context) error {
a.logf("")
}
a.logf("library audit: %d album(s) checked, %d complete, %d needing attention", checked, ok, problems)
+ if fixed > 0 {
+ a.logf("%d library copy(ies) were rebuilt.", fixed)
+ }
+ if problems > 0 && !a.Fix {
+ a.logf("Run it again with --fix to rebuild the library copies that are missing or short.")
+ }
if missingArchives > 0 {
a.logf("%d archived album(s) have no audio in their archive directory.", missingArchives)
}
diff --git a/internal/cli/journal_test.go b/internal/cli/journal_test.go
index 5911c6e..ede26e2 100644
--- a/internal/cli/journal_test.go
+++ b/internal/cli/journal_test.go
@@ -952,3 +952,72 @@ func TestSavedSheetSurvivesBetweenRuns(t *testing.T) {
// 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())
+ }
+}