git/intake/main

feat: find cover art in subfolders, and re-cover every placeholder at once

Two halves of the same problem: art that was sitting right there and never
got used, and albums stuck on a generated placeholder with no way to retry
them in bulk.

Art is now found one level below the album as well as beside the tracks.
That is how scans arrive -- a Scans/ or Artwork/ folder holding front.jpg --
and it used to be invisible, so an album whose real sleeve was in the
staging folder was filed with a placeholder and the art stayed in staging
for ever. Art beside the tracks still wins outright; the subfolder is only a
fallback, so a stray back cover in extras/ cannot displace the real front
cover. A candidate found in a subfolder says where it came from.

'intake library re-cover --placeholders' runs the fetch again for every
album still on a placeholder, sorted by artist and album, reporting each
one. Only a confident match is taken: replacing a placeholder with the
wrong sleeve is worse than keeping the placeholder, so anything less is
left alone and counted separately.

Both paths go through one function that writes the cover, embeds it in the
masters, records the change and rebuilds the library copy, because the
library has its own cover.jpg and its own embedded picture. Replacing the
archive art without that left the placeholder list clear while the copy
people actually stream still carried the placeholder.

Closes 2b459d0
diff --git a/README.md b/README.md
index 7772faa..faf72d2 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,9 @@ produces a clean, consistently tagged, consistently named library: lossless mast
confirmation step
- Strips every tag except a fixed whitelist, all of it recomputed rather than copied
- Names tracks `disc-track_title.ext`, with collision and length guards
-- Finds cover art from embedded art, the folder, the Cover Art Archive, Deezer and iTunes
+- 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
- 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
@@ -58,6 +60,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 re-cover --placeholders # fetch art again for every placeholder
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
index 8008ad0..f0279cf 100644
--- a/cmd/intake/main.go
+++ b/cmd/intake/main.go
@@ -26,6 +26,9 @@ Commands:
album <name> Process one album (metadata, names, then archive)
resume Continue the first album that is not archived yet
batch File every ready album, parking the ones that need you
+ ignore [name] List, add or --remove staging folders to leave alone
+ artist rename Move an artist folder in the archive and library
+ artist merge Move every album of one artist folder into another
library verify Check the archive and list placeholder covers
library re-cover Replace the cover of an archived album
library transcode Rebuild an album's streaming copy from its masters
@@ -309,17 +312,22 @@ func run(ctx context.Context, args []string) error {
case "re-cover":
fs := flag.NewFlagSet("library re-cover", flag.ExitOnError)
cf := register(fs)
+ all := fs.Bool("placeholders", false, "fetch art again for every album on a placeholder")
if err := fs.Parse(rest[1:]); err != nil {
return err
}
- if fs.NArg() == 0 {
- return fmt.Errorf("library re-cover: an album path or name is required")
- }
cfg, err := resolve(cf)
if err != nil {
return err
}
- return app(cfg, cf).ReCover(ctx, strings.Join(fs.Args(), " "))
+ app := app(cfg, cf)
+ if *all {
+ return app.ReCoverPlaceholders(ctx)
+ }
+ if fs.NArg() == 0 {
+ return fmt.Errorf("library re-cover: an album path or name is required, or --placeholders")
+ }
+ return app.ReCover(ctx, strings.Join(fs.Args(), " "))
default:
return fmt.Errorf("library: unknown subcommand %q", rest[0])
}
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index c6e2d49..4fc5725 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -224,6 +224,21 @@ A genuine failure is reported and also not fatal, because the next album has not
with it. The command exits non-zero at the end if anything failed, so a script can tell the
difference between "finished" and "finished with problems".
+## Cover art
+
+Candidates come from embedded pictures, images beside the tracks, images one level down
+(which is how scans arrive, in a `Scans/` or `Artwork` folder), a configured local folder,
+and then the network sources. Art beside the tracks wins outright; the subfolder is only a
+fallback, so a stray back cover in `extras/` cannot displace the real front cover.
+
+`library re-cover --placeholders` runs the fetch again for every album still on a generated
+placeholder. Only a confident match is taken: replacing a placeholder with the wrong sleeve
+is worse than keeping the placeholder, so anything less is left alone and reported.
+
+Replacing an album's art rebuilds its library copy, because the library has its own
+`cover.jpg` and its own embedded picture. Without that, the placeholder list stops
+mentioning the album while the copy people actually stream still carries the placeholder.
+
## The album sheet
Everything intake is about to write is editable in one `$EDITOR` document, reachable from
diff --git a/internal/artwork/artwork_test.go b/internal/artwork/artwork_test.go
index 57d7a24..4052f55 100644
--- a/internal/artwork/artwork_test.go
+++ b/internal/artwork/artwork_test.go
@@ -141,3 +141,41 @@ func TestFolderCandidatesSkipFilesThatAreNotImages(t *testing.T) {
t.Errorf("kept %q, which is not an image", filepath.Base(got[0].Path))
}
}
+
+// Scans arrive in a subfolder, and that used to be invisible: the album was
+// filed with a generated placeholder while the real sleeve sat next to it in
+// "Scans/".
+func TestArtIsFoundInASubfolder(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "Scans"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ savePNG(t, filepath.Join(dir, "Scans", "front.jpg"), picture(200, 1))
+
+ got := folderCandidates(context.Background(), dir, dir)
+ if len(got) != 1 {
+ t.Fatalf("got %d candidate(s), want the one in Scans/", len(got))
+ }
+ if got[0].Label != "Scans/front.jpg" {
+ t.Errorf("label = %q; the list has to say where it came from", got[0].Label)
+ }
+}
+
+// Art beside the tracks wins outright; the subfolder is only a fallback, so a
+// stray back cover in "extras/" cannot displace the real front cover.
+func TestArtBesideTheTracksBeatsASubfolder(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "extras"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ savePNG(t, filepath.Join(dir, "cover.jpg"), picture(200, 2))
+ savePNG(t, filepath.Join(dir, "extras", "back.jpg"), picture(200, 3))
+
+ got := folderCandidates(context.Background(), dir, dir)
+ if len(got) != 1 {
+ t.Fatalf("got %d candidate(s), want just the folder cover", len(got))
+ }
+ if got[0].Label != "cover.jpg" {
+ t.Errorf("picked %q", got[0].Label)
+ }
+}
diff --git a/internal/artwork/local.go b/internal/artwork/local.go
index f20e72d..9cc33e3 100644
--- a/internal/artwork/local.go
+++ b/internal/artwork/local.go
@@ -122,7 +122,47 @@ func sniffMIME(data []byte) string {
return "application/octet-stream"
}
+// folderCandidates finds cover art beside the album, and one level below it.
+//
+// Art beside the tracks wins outright. Only when there is none does it look in
+// subdirectories, because that is how scans usually arrive: a "Scans" or
+// "Artwork" folder holding front.jpg. That used to be invisible, so an album
+// whose sleeve was sitting right there was filed with a generated placeholder
+// and the real art stayed in staging for ever.
func folderCandidates(ctx context.Context, dir, albumRoot string) []Candidate {
+ if out := imagesIn(ctx, dir, ""); len(out) > 0 {
+ sortFolderCandidates(out, filepath.Base(dir))
+ return out
+ }
+
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil
+ }
+ var out []Candidate
+ for _, e := range entries {
+ if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
+ continue
+ }
+ sub := filepath.Join(dir, e.Name())
+ found := imagesIn(ctx, sub, e.Name())
+ if len(found) == 0 {
+ continue
+ }
+ // Folders whose name says what they hold come first, so "Scans/front.jpg"
+ // is preferred to "extras/back.jpg".
+ sortFolderCandidates(found, e.Name())
+ out = append(out, found...)
+ }
+ if len(out) > 0 {
+ sortFolderCandidates(out, filepath.Base(dir))
+ }
+ return out
+}
+
+// imagesIn collects the images directly inside one directory. prefix names the
+// subdirectory they came from, so the list can say where a candidate was found.
+func imagesIn(ctx context.Context, dir, prefix string) []Candidate {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
@@ -140,9 +180,13 @@ func folderCandidates(ctx context.Context, dir, albumRoot string) []Candidate {
if err != nil {
continue
}
+ label := e.Name()
+ if prefix != "" {
+ label = prefix + string(filepath.Separator) + e.Name()
+ }
c := Candidate{
Source: SourceFolder,
- Label: e.Name(),
+ Label: label,
Path: path,
Bytes: info.Size(),
MatchScore: 3,
@@ -152,7 +196,6 @@ func folderCandidates(ctx context.Context, dir, albumRoot string) []Candidate {
}
out = append(out, c)
}
- sortFolderCandidates(out, filepath.Base(dir))
return out
}
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index 84e96e4..7ec2908 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -108,6 +108,9 @@ func (a *App) Scan(ctx context.Context) error {
return nil
}
+// 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) listStaging(ctx context.Context) ([]scan.Album, error) {
if !audio.HasTool("ffprobe") || !audio.HasTool("metaflac") {
return nil, errors.New("intake needs ffprobe and metaflac on PATH")
@@ -1147,6 +1150,28 @@ func (a *App) ReCover(ctx context.Context, target string) error {
return fmt.Errorf("no cover chosen; nothing changed")
}
+ if err := a.applyCover(ctx, rec, choice, tracks); err != nil {
+ return err
+ }
+
+ a.logf("")
+ a.logf("cover written to %s and embedded in %d file(s)", filepath.Join(rec.ArchiveDir, "cover.jpg"), len(tracks))
+ if choice.Placeholder {
+ a.logf("note: this is still a placeholder")
+ } else {
+ a.logf("this album no longer appears in the placeholder list")
+ }
+ return nil
+}
+
+// applyCover writes a new cover into an archived album, embeds it in the
+// masters, records the change, and rebuilds the library copy if there is one.
+//
+// The library has its own cover.jpg and its own embedded picture, so replacing
+// the archive's art and stopping there leaves the two disagreeing: the
+// placeholder list stops mentioning the album while the copy people actually
+// stream still carries the placeholder.
+func (a *App) applyCover(ctx context.Context, rec state.Record, choice coverChoice, tracks []audio.Track) error {
dest := filepath.Join(rec.ArchiveDir, "cover.jpg")
if err := copyCoverFile(choice.Path, dest); err != nil {
return err
@@ -1163,26 +1188,142 @@ func (a *App) ReCover(ctx context.Context, target string) error {
}); err != nil {
return fmt.Errorf("recording the new cover for %q: %w", rec.Album, err)
}
+ if !a.Cfg.NoLibrary && rec.LibraryBuilt && rec.LibraryDir != "" {
+ _, err := a.rebuildLibrary(ctx, rec)
+ return err
+ }
+ return nil
+}
- a.logf("")
- a.logf("cover written to %s and embedded in %d file(s)", dest, len(tracks))
- if choice.Placeholder {
- a.logf("note: this is still a placeholder")
- } else {
- a.logf("this album no longer appears in the placeholder list")
+// archiveTracks probes the masters of an album that is already in the archive.
+func (a *App) archiveTracks(ctx context.Context, dir string) ([]audio.Track, error) {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil, err
+ }
+ var tracks []audio.Track
+ for _, e := range entries {
+ if e.IsDir() || !audio.IsAudio(e.Name()) {
+ continue
+ }
+ t, err := audio.Probe(ctx, filepath.Join(dir, e.Name()))
+ if err != nil {
+ continue
+ }
+ tracks = append(tracks, t)
}
+ return tracks, nil
+}
- // 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)
+// ReCoverPlaceholders fetches art again for every album still on a placeholder.
+//
+// Placeholders accumulate: an album filed before a source had the art, or filed
+// offline, sits on a generated stand-in, and replacing them one at a time is the
+// kind of chore that never gets done. Only a confident match is used; anything
+// less is left alone and reported, because guessing here would replace a
+// placeholder with the wrong sleeve, which is worse.
+func (a *App) ReCoverPlaceholders(ctx context.Context) error {
+ j, err := a.journal()
+ if err != nil {
return err
}
+ var targets []state.Record
+ for _, rec := range j.Albums {
+ if rec.Phase == state.PhaseArchived && rec.Placeholder && rec.ArchiveDir != "" {
+ targets = append(targets, rec)
+ }
+ }
+ if len(targets) == 0 {
+ a.logf("no album is on a placeholder cover")
+ return nil
+ }
+ sort.Slice(targets, func(i, k int) bool {
+ if targets[i].AlbumArtist != targets[k].AlbumArtist {
+ return targets[i].AlbumArtist < targets[k].AlbumArtist
+ }
+ return targets[i].Album < targets[k].Album
+ })
+
+ a.logf("%d album(s) are on placeholder art:", len(targets))
+ for _, rec := range targets {
+ a.logf(" %s - %s", rec.AlbumArtist, rec.Album)
+ }
+ a.logf("")
+ a.logf("A confident match is used; anything less is left alone and reported, because")
+ a.logf("replacing a placeholder with the wrong sleeve is worse than keeping it.")
+ if !a.Yes {
+ ok, err := a.confirm("fetch art again for them?")
+ if err != nil {
+ return err
+ }
+ if !ok {
+ a.logf("aborted; nothing changed")
+ return nil
+ }
+ }
+ if a.DryRun {
+ a.logf("dry run: would look for art for %d album(s)", len(targets))
+ return nil
+ }
+
+ var fixed, unsure, failed int
+ for _, rec := range targets {
+ tracks, err := a.archiveTracks(ctx, rec.ArchiveDir)
+ if err != nil || len(tracks) == 0 {
+ failed++
+ a.logf("%s - %s: could not read its masters", rec.AlbumArtist, rec.Album)
+ continue
+ }
+ var year string
+ if v, ok := tags.Get(tracks[0].Tags, tags.FieldDate); ok {
+ year = v
+ }
+ info := artwork.AlbumInfo{Artist: rec.AlbumArtist, Album: rec.Album, Year: year, Tracks: len(tracks)}
+ cands, _, err := artwork.Discover(ctx, info, tracks, artwork.Options{
+ ArchiveDir: rec.ArchiveDir,
+ LocalCoverFolder: a.Cfg.CoverFolder,
+ Offline: a.Cfg.Offline,
+ CacheDir: filepath.Join(a.Cfg.StateDir, "art-cache"),
+ Sources: []artwork.Source{artwork.SourceCoverArt, artwork.SourceDeezer, artwork.SourceITunes},
+ Progress: func(msg string) { a.logf(" %s: %s", rec.Album, msg) },
+ })
+ if err != nil {
+ failed++
+ a.logf("%s - %s: %v", rec.AlbumArtist, rec.Album, err)
+ continue
+ }
+ picked := -1
+ for i, c := range cands {
+ if c.Source == artwork.SourcePlaceholder {
+ continue
+ }
+ // Art found in the album's own folder is always right; a remote
+ // candidate has to say so itself.
+ if c.Source == artwork.SourceFolder || c.Source == artwork.SourceLocalFolder || c.ExactMatch() {
+ picked = i
+ break
+ }
+ }
+ if picked < 0 {
+ unsure++
+ a.logf("%s - %s: no confident match (%d candidate(s) offered)", rec.AlbumArtist, rec.Album, len(cands))
+ continue
+ }
+ c := cands[picked]
+ if err := a.applyCover(ctx, rec, coverChoice{Path: c.Path, Source: c.Source, Label: c.Label}, tracks); err != nil {
+ failed++
+ a.logf("%s - %s: %v", rec.AlbumArtist, rec.Album, err)
+ continue
+ }
+ fixed++
+ a.logf("%s - %s: %s", rec.AlbumArtist, rec.Album, c.Label)
+ }
+
+ a.logf("")
+ a.logf("re-cover: %d fixed, %d left alone for want of a confident match, %d failed", fixed, unsure, failed)
+ if failed > 0 {
+ return fmt.Errorf("%d album(s) could not be re-covered", failed)
+ }
return nil
}
@@ -1790,3 +1931,22 @@ func relToArchive(root, dir string) string {
}
return rel
}
+
+// Ignore adds staging folders to the list that scan, the album list and a batch
+// all skip.
+
+// RenameArtist moves or merges an artist folder in the archive and the library
+// and repoints everything that named it.
+//
+// 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 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.
+
+// 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.
+
+// moveMirror moves one album inside the library tree, if it is there at all.
+
+// repointAliases moves every artists.toml entry that named the old folder.
diff --git a/internal/cli/journal_test.go b/internal/cli/journal_test.go
index a3b973b..c525e59 100644
--- a/internal/cli/journal_test.go
+++ b/internal/cli/journal_test.go
@@ -10,6 +10,7 @@ import (
"strings"
"testing"
+ "intake/internal/artwork"
"intake/internal/config"
"intake/internal/plan"
"intake/internal/scan"
@@ -628,3 +629,65 @@ func TestAdoptFlagsAnAlbumWithNoCover(t *testing.T) {
t.Error("an album with no cover should be flagged as needing art")
}
}
+
+// 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.
+
+// 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
+// wrong sleeve is worse than keeping the placeholder.
+func TestBulkReCover(t *testing.T) {
+ needTools(t, "ffmpeg", "metaflac", "flac", "magick")
+ root := t.TempDir()
+ a, out := cliApp(t, root)
+ a.Yes = true
+ a.Cfg.NoLibrary = true
+
+ // An archived album with its own cover.jpg sitting beside the masters: that
+ // is a candidate that is always right, and it needs no network.
+ 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)
+ }
+ art := filepath.Join(archive, "front.jpg")
+ if err := artwork.Placeholder(context.Background(), artwork.AlbumInfo{Artist: "An Artist", Album: "An Album"}, art, 300); err != nil {
+ t.Skipf("could not build a cover image: %v", err)
+ }
+ seed(t, a, state.Record{
+ Source: "/staging/old", Phase: state.PhaseArchived, AlbumArtist: "An Artist",
+ Album: "An Album", ArchiveDir: archive, Tracks: 1, Placeholder: true,
+ })
+
+ // With no cover anywhere and no network, it has no confident match and must
+ // leave the album alone rather than guess.
+ a.Cfg.Offline = true
+ if err := os.Remove(art); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.ReCoverPlaceholders(context.Background()); err != nil {
+ t.Fatalf("%v\n%s", err, out.String())
+ }
+ j, _ := a.journal()
+ rec, _ := j.Get("/staging/old")
+ if !rec.Placeholder {
+ t.Error("an album with no candidate should have been left on its placeholder")
+ }
+ if !strings.Contains(out.String(), "no confident match") {
+ t.Errorf("the run should say why it left the album alone:\n%s", out.String())
+ }
+}
+
+// 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 folders that are not there.