Files
minstrel/internal/coverart/provider_lastfm.go
T
bvandeusen 238920540f feat(server/coverart): Last.fm artist + album provider
Adds Last.fm as a hybrid MBID-or-name provider in the artist + album
chains. Default-disabled; activates when the operator pastes a free
API key into the admin Cover Art panel. Rate-limited to 4 req/s under
their 5/s per-key ceiling.

Prefers MBID lookup when present (artist.getInfo accepts &mbid=),
falls back to artist-name search. For albums, requires both
ArtistName and AlbumTitle; MBID is sent as an additional hint.

Includes placeholder-image detection: Last.fm returns a generic
"default star" image hash for many artists rather than 404'ing.
Provider parses the response URL's basename hash and treats known
placeholders as ErrNotFound so we never persist a placeholder thumb
as real art. Fail-open: when in doubt, return ErrNotFound rather than
risk persisting wrong art.

When API key is empty, every call returns ErrNotFound regardless of
the enabled flag — defensive so an operator who toggles enabled
without setting a key doesn't see confusing transient errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 07:51:56 -04:00

350 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package coverart
import (
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"path"
"strings"
"sync"
"sync/atomic"
"time"
)
// lastfmBaseURL is a var (not const) so tests can override it.
var lastfmBaseURL = "https://ws.audioscrobbler.com/2.0/"
const (
// lastfmMinPeriod throttles to 4 req/s under their 5/s per-key ceiling.
lastfmMinPeriod = 250 * time.Millisecond
lastfmMaxRetries = 3
)
// lastfmPlaceholderHashes is the set of known "default" image basename
// hashes Last.fm returns instead of 404'ing for artists/albums that
// don't have art. Provider detects these in the response URL and
// treats them as ErrNotFound to avoid persisting placeholders as art.
//
// The well-known generic-artist hash has been stable for years; if
// Last.fm ships new placeholder hashes, we'll start persisting them
// and need to add them here. Fail-open: when in doubt, return
// ErrNotFound rather than risk persisting a placeholder.
var lastfmPlaceholderHashes = map[string]bool{
"2a96cbd8b46e442fc41c2b86b821562f": true, // generic artist star
"c6f59c1e5e7240a4c0d427abd71f3dbb": true, // generic album cover
}
// lastfmProvider implements Provider, AlbumCoverProvider, and
// ArtistArtProvider against Last.fm's audioscrobbler API. Requires an
// operator-supplied API key — provider stays effectively disabled
// (returns ErrNotFound on every call) when key is empty.
type lastfmProvider struct {
enabled atomic.Bool
apiKey atomic.Pointer[string]
mu sync.Mutex
lastCall time.Time
client *http.Client
}
func init() {
Register(&lastfmProvider{
client: &http.Client{Timeout: 30 * time.Second},
})
}
func (p *lastfmProvider) ID() string { return "lastfm" }
func (p *lastfmProvider) DisplayName() string { return "Last.fm" }
func (p *lastfmProvider) RequiresAPIKey() bool { return true }
func (p *lastfmProvider) DefaultEnabled() bool { return false }
func (p *lastfmProvider) Configure(s ProviderSettings) error {
p.enabled.Store(s.Enabled)
key := s.APIKey
p.apiKey.Store(&key)
return nil
}
// currentKey returns the configured API key (empty string when unset).
func (p *lastfmProvider) currentKey() string {
if k := p.apiKey.Load(); k != nil {
return *k
}
return ""
}
// FetchArtistArt prefers MBID-based lookup (artist.getInfo accepts
// &mbid=) and falls back to artist-name search. Returns ErrNotFound
// on:
// - provider disabled OR API key empty
// - both MBID and Name empty
// - Last.fm returns no image OR returns a known placeholder URL
//
// Last.fm has a single artist image (no separate fanart) — return
// it as thumb, leave fanart nil.
func (p *lastfmProvider) FetchArtistArt(ctx context.Context, ref ArtistRef) (thumb, fanart []byte, err error) {
if !p.enabled.Load() || p.currentKey() == "" {
return nil, nil, ErrNotFound
}
if ref.MBID == "" && ref.Name == "" {
return nil, nil, ErrNotFound
}
q := url.Values{
"method": {"artist.getInfo"},
"api_key": {p.currentKey()},
"format": {"json"},
}
if ref.MBID != "" {
q.Set("mbid", ref.MBID)
} else {
q.Set("artist", ref.Name)
}
var resp struct {
Artist struct {
Name string `json:"name"`
Image []lastfmImage `json:"image"`
} `json:"artist"`
Error int `json:"error"` // present on error responses
}
if err := p.getJSON(ctx, q, &resp); err != nil {
return nil, nil, err
}
if resp.Error != 0 || resp.Artist.Name == "" {
return nil, nil, ErrNotFound
}
imgURL := pickLargestImage(resp.Artist.Image)
if imgURL == "" || isLastfmPlaceholder(imgURL) {
return nil, nil, ErrNotFound
}
img, ferr := p.getImage(ctx, imgURL)
if ferr != nil {
return nil, nil, ferr
}
return img, nil, nil
}
// FetchAlbumCover requires both ArtistName and AlbumTitle (album.getInfo
// has no MBID-only mode that's reliable). MBID is sent as an additional
// hint when present.
func (p *lastfmProvider) FetchAlbumCover(ctx context.Context, ref AlbumRef) ([]byte, error) {
if !p.enabled.Load() || p.currentKey() == "" {
return nil, ErrNotFound
}
if ref.ArtistName == "" || ref.AlbumTitle == "" {
return nil, ErrNotFound
}
q := url.Values{
"method": {"album.getInfo"},
"api_key": {p.currentKey()},
"format": {"json"},
"artist": {ref.ArtistName},
"album": {ref.AlbumTitle},
}
if ref.MBID != "" {
q.Set("mbid", ref.MBID)
}
var resp struct {
Album struct {
Name string `json:"name"`
Image []lastfmImage `json:"image"`
} `json:"album"`
Error int `json:"error"`
}
if err := p.getJSON(ctx, q, &resp); err != nil {
return nil, err
}
if resp.Error != 0 || resp.Album.Name == "" {
return nil, ErrNotFound
}
imgURL := pickLargestImage(resp.Album.Image)
if imgURL == "" || isLastfmPlaceholder(imgURL) {
return nil, ErrNotFound
}
return p.getImage(ctx, imgURL)
}
// lastfmImage models one entry of Last.fm's image[] array.
type lastfmImage struct {
Text string `json:"#text"`
Size string `json:"size"`
}
// pickLargestImage returns the URL of the largest available image.
// Preference order: mega → extralarge → large → medium → small.
// Returns empty when no entry has a non-empty URL.
func pickLargestImage(imgs []lastfmImage) string {
pref := []string{"mega", "extralarge", "large", "medium", "small"}
bySize := make(map[string]string, len(imgs))
for _, im := range imgs {
if im.Text != "" {
bySize[im.Size] = im.Text
}
}
for _, sz := range pref {
if u, ok := bySize[sz]; ok {
return u
}
}
return ""
}
// isLastfmPlaceholder returns true if the URL points at one of the
// known generic placeholder image hashes.
func isLastfmPlaceholder(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
}
base := path.Base(u.Path)
if i := strings.LastIndex(base, "."); i > 0 {
base = base[:i]
}
return lastfmPlaceholderHashes[strings.ToLower(base)]
}
// getJSON performs a rate-limited GET against Last.fm's JSON API.
// Retries on 429 / 5xx with exponential backoff up to lastfmMaxRetries.
func (p *lastfmProvider) getJSON(ctx context.Context, q url.Values, out any) error {
urlStr := lastfmBaseURL + "?" + q.Encode()
for attempt := 0; attempt <= lastfmMaxRetries; attempt++ {
if err := p.rateLimitWait(ctx); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
if err != nil {
return fmt.Errorf("coverart lastfm: build request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
if attempt < lastfmMaxRetries {
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 < lastfmMaxRetries {
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))
_ = 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. 404 → ErrNotFound;
// 5xx / network → ErrTransient.
func (p *lastfmProvider) getImage(ctx context.Context, urlStr string) ([]byte, error) {
if err := p.rateLimitWait(ctx); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
if err != nil {
return nil, fmt.Errorf("coverart lastfm: 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))
if err != nil {
return nil, fmt.Errorf("%w: read body: %v", ErrTransient, err)
}
return body, nil
}
// rateLimitWait blocks until at least lastfmMinPeriod has elapsed since
// the last call, then claims the slot.
func (p *lastfmProvider) rateLimitWait(ctx context.Context) error {
p.mu.Lock()
wait := time.Until(p.lastCall.Add(lastfmMinPeriod))
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.
func (p *lastfmProvider) 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
}
}
// Compile-time checks: lastfmProvider satisfies both capability interfaces.
var (
_ AlbumCoverProvider = (*lastfmProvider)(nil)
_ ArtistArtProvider = (*lastfmProvider)(nil)
)