Files
minstrel/internal/coverart/provider_theaudiodb.go
T
bvandeusen 8f64bcdb5f feat(server/m7-cover-sources): TheAudioDB provider
New album-cover and artist-art source implementing
AlbumCoverProvider, ArtistArtProvider, and TestableProvider. Hits
the documented /album-mb.php and /artist-mb.php JSON endpoints by
MBID, then GETs the returned image URLs (CDN, key-less). 2 req/sec
rate limit per the upstream's documented free/test-key tier;
mutex+lastCall serialises ALL HTTP calls regardless of method so
JSON metadata fetches and image GETs share the same budget.

429 / 5xx triggers exponential backoff with jitter (250ms × 2^attempt
± 20%) up to 3 retries before surfacing ErrTransient. 401 / 403
surface as ErrTransient (auth issues are config errors, not "this
album has no art" — the row stays NULL across passes so the operator
sees pending count not decreasing as the diagnostic signal).

Artist art is atomic at the JSON metadata step (one round-trip
yields both URLs); the two image GETs are independent — partial
success returns whatever bytes landed and ErrNotFound only if
neither URL is present in the response.

Configure() falls back to the documented public test key (2) when
the operator's APIKey is empty, per the no-coercive-settings
principle. Test connection hits a stable popular release MBID
(Pink Floyd, "The Dark Side of the Moon").

theAudioDBBaseURL is a var (not const) so tests can override it.
2026-05-06 12:39:50 -04:00

