Files
playlink/internal/engine/artwork_test.go
T
cdnutterandClaude Opus 5.5 d8ac25a55d 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>
2026-09-29 15:30:19 -07:00

182 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}