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. 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=, 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<