feat(server/coverart): Deezer artist + album provider

Adds Deezer as a name-based provider in the artist + album chains.
Public read API, no API key required, default-on. Rate-limited to
5 req/s (well under their 50/5s per-IP ceiling).

Includes exact-name match guard against false positives: if the top
search result's artist/album name doesn't case-insensitive-match
what we sent, returns ErrNotFound rather than persisting the wrong
art. This is critical for one-word artist names (e.g. "Time")
where Deezer's relevance ranking can't distinguish.

Provides the long-promised path for the 53 MBID-less artist rows in
the dev DB (featured/collab artists from track-tag splits) which
TheAudioDB cannot reach.

For artists, Deezer returns a single image which we use as thumb;
fanart stays nil. The enricher's persistence layer already handles
thumb-only correctly.
This commit is contained in:
2026-05-07 07:48:02 -04:00
parent 425f12db38
commit 8f8e88c8b9
2 changed files with 519 additions and 0 deletions
+290
View File
@@ -0,0 +1,290 @@
package coverart
import (
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
)
// deezerBaseURL is a var (not const) so tests can override it to point
// at a test server.
var deezerBaseURL = "https://api.deezer.com"
const (
// deezerMinPeriod throttles requests to 5/s — well under Deezer's
// documented 50/5s per-IP limit. Conservative because the limit is
// shared across the entire host, not per-API-key.
deezerMinPeriod = 200 * time.Millisecond
// deezerMaxRetries: number of retry attempts on 429 / 5xx.
deezerMaxRetries = 3
)
// deezerProvider implements Provider, AlbumCoverProvider, and
// ArtistArtProvider against Deezer's public read API. No API key
// required. Safe for concurrent use; the internal mutex serialises
// ALL HTTP calls so the rate limit applies uniformly.
type deezerProvider struct {
enabled atomic.Bool
mu sync.Mutex
lastCall time.Time
client *http.Client
}
func init() {
Register(&deezerProvider{
client: &http.Client{Timeout: 30 * time.Second},
})
}
func (p *deezerProvider) ID() string { return "deezer" }
func (p *deezerProvider) DisplayName() string { return "Deezer" }
func (p *deezerProvider) RequiresAPIKey() bool { return false }
func (p *deezerProvider) DefaultEnabled() bool { return true }
func (p *deezerProvider) Configure(s ProviderSettings) error {
p.enabled.Store(s.Enabled)
return nil
}
// FetchArtistArt searches Deezer's artist catalog by name.
//
// Returns ErrNotFound when:
// - provider disabled
// - ref.Name empty (Deezer is name-based, can't act on MBID alone)
// - search returns no results
// - top result's name doesn't case-insensitive-match ref.Name
// (false-positive guard)
// - response has no usable picture URL
//
// Deezer returns a single artist image (no separate fanart concept) —
// we use it as the thumb and leave fanart nil. The enricher's writer
// handles thumb-only correctly (writes thumb.jpg, skips fanart.jpg).
func (p *deezerProvider) FetchArtistArt(ctx context.Context, ref ArtistRef) (thumb, fanart []byte, err error) {
if !p.enabled.Load() {
return nil, nil, ErrNotFound
}
if ref.Name == "" {
return nil, nil, ErrNotFound
}
q := url.Values{"q": {ref.Name}, "limit": {"1"}}
var resp struct {
Data []struct {
Name string `json:"name"`
PictureXL string `json:"picture_xl"`
PictureBig string `json:"picture_big"`
} `json:"data"`
}
if err := p.getJSON(ctx, "/search/artist?"+q.Encode(), &resp); err != nil {
return nil, nil, err
}
if len(resp.Data) == 0 {
return nil, nil, ErrNotFound
}
hit := resp.Data[0]
if !strings.EqualFold(hit.Name, ref.Name) {
return nil, nil, ErrNotFound
}
imgURL := hit.PictureXL
if imgURL == "" {
imgURL = hit.PictureBig
}
if imgURL == "" {
return nil, nil, ErrNotFound
}
img, ferr := p.getImage(ctx, imgURL)
if ferr != nil {
return nil, nil, ferr
}
return img, nil, nil
}
// FetchAlbumCover searches Deezer's album catalog using the advanced
// query syntax `artist:"<name>" album:"<title>"` to keep the result
// tight. Returns ErrNotFound on the same set of conditions as
// FetchArtistArt; additionally requires both ArtistName and AlbumTitle
// to be non-empty (Deezer search is unreliable with one missing).
func (p *deezerProvider) FetchAlbumCover(ctx context.Context, ref AlbumRef) ([]byte, error) {
if !p.enabled.Load() {
return nil, ErrNotFound
}
if ref.ArtistName == "" || ref.AlbumTitle == "" {
return nil, ErrNotFound
}
qStr := fmt.Sprintf(`artist:"%s" album:"%s"`, ref.ArtistName, ref.AlbumTitle)
q := url.Values{"q": {qStr}, "limit": {"1"}}
var resp struct {
Data []struct {
Title string `json:"title"`
CoverXL string `json:"cover_xl"`
CoverBig string `json:"cover_big"`
Artist struct {
Name string `json:"name"`
} `json:"artist"`
} `json:"data"`
}
if err := p.getJSON(ctx, "/search/album?"+q.Encode(), &resp); err != nil {
return nil, err
}
if len(resp.Data) == 0 {
return nil, ErrNotFound
}
hit := resp.Data[0]
if !strings.EqualFold(hit.Title, ref.AlbumTitle) || !strings.EqualFold(hit.Artist.Name, ref.ArtistName) {
return nil, ErrNotFound
}
imgURL := hit.CoverXL
if imgURL == "" {
imgURL = hit.CoverBig
}
if imgURL == "" {
return nil, ErrNotFound
}
return p.getImage(ctx, imgURL)
}
// getJSON performs a rate-limited GET against Deezer's JSON API.
// Retries on 429 / 5xx with exponential backoff up to deezerMaxRetries.
func (p *deezerProvider) getJSON(ctx context.Context, path string, out any) error {
fullURL := deezerBaseURL + path
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
if err := p.rateLimitWait(ctx); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
return fmt.Errorf("coverart deezer: build request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
if attempt < deezerMaxRetries {
if waitErr := p.backoff(ctx, attempt); waitErr != nil {
return waitErr
}
continue
}
return fmt.Errorf("%w: %v", ErrTransient, err)
}
switch {
case resp.StatusCode == http.StatusNotFound:
_ = resp.Body.Close()
return ErrNotFound
case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500:
_ = resp.Body.Close()
if attempt < deezerMaxRetries {
if waitErr := p.backoff(ctx, attempt); waitErr != nil {
return waitErr
}
continue
}
return fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode)
case resp.StatusCode != http.StatusOK:
_ = resp.Body.Close()
return fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1*1024*1024))
_ = resp.Body.Close()
if err != nil {
return fmt.Errorf("%w: read body: %v", ErrTransient, err)
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("%w: decode body: %v", ErrTransient, err)
}
return nil
}
return fmt.Errorf("%w: exhausted retries", ErrTransient)
}
// getImage performs a rate-limited GET of an image URL. Same
// 404/5xx/network handling as TheAudioDB's getImage.
func (p *deezerProvider) getImage(ctx context.Context, imgURL string) ([]byte, error) {
if err := p.rateLimitWait(ctx); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imgURL, nil)
if err != nil {
return nil, fmt.Errorf("coverart deezer: build request: %w", err)
}
req.Header.Set("Accept", "image/*")
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTransient, err)
}
defer func() { _ = resp.Body.Close() }()
switch {
case resp.StatusCode == http.StatusNotFound:
return nil, ErrNotFound
case resp.StatusCode != http.StatusOK:
return nil, fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
if err != nil {
return nil, fmt.Errorf("%w: read body: %v", ErrTransient, err)
}
return body, nil
}
// rateLimitWait blocks until at least deezerMinPeriod has elapsed since
// the last call, then claims the slot.
func (p *deezerProvider) rateLimitWait(ctx context.Context) error {
p.mu.Lock()
wait := time.Until(p.lastCall.Add(deezerMinPeriod))
if wait > 0 {
timer := time.NewTimer(wait)
p.mu.Unlock()
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
p.mu.Lock()
}
p.lastCall = time.Now()
p.mu.Unlock()
return nil
}
// backoff sleeps for 2^attempt × 250ms ± 20% jitter.
func (p *deezerProvider) backoff(ctx context.Context, attempt int) error {
base := time.Duration(250*(1<<attempt)) * time.Millisecond
jitterRange := int64(base) / 5
if jitterRange <= 0 {
jitterRange = 1
}
jitter := time.Duration(rand.Int63n(jitterRange))
if rand.Intn(2) == 0 {
jitter = -jitter
}
d := base + jitter
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
// Compile-time check: the new provider implements both interfaces.
var (
_ AlbumCoverProvider = (*deezerProvider)(nil)
_ ArtistArtProvider = (*deezerProvider)(nil)
)