Add album art search, auto-match and a whole-album tag editor

Search iTunes, Deezer, MusicBrainz, DuckDuckGo and Google for square covers,
embed them and/or save cover.jpg, auto-match covers for albums, and edit an
album's shared tags and track list in one place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-29 15:30:19 -07:00
1 parent 5fb195f0d6
commit d8ac25a55d
21 files changed
+2719 -38

No files matched your search

+181
View File
@@ -0,0 +1,181 @@
package engine
import (
"bytes"
"fmt"
"image"
"image/color"
"image/png"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/cdnutter/playlink/internal/art"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
func squarePNG(n int) []byte {
img := image.NewRGBA(image.Rect(0, 0, n, n))
for i := 0; i < n; i++ {
img.Set(i, i, color.RGBA{0, 0, 200, 255})
}
var b bytes.Buffer
png.Encode(&b, img)
return b.Bytes()
}
// artEnv is a tag-editing env with an art store and a fake iTunes that knows
// one album: Avenged Sevenfold - Nightmare.
func artEnv(t *testing.T) *env {
t.Helper()
ev := tagEnv(t, false)
var base string
mux := http.NewServeMux()
mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"results":[{"artistName":"Avenged Sevenfold","collectionName":"Nightmare (Deluxe Edition)","artworkUrl100":"%s/img/100x100bb.jpg"}]}`, base)
})
mux.HandleFunc("/img/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write(squarePNG(1200))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
base = srv.URL
c := art.New("test")
c.HTTP = srv.Client()
c.ITunesURL = base
ff, _ := exec.LookPath("ffmpeg")
ev.e.SetArt(c, &art.Store{Dir: filepath.Join(ev.root, "art-cache"), FFmpeg: ff})
ev.st.SetSetting(ctx, "art_sources", "itunes")
return ev
}
func prepared(t *testing.T, ev *env) string {
t.Helper()
p, err := ev.e.artStore.Prepare(ctx, squarePNG(800), 1000)
if err != nil {
t.Fatal(err)
}
return p.Token
}
func TestAlbumEditWithArt(t *testing.T) {
ev := artEnv(t)
dir := "Avenged Sevenfold/Nightmare"
mkTagged(t, filepath.Join(ev.slave, dir, "a.mp3"), "title", "x", "artist", "Avenged Sevenfold", "album", "Nightmar")
mkTagged(t, filepath.Join(ev.slave, dir, "Disc 2/b.flac"), "title", "y", "artist", "Avenged Sevenfold", "album", "Nightmar")
mkTagged(t, filepath.Join(ev.slave, dir, "c.ogg"), "title", "z", "artist", "Avenged Sevenfold", "album", "Nightmar")
a, err := ev.e.AlbumSongs(ctx, ev.pair.ID, SideSlave, dir)
if err != nil || len(a.Songs) != 3 {
t.Fatalf("album should include the disc folder: %+v %v", a, err)
}
req := TagEdit{Side: SideSlave, Set: map[string]string{tags.Album: "Nightmare", tags.Genre: "Metal", tags.TrackTotal: "3"},
Files: []FileEdit{
{Path: dir + "/a.mp3", Set: map[string]string{tags.Title: "Nightmare", tags.Track: "1"}},
{Path: dir + "/Disc 2/b.flac", Set: map[string]string{tags.Title: "Welcome to the Family", tags.Track: "2"}},
{Path: dir + "/c.ogg", Set: map[string]string{tags.Title: "Danger Line", tags.Track: "3"}},
},
Art: &ArtApply{Token: prepared(t, ev), Embed: true, FolderFile: true}}
if _, err := ev.e.StartTagEdit(ev.pair.ID, req); err != nil {
t.Fatal(err)
}
j := ev.wait()
if j.Results["edited"] != 3 || j.Results["folder art"] != 1 {
t.Fatalf("results %+v %v", j.Results, j.Skipped)
}
for rel, title := range map[string]string{"a.mp3": "Nightmare", "Disc 2/b.flac": "Welcome to the Family", "c.ogg": "Danger Line"} {
p := filepath.Join(ev.slave, dir, rel)
f, _ := ev.e.tagTools.Read(ctx, p)
if f.Fields[tags.Title] != title || f.Fields[tags.Album] != "Nightmare" || f.Fields[tags.Genre] != "Metal" {
t.Errorf("%s: fields %+v", rel, f.Fields)
}
wantCover := !strings.HasSuffix(rel, ".ogg")
if ev.e.tagTools.HasCover(ctx, p) != wantCover {
t.Errorf("%s: embedded cover should be %v", rel, wantCover)
}
}
if _, err := os.Stat(filepath.Join(ev.slave, dir, "cover.jpg")); err != nil {
t.Fatal("the album folder should get cover.jpg")
}
// Undo the folder image: it is removed again.
ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID})
for _, o := range ops {
if o.Action == store.ActCover {
if err := ev.e.Undo(ctx, o.ID); err != nil {
t.Fatal(err)
}
}
}
if _, err := os.Stat(filepath.Join(ev.slave, dir, "cover.jpg")); !os.IsNotExist(err) {
t.Fatal("undo should remove the new cover.jpg")
}
}
func TestFolderCoverReplaceKeepsOld(t *testing.T) {
ev := artEnv(t)
dir := "A/B"
mkTagged(t, filepath.Join(ev.slave, dir, "s.flac"), "title", "S", "artist", "A", "album", "B")
old := filepath.Join(ev.slave, dir, "cover.jpg")
os.WriteFile(old, []byte("old cover"), 0o644)
ev.e.StartTagEdit(ev.pair.ID, TagEdit{Side: SideSlave, Paths: []string{dir + "/s.flac"}, Art: &ArtApply{Token: prepared(t, ev), FolderFile: true}})
ev.wait()
if b, _ := os.ReadFile(old); string(b) == "old cover" {
t.Fatal("cover.jpg should be replaced")
}
ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID, Limit: 1})
if ops[0].Action != store.ActCover || ops[0].QuarantinePath == "" {
t.Fatalf("old cover should be quarantined: %+v", ops[0])
}
if err := ev.e.Undo(ctx, ops[0].ID); err != nil {
t.Fatal(err)
}
if b, _ := os.ReadFile(old); string(b) != "old cover" {
t.Fatal("undo should restore the old cover.jpg")
}
}
func TestAutoMatchArt(t *testing.T) {
ev := artEnv(t)
mkTagged(t, filepath.Join(ev.slave, "Avenged Sevenfold/Nightmare/01.mp3"), "title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(ev.slave, "Other/Record/01.mp3"), "title", "Song", "artist", "Other Band", "album", "Record")
m := ArtMatch{Side: SideSlave, OnlyMissing: true, Embed: true, FolderFile: true}
if _, err := ev.e.StartArtAutoMatch(ev.pair.ID, m); err != nil {
t.Fatal(err)
}
j := ev.wait()
if j.Results["albums matched"] != 1 || len(j.Notes) != 1 || !strings.Contains(strings.Join(j.Skipped, "\n"), "Other Band – Record: no confident match") {
t.Fatalf("results %+v notes %v skipped %v", j.Results, j.Notes, j.Skipped)
}
if !ev.e.tagTools.HasCover(ctx, filepath.Join(ev.slave, "Avenged Sevenfold/Nightmare/01.mp3")) {
t.Fatal("the matched album should get embedded art")
}
if ev.e.tagTools.HasCover(ctx, filepath.Join(ev.slave, "Other/Record/01.mp3")) {
t.Fatal("an unmatched album must not get art")
}
// Running again: the matched album already has art.
ev.e.StartArtAutoMatch(ev.pair.ID, m)
if j := ev.wait(); j.Results["already have art"] != 1 {
t.Fatalf("second run: %+v", j.Results)
}
}
func TestBestArt(t *testing.T) {
res := []art.Result{
{Source: "itunes", Artist: "Avenged Sevenfold", Title: "Nightmare (Deluxe Edition)", Width: 1000},
{Source: "deezer", Artist: "Avenged Sevenfold", Title: "Hail to the King", Width: 1000},
{Source: "deezer", Artist: "Someone Else", Title: "Nightmare", Width: 1000},
}
best, _, ok := bestArt(res, "Avenged Sevenfold", "Nightmare")
if !ok || best.Title != "Nightmare (Deluxe Edition)" {
t.Fatalf("got %+v", best)
}
if _, _, ok := bestArt(res, "The Beatles", "Abbey Road"); ok {
t.Fatal("no confident match expected")
}
}