From 3d5e816c5fe6be320772105e0fb83972f040215a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 07:53:09 -0400 Subject: [PATCH] refactor(coverart/deezer): delegate HTTP plumbing to httpClient (C1 2/3) --- internal/coverart/provider_deezer.go | 159 +++------------------------ 1 file changed, 14 insertions(+), 145 deletions(-) diff --git a/internal/coverart/provider_deezer.go b/internal/coverart/provider_deezer.go index d0ab57c6..fd58493e 100644 --- a/internal/coverart/provider_deezer.go +++ b/internal/coverart/provider_deezer.go @@ -2,14 +2,10 @@ package coverart import ( "context" - "encoding/json" "fmt" - "io" - "math/rand" "net/http" "net/url" "strings" - "sync" "sync/atomic" "time" ) @@ -30,18 +26,21 @@ const ( // 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. +// required. Safe for concurrent use; the underlying httpClient +// serialises calls so the rate limit applies uniformly. type deezerProvider struct { - enabled atomic.Bool - mu sync.Mutex - lastCall time.Time - client *http.Client + enabled atomic.Bool + client *httpClient } func init() { Register(&deezerProvider{ - client: &http.Client{Timeout: 30 * time.Second}, + client: newHTTPClient(httpClientOptions{ + Name: "deezer", + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + MinInterval: deezerMinPeriod, + MaxRetries: deezerMaxRetries, + }), }) } @@ -84,7 +83,7 @@ func (p *deezerProvider) FetchArtistArt(ctx context.Context, ref ArtistRef) (thu PictureBig string `json:"picture_big"` } `json:"data"` } - if err := p.getJSON(ctx, "/search/artist?"+q.Encode(), &resp); err != nil { + if err := p.client.getJSON(ctx, deezerBaseURL+"/search/artist?"+q.Encode(), &resp); err != nil { return nil, nil, err } if len(resp.Data) == 0 { @@ -101,7 +100,7 @@ func (p *deezerProvider) FetchArtistArt(ctx context.Context, ref ArtistRef) (thu if imgURL == "" { return nil, nil, ErrNotFound } - img, ferr := p.getImage(ctx, imgURL) + img, ferr := p.client.getImage(ctx, imgURL) if ferr != nil { return nil, nil, ferr } @@ -133,7 +132,7 @@ func (p *deezerProvider) FetchAlbumCover(ctx context.Context, ref AlbumRef) ([]b } `json:"artist"` } `json:"data"` } - if err := p.getJSON(ctx, "/search/album?"+q.Encode(), &resp); err != nil { + if err := p.client.getJSON(ctx, deezerBaseURL+"/search/album?"+q.Encode(), &resp); err != nil { return nil, err } if len(resp.Data) == 0 { @@ -150,137 +149,7 @@ func (p *deezerProvider) FetchAlbumCover(ctx context.Context, ref AlbumRef) ([]b 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<