package coverart import ( "context" "net/http" "net/url" "path" "strings" "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] client *httpClient } func init() { Register(&lastfmProvider{ client: newHTTPClient(httpClientOptions{ Name: "lastfm", HTTPClient: &http.Client{Timeout: 30 * time.Second}, MinInterval: lastfmMinPeriod, MaxRetries: lastfmMaxRetries, TreatAuthAsTransient: true, }), }) } 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.client.getJSON(ctx, lastfmBaseURL+"?"+q.Encode(), &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.client.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.client.getJSON(ctx, lastfmBaseURL+"?"+q.Encode(), &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.client.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)] } // Compile-time checks: lastfmProvider satisfies both capability interfaces. var ( _ AlbumCoverProvider = (*lastfmProvider)(nil) _ ArtistArtProvider = (*lastfmProvider)(nil) )