// Package artistphoto looks up an artist portrait URL by name (and, if // available, MusicBrainz ID) from fanart.tv and Wikidata/Wikimedia // Commons. It never talks to anything on a hot path and, like // internal/metadataenrich, always degrades to "no photo found" rather // than an error when a source has nothing to offer — only real // transport failures are returned as errors. package artistphoto import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strings" "time" ) const userAgent = "VibeSonic/1.0 (+https://git.noroutes.net/NoRoutes/vibetunes)" // commonsThumbnailWidth bounds the resolution requested from Wikimedia // Commons for a Wikidata-sourced photo — plenty for an artist portrait, // and small enough to comfortably clear internal/web's download size cap // (a full-resolution original can be many times this). const commonsThumbnailWidth = 800 const ( defaultFanartTVBaseURL = "https://webservice.fanart.tv/v3/music" defaultWikidataSearchBaseURL = "https://www.wikidata.org/w/api.php" defaultWikidataEntityBaseURL = "https://www.wikidata.org/wiki/Special:EntityData" defaultCommonsBaseURL = "https://commons.wikimedia.org/wiki/Special:FilePath" ) // musicDescriptionKeywords is a lightweight heuristic for preferring a // Wikidata search hit that's actually a musician/band: a plain name // search can just as easily match an actor, a film, or anyone else who // happens to share the name. It's deliberately permissive — VibeSonic // would rather occasionally attach a plausible-but-wrong photo (the // caller asked for a best-effort name lookup, not a guarantee) than // return nothing for an artist a stricter check would have found. var musicDescriptionKeywords = []string{ "band", "singer", "musician", "rapper", "songwriter", "duo", "group", "orchestra", "rock", "pop", "hip hop", "hip-hop", "dj", "composer", "vocalist", } // Client resolves an artist photo URL from fanart.tv (when both an API // key and a locally-tagged MusicBrainz ID are available) or, that // failing, by searching Wikidata directly by artist name. It // deliberately never calls the MusicBrainz API: MusicBrainz's public // instance is shared, heavily used, and prone to returning HTTP 503 // under its own load regardless of how considerately a single client // paces its requests, which made it an unreliable dependency for a // feature that's supposed to degrade gracefully. The zero value talks // to the real public services; the BaseURL fields exist so tests can // point it at httptest servers instead. type Client struct { HTTPClient *http.Client FanartTVBaseURL string WikidataSearchBaseURL string WikidataEntityBaseURL string CommonsBaseURL string } func (c *Client) httpClient() *http.Client { if c.HTTPClient != nil { return c.HTTPClient } return &http.Client{Timeout: 15 * time.Second} } func (c *Client) fanartTVBaseURL() string { if c.FanartTVBaseURL != "" { return c.FanartTVBaseURL } return defaultFanartTVBaseURL } func (c *Client) wikidataSearchBaseURL() string { if c.WikidataSearchBaseURL != "" { return c.WikidataSearchBaseURL } return defaultWikidataSearchBaseURL } func (c *Client) wikidataEntityBaseURL() string { if c.WikidataEntityBaseURL != "" { return c.WikidataEntityBaseURL } return defaultWikidataEntityBaseURL } func (c *Client) commonsBaseURL() string { if c.CommonsBaseURL != "" { return c.CommonsBaseURL } return defaultCommonsBaseURL } // getWithRetry performs a GET and decodes a JSON response, retrying // once on a transient transport error (a dropped connection or a slow // response past the client timeout) rather than giving up on the first // hiccup — free public services occasionally have one. func (c *Client) getWithRetry(ctx context.Context, rawURL string, out any) (found bool, err error) { for attempt := 0; ; attempt++ { found, err = c.get(ctx, rawURL, out) if err == nil || attempt >= 1 || ctx.Err() != nil { return found, err } } } func (c *Client) get(ctx context.Context, rawURL string, out any) (found bool, err error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return false, err } req.Header.Set("User-Agent", userAgent) req.Header.Set("Accept", "application/json") resp, err := c.httpClient().Do(req) if err != nil { return false, err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return false, nil } if resp.StatusCode != http.StatusOK { return false, fmt.Errorf("%s: HTTP %d", rawURL, resp.StatusCode) } if err := json.NewDecoder(resp.Body).Decode(out); err != nil { return false, err } return true, nil } // FetchPhotoURL resolves an artist photo URL. When mbid is a // locally-tagged MusicBrainz ID and fanartAPIKey is configured, // fanart.tv (keyed directly by mbid — no MusicBrainz call involved) is // tried first; otherwise, and as a fallback, name is searched directly // against Wikidata. Returns ("", nil) — not an error — when no photo // can be found through either source, since that's a normal, common // outcome. func (c *Client) FetchPhotoURL(ctx context.Context, mbid, name, fanartAPIKey string) (string, error) { if mbid != "" && fanartAPIKey != "" { u, err := c.fetchFanartTVPhoto(ctx, mbid, fanartAPIKey) if err != nil { return "", err } if u != "" { return u, nil } } return c.fetchWikidataPhotoByName(ctx, name) } type fanartResponse struct { ArtistThumb []struct { URL string `json:"url"` } `json:"artistthumb"` } func (c *Client) fetchFanartTVPhoto(ctx context.Context, mbid, apiKey string) (string, error) { u := fmt.Sprintf("%s/%s?api_key=%s", c.fanartTVBaseURL(), url.PathEscape(mbid), url.QueryEscape(apiKey)) var raw fanartResponse found, err := c.getWithRetry(ctx, u, &raw) if err != nil || !found || len(raw.ArtistThumb) == 0 { return "", err } return raw.ArtistThumb[0].URL, nil } type wikidataSearchResponse struct { Search []struct { ID string `json:"id"` Label string `json:"label"` Description string `json:"description"` } `json:"search"` } // SearchArtistWikidataID looks up a Wikidata item ID (a "QID") by // artist name, entirely independent of MusicBrainz. Only an exact // (case-insensitive) label match is considered; among those, one whose // description reads like a musician/band (see musicDescriptionKeywords) // is preferred, falling back to the first exact match otherwise. No // match at all — including a same-named but clearly unrelated entity — // returns ("", nil). func (c *Client) SearchArtistWikidataID(ctx context.Context, name string) (string, error) { name = strings.TrimSpace(name) if name == "" { return "", nil } u := fmt.Sprintf("%s?action=wbsearchentities&search=%s&language=en&format=json&type=item&limit=8", c.wikidataSearchBaseURL(), url.QueryEscape(name)) var raw wikidataSearchResponse found, err := c.getWithRetry(ctx, u, &raw) if err != nil || !found { return "", err } var exact []struct { ID string `json:"id"` Label string `json:"label"` Description string `json:"description"` } for _, r := range raw.Search { if strings.EqualFold(r.Label, name) { exact = append(exact, r) } } if len(exact) == 0 { return "", nil } for _, r := range exact { desc := strings.ToLower(r.Description) for _, kw := range musicDescriptionKeywords { if strings.Contains(desc, kw) { return r.ID, nil } } } return exact[0].ID, nil } // wikidataEntityResponse only decodes each claim's raw JSON, not its // value: a Wikidata entity carries many properties whose datavalue.value // is an object (e.g. P31 "instance of" is an entity reference), not a // string, so unmarshaling the whole claims map into one string-valued // struct fails on the first non-string property it hits — long before // ever reaching P18. Only the property actually needed (P18, the image) // gets decoded into a concrete shape, on demand. type wikidataEntityResponse struct { Entities map[string]struct { Claims map[string]json.RawMessage `json:"claims"` } `json:"entities"` } type wikidataImageClaim struct { MainSnak struct { DataValue struct { Value string `json:"value"` } `json:"datavalue"` } `json:"mainsnak"` } // fetchWikidataPhotoByName resolves a photo by searching Wikidata for // name, then reading the matched entity's P18 (image) claim, then // building Commons' canonical file-path URL for that filename. func (c *Client) fetchWikidataPhotoByName(ctx context.Context, name string) (string, error) { qid, err := c.SearchArtistWikidataID(ctx, name) if err != nil || qid == "" { return "", err } entityURL := fmt.Sprintf("%s/%s.json", c.wikidataEntityBaseURL(), url.PathEscape(qid)) var entity wikidataEntityResponse found, err := c.getWithRetry(ctx, entityURL, &entity) if err != nil || !found { return "", err } claims, ok := entity.Entities[qid] if !ok { return "", nil } p18Raw, ok := claims.Claims["P18"] if !ok { return "", nil } var images []wikidataImageClaim if err := json.Unmarshal(p18Raw, &images); err != nil { return "", err } if len(images) == 0 || images[0].MainSnak.DataValue.Value == "" { return "", nil } filename := images[0].MainSnak.DataValue.Value // Special:FilePath serves the original file by default, which for a // press photo can be a many-megapixel, many-megabyte original — ask // for a reasonably sized rendition instead (Commons redirects this // to the matching pre-generated thumbnail rather than resizing on // the fly). return fmt.Sprintf("%s/%s?width=%d", c.commonsBaseURL(), url.PathEscape(filename), commonsThumbnailWidth), nil }