Files

207 lines
6.9 KiB
Go

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=<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.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=<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.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)
}