weee
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user