217 lines
6.1 KiB
Go
217 lines
6.1 KiB
Go
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)
|
|
)
|