286 lines
9.5 KiB
Go
286 lines
9.5 KiB
Go
// 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
|
|
}
|