diff --git a/internal/coverart/provider_theaudiodb.go b/internal/coverart/provider_theaudiodb.go new file mode 100644 index 00000000..98bf7599 --- /dev/null +++ b/internal/coverart/provider_theaudiodb.go @@ -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=, 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=, 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<= 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") + } +}