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.
This commit is contained in:
2026-05-06 12:39:50 -04:00
parent 1e3a76eba8
commit 8f64bcdb5f
2 changed files with 631 additions and 0 deletions
+341
View File
@@ -0,0 +1,341 @@
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
}
}
@@ -0,0 +1,290 @@
package coverart
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// withTheAudioDBBaseURL temporarily redirects theAudioDBBaseURL to a
// test server, returning a function the caller defers to restore.
func withTheAudioDBBaseURL(t *testing.T, mockURL string) {
t.Helper()
old := theAudioDBBaseURL
theAudioDBBaseURL = mockURL
t.Cleanup(func() { theAudioDBBaseURL = old })
}
func newTheAudioDBProviderForTest(t *testing.T, mockURL string) *theAudioDBProvider {
t.Helper()
resetRegistryForTests()
t.Cleanup(resetRegistryForTests)
withTheAudioDBBaseURL(t, mockURL)
p := &theAudioDBProvider{
client: &http.Client{Timeout: 5 * time.Second},
}
Register(p)
p.enabled.Store(true)
key := theAudioDBTestKey
p.apiKey.Store(&key)
return p
}
func TestTheAudioDB_ID(t *testing.T) {
resetRegistryForTests()
defer resetRegistryForTests()
p := &theAudioDBProvider{}
if p.ID() != "theaudiodb" {
t.Errorf("ID = %q, want theaudiodb", p.ID())
}
if !p.RequiresAPIKey() {
t.Error("RequiresAPIKey = false, want true")
}
if !p.DefaultEnabled() {
t.Error("DefaultEnabled = false, want true")
}
}
func TestTheAudioDB_FetchAlbumCover_Success(t *testing.T) {
imageBody := []byte("FAKE_JPEG")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/album-mb.php"):
w.Header().Set("Content-Type", "application/json")
// Use the test server's own URL for the image, so getImage
// hits the same server.
imageURL := "http://" + r.Host + "/img/album.jpg"
_ = json.NewEncoder(w).Encode(map[string]any{
"album": []map[string]any{
{"strAlbumThumb": imageURL},
},
})
case r.URL.Path == "/img/album.jpg":
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(imageBody)
default:
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusBadRequest)
}
}))
defer srv.Close()
p := newTheAudioDBProviderForTest(t, srv.URL)
body, err := p.FetchAlbumCover(context.Background(), "test-mbid")
if err != nil {
t.Fatalf("FetchAlbumCover err = %v, want nil", err)
}
if string(body) != string(imageBody) {
t.Errorf("body = %q, want %q", body, imageBody)
}
}
func TestTheAudioDB_FetchAlbumCover_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"album":null}`))
}))
defer srv.Close()
p := newTheAudioDBProviderForTest(t, srv.URL)
_, err := p.FetchAlbumCover(context.Background(), "missing-mbid")
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestTheAudioDB_FetchAlbumCover_NullThumbURL(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"album":[{"strAlbumThumb":null}]}`))
}))
defer srv.Close()
p := newTheAudioDBProviderForTest(t, srv.URL)
_, err := p.FetchAlbumCover(context.Background(), "mbid")
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound when thumb URL is null", err)
}
}
func TestTheAudioDB_FetchAlbumCover_429TriggersRetry(t *testing.T) {
var attempts atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *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(`{"album":null}`)) // second attempt: empty (still ErrNotFound, but past the retry)
}))
defer srv.Close()
p := newTheAudioDBProviderForTest(t, srv.URL)
_, err := p.FetchAlbumCover(context.Background(), "mbid")
if attempts.Load() < 2 {
t.Errorf("attempts = %d, want >= 2 (retry should have run)", attempts.Load())
}
// Final result is ErrNotFound (empty album response on retry).
if !errors.Is(err, ErrNotFound) {
t.Errorf("final err = %v, want ErrNotFound", err)
}
}
func TestTheAudioDB_FetchArtistArt_BothPresent(t *testing.T) {
thumbBody := []byte("THUMB")
fanartBody := []byte("FANART")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/artist-mb.php"):
thumbURL := "http://" + r.Host + "/img/thumb.jpg"
fanartURL := "http://" + r.Host + "/img/fanart.jpg"
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"artists": []map[string]any{
{"strArtistThumb": thumbURL, "strArtistFanart": fanartURL},
},
})
case r.URL.Path == "/img/thumb.jpg":
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(thumbBody)
case r.URL.Path == "/img/fanart.jpg":
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(fanartBody)
default:
http.Error(w, "unexpected: "+r.URL.Path, http.StatusBadRequest)
}
}))
defer srv.Close()
p := newTheAudioDBProviderForTest(t, srv.URL)
thumb, fanart, err := p.FetchArtistArt(context.Background(), "mbid")
if err != nil {
t.Fatalf("err = %v", err)
}
if string(thumb) != "THUMB" {
t.Errorf("thumb = %q, want THUMB", thumb)
}
if string(fanart) != "FANART" {
t.Errorf("fanart = %q, want FANART", fanart)
}
}
func TestTheAudioDB_FetchArtistArt_ThumbOnly(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/artist-mb.php"):
thumbURL := "http://" + r.Host + "/img/thumb.jpg"
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"artists":[{"strArtistThumb":"` + thumbURL + `","strArtistFanart":null}]}`))
case r.URL.Path == "/img/thumb.jpg":
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte("T"))
default:
http.Error(w, "unexpected: "+r.URL.Path, http.StatusBadRequest)
}
}))
defer srv.Close()
p := newTheAudioDBProviderForTest(t, srv.URL)
thumb, fanart, err := p.FetchArtistArt(context.Background(), "mbid")
if err != nil {
t.Fatalf("err = %v", err)
}
if string(thumb) != "T" {
t.Errorf("thumb = %q, want T", thumb)
}
if fanart != nil {
t.Errorf("fanart = %v, want nil", fanart)
}
}
func TestTheAudioDB_FetchArtistArt_BothEmpty(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"artists":[{"strArtistThumb":null,"strArtistFanart":null}]}`))
}))
defer srv.Close()
p := newTheAudioDBProviderForTest(t, srv.URL)
_, _, err := p.FetchArtistArt(context.Background(), "mbid")
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestTheAudioDB_DefaultsToTestKeyOnEmpty(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Inspect the URL — should contain "/2/" (the test key).
if !strings.Contains(r.URL.Path, "/"+theAudioDBTestKey+"/") {
t.Errorf("URL path %q missing test key", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"album":null}`))
}))
defer srv.Close()
resetRegistryForTests()
defer resetRegistryForTests()
withTheAudioDBBaseURL(t, srv.URL)
p := &theAudioDBProvider{client: &http.Client{Timeout: 5 * time.Second}}
Register(p)
// Configure with empty key — should fall back to test key.
if err := p.Configure(ProviderSettings{Enabled: true, APIKey: ""}); err != nil {
t.Fatalf("Configure: %v", err)
}
_, _ = p.FetchAlbumCover(context.Background(), "mbid")
}
func TestTheAudioDB_DisabledReturnsNotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("server should not be hit when provider is disabled")
}))
defer srv.Close()
p := newTheAudioDBProviderForTest(t, srv.URL)
p.enabled.Store(false)
_, err := p.FetchAlbumCover(context.Background(), "any")
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound (disabled)", err)
}
_, _, err = p.FetchArtistArt(context.Background(), "any")
if !errors.Is(err, ErrNotFound) {
t.Errorf("artist err = %v, want ErrNotFound (disabled)", err)
}
}
func TestTheAudioDB_TestConnection_SuccessOnEmptyAlbum(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"album":null}`))
}))
defer srv.Close()
p := newTheAudioDBProviderForTest(t, srv.URL)
if err := p.TestConnection(context.Background()); err != nil {
t.Errorf("TestConnection err = %v, want nil (empty album response is still 200)", err)
}
}
func TestTheAudioDB_TestConnection_FailureOn401(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
}))
defer srv.Close()
p := newTheAudioDBProviderForTest(t, srv.URL)
if err := p.TestConnection(context.Background()); err == nil {
t.Error("TestConnection err = nil, want non-nil for 401")
}
}