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:"" album:""` 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) )