Files
vibesonic/internal/metadataenrich/client.go
T
owenandClaude Sonnet 5 a921f9e2bc 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
2026-08-23 08:35:40 -05:00

161 lines
4.7 KiB
Go

// 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
}