Files
playlink/internal/art/art_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

205 lines
6.7 KiB
Go

package art
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/color"
"image/png"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"strings"
"testing"
)
var ctx = context.Background()
func pngBytes(w, h int) []byte {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for x := 0; x < w; x++ {
img.Set(x, 0, color.RGBA{200, 0, 0, 255})
}
var b bytes.Buffer
png.Encode(&b, img)
return b.Bytes()
}
// fakeSources serves canned answers for every source.
func fakeSources(t *testing.T, failDeezer bool) *Client {
t.Helper()
mux := http.NewServeMux()
var base string
mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) { // iTunes
fmt.Fprintf(w, `{"results":[{"artistName":"Avenged Sevenfold","collectionName":"Nightmare","artworkUrl100":"%s/img/100x100bb.jpg"}]}`, base)
})
mux.HandleFunc("/search/album", func(w http.ResponseWriter, r *http.Request) { // Deezer
if failDeezer {
http.Error(w, "down", 500)
return
}
fmt.Fprintf(w, `{"data":[{"title":"Nightmare","cover_xl":"%s/dz/xl.jpg","cover_medium":"%s/dz/m.jpg","artist":{"name":"Avenged Sevenfold"}}]}`, base, base)
})
mux.HandleFunc("/ws/2/release-group/", func(w http.ResponseWriter, r *http.Request) { // MusicBrainz
fmt.Fprint(w, `{"release-groups":[{"id":"sq","title":"Nightmare","artist-credit":[{"name":"Avenged Sevenfold"}]},
{"id":"wide","title":"Nightmare (Live)","artist-credit":[{"name":"Avenged Sevenfold"}]},{"id":"none","title":"X"}]}`)
})
mux.HandleFunc("/release-group/sq/front-250", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write(pngBytes(250, 250))
})
mux.HandleFunc("/release-group/wide/front-250", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write(pngBytes(250, 180))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { // DDG page with token
if r.URL.Path == "/" {
fmt.Fprint(w, `<html><script>vqd="4-12345";</script></html>`)
return
}
http.NotFound(w, r)
})
mux.HandleFunc("/i.js", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("vqd") != "4-12345" || !strings.Contains(r.URL.Query().Get("f"), "layout:Square") {
http.Error(w, "bad", 400)
return
}
fmt.Fprint(w, `{"results":[{"title":"near square","image":"https://x/a.jpg","thumbnail":"t","width":600,"height":594},
{"title":"square","image":"https://x/b.jpg","thumbnail":"t","width":800,"height":800},
{"title":"tiny","image":"https://x/c.jpg","thumbnail":"t","width":200,"height":200}]}`)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
base = srv.URL
c := New("test")
c.HTTP = srv.Client() // the safe client refuses loopback, which the fakes use
c.ITunesURL, c.DeezerURL, c.MusicBrainzURL, c.CAAURL, c.DDGURL, c.GoogleURL = base, base, base, base, base, base
return c
}
func TestSearchSquareOnly(t *testing.T) {
c := fakeSources(t, false)
res, errs := c.Search(ctx, Query{Artist: "Avenged Sevenfold", Album: "Nightmare"}, Config{Sources: AllSources})
if len(errs) != 0 {
t.Fatalf("errors: %v", errs)
}
got := map[string]Result{}
for _, r := range res {
got[r.Source+":"+r.Title] = r
}
for _, want := range []string{"itunes:Nightmare", "deezer:Nightmare", "musicbrainz:Nightmare", "duckduckgo:square"} {
if _, ok := got[want]; !ok {
t.Errorf("missing %s in %+v", want, res)
}
}
for _, not := range []string{"duckduckgo:near square", "duckduckgo:tiny", "musicbrainz:Nightmare (Live)"} {
if _, ok := got[not]; ok {
t.Errorf("%s must be filtered out (not square or too small)", not)
}
}
if r := got["itunes:Nightmare"]; !strings.HasSuffix(r.URL, "/img/1000x1000bb.jpg") || res[0].Source != ITunes {
t.Errorf("iTunes should come first at 1000px: %+v", r)
}
for _, r := range res {
if r.Source == Google {
t.Error("Google must not be queried without an API key")
}
}
}
func TestOneFailingSourceDoesNotHideOthers(t *testing.T) {
c := fakeSources(t, true)
res, errs := c.Search(ctx, Query{Artist: "A", Album: "B"}, Config{Sources: []string{ITunes, Deezer}})
if errs[Deezer] == "" || len(res) != 1 || res[0].Source != ITunes {
t.Fatalf("want iTunes result plus a Deezer error, got %+v %v", res, errs)
}
}
func TestSquare(t *testing.T) {
for _, c := range []struct {
w, h int
want bool
}{{1000, 1000, true}, {1000, 996, true}, {600, 594, false}, {500, 498, true}, {0, 0, false}, {1200, 1100, false}} {
if Square(c.w, c.h) != c.want {
t.Errorf("Square(%d,%d) != %v", c.w, c.h, c.want)
}
}
}
func TestSafeFetch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/page" {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, "<html>")
return
}
w.Header().Set("Content-Type", "image/png")
w.Write(pngBytes(10, 10))
}))
defer srv.Close()
c := New("test") // the real, safe client
if _, err := c.FetchImage(ctx, srv.URL+"/img.png"); !errors.Is(err, ErrBlockedAddress) {
t.Fatalf("loopback must be refused, got %v", err)
}
for _, u := range []string{"file:///etc/passwd", "ftp://x/y.png", "gopher://x", "not a url"} {
if _, err := c.FetchImage(ctx, u); err == nil {
t.Errorf("%q must be refused", u)
}
}
c.HTTP = srv.Client()
if _, err := c.FetchImage(ctx, srv.URL+"/page"); err == nil || !strings.Contains(err.Error(), "not an image") {
t.Fatalf("non-images must be refused, got %v", err)
}
if b, err := c.FetchImage(ctx, srv.URL+"/img.png"); err != nil || len(b) == 0 {
t.Fatalf("image fetch: %v", err)
}
for _, ip := range []string{"10.0.0.1", "192.168.1.1", "172.16.0.1", "169.254.169.254", "100.64.0.1", "::1", "fc00::1", "0.0.0.0"} {
if !blockedIP(parseIP(ip)) {
t.Errorf("%s should be blocked", ip)
}
}
if blockedIP(parseIP("1.1.1.1")) {
t.Error("public addresses are allowed")
}
}
func TestPrepare(t *testing.T) {
ff, err := exec.LookPath("ffmpeg")
if err != nil {
t.Skip("ffmpeg not installed")
}
s := &Store{Dir: t.TempDir(), FFmpeg: ff}
p, err := s.Prepare(ctx, pngBytes(1600, 1600), 1000)
if err != nil {
t.Fatal(err)
}
if p.Width != 1000 || p.Height != 1000 || !p.Square {
t.Fatalf("want 1000x1000 square, got %+v", p)
}
path, err := s.Path(p.Token)
if err != nil {
t.Fatal(err)
}
b, _ := os.ReadFile(path)
if len(b) < 3 || b[0] != 0xFF || b[1] != 0xD8 {
t.Fatal("prepared image should be a JPEG")
}
if small, _ := s.Prepare(ctx, pngBytes(300, 300), 1000); small.Width != 300 {
t.Fatal("images are never enlarged")
}
if _, err := s.Prepare(ctx, []byte("not an image"), 1000); err == nil {
t.Fatal("garbage must be refused")
}
for _, bad := range []string{"../etc", "abc", strings.Repeat("g", 32)} {
if _, err := s.Path(bad); !errors.Is(err, ErrBadToken) {
t.Errorf("token %q must be refused", bad)
}
}
}
func parseIP(s string) net.IP { return net.ParseIP(s) }