package artwork
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
var UserAgent = "intake/0.1 ( https://example.invalid )"
var (
httpClient = &http.Client{Timeout: 20 * time.Second}
mbMu sync.Mutex
mbLastCall time.Time
)
func mbWait(ctx context.Context) error {
mbMu.Lock()
defer mbMu.Unlock()
if wait := time.Until(mbLastCall.Add(1100 * time.Millisecond)); wait > 0 {
select {
case <-time.After(wait):
case <-ctx.Done():
return ctx.Err()
}
}
mbLastCall = time.Now()
return nil
}
func get(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", UserAgent)
req.Header.Set("Accept", "*/*")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, 40<<20))
}
func downloadToCache(ctx context.Context, rawURL, cacheDir string) (string, int64, error) {
if cacheDir != "" {
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return "", 0, err
}
path := CachePath(cacheDir, rawURL)
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
return path, info.Size(), nil
}
data, err := get(ctx, rawURL)
if err != nil {
return "", 0, err
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return "", 0, err
}
return path, int64(len(data)), nil
}
data, err := get(ctx, rawURL)
if err != nil {
return "", 0, err
}
f, err := os.CreateTemp("", "intake-art-*"+extForURL(rawURL))
if err != nil {
return "", 0, err
}
if _, err := f.Write(data); err != nil {
f.Close()
return "", 0, err
}
f.Close()
return f.Name(), int64(len(data)), nil
}
func extForURL(u string) string {
if i := strings.IndexByte(u, '?'); i >= 0 {
u = u[:i]
}
ext := strings.ToLower(filepath.Ext(u))
if imageExts[ext] {
return ext
}
return ".jpg"
}
type mbRelease struct {
ID string `json:"id"`
Score int `json:"score"`
Title string `json:"title"`
Date string `json:"date"`
Country string `json:"country"`
Disambiguation string `json:"disambiguation"`
}
type mbSearch struct {
Releases []mbRelease `json:"releases"`
}
func coverArtArchive(ctx context.Context, album AlbumInfo, opts Options) ([]Candidate, error) {
query := fmt.Sprintf(`release:"%s" AND artist:"%s"`, album.Album, album.Artist)
endpoint := "https://musicbrainz.org/ws/2/release/?query=" +
url.QueryEscape(query) + "&fmt=json&limit=5"
if err := mbWait(ctx); err != nil {
return nil, err
}
body, err := get(ctx, endpoint)
if err != nil {
return nil, err
}
var parsed mbSearch
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, fmt.Errorf("unreadable MusicBrainz response: %w", err)
}
if len(parsed.Releases) == 0 {
return nil, nil
}
releases := parsed.Releases
sortByYearMatch(releases, album.Year)
var out []Candidate
for _, rel := range releases {
for _, size := range []string{"front-1200", "front-500"} {
u := fmt.Sprintf("https://coverartarchive.org/release/%s/%s", rel.ID, size)
path, sizeBytes, err := downloadToCache(ctx, u, opts.CacheDir)
if err != nil {
continue
}
label := "Cover Art Archive"
if y := yearOf(rel.Date); y != "" {
label += " · " + y
}
if rel.Country != "" {
label += " · " + rel.Country
}
if rel.Disambiguation != "" {
label += " · " + rel.Disambiguation
}
label += " · " + rel.Title
c := Candidate{
Source: SourceCoverArt,
Label: label,
ProviderTitle: rel.Title,
MatchScore: matchScore(rel.Title, album.Album),
Path: path,
URL: u,
Bytes: sizeBytes,
}
if !fillDimensions(ctx, &c) {
continue
}
out = append(out, c)
break
}
if len(out) >= 2 {
break
}
}
return out, nil
}
func yearOf(date string) string {
if len(date) >= 4 {
for _, r := range date[:4] {
if r < '0' || r > '9' {
return ""
}
}
return date[:4]
}
return ""
}
func sortByYearMatch(releases []mbRelease, want string) {
score := func(r mbRelease) int {
s := r.Score
if want != "" && strings.HasPrefix(r.Date, want) {
s += 100
}
return s
}
for i := range releases {
for j := i + 1; j < len(releases); j++ {
if score(releases[j]) > score(releases[i]) {
releases[i], releases[j] = releases[j], releases[i]
}
}
}
}
type deezerSearch struct {
Data []struct {
ID int `json:"id"`
Title string `json:"title"`
Artist struct {
Name string `json:"name"`
} `json:"artist"`
Cover string `json:"cover"`
CoverXL string `json:"cover_xl"`
CoverBig string `json:"cover_big"`
NbTracks int `json:"nb_tracks"`
ReleaseDate string `json:"release_date"`
} `json:"data"`
}
func deezer(ctx context.Context, album AlbumInfo, opts Options) ([]Candidate, error) {
q := url.QueryEscape(album.Artist + " " + album.Album)
body, err := get(ctx, "https://api.deezer.com/search/album?q="+q+"&limit=10")
if err != nil {
return nil, err
}
var parsed deezerSearch
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, fmt.Errorf("unreadable Deezer response: %w", err)
}
var out []Candidate
for _, d := range parsed.Data {
if !looseMatch(d.Artist.Name, album.Artist) || !looseMatch(d.Title, album.Album) {
continue
}
score := matchScore(d.Title, album.Album)
cover := d.CoverXL
if cover == "" {
cover = d.CoverBig
}
if cover == "" {
cover = d.Cover
}
if cover == "" {
continue
}
path, size, err := downloadToCache(ctx, cover, opts.CacheDir)
if err != nil {
continue
}
c := Candidate{
Source: SourceDeezer,
Label: fmt.Sprintf("Deezer: %s - %s", d.Artist.Name, d.Title),
ProviderTitle: d.Title,
MatchScore: score,
Path: path,
URL: cover,
Bytes: size,
}
if !fillDimensions(ctx, &c) {
continue
}
out = append(out, c)
if len(out) >= 2 {
break
}
}
return out, nil
}
type itunesSearch struct {
Results []struct {
CollectionName string `json:"collectionName"`
ArtistName string `json:"artistName"`
ArtworkURL100 string `json:"artworkUrl100"`
TrackCount int `json:"trackCount"`
ReleaseDate string `json:"releaseDate"`
} `json:"results"`
}
func itunes(ctx context.Context, album AlbumInfo, opts Options) ([]Candidate, error) {
q := url.QueryEscape(album.Artist + " " + album.Album)
endpoint := fmt.Sprintf("https://itunes.apple.com/search?term=%s&entity=album&limit=10", q)
body, err := get(ctx, endpoint)
if err != nil {
return nil, err
}
var parsed itunesSearch
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, fmt.Errorf("unreadable iTunes response: %w", err)
}
var out []Candidate
for _, r := range parsed.Results {
if !looseMatch(r.ArtistName, album.Artist) || !looseMatch(r.CollectionName, album.Album) {
continue
}
score := matchScore(r.CollectionName, album.Album)
if r.ArtworkURL100 == "" {
continue
}
big := upscaleArtwork(r.ArtworkURL100)
path, size, err := downloadToCache(ctx, big, opts.CacheDir)
if err != nil {
path, size, err = downloadToCache(ctx, r.ArtworkURL100, opts.CacheDir)
if err != nil {
continue
}
big = r.ArtworkURL100
}
c := Candidate{
Source: SourceITunes,
Label: fmt.Sprintf("iTunes: %s - %s", r.ArtistName, r.CollectionName),
ProviderTitle: r.CollectionName,
MatchScore: score,
Path: path,
URL: big,
Bytes: size,
}
if !fillDimensions(ctx, &c) {
continue
}
out = append(out, c)
if len(out) >= 2 {
break
}
}
return out, nil
}
func upscaleArtwork(u string) string {
if u == "" {
return u
}
slash := strings.LastIndexByte(u, '/')
if slash < 0 {
return u
}
prefix, last := u[:slash+1], u[slash+1:]
if idx := strings.Index(last, "x"); idx > 0 {
if rest := last[idx+1:]; strings.HasSuffix(rest, ".jpg") || strings.HasSuffix(rest, ".png") {
suffix := strings.TrimSuffix(rest, ".jpg")
suffix = strings.TrimSuffix(suffix, ".png")
ext := ".jpg"
if strings.HasSuffix(last, ".png") {
ext = ".png"
}
name := suffix
if i := strings.IndexFunc(suffix, func(r rune) bool { return r < '0' || r > '9' }); i >= 0 {
name = suffix[i:]
}
return prefix + "1400x1400" + name + ext
}
}
return u
}
func matchScore(providerTitle, album string) int {
a, b := sanitizeForMatch(providerTitle), sanitizeForMatch(album)
switch {
case a == "" || b == "":
return 0
case a == b:
return 3
case strings.Contains(a, b) || strings.Contains(b, a):
return 2
default:
return 1
}
}
func looseMatch(a, b string) bool {
na, nb := sanitizeForMatch(a), sanitizeForMatch(b)
if na == "" || nb == "" {
return false
}
if na == nb {
return true
}
return strings.Contains(na, nb) || strings.Contains(nb, na)
}
func escapeCaption(s string) string {
replacer := strings.NewReplacer(
"\\", "\\\\",
"\n", " ",
"%", "%%",
)
return replacer.Replace(s)
}
func Placeholder(ctx context.Context, album AlbumInfo, outPath string, size int) error {
if size <= 0 {
size = 1000
}
label := album.Album
if album.Artist != "" {
label = album.Artist + "\n" + album.Album
}
if label == "" {
label = "no cover"
}
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
return err
}
args := []string{
"-size", strconv.Itoa(size) + "x" + strconv.Itoa(size),
"-background", "#2b2b2b",
"-fill", "#e0e0e0",
"-gravity", "center",
"-size", strconv.Itoa(size*8/10) + "x" + strconv.Itoa(size*8/10),
"caption:NO COVER ART\\n" + escapeCaption(label),
"-gravity", "center",
"-extent", strconv.Itoa(size) + "x" + strconv.Itoa(size),
"-type", "TrueColor",
"-quality", "88",
outPath,
}
var errb bytes.Buffer
cmd := execCommandContext(ctx, "magick", args...)
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
return fmt.Errorf("generating placeholder: %s", strings.TrimSpace(errb.String()))
}
return nil
}