Files

160 lines
4.5 KiB
Go

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:"<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.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)
)