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