Files
vibesonic/internal/artistphoto/client.go
T
2026-08-23 14:13:18 -05:00

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
}