342 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package coverart
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"net/http"
"sync"
"sync/atomic"
"time"
)
// theAudioDBBaseURL is a var (not const) so tests can override it
// to point at a test server. Production callers should never modify
// this.
var theAudioDBBaseURL = "https://www.theaudiodb.com/api/v1/json"
const (
// theAudioDBTestKey is the upstream's documented public test key.
// Functional but rate-limited identically to personal keys (2
// req/sec). Used as the default when an operator hasn't supplied
// their own key, per the no-coercive-settings principle.
theAudioDBTestKey = "2"
// theAudioDBMinPeriod enforces TheAudioDB's documented free/test
// rate limit (2 req/sec). Patreon supporter keys allow higher
// rates; we don't differentiate in v1 — operators with supporter
// keys experience the conservative 2 req/sec ceiling.
theAudioDBMinPeriod = 500 * time.Millisecond
// theAudioDBMaxRetries is the number of retry attempts on 429 /
// 5xx before giving up and surfacing ErrTransient.
theAudioDBMaxRetries = 3
// theAudioDBTestSampleMBID is used by TestConnection. Pink Floyd's
// "The Dark Side of the Moon" — a stable popular release verified
// present in TheAudioDB at design time.
theAudioDBTestSampleMBID = "f5093c06-23e3-404f-aeaa-40f72885ee3a"
)
// theAudioDBProvider implements Provider, AlbumCoverProvider,
// ArtistArtProvider, and TestableProvider against the TheAudioDB
// REST API. Safe for concurrent use; the internal mutex serialises
// ALL HTTP calls (JSON metadata fetches and image GETs) so the
// 2 req/sec rate limit applies uniformly.
type theAudioDBProvider struct {
enabled atomic.Bool
apiKey atomic.Pointer[string] // nil until first Configure; falls back to test key when empty
mu sync.Mutex // serialises ALL HTTP calls for rate-limit compliance
lastCall time.Time
client *http.Client
}
func init() {
Register(&theAudioDBProvider{
client: &http.Client{Timeout: 30 * time.Second},
})
}
func (p *theAudioDBProvider) ID() string { return "theaudiodb" }
func (p *theAudioDBProvider) DisplayName() string { return "TheAudioDB" }
func (p *theAudioDBProvider) RequiresAPIKey() bool { return true }
func (p *theAudioDBProvider) DefaultEnabled() bool { return true }
func (p *theAudioDBProvider) Configure(s ProviderSettings) error {
p.enabled.Store(s.Enabled)
key := s.APIKey
if key == "" {
key = theAudioDBTestKey // ships working out of the box
}
p.apiKey.Store(&key)
return nil
}
// currentKey returns the active API key (operator-supplied or the
// public test-key fallback).
func (p *theAudioDBProvider) currentKey() string {
if k := p.apiKey.Load(); k != nil {
return *k
}
return theAudioDBTestKey
}
// FetchAlbumCover hits /album-mb.php?i=<mbid>, parses strAlbumThumb,
// then GETs the image URL. Two HTTP round-trips per call; both
// counted against the rate-limit guard.
func (p *theAudioDBProvider) FetchAlbumCover(ctx context.Context, mbid string) ([]byte, error) {
if !p.enabled.Load() {
return nil, ErrNotFound // defensive; enricher already filters on enabled
}
if mbid == "" {
return nil, fmt.Errorf("coverart: empty mbid")
}
var resp struct {
Album []struct {
StrAlbumThumb *string `json:"strAlbumThumb"`
} `json:"album"`
}
if err := p.getJSON(ctx, "album-mb.php?i="+mbid, &resp); err != nil {
return nil, err
}
if len(resp.Album) == 0 || resp.Album[0].StrAlbumThumb == nil || *resp.Album[0].StrAlbumThumb == "" {
return nil, ErrNotFound
}
return p.getImage(ctx, *resp.Album[0].StrAlbumThumb)
}
// FetchArtistArt hits /artist-mb.php?i=<mbid>, parses strArtistThumb +
// strArtistFanart, GETs both. Up to three HTTP round-trips per call.
//
// Atomic at the JSON step: if the JSON parse yields no images at all
// (artist not found OR both image fields null/empty), returns
// ErrNotFound. If only one URL is present, returns just that image.
// If both URLs are present but one image GET fails transiently,
// returns the successful one with nil for the failed one — the
// enricher persists whichever bytes landed. If BOTH image GETs fail
// transiently, returns ErrTransient so the row stays NULL for retry.
func (p *theAudioDBProvider) FetchArtistArt(ctx context.Context, mbid string) (thumb, fanart []byte, err error) {
if !p.enabled.Load() {
return nil, nil, ErrNotFound
}
if mbid == "" {
return nil, nil, fmt.Errorf("coverart: empty mbid")
}
var resp struct {
Artists []struct {
StrArtistThumb *string `json:"strArtistThumb"`
StrArtistFanart *string `json:"strArtistFanart"`
} `json:"artists"`
}
if err := p.getJSON(ctx, "artist-mb.php?i="+mbid, &resp); err != nil {
return nil, nil, err
}
if len(resp.Artists) == 0 {
return nil, nil, ErrNotFound
}
a := resp.Artists[0]
var thumbURL, fanartURL string
if a.StrArtistThumb != nil {
thumbURL = *a.StrArtistThumb
}
if a.StrArtistFanart != nil {
fanartURL = *a.StrArtistFanart
}
if thumbURL == "" && fanartURL == "" {
return nil, nil, ErrNotFound
}
if thumbURL != "" {
thumb, err = p.getImage(ctx, thumbURL)
if err != nil && !errors.Is(err, ErrTransient) && !errors.Is(err, ErrNotFound) {
return nil, nil, err
}
// On ErrTransient or ErrNotFound, leave thumb=nil; continue.
if err != nil {
thumb = nil
}
}
if fanartURL != "" {
fanart, err = p.getImage(ctx, fanartURL)
if err != nil && !errors.Is(err, ErrTransient) && !errors.Is(err, ErrNotFound) {
return thumb, nil, err
}
if err != nil {
fanart = nil
}
}
// At least one of thumbURL/fanartURL was non-empty by the check
// above. If both image GETs returned nil bytes (transient or
// 404 on the CDN URL), surface ErrTransient so the enricher
// leaves the row NULL for next pass. Otherwise return what we got.
if thumb == nil && fanart == nil {
return nil, nil, ErrTransient
}
return thumb, fanart, nil
}
// TestConnection hits the sample MBID's /album-mb.php endpoint. A
// successful response (200 with parseable JSON, even if the album
// data is empty) indicates the connection works. 401/403/5xx /
// network failures surface as TestConnection failures.
func (p *theAudioDBProvider) TestConnection(ctx context.Context) error {
if !p.enabled.Load() {
return errors.New("provider is disabled")
}
var resp struct {
Album []struct {
IDAlbum *string `json:"idAlbum"`
} `json:"album"`
}
return p.getJSON(ctx, "album-mb.php?i="+theAudioDBTestSampleMBID, &resp)
}
// getJSON performs a rate-limited GET against the JSON API, decodes
// the response into out, retries on 429 / 5xx with exponential
// backoff up to theAudioDBMaxRetries.
func (p *theAudioDBProvider) getJSON(ctx context.Context, path string, out any) error {
url := fmt.Sprintf("%s/%s/%s", theAudioDBBaseURL, p.currentKey(), path)
for attempt := 0; attempt <= theAudioDBMaxRetries; attempt++ {
if err := p.rateLimitWait(ctx); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("coverart: build request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
if attempt < theAudioDBMaxRetries {
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.StatusUnauthorized,
resp.StatusCode == http.StatusForbidden:
_ = resp.Body.Close()
return fmt.Errorf("%w: status %d (auth)", ErrTransient, resp.StatusCode)
case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500:
_ = resp.Body.Close()
if attempt < theAudioDBMaxRetries {
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)) // 1MB cap on JSON
_ = 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 (CDN, key-less),
// returns the bytes. 404 → ErrNotFound (the URL got stale on
// TheAudioDB's CDN); 5xx / network → ErrTransient.
func (p *theAudioDBProvider) getImage(ctx context.Context, url string) ([]byte, error) {
if err := p.rateLimitWait(ctx); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("coverart: 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)) // 5MB cap
if err != nil {
return nil, fmt.Errorf("%w: read body: %v", ErrTransient, err)
}
return body, nil
}
// rateLimitWait blocks until at least theAudioDBMinPeriod has elapsed
// since the last call, then claims the slot. Returns ctx.Err() if
// the context expires while waiting.
func (p *theAudioDBProvider) rateLimitWait(ctx context.Context) error {
p.mu.Lock()
wait := time.Until(p.lastCall.Add(theAudioDBMinPeriod))
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. Returns
// ctx.Err() if the context expires.
func (p *theAudioDBProvider) 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
}
}