Files
minstrel/internal/coverart/provider_deezer.go
T
bvandeusen 8f8e88c8b9 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.
2026-05-07 07:48:02 -04:00

291 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
)