From 8f8e88c8b9548a869352be3a9a86bc9f42aa9599 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 7 May 2026 07:48:02 -0400 Subject: [PATCH] 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. --- internal/coverart/provider_deezer.go | 290 ++++++++++++++++++++++ internal/coverart/provider_deezer_test.go | 229 +++++++++++++++++ 2 files changed, 519 insertions(+) create mode 100644 internal/coverart/provider_deezer.go create mode 100644 internal/coverart/provider_deezer_test.go diff --git a/internal/coverart/provider_deezer.go b/internal/coverart/provider_deezer.go new file mode 100644 index 00000000..d0ab57c6 --- /dev/null +++ b/internal/coverart/provider_deezer.go @@ -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:"" 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) +) diff --git a/internal/coverart/provider_deezer_test.go b/internal/coverart/provider_deezer_test.go new file mode 100644 index 00000000..31708562 --- /dev/null +++ b/internal/coverart/provider_deezer_test.go @@ -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) + } +}