package coverart import ( "context" "fmt" "net/http" "net/url" "strings" "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 underlying httpClient // serialises calls so the rate limit applies uniformly. type deezerProvider struct { enabled atomic.Bool client *httpClient } func init() { Register(&deezerProvider{ client: newHTTPClient(httpClientOptions{ Name: "deezer", HTTPClient: &http.Client{Timeout: 30 * time.Second}, MinInterval: deezerMinPeriod, MaxRetries: deezerMaxRetries, }), }) } 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.client.getJSON(ctx, deezerBaseURL+"/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.client.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.client.getJSON(ctx, deezerBaseURL+"/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.client.getImage(ctx, imgURL) } // Compile-time check: the new provider implements both interfaces. var ( _ AlbumCoverProvider = (*deezerProvider)(nil) _ ArtistArtProvider = (*deezerProvider)(nil) )