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.
This commit is contained in:
@@ -0,0 +1,290 @@
|
|||||||
|
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)
|
||||||
|
)
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package coverart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newDeezerTestProvider returns a fresh enabled provider configured
|
||||||
|
// against a test server URL.
|
||||||
|
func newDeezerTestProvider(t *testing.T, baseURL string) *deezerProvider {
|
||||||
|
t.Helper()
|
||||||
|
old := deezerBaseURL
|
||||||
|
deezerBaseURL = baseURL
|
||||||
|
t.Cleanup(func() { deezerBaseURL = old })
|
||||||
|
|
||||||
|
p := &deezerProvider{client: &http.Client{Timeout: 5 * time.Second}}
|
||||||
|
p.enabled.Store(true)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeezer_ID(t *testing.T) {
|
||||||
|
p := &deezerProvider{}
|
||||||
|
if p.ID() != "deezer" {
|
||||||
|
t.Errorf("ID = %q, want deezer", p.ID())
|
||||||
|
}
|
||||||
|
if p.RequiresAPIKey() {
|
||||||
|
t.Error("RequiresAPIKey = true, want false")
|
||||||
|
}
|
||||||
|
if !p.DefaultEnabled() {
|
||||||
|
t.Error("DefaultEnabled = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeezer_FetchArtistArt_Success(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
mux.HandleFunc("/search/artist", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
imgURL := "http://" + r.Host + "/img/cp.jpg"
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"name":"Caravan Palace","picture_xl":"` + imgURL + `"}]}`))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/img/", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
_, _ = w.Write([]byte("img-bytes"))
|
||||||
|
})
|
||||||
|
|
||||||
|
p := newDeezerTestProvider(t, srv.URL)
|
||||||
|
thumb, fanart, err := p.FetchArtistArt(context.Background(), ArtistRef{Name: "Caravan Palace"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if string(thumb) != "img-bytes" {
|
||||||
|
t.Errorf("thumb = %q, want img-bytes", thumb)
|
||||||
|
}
|
||||||
|
if fanart != nil {
|
||||||
|
t.Errorf("fanart = %v, want nil (Deezer has no separate fanart)", fanart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeezer_FetchArtistArt_EmptyResults(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":[],"total":0}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p := newDeezerTestProvider(t, srv.URL)
|
||||||
|
_, _, err := p.FetchArtistArt(context.Background(), ArtistRef{Name: "Daisy Grenade"})
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeezer_FetchArtistArt_NameMismatch(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"name":"Some Other Artist","picture_xl":"http://example/x.jpg"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p := newDeezerTestProvider(t, srv.URL)
|
||||||
|
_, _, err := p.FetchArtistArt(context.Background(), ArtistRef{Name: "Real Artist"})
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("err = %v, want ErrNotFound (name mismatch should not match)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeezer_FetchArtistArt_EmptyName(t *testing.T) {
|
||||||
|
p := &deezerProvider{client: &http.Client{Timeout: 5 * time.Second}}
|
||||||
|
p.enabled.Store(true)
|
||||||
|
_, _, err := p.FetchArtistArt(context.Background(), ArtistRef{})
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("err = %v, want ErrNotFound on empty Name", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeezer_FetchArtistArt_Disabled(t *testing.T) {
|
||||||
|
p := &deezerProvider{client: &http.Client{Timeout: 5 * time.Second}}
|
||||||
|
// enabled defaults to false
|
||||||
|
_, _, err := p.FetchArtistArt(context.Background(), ArtistRef{Name: "X"})
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("err = %v, want ErrNotFound when disabled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeezer_FetchAlbumCover_Success(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
mux.HandleFunc("/search/album", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
imgURL := "http://" + r.Host + "/img/r.jpg"
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"title":"The Record","cover_xl":"` + imgURL + `","artist":{"name":"The Band"}}]}`))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/img/", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
_, _ = w.Write([]byte("album-bytes"))
|
||||||
|
})
|
||||||
|
|
||||||
|
p := newDeezerTestProvider(t, srv.URL)
|
||||||
|
body, err := p.FetchAlbumCover(context.Background(), AlbumRef{ArtistName: "The Band", AlbumTitle: "The Record"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if string(body) != "album-bytes" {
|
||||||
|
t.Errorf("body = %q, want album-bytes", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeezer_FetchAlbumCover_TitleMismatch(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"title":"Wrong Album","cover_xl":"http://example/x.jpg","artist":{"name":"The Band"}}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p := newDeezerTestProvider(t, srv.URL)
|
||||||
|
_, err := p.FetchAlbumCover(context.Background(), AlbumRef{ArtistName: "The Band", AlbumTitle: "The Record"})
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("err = %v, want ErrNotFound (title mismatch)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeezer_FetchAlbumCover_MissingFields(t *testing.T) {
|
||||||
|
p := &deezerProvider{client: &http.Client{Timeout: 5 * time.Second}}
|
||||||
|
p.enabled.Store(true)
|
||||||
|
|
||||||
|
cases := []AlbumRef{
|
||||||
|
{},
|
||||||
|
{ArtistName: "X"}, // no title
|
||||||
|
{AlbumTitle: "Y"}, // no artist
|
||||||
|
}
|
||||||
|
for _, ref := range cases {
|
||||||
|
_, err := p.FetchAlbumCover(context.Background(), ref)
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("ref=%+v err=%v, want ErrNotFound", ref, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeezer_RateLimit_Serializes(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":[]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p := newDeezerTestProvider(t, srv.URL)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, _, _ = p.FetchArtistArt(context.Background(), ArtistRef{Name: "A"})
|
||||||
|
_, _, _ = p.FetchArtistArt(context.Background(), ArtistRef{Name: "B"})
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if elapsed < deezerMinPeriod {
|
||||||
|
t.Errorf("two back-to-back calls completed in %v, want at least %v (rate-limit didn't serialize)",
|
||||||
|
elapsed, deezerMinPeriod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeezer_FetchArtistArt_ServerError(t *testing.T) {
|
||||||
|
var attempts atomic.Int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
attempts.Add(1)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p := newDeezerTestProvider(t, srv.URL)
|
||||||
|
_, _, err := p.FetchArtistArt(context.Background(), ArtistRef{Name: "X"})
|
||||||
|
if !errors.Is(err, ErrTransient) {
|
||||||
|
t.Errorf("err = %v, want ErrTransient on 500", err)
|
||||||
|
}
|
||||||
|
// Should have retried up to deezerMaxRetries+1 times total.
|
||||||
|
if attempts.Load() < 2 {
|
||||||
|
t.Errorf("attempts = %d, want >= 2 (retry should have run)", attempts.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeezer_FetchArtistArt_429TriggersRetry(t *testing.T) {
|
||||||
|
var attempts atomic.Int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
n := attempts.Add(1)
|
||||||
|
if n == 1 {
|
||||||
|
http.Error(w, "rate limit", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":[]}`)) // second attempt: empty → ErrNotFound
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p := newDeezerTestProvider(t, srv.URL)
|
||||||
|
_, _, err := p.FetchArtistArt(context.Background(), ArtistRef{Name: "X"})
|
||||||
|
if attempts.Load() < 2 {
|
||||||
|
t.Errorf("attempts = %d, want >= 2 (retry should have run)", attempts.Load())
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("final err = %v, want ErrNotFound (empty results after retry)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user