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

463 lines
14 KiB
Go

// 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
}