feat(server/m7-cover-sources): TheAudioDB provider
New album-cover and artist-art source implementing AlbumCoverProvider, ArtistArtProvider, and TestableProvider. Hits the documented /album-mb.php and /artist-mb.php JSON endpoints by MBID, then GETs the returned image URLs (CDN, key-less). 2 req/sec rate limit per the upstream's documented free/test-key tier; mutex+lastCall serialises ALL HTTP calls regardless of method so JSON metadata fetches and image GETs share the same budget. 429 / 5xx triggers exponential backoff with jitter (250ms × 2^attempt ± 20%) up to 3 retries before surfacing ErrTransient. 401 / 403 surface as ErrTransient (auth issues are config errors, not "this album has no art" — the row stays NULL across passes so the operator sees pending count not decreasing as the diagnostic signal). Artist art is atomic at the JSON metadata step (one round-trip yields both URLs); the two image GETs are independent — partial success returns whatever bytes landed and ErrNotFound only if neither URL is present in the response. Configure() falls back to the documented public test key (2) when the operator's APIKey is empty, per the no-coercive-settings principle. Test connection hits a stable popular release MBID (Pink Floyd, "The Dark Side of the Moon"). theAudioDBBaseURL is a var (not const) so tests can override it.
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
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.
|
||||
func (p *theAudioDBProvider) FetchAlbumCover(ctx context.Context, mbid string) ([]byte, error) {
|
||||
if !p.enabled.Load() {
|
||||
return nil, ErrNotFound // defensive; enricher already filters on enabled
|
||||
}
|
||||
if mbid == "" {
|
||||
return nil, fmt.Errorf("coverart: empty mbid")
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Album []struct {
|
||||
StrAlbumThumb *string `json:"strAlbumThumb"`
|
||||
} `json:"album"`
|
||||
}
|
||||
if err := p.getJSON(ctx, "album-mb.php?i="+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.
|
||||
//
|
||||
// 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, mbid string) (thumb, fanart []byte, err error) {
|
||||
if !p.enabled.Load() {
|
||||
return nil, nil, ErrNotFound
|
||||
}
|
||||
if mbid == "" {
|
||||
return nil, nil, fmt.Errorf("coverart: empty mbid")
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Artists []struct {
|
||||
StrArtistThumb *string `json:"strArtistThumb"`
|
||||
StrArtistFanart *string `json:"strArtistFanart"`
|
||||
} `json:"artists"`
|
||||
}
|
||||
if err := p.getJSON(ctx, "artist-mb.php?i="+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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package coverart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// withTheAudioDBBaseURL temporarily redirects theAudioDBBaseURL to a
|
||||
// test server, returning a function the caller defers to restore.
|
||||
func withTheAudioDBBaseURL(t *testing.T, mockURL string) {
|
||||
t.Helper()
|
||||
old := theAudioDBBaseURL
|
||||
theAudioDBBaseURL = mockURL
|
||||
t.Cleanup(func() { theAudioDBBaseURL = old })
|
||||
}
|
||||
|
||||
func newTheAudioDBProviderForTest(t *testing.T, mockURL string) *theAudioDBProvider {
|
||||
t.Helper()
|
||||
resetRegistryForTests()
|
||||
t.Cleanup(resetRegistryForTests)
|
||||
withTheAudioDBBaseURL(t, mockURL)
|
||||
|
||||
p := &theAudioDBProvider{
|
||||
client: &http.Client{Timeout: 5 * time.Second},
|
||||
}
|
||||
Register(p)
|
||||
p.enabled.Store(true)
|
||||
key := theAudioDBTestKey
|
||||
p.apiKey.Store(&key)
|
||||
return p
|
||||
}
|
||||
|
||||
func TestTheAudioDB_ID(t *testing.T) {
|
||||
resetRegistryForTests()
|
||||
defer resetRegistryForTests()
|
||||
p := &theAudioDBProvider{}
|
||||
if p.ID() != "theaudiodb" {
|
||||
t.Errorf("ID = %q, want theaudiodb", p.ID())
|
||||
}
|
||||
if !p.RequiresAPIKey() {
|
||||
t.Error("RequiresAPIKey = false, want true")
|
||||
}
|
||||
if !p.DefaultEnabled() {
|
||||
t.Error("DefaultEnabled = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAudioDB_FetchAlbumCover_Success(t *testing.T) {
|
||||
imageBody := []byte("FAKE_JPEG")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/album-mb.php"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Use the test server's own URL for the image, so getImage
|
||||
// hits the same server.
|
||||
imageURL := "http://" + r.Host + "/img/album.jpg"
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"album": []map[string]any{
|
||||
{"strAlbumThumb": imageURL},
|
||||
},
|
||||
})
|
||||
case r.URL.Path == "/img/album.jpg":
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(imageBody)
|
||||
default:
|
||||
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newTheAudioDBProviderForTest(t, srv.URL)
|
||||
body, err := p.FetchAlbumCover(context.Background(), "test-mbid")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchAlbumCover err = %v, want nil", err)
|
||||
}
|
||||
if string(body) != string(imageBody) {
|
||||
t.Errorf("body = %q, want %q", body, imageBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAudioDB_FetchAlbumCover_NotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"album":null}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newTheAudioDBProviderForTest(t, srv.URL)
|
||||
_, err := p.FetchAlbumCover(context.Background(), "missing-mbid")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAudioDB_FetchAlbumCover_NullThumbURL(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"album":[{"strAlbumThumb":null}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newTheAudioDBProviderForTest(t, srv.URL)
|
||||
_, err := p.FetchAlbumCover(context.Background(), "mbid")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("err = %v, want ErrNotFound when thumb URL is null", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAudioDB_FetchAlbumCover_429TriggersRetry(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := attempts.Add(1)
|
||||
if n == 1 {
|
||||
http.Error(w, "rate limit", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"album":null}`)) // second attempt: empty (still ErrNotFound, but past the retry)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newTheAudioDBProviderForTest(t, srv.URL)
|
||||
_, err := p.FetchAlbumCover(context.Background(), "mbid")
|
||||
if attempts.Load() < 2 {
|
||||
t.Errorf("attempts = %d, want >= 2 (retry should have run)", attempts.Load())
|
||||
}
|
||||
// Final result is ErrNotFound (empty album response on retry).
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("final err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAudioDB_FetchArtistArt_BothPresent(t *testing.T) {
|
||||
thumbBody := []byte("THUMB")
|
||||
fanartBody := []byte("FANART")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/artist-mb.php"):
|
||||
thumbURL := "http://" + r.Host + "/img/thumb.jpg"
|
||||
fanartURL := "http://" + r.Host + "/img/fanart.jpg"
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"artists": []map[string]any{
|
||||
{"strArtistThumb": thumbURL, "strArtistFanart": fanartURL},
|
||||
},
|
||||
})
|
||||
case r.URL.Path == "/img/thumb.jpg":
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(thumbBody)
|
||||
case r.URL.Path == "/img/fanart.jpg":
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(fanartBody)
|
||||
default:
|
||||
http.Error(w, "unexpected: "+r.URL.Path, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newTheAudioDBProviderForTest(t, srv.URL)
|
||||
thumb, fanart, err := p.FetchArtistArt(context.Background(), "mbid")
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if string(thumb) != "THUMB" {
|
||||
t.Errorf("thumb = %q, want THUMB", thumb)
|
||||
}
|
||||
if string(fanart) != "FANART" {
|
||||
t.Errorf("fanart = %q, want FANART", fanart)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAudioDB_FetchArtistArt_ThumbOnly(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/artist-mb.php"):
|
||||
thumbURL := "http://" + r.Host + "/img/thumb.jpg"
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"artists":[{"strArtistThumb":"` + thumbURL + `","strArtistFanart":null}]}`))
|
||||
case r.URL.Path == "/img/thumb.jpg":
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("T"))
|
||||
default:
|
||||
http.Error(w, "unexpected: "+r.URL.Path, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newTheAudioDBProviderForTest(t, srv.URL)
|
||||
thumb, fanart, err := p.FetchArtistArt(context.Background(), "mbid")
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if string(thumb) != "T" {
|
||||
t.Errorf("thumb = %q, want T", thumb)
|
||||
}
|
||||
if fanart != nil {
|
||||
t.Errorf("fanart = %v, want nil", fanart)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAudioDB_FetchArtistArt_BothEmpty(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"artists":[{"strArtistThumb":null,"strArtistFanart":null}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newTheAudioDBProviderForTest(t, srv.URL)
|
||||
_, _, err := p.FetchArtistArt(context.Background(), "mbid")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAudioDB_DefaultsToTestKeyOnEmpty(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Inspect the URL — should contain "/2/" (the test key).
|
||||
if !strings.Contains(r.URL.Path, "/"+theAudioDBTestKey+"/") {
|
||||
t.Errorf("URL path %q missing test key", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"album":null}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
resetRegistryForTests()
|
||||
defer resetRegistryForTests()
|
||||
withTheAudioDBBaseURL(t, srv.URL)
|
||||
|
||||
p := &theAudioDBProvider{client: &http.Client{Timeout: 5 * time.Second}}
|
||||
Register(p)
|
||||
// Configure with empty key — should fall back to test key.
|
||||
if err := p.Configure(ProviderSettings{Enabled: true, APIKey: ""}); err != nil {
|
||||
t.Fatalf("Configure: %v", err)
|
||||
}
|
||||
|
||||
_, _ = p.FetchAlbumCover(context.Background(), "mbid")
|
||||
}
|
||||
|
||||
func TestTheAudioDB_DisabledReturnsNotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("server should not be hit when provider is disabled")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newTheAudioDBProviderForTest(t, srv.URL)
|
||||
p.enabled.Store(false)
|
||||
|
||||
_, err := p.FetchAlbumCover(context.Background(), "any")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("err = %v, want ErrNotFound (disabled)", err)
|
||||
}
|
||||
|
||||
_, _, err = p.FetchArtistArt(context.Background(), "any")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("artist err = %v, want ErrNotFound (disabled)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAudioDB_TestConnection_SuccessOnEmptyAlbum(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"album":null}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newTheAudioDBProviderForTest(t, srv.URL)
|
||||
if err := p.TestConnection(context.Background()); err != nil {
|
||||
t.Errorf("TestConnection err = %v, want nil (empty album response is still 200)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAudioDB_TestConnection_FailureOn401(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newTheAudioDBProviderForTest(t, srv.URL)
|
||||
if err := p.TestConnection(context.Background()); err == nil {
|
||||
t.Error("TestConnection err = nil, want non-nil for 401")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user