This commit is contained in:
2026-08-23 14:13:18 -05:00
parent 789bf91eed
commit 866f243f1b
37 changed files with 1761 additions and 58 deletions
+285
View File
@@ -0,0 +1,285 @@
// 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
}
+213
View File
@@ -0,0 +1,213 @@
package artistphoto
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestFetchPhotoURLUsesFanartTVWhenMBIDAndKeyProvided(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/mbid-1" {
t.Errorf("unexpected path %q", r.URL.Path)
}
if r.URL.Query().Get("api_key") != "secret" {
t.Errorf("api_key = %q", r.URL.Query().Get("api_key"))
}
json.NewEncoder(w).Encode(map[string]any{
"artistthumb": []map[string]string{{"url": "https://fanart.example/photo.jpg"}},
})
}))
defer srv.Close()
c := &Client{FanartTVBaseURL: srv.URL}
u, err := c.FetchPhotoURL(context.Background(), "mbid-1", "Some Artist", "secret")
if err != nil {
t.Fatalf("FetchPhotoURL: %v", err)
}
if u != "https://fanart.example/photo.jpg" {
t.Errorf("url = %q", u)
}
}
func TestFetchPhotoURLSkipsFanartTVWithoutMBID(t *testing.T) {
fanartCalled := false
fanart := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fanartCalled = true
}))
defer fanart.Close()
wds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{"search": []map[string]any{}})
}))
defer wds.Close()
c := &Client{FanartTVBaseURL: fanart.URL, WikidataSearchBaseURL: wds.URL}
if _, err := c.FetchPhotoURL(context.Background(), "", "Some Artist", "secret"); err != nil {
t.Fatalf("FetchPhotoURL: %v", err)
}
if fanartCalled {
t.Error("fanart.tv should never be called without a MusicBrainz ID (it has no name-search endpoint)")
}
}
func TestFetchPhotoURLSearchesWikidataByName(t *testing.T) {
wds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("search"); got != "The Fray" {
t.Errorf("search query = %q", got)
}
json.NewEncoder(w).Encode(map[string]any{
"search": []map[string]any{
{"id": "Q191571", "label": "The Fray", "description": "American rock band"},
},
})
}))
defer wds.Close()
wde := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/Q191571.json" {
t.Errorf("unexpected entity path %q", r.URL.Path)
}
json.NewEncoder(w).Encode(map[string]any{
"entities": map[string]any{
"Q191571": map[string]any{
"claims": map[string]any{
// A real Wikidata entity carries many other
// properties whose datavalue.value is an
// object, not a string (P31 "instance of" is
// an entity reference, for example) — this
// must not prevent P18 from being read.
"P31": []map[string]any{
{"mainsnak": map[string]any{"datavalue": map[string]any{"value": map[string]any{"entity-type": "item", "id": "Q215380"}}}},
},
"P18": []map[string]any{
{"mainsnak": map[string]any{"datavalue": map[string]any{"value": "The Fray.jpg"}}},
},
},
},
},
})
}))
defer wde.Close()
commons := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer commons.Close()
c := &Client{WikidataSearchBaseURL: wds.URL, WikidataEntityBaseURL: wde.URL, CommonsBaseURL: commons.URL}
u, err := c.FetchPhotoURL(context.Background(), "", "The Fray", "")
if err != nil {
t.Fatalf("FetchPhotoURL: %v", err)
}
want := commons.URL + "/The%20Fray.jpg?width=800"
if u != want {
t.Errorf("url = %q, want %q", u, want)
}
}
func TestFetchPhotoURLFallsBackToWikidataWhenFanartHasNoPhoto(t *testing.T) {
fanart := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer fanart.Close()
wds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"search": []map[string]any{
{"id": "Q1", "label": "Some Artist", "description": "American singer"},
},
})
}))
defer wds.Close()
wde := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{"entities": map[string]any{"Q1": map[string]any{"claims": map[string]any{}}}})
}))
defer wde.Close()
c := &Client{FanartTVBaseURL: fanart.URL, WikidataSearchBaseURL: wds.URL, WikidataEntityBaseURL: wde.URL}
u, err := c.FetchPhotoURL(context.Background(), "mbid-2", "Some Artist", "secret")
if err != nil {
t.Fatalf("FetchPhotoURL: %v", err)
}
if u != "" {
t.Errorf("expected empty URL (no P18 claim), got %q", u)
}
}
func TestSearchArtistWikidataIDPrefersMusicDescription(t *testing.T) {
wds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"search": []map[string]any{
{"id": "Q-actor", "label": "Gigi Perez", "description": "Filipino actor"},
{"id": "Q-singer", "label": "Gigi Perez", "description": "American singer-songwriter"},
},
})
}))
defer wds.Close()
c := &Client{WikidataSearchBaseURL: wds.URL}
qid, err := c.SearchArtistWikidataID(context.Background(), "Gigi Perez")
if err != nil {
t.Fatalf("SearchArtistWikidataID: %v", err)
}
if qid != "Q-singer" {
t.Errorf("qid = %q, want the music-flavored match Q-singer", qid)
}
}
func TestSearchArtistWikidataIDRequiresExactLabelMatch(t *testing.T) {
wds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"search": []map[string]any{
{"id": "Q-tribute", "label": "Not Related! with Luke Smith Tribute", "description": "cover band"},
},
})
}))
defer wds.Close()
c := &Client{WikidataSearchBaseURL: wds.URL}
qid, err := c.SearchArtistWikidataID(context.Background(), "Not Related! with Luke Smith")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if qid != "" {
t.Errorf("expected no match for a non-exact label, got %q", qid)
}
}
func TestFetchWikidataPhotoByNameRetriesOnTransientError(t *testing.T) {
var calls int
wds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls == 1 {
// Simulate a transient network hiccup by closing the
// connection without responding at all.
hj, ok := w.(http.Hijacker)
if !ok {
t.Fatal("ResponseWriter does not support hijacking")
}
conn, _, err := hj.Hijack()
if err != nil {
t.Fatal(err)
}
conn.Close()
return
}
json.NewEncoder(w).Encode(map[string]any{"search": []map[string]any{}})
}))
defer wds.Close()
c := &Client{WikidataSearchBaseURL: wds.URL}
u, err := c.FetchPhotoURL(context.Background(), "", "Retry Artist", "")
if err != nil {
t.Fatalf("expected the retry to recover from the first request's transient failure, got %v", err)
}
if u != "" {
t.Errorf("expected empty URL (no search results), got %q", u)
}
if calls != 2 {
t.Errorf("expected exactly one retry (2 total calls), got %d calls", calls)
}
}