425f12db38
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>
344 lines
11 KiB
Go
344 lines
11 KiB
Go
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
|
||
}
|
||
}
|