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:
1 parent
5fb195f0d6
commit
d8ac25a55d
21 files changed
+2719
-38
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user