Files
minstrel/internal/coverart/provider_theaudiodb.go
T
bvandeusen 425f12db38 refactor(server/coverart): provider interface takes ArtistRef/AlbumRef
Changes the AlbumCoverProvider and ArtistArtProvider interfaces from
MBID-only string parameters to ref-struct signatures so name-based
providers (Deezer, Last.fm in upcoming tasks) can act on rows
without MBIDs.

Today, 53 of 213 artists in the dev DB have NULL MBIDs — featured
artists, remixers, and compilation contributors created from
track-tag splits where the primary-artist MBID is in the file but
collaborator IDs aren't. These rows are unreachable by the current
MBID-only chain. Refactoring the interface unblocks future
name-based providers from acting on them.

TheAudioDB and MBCAA keep MBID-only behavior internally: they return
ErrNotFound when ref.MBID is empty rather than attempting a
name-based fallback. The MBID guard inside EnrichAlbum/EnrichArtist
is removed; providers receive the ref unconditionally and decide
whether they can act on it.

GetAlbumWithFirstTrackPath now JOINs artists to return artist_name
alongside the existing fields, supporting AlbumRef construction.

No behavioral change today (only TheAudioDB + MBCAA registered, both
keep MBID-only behavior). The change unblocks T3 (Deezer) and T4
(Last.fm) which can now register and act on every artist/album
regardless of MBID presence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 07:44:34 -04:00

344 lines
11 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. TheAudioDB is MBID-only;
// returns ErrNotFound when ref.MBID is empty.
func (p *theAudioDBProvider) FetchAlbumCover(ctx context.Context, ref AlbumRef) ([]byte, error) {
if !p.enabled.Load() {
return nil, ErrNotFound // defensive; enricher already filters on enabled
}
if ref.MBID == "" {
return nil, ErrNotFound // TheAudioDB is MBID-only
}
var resp struct {
Album []struct {
StrAlbumThumb *string `json:"strAlbumThumb"`
} `json:"album"`
}
if err := p.getJSON(ctx, "album-mb.php?i="+ref.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.
// TheAudioDB is MBID-only; returns ErrNotFound when ref.MBID is empty.
//
// 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, ref ArtistRef) (thumb, fanart []byte, err error) {
if !p.enabled.Load() {
return nil, nil, ErrNotFound
}
if ref.MBID == "" {
return nil, nil, ErrNotFound // TheAudioDB is MBID-only
}
var resp struct {
Artists []struct {
StrArtistThumb *string `json:"strArtistThumb"`
StrArtistFanart *string `json:"strArtistFanart"`
} `json:"artists"`
}
if err := p.getJSON(ctx, "artist-mb.php?i="+ref.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
}
}