feat: build journal records for an archive that has none
The journal is the only index intake has, and an archive can exist without it: albums filed by an older version, albums copied in by hand, or an album whose record was lost. Those albums are invisible to verify and audit, and a batch would file a second copy beside them. 'intake library adopt' walks the archive, finds the album directories no record names, reads the artist and album from the masters' own tags -- the folder names may have been normalised on the way in and no longer say what the album was called -- and writes a record for each. Nothing is written to the audio. The record is keyed by the archive directory rather than by a staging path, because the staging path is long gone and inventing one would be a lie; Record.Adopted marks these so they are distinguishable from albums this tool filed. An adopted album with no cover.jpg is flagged as needing art, so it turns up in the placeholder list instead of looking finished. Adopting reports what it will do before doing it, --yes skips the confirmation, --dry-run changes nothing, and running it again finds nothing, which is what makes it safe after any import. Closes de700dc
diff --git a/README.md b/README.md
@@ -58,6 +58,7 @@ intake library verify # placeholder covers and missing libraries
intake library audit # compare archive and library for drift
intake library transcode <album> # rebuild a streaming copy from the masters
intake library re-cover <album> # replace an album's cover
+intake library adopt # build records for albums already in the archive
intake library forget <album> # drop the record, leave every file alone
intake library unfile <album> # move the masters back to staging and forget it
intake config # effective config and resolved paths
diff --git a/cmd/intake/main.go b/cmd/intake/main.go
@@ -30,6 +30,7 @@ Commands:
library re-cover Replace the cover of an archived album
library transcode Rebuild an album's streaming copy from its masters
library audit Compare the archive and library for drift
+ library adopt Build records for albums already in the archive
library forget Drop an album's journal record, leaving the files alone
library unfile Move an archived album back to staging and forget it
config Show the effective configuration and resolved paths
@@ -266,6 +267,17 @@ func run(ctx context.Context, args []string) error {
return err
}
return app(cfg, cf).Audit(ctx)
+ case "adopt":
+ fs := flag.NewFlagSet("library adopt", flag.ExitOnError)
+ cf := register(fs)
+ if err := fs.Parse(rest[1:]); err != nil {
+ return err
+ }
+ cfg, err := resolve(cf)
+ if err != nil {
+ return err
+ }
+ return app(cfg, cf).Adopt(ctx)
case "forget":
fs := flag.NewFlagSet("library forget", flag.ExitOnError)
cf := register(fs)
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
@@ -188,6 +188,12 @@ promotion and the write, or somebody copied it in — would otherwise be invisib
later run would file a second copy beside it. `library verify` and `library audit` list
those, and the journal screen marks them.
+An archive can also exist without a journal: albums filed by an older version, albums
+copied in by hand, or a record that was lost. `library adopt` builds records for those
+from the masters' own tags, because the folder names may have been normalised on the way
+in and no longer say what the album was called. It writes nothing to the audio, and an
+adopted album with no `cover.jpg` is flagged as needing art rather than looking finished.
+
Records can be removed on purpose. Forgetting one touches no files at all; un-filing moves
the masters back to staging and removes the library copy, which is derived. Un-filing
moves rather than copies, because after filing the archive holds the only copy of that
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
@@ -1655,3 +1655,138 @@ func leftAlone(err error) (string, bool) {
}
return "", false
}
+
+// Adopt builds journal records for albums that are in the archive with none.
+//
+// The journal is the only index intake has, and an archive can exist without it:
+// albums filed by an older version, albums copied in by hand, or an album whose
+// record was lost. Without this, those albums are invisible to verify and audit,
+// and a batch would happily file a second copy beside them.
+//
+// The record's key is the archive directory rather than a staging path, because
+// the staging path is long gone and inventing one would be a lie. Nothing is
+// written to the audio: the tags of the first master are read to fill in the
+// artist and album, and that is all.
+func (a *App) Adopt(ctx context.Context) error {
+ j, err := a.journal()
+ if err != nil {
+ return err
+ }
+ dirs, err := a.archiveDirs()
+ if err != nil {
+ return err
+ }
+ orphans := a.unrecorded(j, dirs)
+ if len(orphans) == 0 {
+ a.logf("nothing to adopt: every album in the archive has a record")
+ return nil
+ }
+
+ type candidate struct {
+ dir string
+ rec state.Record
+ notes []string
+ }
+ var found []candidate
+ for _, dir := range orphans {
+ masters, _, err := archiveMasters(dir)
+ if err != nil || len(masters) == 0 {
+ a.logf("skipping %s: no audio in it", dir)
+ continue
+ }
+ rec := state.Record{
+ Source: dir,
+ ArchiveDir: dir,
+ LibraryDir: filepath.Join(a.Cfg.Library(), relToArchive(a.Cfg.Archive(), dir)),
+ Phase: state.PhaseArchived,
+ Tracks: len(masters),
+ Adopted: true,
+ Placeholder: false,
+ }
+ // The artist and album come from the masters, which is the only place
+ // they can come from: the folder names may have been normalised.
+ first := masters[0]
+ var notes []string
+ if info, err := audio.Probe(ctx, first); err == nil {
+ rec.AlbumArtist, _ = tags.Get(info.Tags, tags.FieldAlbumArtist)
+ rec.Album, _ = tags.Get(info.Tags, tags.FieldAlbum)
+ if rec.AlbumArtist == "" {
+ notes = append(notes, "no ALBUMARTIST tag; the artist folder name stands in")
+ rec.AlbumArtist = filepath.Base(filepath.Dir(dir))
+ }
+ if rec.Album == "" {
+ notes = append(notes, "no ALBUM tag; the folder name stands in")
+ rec.Album = filepath.Base(dir)
+ }
+ } else {
+ notes = append(notes, "the first master could not be read: "+err.Error())
+ rec.AlbumArtist = filepath.Base(filepath.Dir(dir))
+ rec.Album = filepath.Base(dir)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "cover.jpg")); err != nil {
+ rec.Placeholder = true
+ notes = append(notes, "no cover.jpg, so it will show up as needing art")
+ }
+ if _, err := os.Stat(rec.LibraryDir); err == nil {
+ rec.LibraryBuilt = true
+ }
+ found = append(found, candidate{dir: dir, rec: rec, notes: notes})
+ }
+ if len(found) == 0 {
+ a.logf("nothing to adopt: the albums without records hold no audio either")
+ return nil
+ }
+
+ a.logf("%d album(s) in the archive have no record:", len(found))
+ for _, c := range found {
+ a.logf(" %s - %s", c.rec.AlbumArtist, c.rec.Album)
+ a.logf(" %s (%d track(s))", c.dir, c.rec.Tracks)
+ for _, n := range c.notes {
+ a.logf(" note: %s", n)
+ }
+ }
+ a.logf("")
+ a.logf("Adopting changes no audio. It writes a record so that verify, audit and")
+ a.logf("the journal can see these albums, and so a later run does not file them")
+ a.logf("a second time.")
+ if !a.Yes {
+ ok, err := a.confirm("adopt them?")
+ if err != nil {
+ return err
+ }
+ if !ok {
+ a.logf("aborted; nothing changed")
+ return nil
+ }
+ }
+ if a.DryRun {
+ a.logf("dry run: would adopt %d album(s)", len(found))
+ return nil
+ }
+
+ adopted := 0
+ for _, c := range found {
+ rec := c.rec
+ if err := state.Update(a.journalPath(), func(j *state.Journal) (bool, error) {
+ j.Put(rec)
+ return true, nil
+ }); err != nil {
+ return fmt.Errorf("adopting %q: %w", rec.Album, err)
+ }
+ adopted++
+ }
+ a.logf("adopted %d album(s)", adopted)
+ if len(found) != adopted {
+ return fmt.Errorf("adopted %d of %d", adopted, len(found))
+ }
+ return nil
+}
+
+// relToArchive turns an archive path into the library path that mirrors it.
+func relToArchive(root, dir string) string {
+ rel, err := filepath.Rel(root, dir)
+ if err != nil {
+ return filepath.Base(dir)
+ }
+ return rel
+}
diff --git a/internal/cli/journal_test.go b/internal/cli/journal_test.go
@@ -519,3 +519,112 @@ func TestLookupExistingFindsAnUnrecordedAlbumAtTheSamePath(t *testing.T) {
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")
+ }
+}
diff --git a/internal/state/state.go b/internal/state/state.go
@@ -24,13 +24,16 @@ const (
)
type Record struct {
- Source string `json:"source"`
- AlbumArtist string `json:"album_artist,omitempty"`
- Album string `json:"album,omitempty"`
- Phase Phase `json:"phase"`
- ArchiveDir string `json:"archive_dir,omitempty"`
- LibraryDir string `json:"library_dir,omitempty"`
- Tracks int `json:"tracks"`
+ Source string `json:"source"`
+ AlbumArtist string `json:"album_artist,omitempty"`
+ Album string `json:"album,omitempty"`
+ Phase Phase `json:"phase"`
+ ArchiveDir string `json:"archive_dir,omitempty"`
+ LibraryDir string `json:"library_dir,omitempty"`
+ Tracks int `json:"tracks"`
+ // Adopted marks a record built from an archive that was already on disk
+ // rather than from a run that filed the album.
+ Adopted bool `json:"adopted,omitempty"`
Placeholder bool `json:"placeholder_cover"`
LibraryBuilt bool `json:"library_built"`
Issues []string `json:"issues,omitempty"`