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>
205 lines
6.7 KiB
Go
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) }
|