refactor(coverart/deezer): delegate HTTP plumbing to httpClient (C1 2/3)
This commit is contained in:
@@ -2,14 +2,10 @@ package coverart
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"math/rand"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -30,18 +26,21 @@ const (
|
|||||||
|
|
||||||
// deezerProvider implements Provider, AlbumCoverProvider, and
|
// deezerProvider implements Provider, AlbumCoverProvider, and
|
||||||
// ArtistArtProvider against Deezer's public read API. No API key
|
// ArtistArtProvider against Deezer's public read API. No API key
|
||||||
// required. Safe for concurrent use; the internal mutex serialises
|
// required. Safe for concurrent use; the underlying httpClient
|
||||||
// ALL HTTP calls so the rate limit applies uniformly.
|
// serialises calls so the rate limit applies uniformly.
|
||||||
type deezerProvider struct {
|
type deezerProvider struct {
|
||||||
enabled atomic.Bool
|
enabled atomic.Bool
|
||||||
mu sync.Mutex
|
client *httpClient
|
||||||
lastCall time.Time
|
|
||||||
client *http.Client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
Register(&deezerProvider{
|
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"`
|
PictureBig string `json:"picture_big"`
|
||||||
} `json:"data"`
|
} `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
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if len(resp.Data) == 0 {
|
if len(resp.Data) == 0 {
|
||||||
@@ -101,7 +100,7 @@ func (p *deezerProvider) FetchArtistArt(ctx context.Context, ref ArtistRef) (thu
|
|||||||
if imgURL == "" {
|
if imgURL == "" {
|
||||||
return nil, nil, ErrNotFound
|
return nil, nil, ErrNotFound
|
||||||
}
|
}
|
||||||
img, ferr := p.getImage(ctx, imgURL)
|
img, ferr := p.client.getImage(ctx, imgURL)
|
||||||
if ferr != nil {
|
if ferr != nil {
|
||||||
return nil, nil, ferr
|
return nil, nil, ferr
|
||||||
}
|
}
|
||||||
@@ -133,7 +132,7 @@ func (p *deezerProvider) FetchAlbumCover(ctx context.Context, ref AlbumRef) ([]b
|
|||||||
} `json:"artist"`
|
} `json:"artist"`
|
||||||
} `json:"data"`
|
} `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
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(resp.Data) == 0 {
|
if len(resp.Data) == 0 {
|
||||||
@@ -150,137 +149,7 @@ func (p *deezerProvider) FetchAlbumCover(ctx context.Context, ref AlbumRef) ([]b
|
|||||||
if imgURL == "" {
|
if imgURL == "" {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
return p.getImage(ctx, imgURL)
|
return p.client.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.
|
// Compile-time check: the new provider implements both interfaces.
|
||||||
|
|||||||
Reference in New Issue
Block a user