feat: rename or merge an artist folder
Two spellings of one artist is the most common mess a rebuild produces, and 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 of those leaves the journal describing directories that no longer exist, which library verify then reports as albums that have gone missing. 'intake artist rename <old> <new>' moves the artist folder in the archive and the library and repoints every journal record and alias beneath it. 'intake artist merge <old> <into>' moves every album from one artist folder into another. A clash -- an album of that name already in the destination -- stops the whole thing before anything moves, rather than leaving one album on top of another. The audio moves; nothing is copied or deleted. A rename refuses to run when the destination already exists, and says to merge instead. Closes 71b5cfe
diff --git a/README.md b/README.md
@@ -19,6 +19,8 @@ produces a clean, consistently tagged, consistently named library: lossless mast
- Finds cover art from embedded art, the album folder, a `Scans/`-style subfolder, the
Cover Art Archive, Deezer and iTunes, and can fetch it again for every placeholder album
at once
+- Renames or merges an artist folder in both trees and repoints the journal and
+ `artists.toml`, for the two spellings of one artist that a rebuild always produces
- `intake ignore` leaves a staging folder alone for good, anywhere album lists are built
- Draws real cover previews in the terminal with the Kitty graphics protocol, falling
back to `chafa`
@@ -62,6 +64,8 @@ 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 re-cover --placeholders # fetch art again for every placeholder
+intake artist rename <old> <new> # move an artist folder and repoint everything
+intake artist merge <old> <into> # move every album of one artist into another
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
diff --git a/cmd/intake/main.go b/cmd/intake/main.go
@@ -248,6 +248,29 @@ func run(ctx context.Context, args []string) error {
}
return app(cfg, cf).Resume(ctx)
+ case "artist":
+ if len(rest) == 0 {
+ return fmt.Errorf("artist: expected a subcommand (rename or merge)")
+ }
+ switch rest[0] {
+ case "rename", "merge":
+ fs := flag.NewFlagSet("artist "+rest[0], flag.ExitOnError)
+ cf := register(fs)
+ if err := fs.Parse(rest[1:]); err != nil {
+ return err
+ }
+ cfg, err := resolve(cf)
+ if err != nil {
+ return err
+ }
+ if fs.NArg() != 2 {
+ return fmt.Errorf("artist %s: an old folder name and a new one are required", rest[0])
+ }
+ return app(cfg, cf).RenameArtist(ctx, fs.Arg(0), fs.Arg(1), rest[0] == "merge")
+ default:
+ return fmt.Errorf("artist: unknown subcommand %q", rest[0])
+ }
+
case "library":
if len(rest) == 0 {
return fmt.Errorf("library: expected a subcommand (verify)")
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
@@ -168,6 +168,18 @@ Nothing is guessed silently. An unknown album or artist, an empty normalised nam
different titles collapsing to one name, or two tracks that would be written to the same
file are all hard issues, and `--yes` refuses to proceed rather than inventing metadata.
+## Renaming and merging artists
+
+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 directories that are not there, which `library verify` then reports as albums
+that have gone missing.
+
+`artist rename` refuses when the destination already exists and says to merge instead.
+`artist merge` refuses when an album of that name is already in the destination, before it
+moves anything. Both move rather than copy, and both repoint the journal and the aliases.
+
## The journal
One record per staging path, holding where the album ended up and what is known about it.
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
@@ -13,6 +13,7 @@ import (
"strings"
"time"
+ "intake/internal/artist"
"intake/internal/artwork"
"intake/internal/audio"
"intake/internal/config"
@@ -2021,10 +2022,202 @@ func (a *App) ListIgnore() error {
// underneath them, and finding every artists.toml entry that points at the old
// name. Missing any one of those leaves the journal describing directories that
// no longer exist, which verify then reports as albums that have gone missing.
+func (a *App) RenameArtist(ctx context.Context, from, to string, merge bool) error {
+ from, to = strings.TrimSpace(from), strings.TrimSpace(to)
+ if from == "" || to == "" {
+ return errors.New("both the old and the new artist folder name are required")
+ }
+ if from == to {
+ return errors.New("the old and new names are the same")
+ }
+ for _, name := range []string{from, to} {
+ if strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
+ return fmt.Errorf("%q is not a folder name", name)
+ }
+ }
+
+ fromArchive, toArchive := filepath.Join(a.Cfg.Archive(), from), filepath.Join(a.Cfg.Archive(), to)
+ fromLibrary, toLibrary := filepath.Join(a.Cfg.Library(), from), filepath.Join(a.Cfg.Library(), to)
+
+ if _, err := os.Stat(fromArchive); err != nil {
+ return fmt.Errorf("there is no artist folder %s in the archive: %w", from, err)
+ }
+ if merge {
+ if _, err := os.Stat(toArchive); err != nil {
+ return fmt.Errorf("merging into %s needs it to exist already: %w", to, err)
+ }
+ } else if _, err := os.Stat(toArchive); err == nil {
+ return fmt.Errorf("%s already exists in the archive; merge into it instead, or pick another name", to)
+ }
+
+ plan, err := a.artistMovePlan(fromArchive, toArchive, merge)
+ if err != nil {
+ return err
+ }
+ if len(plan) == 0 {
+ a.logf("%s holds no album folders", from)
+ return nil
+ }
+
+ verb := "rename"
+ if merge {
+ verb = "merge"
+ }
+ a.logf("%s %s -> %s", verb, from, to)
+ for _, m := range plan {
+ a.logf(" %s", m)
+ }
+ a.logf("")
+ a.logf("The audio moves; nothing is copied or deleted. Every journal record beneath")
+ a.logf("the folder is repointed, and so is every artists.toml entry that named it.")
+ if !a.Yes {
+ ok, err := a.confirm("do it?")
+ if err != nil {
+ return err
+ }
+ if !ok {
+ a.logf("aborted; nothing changed")
+ return nil
+ }
+ }
+ if a.DryRun {
+ a.logf("dry run: nothing was moved")
+ return nil
+ }
+
+ if merge {
+ if err := os.MkdirAll(toArchive, 0o755); err != nil {
+ return err
+ }
+ }
+ if merge {
+ for _, album := range plan {
+ fromPath, toPath := filepath.Join(fromArchive, album), filepath.Join(toArchive, album)
+ if err := os.Rename(fromPath, toPath); err != nil {
+ return fmt.Errorf("moving %s: %w", album, err)
+ }
+ if err := moveMirror(fromLibrary, toLibrary, album); err != nil {
+ return err
+ }
+ }
+ } else {
+ if err := os.Rename(fromArchive, toArchive); err != nil {
+ return fmt.Errorf("moving the artist folder: %w", err)
+ }
+ if _, err := os.Stat(fromLibrary); err == nil {
+ if err := os.Rename(fromLibrary, toLibrary); err != nil {
+ // The archive has moved; say exactly where things stand rather
+ // than pretending the whole thing failed.
+ return fmt.Errorf("the archive was renamed, but the library copy was not: %w", err)
+ }
+ }
+ }
+
+ // Anything left empty on the way out goes. os.Remove refuses a non-empty
+ // directory, so this can only ever tidy up.
+ os.Remove(fromArchive)
+ os.Remove(fromLibrary)
+
+ moved := 0
+ if err := state.Update(a.journalPath(), func(j *state.Journal) (bool, error) {
+ for _, rec := range j.Albums {
+ if !underDir(rec.ArchiveDir, fromArchive) && !underDir(rec.LibraryDir, fromLibrary) {
+ continue
+ }
+ if rec.ArchiveDir != "" {
+ rec.ArchiveDir = repoint(rec.ArchiveDir, fromArchive, toArchive)
+ }
+ if rec.LibraryDir != "" {
+ rec.LibraryDir = repoint(rec.LibraryDir, fromLibrary, toLibrary)
+ }
+ j.Put(rec)
+ moved++
+ }
+ return moved > 0, nil
+ }); err != nil {
+ return fmt.Errorf("the folders moved, but the journal could not be repointed: %w", err)
+ }
+
+ rewritten, err := a.repointAliases(from, to)
+ if err != nil {
+ return fmt.Errorf("the folders moved, but %s could not be updated: %w", artist.AliasPath(a.Cfg.Root), err)
+ }
+
+ a.logf("")
+ a.logf("moved %d album(s); repointed %d journal record(s) and %d alias(es)", len(plan), moved, rewritten)
+ return nil
+}
// artistMovePlan lists the album folders that will move, and refuses before
// anything is touched if one of them would land on top of an existing album.
+func (a *App) artistMovePlan(fromArchive, toArchive string, merge bool) ([]string, error) {
+ entries, err := os.ReadDir(fromArchive)
+ if err != nil {
+ return nil, err
+ }
+ var albums []string
+ for _, e := range entries {
+ if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
+ continue
+ }
+ if merge {
+ if _, err := os.Stat(filepath.Join(toArchive, e.Name())); err == nil {
+ return nil, fmt.Errorf("%s already has an album called %q; rename that one first, or file it again", filepath.Base(toArchive), e.Name())
+ }
+ }
+ albums = append(albums, e.Name())
+ }
+ sort.Strings(albums)
+ return albums, nil
+}
// moveMirror moves one album inside the library tree, if it is there at all.
+func moveMirror(fromLibrary, toLibrary, album string) error {
+ src := filepath.Join(fromLibrary, album)
+ if _, err := os.Stat(src); err != nil {
+ return nil
+ }
+ if err := os.MkdirAll(toLibrary, 0o755); err != nil {
+ return err
+ }
+ return os.Rename(src, filepath.Join(toLibrary, album))
+}
+
+func underDir(path, dir string) bool {
+ if path == "" || dir == "" {
+ return false
+ }
+ rel, err := filepath.Rel(dir, path)
+ return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
+}
+
+func repoint(path, from, to string) string {
+ rel, err := filepath.Rel(from, path)
+ if err != nil {
+ return path
+ }
+ return filepath.Join(to, rel)
+}
// repointAliases moves every artists.toml entry that named the old folder.
+func (a *App) repointAliases(from, to string) (int, error) {
+ ix, err := a.artistIndex("")
+ if err != nil {
+ return 0, err
+ }
+ changed := 0
+ for credit, dir := range ix.Alias {
+ if dir != from {
+ continue
+ }
+ ix.Alias[credit] = to
+ changed++
+ }
+ if changed == 0 || a.DryRun {
+ return changed, nil
+ }
+ if err := ix.SaveAliases(artist.AliasPath(a.Cfg.Root)); err != nil {
+ return changed, err
+ }
+ return changed, nil
+}
diff --git a/internal/cli/journal_test.go b/internal/cli/journal_test.go
@@ -10,6 +10,7 @@ import (
"strings"
"testing"
+ "intake/internal/artist"
"intake/internal/artwork"
"intake/internal/config"
"intake/internal/plan"
@@ -766,3 +767,115 @@ func TestBulkReCover(t *testing.T) {
// 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)
+ }
+}