160 lines
4.5 KiB
Go
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)
|
|
)
|