Add optional MusicBrainz/Cover Art Archive metadata enrichment
getArtistInfo/2 and getAlbumInfo/2 now return a real MusicBrainz disambiguation/cover-art lookup for entities that carry a locally tagged MusicBrainz ID, gated behind the new VIBESONIC_ENABLE_EXTERNAL_METADATA flag (default off). Responses are cached in the existing external_metadata_cache table for 30 days to avoid hammering the free MusicBrainz/CAA services. VibeSonic still works completely offline: with the flag off, or when an entity has no tagged MusicBrainz ID, or on any network/lookup failure, the handlers fall back to local-only fields exactly as before — nothing is ever fabricated, and a failed external lookup never fails the request. internal/metadataenrich is a small, independently-testable HTTP client for the two real APIs (base URLs overridable for tests, exercised against httptest servers rather than the live network). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
// Package metadataenrich looks up optional supplementary metadata for
|
||||
// artists and albums that already carry a MusicBrainz ID from local
|
||||
// tags — from MusicBrainz itself and the Cover Art Archive. It is never
|
||||
// on any hot path and never required: VibeSonic must work completely
|
||||
// offline, so every function here degrades to "no data" rather than an
|
||||
// error when the network is unavailable, and nothing here ever
|
||||
// fabricates a value the API didn't actually return.
|
||||
package metadataenrich
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const userAgent = "VibeSonic/1.0 (+https://git.noroutes.net/NoRoutes/vibetunes)"
|
||||
|
||||
const (
|
||||
defaultMusicBrainzBaseURL = "https://musicbrainz.org/ws/2"
|
||||
defaultCoverArtArchiveBaseURL = "https://coverartarchive.org"
|
||||
)
|
||||
|
||||
// Client talks to the MusicBrainz and Cover Art Archive HTTP APIs. The
|
||||
// zero value is usable and talks to the real public services;
|
||||
// MusicBrainzBaseURL/CoverArtArchiveBaseURL exist so tests can point it
|
||||
// at an httptest server instead.
|
||||
type Client struct {
|
||||
HTTPClient *http.Client
|
||||
MusicBrainzBaseURL string
|
||||
CoverArtArchiveBaseURL string
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
}
|
||||
return &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
|
||||
func (c *Client) musicBrainzBaseURL() string {
|
||||
if c.MusicBrainzBaseURL != "" {
|
||||
return c.MusicBrainzBaseURL
|
||||
}
|
||||
return defaultMusicBrainzBaseURL
|
||||
}
|
||||
|
||||
func (c *Client) coverArtArchiveBaseURL() string {
|
||||
if c.CoverArtArchiveBaseURL != "" {
|
||||
return c.CoverArtArchiveBaseURL
|
||||
}
|
||||
return defaultCoverArtArchiveBaseURL
|
||||
}
|
||||
|
||||
// ArtistData is the (small) subset of a MusicBrainz artist entity
|
||||
// VibeSonic actually surfaces. Fields MusicBrainz didn't return are
|
||||
// left as their zero value.
|
||||
type ArtistData struct {
|
||||
Disambiguation string `json:"disambiguation"`
|
||||
Genres []string `json:"genres"`
|
||||
}
|
||||
|
||||
type mbArtistResponse struct {
|
||||
Disambiguation string `json:"disambiguation"`
|
||||
Tags []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"tags"`
|
||||
}
|
||||
|
||||
// FetchArtist looks up a MusicBrainz artist entity by MBID. A network
|
||||
// or lookup failure returns an error; there is no fallback value.
|
||||
func (c *Client) FetchArtist(ctx context.Context, mbid string) (*ArtistData, error) {
|
||||
url := fmt.Sprintf("%s/artist/%s?fmt=json&inc=tags", c.musicBrainzBaseURL(), mbid)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("musicbrainz: artist %s: HTTP %d", mbid, resp.StatusCode)
|
||||
}
|
||||
|
||||
var raw mbArtistResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := &ArtistData{Disambiguation: raw.Disambiguation}
|
||||
for _, t := range raw.Tags {
|
||||
data.Genres = append(data.Genres, t.Name)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// CoverArt is the image URLs the Cover Art Archive returned for a
|
||||
// release's front cover, at three sizes. A blank field means the
|
||||
// archive simply didn't provide that size, not that VibeSonic
|
||||
// substituted anything.
|
||||
type CoverArt struct {
|
||||
SmallURL string
|
||||
MediumURL string
|
||||
LargeURL string
|
||||
}
|
||||
|
||||
type caaResponse struct {
|
||||
Images []struct {
|
||||
Front bool `json:"front"`
|
||||
Image string `json:"image"`
|
||||
Thumbnails map[string]string `json:"thumbnails"`
|
||||
} `json:"images"`
|
||||
}
|
||||
|
||||
// FetchReleaseCoverArt looks up the Cover Art Archive entry for a
|
||||
// release MBID and returns its front-cover image URLs. Returns
|
||||
// (nil, nil) — not an error — when the release has no cover art
|
||||
// registered, since that's a normal, common outcome, not a failure.
|
||||
func (c *Client) FetchReleaseCoverArt(ctx context.Context, mbid string) (*CoverArt, error) {
|
||||
url := fmt.Sprintf("%s/release/%s", c.coverArtArchiveBaseURL(), mbid)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("coverartarchive: release %s: HTTP %d", mbid, resp.StatusCode)
|
||||
}
|
||||
|
||||
var raw caaResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, img := range raw.Images {
|
||||
if !img.Front {
|
||||
continue
|
||||
}
|
||||
return &CoverArt{
|
||||
SmallURL: img.Thumbnails["small"],
|
||||
MediumURL: img.Thumbnails["large"],
|
||||
LargeURL: img.Image,
|
||||
}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package metadataenrich
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchArtistParsesRealFields(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/artist/abc-123" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("User-Agent") == "" {
|
||||
t.Error("MusicBrainz requires a non-empty User-Agent")
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"disambiguation": "English rock band",
|
||||
"tags": []map[string]string{{"name": "rock"}, {"name": "alternative"}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{MusicBrainzBaseURL: srv.URL}
|
||||
data, err := c.FetchArtist(context.Background(), "abc-123")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchArtist: %v", err)
|
||||
}
|
||||
if data.Disambiguation != "English rock band" {
|
||||
t.Errorf("disambiguation = %q", data.Disambiguation)
|
||||
}
|
||||
if len(data.Genres) != 2 || data.Genres[0] != "rock" {
|
||||
t.Errorf("genres = %v", data.Genres)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchArtistHTTPError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{MusicBrainzBaseURL: srv.URL}
|
||||
if _, err := c.FetchArtist(context.Background(), "does-not-exist"); err == nil {
|
||||
t.Fatal("expected an error for a 404 from MusicBrainz, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReleaseCoverArtParsesFrontImage(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"images": []map[string]any{
|
||||
{"front": false, "image": "http://example.com/back.jpg"},
|
||||
{
|
||||
"front": true,
|
||||
"image": "http://example.com/full.jpg",
|
||||
"thumbnails": map[string]string{
|
||||
"small": "http://example.com/small.jpg",
|
||||
"large": "http://example.com/large.jpg",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{CoverArtArchiveBaseURL: srv.URL}
|
||||
art, err := c.FetchReleaseCoverArt(context.Background(), "release-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchReleaseCoverArt: %v", err)
|
||||
}
|
||||
if art == nil {
|
||||
t.Fatal("expected cover art, got nil")
|
||||
}
|
||||
if art.LargeURL != "http://example.com/full.jpg" {
|
||||
t.Errorf("LargeURL = %q", art.LargeURL)
|
||||
}
|
||||
if art.SmallURL != "http://example.com/small.jpg" || art.MediumURL != "http://example.com/large.jpg" {
|
||||
t.Errorf("art = %+v", art)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReleaseCoverArtNotFoundReturnsNilNotError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{CoverArtArchiveBaseURL: srv.URL}
|
||||
art, err := c.FetchReleaseCoverArt(context.Background(), "no-art")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for a release with no registered cover art, got %v", err)
|
||||
}
|
||||
if art != nil {
|
||||
t.Errorf("expected nil CoverArt, got %+v", art)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReleaseCoverArtNoFrontImage(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"images": []map[string]any{
|
||||
{"front": false, "image": "http://example.com/back.jpg"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{CoverArtArchiveBaseURL: srv.URL}
|
||||
art, err := c.FetchReleaseCoverArt(context.Background(), "back-only")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if art != nil {
|
||||
t.Errorf("expected nil when no image is marked front, got %+v (must never substitute a back cover)", art)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user