// Package art finds and prepares album art. // // Sources: iTunes, Deezer and MusicBrainz/Cover Art Archive (no key, made // for album covers), DuckDuckGo images (unofficial endpoint, best effort) and // Google Custom Search (needs an API key and search engine id). Only square // images are returned. // // Images are fetched by the server, so all fetching goes through a client // that refuses private, loopback and link-local addresses: a crafted result // or pasted URL cannot reach the local network. package art import ( "bytes" "context" "encoding/json" "errors" "fmt" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "io" "net" "net/http" "net/url" "regexp" "sort" "strings" "sync" "syscall" "time" ) // Sources. const ( ITunes = "itunes" Deezer = "deezer" MusicBrainz = "musicbrainz" DuckDuckGo = "duckduckgo" Google = "google" ) var AllSources = []string{ITunes, Deezer, MusicBrainz, DuckDuckGo, Google} // MatchSources are reliable enough for automatic matching. var MatchSources = []string{ITunes, Deezer, MusicBrainz} type Result struct { Source string `json:"source"` Title string `json:"title"` Artist string `json:"artist,omitempty"` URL string `json:"url"` Thumb string `json:"thumb"` Width int `json:"width"` Height int `json:"height"` } type Query struct { Artist string `json:"artist"` Album string `json:"album"` Text string `json:"text"` // free-form search for the web engines } func (q Query) text() string { if strings.TrimSpace(q.Text) != "" { return strings.TrimSpace(q.Text) } return strings.TrimSpace(q.Artist + " " + q.Album) } type Config struct { Sources []string GoogleKey string GoogleCX string MinSize int } // Client talks to the art sources. The base URLs can be pointed at test // servers. type Client struct { HTTP *http.Client UserAgent string ITunesURL, DeezerURL, MusicBrainzURL, CAAURL, DDGURL, GoogleURL string } func New(version string) *Client { return &Client{ HTTP: SafeHTTPClient(20 * time.Second), UserAgent: "playlink/" + version + " (self-hosted music library tool)", ITunesURL: "https://itunes.apple.com", DeezerURL: "https://api.deezer.com", MusicBrainzURL: "https://musicbrainz.org", CAAURL: "https://coverartarchive.org", DDGURL: "https://duckduckgo.com", GoogleURL: "https://www.googleapis.com", } } // ---- safe fetching ---- var ErrBlockedAddress = errors.New("that address is on a private or local network and is not fetched") func blockedIP(ip net.IP) bool { if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() || ip.IsInterfaceLocalMulticast() { return true } if v4 := ip.To4(); v4 != nil && v4[0] == 100 && v4[1]&0xc0 == 64 { // 100.64.0.0/10 (CGNAT) return true } return false } // SafeHTTPClient refuses to connect to private/local addresses, including // after redirects and DNS tricks (the check runs on the resolved address). func SafeHTTPClient(timeout time.Duration) *http.Client { dialer := &net.Dialer{Timeout: 10 * time.Second, Control: func(network, address string, _ syscall.RawConn) error { host, _, err := net.SplitHostPort(address) if err != nil { return err } if ip := net.ParseIP(host); ip == nil || blockedIP(ip) { return ErrBlockedAddress } return nil }} tr := &http.Transport{Proxy: nil, DialContext: dialer.DialContext, TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 15 * time.Second, MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second} return &http.Client{Transport: tr, Timeout: timeout, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 5 { return errors.New("too many redirects") } if req.URL.Scheme != "http" && req.URL.Scheme != "https" { return errors.New("only http and https are fetched") } return nil }} } // MaxImageBytes caps a downloaded image. const MaxImageBytes = 20 << 20 // FetchImage downloads an image URL (http/https only, size capped). func (c *Client) FetchImage(ctx context.Context, raw string) ([]byte, error) { u, err := url.Parse(raw) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return nil, errors.New("enter an http or https image address") } req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, err } req.Header.Set("User-Agent", c.UserAgent) req.Header.Set("Accept", "image/*") res, err := c.HTTP.Do(req) if err != nil { return nil, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { return nil, fmt.Errorf("the image server answered %s", res.Status) } if ct := res.Header.Get("Content-Type"); ct != "" && !strings.HasPrefix(ct, "image/") && !strings.HasPrefix(ct, "application/octet-stream") { return nil, fmt.Errorf("that address is not an image (%s)", ct) } b, err := io.ReadAll(io.LimitReader(res.Body, MaxImageBytes+1)) if err != nil { return nil, err } if len(b) > MaxImageBytes { return nil, errors.New("the image is larger than 20 MB") } return b, nil } func (c *Client) getJSON(ctx context.Context, u string, header map[string]string, v any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return err } req.Header.Set("User-Agent", c.UserAgent) req.Header.Set("Accept", "application/json") for k, val := range header { req.Header.Set(k, val) } res, err := c.HTTP.Do(req) if err != nil { return err } defer res.Body.Close() if res.StatusCode != http.StatusOK { return fmt.Errorf("%s", res.Status) } return json.NewDecoder(io.LimitReader(res.Body, 4<<20)).Decode(v) } // ---- search ---- // Square reports whether an image is square: within 0.5% or 2 pixels. func Square(w, h int) bool { if w <= 0 || h <= 0 { return false } d := w - h if d < 0 { d = -d } return d <= max(2, max(w, h)/200) } // Search queries the enabled sources concurrently and returns square images // of at least the minimum size, best first. Per-source errors are returned // separately so one failing source does not hide the others. func (c *Client) Search(ctx context.Context, q Query, cfg Config) ([]Result, map[string]string) { if cfg.MinSize <= 0 { cfg.MinSize = 300 } type out struct { source string res []Result err error } ch := make(chan out, len(cfg.Sources)) var wg sync.WaitGroup for _, s := range cfg.Sources { var fn func(context.Context, Query, Config) ([]Result, error) switch s { case ITunes: fn = c.itunes case Deezer: fn = c.deezer case MusicBrainz: fn = c.musicbrainz case DuckDuckGo: fn = c.duckduckgo case Google: if cfg.GoogleKey == "" || cfg.GoogleCX == "" { continue } fn = c.google default: continue } wg.Add(1) go func(s string, fn func(context.Context, Query, Config) ([]Result, error)) { defer wg.Done() ctx, cancel := context.WithTimeout(ctx, 25*time.Second) defer cancel() r, err := fn(ctx, q, cfg) ch <- out{s, r, err} }(s, fn) } wg.Wait() close(ch) errs := map[string]string{} seen := map[string]bool{} var all []Result for o := range ch { if o.err != nil { errs[o.source] = o.err.Error() } for _, r := range o.res { if seen[r.URL] || !Square(r.Width, r.Height) || min(r.Width, r.Height) < cfg.MinSize { continue } seen[r.URL] = true all = append(all, r) } } rank := map[string]int{ITunes: 0, Deezer: 1, MusicBrainz: 2, Google: 3, DuckDuckGo: 4} sort.SliceStable(all, func(i, j int) bool { if rank[all[i].Source] != rank[all[j].Source] { return rank[all[i].Source] < rank[all[j].Source] } return all[i].Width > all[j].Width }) return all, errs } func (c *Client) itunes(ctx context.Context, q Query, _ Config) ([]Result, error) { var d struct { Results []struct { ArtistName string `json:"artistName"` CollectionName string `json:"collectionName"` ArtworkURL100 string `json:"artworkUrl100"` } `json:"results"` } u := c.ITunesURL + "/search?" + url.Values{"term": {q.text()}, "entity": {"album"}, "limit": {"25"}}.Encode() if err := c.getJSON(ctx, u, nil, &d); err != nil { return nil, err } var out []Result for _, r := range d.Results { if !strings.Contains(r.ArtworkURL100, "100x100") { continue } // iTunes serves the same artwork at any requested size. out = append(out, Result{Source: ITunes, Title: r.CollectionName, Artist: r.ArtistName, Width: 1000, Height: 1000, URL: strings.Replace(r.ArtworkURL100, "100x100", "1000x1000", 1), Thumb: strings.Replace(r.ArtworkURL100, "100x100", "300x300", 1)}) } return out, nil } func (c *Client) deezer(ctx context.Context, q Query, _ Config) ([]Result, error) { query := q.text() if q.Artist != "" && q.Album != "" && q.Text == "" { query = fmt.Sprintf(`artist:"%s" album:"%s"`, q.Artist, q.Album) } var d struct { Data []struct { Title string `json:"title"` CoverXL string `json:"cover_xl"` CoverMedium string `json:"cover_medium"` Artist struct { Name string `json:"name"` } `json:"artist"` } `json:"data"` } if err := c.getJSON(ctx, c.DeezerURL+"/search/album?"+url.Values{"q": {query}, "limit": {"25"}}.Encode(), nil, &d); err != nil { return nil, err } var out []Result for _, r := range d.Data { if r.CoverXL == "" { continue } out = append(out, Result{Source: Deezer, Title: r.Title, Artist: r.Artist.Name, URL: r.CoverXL, Thumb: r.CoverMedium, Width: 1000, Height: 1000}) } return out, nil } func (c *Client) musicbrainz(ctx context.Context, q Query, cfg Config) ([]Result, error) { query := q.text() if q.Album != "" && q.Text == "" { query = fmt.Sprintf(`releasegroup:"%s"`, strings.ReplaceAll(q.Album, `"`, "")) if q.Artist != "" { query += fmt.Sprintf(` AND artist:"%s"`, strings.ReplaceAll(q.Artist, `"`, "")) } } var d struct { ReleaseGroups []struct { ID string `json:"id"` Title string `json:"title"` ArtistCredit []struct { Name string `json:"name"` } `json:"artist-credit"` } `json:"release-groups"` } u := c.MusicBrainzURL + "/ws/2/release-group/?" + url.Values{"query": {query}, "fmt": {"json"}, "limit": {"8"}}.Encode() if err := c.getJSON(ctx, u, nil, &d); err != nil { return nil, err } // The Cover Art Archive does not report sizes: check each front cover's // thumbnail to keep only square ones (and skip groups without art). var mu sync.Mutex var wg sync.WaitGroup var out []Result for _, rg := range d.ReleaseGroups { artist := "" if len(rg.ArtistCredit) > 0 { artist = rg.ArtistCredit[0].Name } wg.Add(1) go func(id, title, artist string) { defer wg.Done() thumb := c.CAAURL + "/release-group/" + id + "/front-250" w, h, err := c.imageSize(ctx, thumb) if err != nil || !Square(w, h) { return } mu.Lock() out = append(out, Result{Source: MusicBrainz, Title: title, Artist: artist, URL: c.CAAURL + "/release-group/" + id + "/front-1200", Thumb: thumb, Width: 1200, Height: 1200}) mu.Unlock() }(rg.ID, rg.Title, artist) } wg.Wait() return out, nil } // imageSize reads just enough of an image to learn its dimensions. func (c *Client) imageSize(ctx context.Context, u string) (int, int, error) { b, err := c.FetchImage(ctx, u) if err != nil { return 0, 0, err } cfg, _, err := image.DecodeConfig(bytes.NewReader(b)) return cfg.Width, cfg.Height, err } var vqdRe = regexp.MustCompile(`vqd=["']?([0-9-]+)`) func (c *Client) duckduckgo(ctx context.Context, q Query, _ Config) ([]Result, error) { text := q.Text if strings.TrimSpace(text) == "" { text = q.text() + " album cover" } // DuckDuckGo has no official image API: a page request yields the token // its own image search uses. This can break if they change their site. req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.DDGURL+"/?"+url.Values{"q": {text}, "iax": {"images"}, "ia": {"images"}}.Encode(), nil) req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) playlink") res, err := c.HTTP.Do(req) if err != nil { return nil, err } page, _ := io.ReadAll(io.LimitReader(res.Body, 2<<20)) res.Body.Close() m := vqdRe.FindSubmatch(page) if m == nil { return nil, errors.New("DuckDuckGo did not return a search token (their site may have changed)") } var d struct { Results []struct { Title string `json:"title"` Image string `json:"image"` Thumbnail string `json:"thumbnail"` Width int `json:"width"` Height int `json:"height"` } `json:"results"` } u := c.DDGURL + "/i.js?" + url.Values{"q": {text}, "vqd": {string(m[1])}, "o": {"json"}, "l": {"us-en"}, "f": {",,,,,layout:Square"}, "p": {"1"}}.Encode() if err := c.getJSON(ctx, u, map[string]string{"Referer": c.DDGURL + "/", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) playlink"}, &d); err != nil { return nil, err } var out []Result for _, r := range d.Results { out = append(out, Result{Source: DuckDuckGo, Title: r.Title, URL: r.Image, Thumb: r.Thumbnail, Width: r.Width, Height: r.Height}) } return out, nil } func (c *Client) google(ctx context.Context, q Query, cfg Config) ([]Result, error) { text := q.Text if strings.TrimSpace(text) == "" { text = q.text() + " album cover" } var d struct { Items []struct { Title string `json:"title"` Link string `json:"link"` Image struct { Width int `json:"width"` Height int `json:"height"` ThumbnailLink string `json:"thumbnailLink"` } `json:"image"` } `json:"items"` Error *struct { Message string `json:"message"` } `json:"error"` } u := c.GoogleURL + "/customsearch/v1?" + url.Values{"key": {cfg.GoogleKey}, "cx": {cfg.GoogleCX}, "q": {text}, "searchType": {"image"}, "num": {"10"}, "imgSize": {"large"}}.Encode() if err := c.getJSON(ctx, u, nil, &d); err != nil { return nil, fmt.Errorf("Google: %v (check the API key and search engine id)", err) } var out []Result for _, it := range d.Items { out = append(out, Result{Source: Google, Title: it.Title, URL: it.Link, Thumb: it.Image.ThumbnailLink, Width: it.Image.Width, Height: it.Image.Height}) } return out, nil }