207 lines
6.9 KiB
Go
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)
|
|
}
|