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>
This commit is contained in:
1 parent
5fb195f0d6
commit
d8ac25a55d
21 files changed
+2719
-38
No files matched your search
@@ -0,0 +1,462 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
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) }
|
||||
@@ -0,0 +1,121 @@
|
||||
package art
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Prepared images are downloaded or uploaded covers converted to a clean
|
||||
// JPEG, waiting to be applied. They live in a cache folder under a random
|
||||
// token and expire after a few hours.
|
||||
|
||||
type Prepared struct {
|
||||
Token string `json:"token"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Size int64 `json:"size"`
|
||||
Square bool `json:"square"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
Dir string
|
||||
FFmpeg string
|
||||
}
|
||||
|
||||
var tokenRe = regexp.MustCompile(`^[0-9a-f]{32}$`)
|
||||
|
||||
var ErrBadToken = errors.New("unknown or expired image; choose it again")
|
||||
|
||||
const preparedTTL = 3 * time.Hour
|
||||
|
||||
// Prepare converts image bytes to a JPEG no larger than maxSize pixels on
|
||||
// its longest side (0 keeps the original size).
|
||||
func (s *Store) Prepare(ctx context.Context, data []byte, maxSize int) (Prepared, error) {
|
||||
if s.FFmpeg == "" {
|
||||
return Prepared{}, errors.New("preparing images needs ffmpeg")
|
||||
}
|
||||
if err := os.MkdirAll(s.Dir, 0o750); err != nil {
|
||||
return Prepared{}, err
|
||||
}
|
||||
s.cleanup()
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return Prepared{}, err
|
||||
}
|
||||
token := hex.EncodeToString(b[:])
|
||||
raw := filepath.Join(s.Dir, token+".src")
|
||||
out := filepath.Join(s.Dir, token+".jpg")
|
||||
defer os.Remove(raw)
|
||||
if err := os.WriteFile(raw, data, 0o640); err != nil {
|
||||
return Prepared{}, err
|
||||
}
|
||||
scale := "scale=iw:ih"
|
||||
if maxSize > 0 {
|
||||
m := strconv.Itoa(maxSize)
|
||||
scale = "scale='if(gt(iw,ih),min(" + m + ",iw),-2)':'if(gt(iw,ih),-2,min(" + m + ",ih))'"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, s.FFmpeg, "-v", "error", "-nostdin", "-y", "-i", raw, "-frames:v", "1",
|
||||
"-vf", scale+",format=yuvj420p", "-q:v", "2", "-f", "image2", out)
|
||||
if msg, err := cmd.CombinedOutput(); err != nil {
|
||||
_ = os.Remove(out)
|
||||
return Prepared{}, fmt.Errorf("that file is not a usable image: %s", firstLine(string(msg)))
|
||||
}
|
||||
f, err := os.Open(out)
|
||||
if err != nil {
|
||||
return Prepared{}, err
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
_ = os.Remove(out)
|
||||
return Prepared{}, fmt.Errorf("converted image unreadable: %w", err)
|
||||
}
|
||||
fi, _ := os.Stat(out)
|
||||
return Prepared{Token: token, Width: cfg.Width, Height: cfg.Height, Size: fi.Size(), Square: Square(cfg.Width, cfg.Height)}, nil
|
||||
}
|
||||
|
||||
// Path returns the prepared JPEG for a token.
|
||||
func (s *Store) Path(token string) (string, error) {
|
||||
if !tokenRe.MatchString(token) {
|
||||
return "", ErrBadToken
|
||||
}
|
||||
p := filepath.Join(s.Dir, token+".jpg")
|
||||
fi, err := os.Stat(p)
|
||||
if err != nil || time.Since(fi.ModTime()) > preparedTTL {
|
||||
return "", ErrBadToken
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Store) cleanup() {
|
||||
ents, _ := os.ReadDir(s.Dir)
|
||||
for _, en := range ents {
|
||||
if fi, err := en.Info(); err == nil && time.Since(fi.ModTime()) > preparedTTL {
|
||||
_ = os.Remove(filepath.Join(s.Dir, en.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
for i, r := range s {
|
||||
if r == '\n' {
|
||||
return s[:i]
|
||||
}
|
||||
}
|
||||
if len(s) > 200 {
|
||||
return s[:200]
|
||||
}
|
||||
return s
|
||||
}
|
||||
Reference in new issue
Block a user