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<