package coverart import ( "context" "errors" "fmt" "net/http" "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 underlying httpClient // serialises all calls 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 client *httpClient } func init() { Register(&theAudioDBProvider{ client: newHTTPClient(httpClientOptions{ Name: "theaudiodb", HTTPClient: &http.Client{Timeout: 30 * time.Second}, MinInterval: theAudioDBMinPeriod, MaxRetries: theAudioDBMaxRetries, TreatAuthAsTransient: true, }), }) } // jsonURL builds a request URL with the API key embedded in the path // segment, the auth strategy TheAudioDB uses. func (p *theAudioDBProvider) jsonURL(path string) string { return fmt.Sprintf("%s/%s/%s", theAudioDBBaseURL, p.currentKey(), path) } 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.client.getJSON(ctx, p.jsonURL("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.client.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.client.getJSON(ctx, p.jsonURL("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.client.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.client.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.client.getJSON(ctx, p.jsonURL("album-mb.php?i="+theAudioDBTestSampleMBID), &resp) }