git/intake/main

feat: ignore a staging folder for good

Junk folders and releases somebody is not ready to deal with keep coming
back in every album list. 'intake ignore' adds a folder name to
<root>/ignore.txt, beside staging, archive and library, and scan, the album
list and a batch all skip it.

Names rather than paths, because a folder called "Various Artists -
Singles" that turned out to be a mess should stay ignored whatever happens
to staging in between.

The file is a plain list with comments and is meant to be hand-editable;
adding and removing rewrite it sorted so it stays readable.

'intake ignore' with no arguments lists what is ignored, --remove takes
names back off the list, and --json gives the list to a script. Ignoring
never touches the folder: it stays in staging exactly as it was.

Closes e718011
diff --git a/README.md b/README.md
index faf72d2..560e3cf 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,7 @@ 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
+- `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`
- Verifies every written master (count, codec, duration, tags, decode) before promoting
diff --git a/cmd/intake/main.go b/cmd/intake/main.go
index f0279cf..05fd678 100644
--- a/cmd/intake/main.go
+++ b/cmd/intake/main.go
@@ -204,6 +204,25 @@ func run(ctx context.Context, args []string) error {
}
return app(cfg, cf).Album(ctx, strings.Join(fs.Args(), " "))
+ case "ignore":
+ fs := flag.NewFlagSet("ignore", flag.ExitOnError)
+ cf := register(fs)
+ remove := fs.Bool("remove", false, "stop ignoring the named folder(s)")
+ if err := fs.Parse(rest); err != nil {
+ return err
+ }
+ cfg, err := resolve(cf)
+ if err != nil {
+ return err
+ }
+ if len(fs.Args()) == 0 {
+ return app(cfg, cf).ListIgnore()
+ }
+ if *remove {
+ return app(cfg, cf).Unignore(fs.Args())
+ }
+ return app(cfg, cf).Ignore(fs.Args())
+
case "batch":
fs := flag.NewFlagSet("batch", flag.ExitOnError)
cf := register(fs)
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index 7ec2908..a1a1f8c 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -110,6 +110,14 @@ func (a *App) Scan(ctx context.Context) error {
// ignore loads the list of staging folders to leave alone. A missing file is not
// an error: not having ignored anything yet is the normal state.
+func (a *App) ignore() map[string]bool {
+ set, err := a.Cfg.LoadIgnore()
+ if err != nil {
+ a.logf("warning: could not read %s: %v", a.Cfg.IgnorePath(), err)
+ return map[string]bool{}
+ }
+ return set
+}
func (a *App) listStaging(ctx context.Context) ([]scan.Album, error) {
if !audio.HasTool("ffprobe") || !audio.HasTool("metaflac") {
@@ -119,7 +127,7 @@ func (a *App) listStaging(ctx context.Context) ([]scan.Album, error) {
if _, err := os.Stat(staging); err != nil {
return nil, fmt.Errorf("staging directory %s is not readable: %w", staging, err)
}
- return scan.StagingWith(ctx, staging, scan.Options{Probe: false})
+ return scan.StagingWith(ctx, staging, scan.Options{Probe: false, Ignore: a.ignore()})
}
func findAlbum(albums []scan.Album, name string) *scan.Album {
@@ -1934,6 +1942,76 @@ func relToArchive(root, dir string) string {
// Ignore adds staging folders to the list that scan, the album list and a batch
// all skip.
+func (a *App) Ignore(names []string) error {
+ changed := 0
+ for _, name := range names {
+ if a.DryRun {
+ a.logf("dry run: would ignore %q", name)
+ continue
+ }
+ ok, err := a.Cfg.AddIgnore(name)
+ if err != nil {
+ return err
+ }
+ if ok {
+ a.logf("ignoring %s", name)
+ changed++
+ } else {
+ a.logf("%s was already ignored", name)
+ }
+ }
+ if changed > 0 {
+ a.logf("")
+ a.logf("It stays in staging; `intake ignore` lists it and `intake ignore --remove` puts it back.")
+ }
+ return nil
+}
+
+func (a *App) Unignore(names []string) error {
+ for _, name := range names {
+ if a.DryRun {
+ a.logf("dry run: would stop ignoring %q", name)
+ continue
+ }
+ ok, err := a.Cfg.RemoveIgnore(name)
+ if err != nil {
+ return err
+ }
+ if ok {
+ a.logf("no longer ignoring %s", name)
+ } else {
+ a.logf("%s was not ignored", name)
+ }
+ }
+ return nil
+}
+
+func (a *App) ListIgnore() error {
+ set, err := a.Cfg.LoadIgnore()
+ if err != nil {
+ return err
+ }
+ names := make([]string, 0, len(set))
+ for n := range set {
+ names = append(names, n)
+ }
+ sort.Strings(names)
+
+ if a.JSON {
+ enc := json.NewEncoder(a.Out)
+ enc.SetIndent("", " ")
+ return enc.Encode(map[string]any{"ignore_file": a.Cfg.IgnorePath(), "ignored": names})
+ }
+ if len(names) == 0 {
+ a.logf("nothing is ignored (%s)", a.Cfg.IgnorePath())
+ return nil
+ }
+ a.logf("%d ignored folder(s), from %s:", len(names), a.Cfg.IgnorePath())
+ for _, n := range names {
+ a.logf(" %s", n)
+ }
+ return nil
+}
// RenameArtist moves or merges an artist folder in the archive and the library
// and repoints everything that named it.
diff --git a/internal/cli/journal_test.go b/internal/cli/journal_test.go
index c525e59..e47a8c6 100644
--- a/internal/cli/journal_test.go
+++ b/internal/cli/journal_test.go
@@ -633,6 +633,81 @@ func TestAdoptFlagsAnAlbumWithNoCover(t *testing.T) {
// Ignoring a folder is how junk stops coming back. It has to be honoured
// everywhere an album list is built, or the folder keeps reappearing in one place
// while being hidden in another.
+func TestIgnoredFoldersAreSkippedEverywhere(t *testing.T) {
+ needTools(t, "ffmpeg", "metaflac")
+ root := t.TempDir()
+ a, out := cliApp(t, root)
+
+ junk := filepath.Join(a.Cfg.Staging(), "junk-folder")
+ if err := os.MkdirAll(junk, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ stagedAlbum(t, a, "a-real-album", 440)
+
+ albums, err := a.listStaging(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(albums) != 2 {
+ t.Fatalf("before ignoring, %d album(s) were listed", len(albums))
+ }
+
+ if err := a.Ignore([]string{"junk-folder"}); err != nil {
+ t.Fatalf("%v\n%s", err, out.String())
+ }
+ albums, err = a.listStaging(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(albums) != 1 || albums[0].Name != "a-real-album" {
+ t.Errorf("the ignored folder was still listed: %+v", albums)
+ }
+
+ // A batch must not touch it either.
+ rep, err := a.Batch(context.Background())
+ if err != nil {
+ t.Fatalf("%v\n%s", err, out.String())
+ }
+ if len(rep.Filed) != 1 || rep.Filed[0] != "a-real-album" {
+ t.Errorf("batch filed %v", rep.Filed)
+ }
+ if _, err := os.Stat(junk); err != nil {
+ t.Errorf("the ignored folder was removed: %v", err)
+ }
+
+ // And it comes back when it stops being ignored.
+ if err := a.Unignore([]string{"junk-folder"}); err != nil {
+ t.Fatal(err)
+ }
+ albums, err = a.listStaging(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(albums) != 1 {
+ t.Errorf("after unignoring, %d album(s) were listed", len(albums))
+ }
+}
+
+func TestListingTheIgnoreFile(t *testing.T) {
+ root := t.TempDir()
+ a, out := cliApp(t, root)
+ if err := a.Ignore([]string{"one", "two"}); err != nil {
+ t.Fatal(err)
+ }
+ out.Reset()
+ a.JSON = true
+ if err := a.ListIgnore(); err != nil {
+ t.Fatal(err)
+ }
+ var parsed map[string]any
+ if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
+ t.Fatalf("ignore --json is not JSON: %v\n%s", err, out.String())
+ }
+ list, _ := parsed["ignored"].([]any)
+ if len(list) != 2 {
+ t.Errorf("ignored = %v", parsed["ignored"])
+ }
+}
// Placeholders accumulate, and replacing them one at a time never gets done. A
// bulk pass only takes a confident match though: replacing a placeholder with the
diff --git a/internal/config/config.go b/internal/config/config.go
index 9e0ecf0..33be98a 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "sort"
"strconv"
"strings"
)
@@ -244,3 +245,94 @@ func (c Config) EditorCommand() string {
}
return "vi"
}
+
+// IgnorePath is where the list of staging folders to leave alone lives. It sits
+// beside staging, archive and library rather than in the state directory, because
+// it describes this library rather than this machine's history of it.
+func (c Config) IgnorePath() string { return filepath.Join(expand(c.Root), "ignore.txt") }
+
+// LoadIgnore reads the names of staging folders to leave alone.
+//
+// Names, not paths: a folder called "Various Artists - Singles" that has turned
+// out to be a mess should stay ignored whatever happens to staging in between.
+func (c Config) LoadIgnore() (map[string]bool, error) {
+ out := map[string]bool{}
+ f, err := os.Open(c.IgnorePath())
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return out, nil
+ }
+ return out, err
+ }
+ defer f.Close()
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ line := strings.TrimSpace(sc.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ out[line] = true
+ }
+ return out, sc.Err()
+}
+
+// AddIgnore adds a name, and RemoveIgnore drops one. Both rewrite the whole file
+// sorted, so it stays readable and hand-editable, and both report whether
+// anything changed.
+func (c Config) AddIgnore(name string) (bool, error) {
+ return c.editIgnore(func(set map[string]bool) bool {
+ name = strings.TrimSpace(name)
+ if name == "" || set[name] {
+ return false
+ }
+ set[name] = true
+ return true
+ })
+}
+
+func (c Config) RemoveIgnore(name string) (bool, error) {
+ return c.editIgnore(func(set map[string]bool) bool {
+ name = strings.TrimSpace(name)
+ if !set[name] {
+ return false
+ }
+ delete(set, name)
+ return true
+ })
+}
+
+func (c Config) editIgnore(change func(map[string]bool) bool) (bool, error) {
+ set, err := c.LoadIgnore()
+ if err != nil {
+ return false, err
+ }
+ if !change(set) {
+ return false, nil
+ }
+ names := make([]string, 0, len(set))
+ for n := range set {
+ names = append(names, n)
+ }
+ sort.Strings(names)
+
+ var b strings.Builder
+ b.WriteString("# intake: staging folders to leave alone\n")
+ b.WriteString("#\n")
+ b.WriteString("# One folder name per line, exactly as it appears under staging/. These are\n")
+ b.WriteString("# skipped by scan, by the album list and by a batch, so junk or a release you\n")
+ b.WriteString("# are not ready to deal with stops coming back.\n\n")
+ for _, n := range names {
+ b.WriteString(n)
+ b.WriteString("\n")
+ }
+ path := c.IgnorePath()
+ tmp := path + ".tmp"
+ if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil {
+ return false, err
+ }
+ if err := os.Rename(tmp, path); err != nil {
+ os.Remove(tmp)
+ return false, err
+ }
+ return true, nil
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index ef16897..81b6bdf 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -62,3 +62,69 @@ func TestQualityRangeIsChecked(t *testing.T) {
}
}
}
+
+// The ignore list lives beside staging, archive and library, one folder name per
+// line, because it describes this library rather than this machine.
+func TestIgnoreListRoundTrips(t *testing.T) {
+ root := t.TempDir()
+ cfg := Defaults()
+ cfg.Root = root
+
+ got, err := cfg.LoadIgnore()
+ if err != nil {
+ t.Fatalf("a missing ignore file is not an error: %v", err)
+ }
+ if len(got) != 0 {
+ t.Errorf("a missing ignore file produced %v", got)
+ }
+
+ if added, err := cfg.AddIgnore("Some Junk Folder"); err != nil || !added {
+ t.Fatalf("AddIgnore: %v %v", added, err)
+ }
+ if added, err := cfg.AddIgnore("Some Junk Folder"); err != nil || added {
+ t.Errorf("adding the same name twice should report no change: %v %v", added, err)
+ }
+ if added, err := cfg.AddIgnore(" "); err != nil || added {
+ t.Errorf("a blank name should be refused: %v %v", added, err)
+ }
+ if _, err := cfg.AddIgnore("another one"); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err = cfg.LoadIgnore()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got["Some Junk Folder"] || !got["another one"] || len(got) != 2 {
+ t.Errorf("ignore set = %v", got)
+ }
+
+ if removed, err := cfg.RemoveIgnore("Some Junk Folder"); err != nil || !removed {
+ t.Fatalf("RemoveIgnore: %v %v", removed, err)
+ }
+ if removed, err := cfg.RemoveIgnore("not there"); err != nil || removed {
+ t.Errorf("removing something absent should report no change: %v %v", removed, err)
+ }
+ got, _ = cfg.LoadIgnore()
+ if got["Some Junk Folder"] || !got["another one"] {
+ t.Errorf("ignore set after removal = %v", got)
+ }
+}
+
+// Hand-editing the file has to work: it is a plain list with comments.
+func TestIgnoreListIsHandEditable(t *testing.T) {
+ root := t.TempDir()
+ cfg := Defaults()
+ cfg.Root = root
+ body := "# my notes\n\n spaced name \n# another comment\nsecond\n"
+ if err := os.WriteFile(cfg.IgnorePath(), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ got, err := cfg.LoadIgnore()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got["spaced name"] || !got["second"] || len(got) != 2 {
+ t.Errorf("ignore set = %v", got)
+ }
+}
diff --git a/internal/scan/scan.go b/internal/scan/scan.go
index 0c43cd0..0d1f636 100644
--- a/internal/scan/scan.go
+++ b/internal/scan/scan.go
@@ -38,6 +38,10 @@ func One(ctx context.Context, dir string) (Album, error) {
type Options struct {
Probe bool
+ // Ignore names the staging folders to leave out entirely. A folder that has
+ // turned out to be junk, or a release somebody is not ready to deal with,
+ // should stop coming back rather than be skipped by hand every run.
+ Ignore map[string]bool
}
func Staging(ctx context.Context, dir string) ([]Album, error) {
@@ -57,6 +61,9 @@ func StagingWith(ctx context.Context, dir string, opts Options) ([]Album, error)
if strings.HasPrefix(e.Name(), ".") {
continue
}
+ if opts.Ignore[e.Name()] {
+ continue
+ }
a, err := album(ctx, filepath.Join(dir, e.Name()), opts)
if err != nil {
return nil, err