// Package musicbrainz adapts MusicBrainz and Cover Art Archive without receiving audio files. package musicbrainz import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "sort" "strings" "sync" "time" "github.com/google/uuid" ) type Track struct { RecordingID string `json:"recording_id"` Title string `json:"title"` Artist string `json:"artist"` Disc int `json:"disc"` Position int `json:"position"` DurationMs int `json:"duration_ms"` } type Release struct { ID string `json:"id"` GroupID string `json:"group_id"` Title string `json:"title"` Artist string `json:"artist"` Type string `json:"type"` Date string `json:"date"` Country string `json:"country"` Comment string `json:"comment"` HasCover bool `json:"has_cover"` Tracks []Track `json:"tracks"` } type Query struct { GroupID string `json:"group_id,omitempty"` Artist string `json:"artist"` Album string `json:"album"` ReleaseID string `json:"release_id"` Audio bool `json:"audio,omitempty"` RecordingIDs []string `json:"-"` } type Cover struct { Data []byte MIME string URL string Exact bool } type Cache interface { GetMetadataCache(context.Context, string) ([]byte, bool, error) PutMetadataCache(context.Context, string, []byte, time.Duration) error } type Client struct { http *http.Client cache Cache mu sync.Mutex next time.Time base string coverBase string interval time.Duration userAgent string } func New(cache Cache, userAgent string) *Client { return &Client{cache: cache, base: "https://musicbrainz.org/ws/2", coverBase: "https://coverartarchive.org", interval: 1100 * time.Millisecond, userAgent: userAgent, http: &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { h := req.URL.Hostname() if len(via) > 5 || req.URL.Scheme != "https" || req.URL.Port() != "" || req.URL.User != nil || !(h == "musicbrainz.org" || h == "coverartarchive.org" || h == "archive.org" || strings.HasSuffix(h, ".archive.org")) { return errors.New("metadata: redirect outside provider") } return nil }}} } func (c *Client) wait(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() if delay := time.Until(c.next); delay > 0 { t := time.NewTimer(delay) defer t.Stop() select { case <-ctx.Done(): return ctx.Err() case <-t.C: } } c.next = time.Now().Add(c.interval) return nil } var ErrNotFound = errors.New("metadata: not found") func (c *Client) get(ctx context.Context, address string, limited bool, maxBytes int64) ([]byte, error) { if data, ok, err := c.cache.GetMetadataCache(ctx, address); err == nil && ok { return data, nil } if limited { if err := c.wait(ctx); err != nil { return nil, err } } req, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", c.userAgent) req.Header.Set("Accept", "application/json, image/jpeg, image/png, image/webp") res, err := c.http.Do(req) if err != nil { return nil, err } defer res.Body.Close() if res.StatusCode == http.StatusNotFound { return nil, ErrNotFound } if res.StatusCode != http.StatusOK { return nil, fmt.Errorf("metadata: HTTP %d", res.StatusCode) } data, err := io.ReadAll(io.LimitReader(res.Body, maxBytes+1)) if err != nil { return nil, err } if int64(len(data)) > maxBytes { return nil, errors.New("metadata: response too large") } if limited && !json.Valid(data) { return nil, errors.New("metadata: invalid JSON response") } if !limited { mime := http.DetectContentType(data) if mime != "image/jpeg" && mime != "image/png" && mime != "image/webp" { return nil, errors.New("metadata: unsupported cover") } } _ = c.cache.PutMetadataCache(ctx, address, data, 7*24*time.Hour) return data, nil } type credit struct { Name string `json:"name"` Join string `json:"joinphrase"` Artist struct { Name string `json:"name"` } `json:"artist"` } func artist(credits []credit) string { var s strings.Builder for _, a := range credits { name := a.Name if name == "" { name = a.Artist.Name } s.WriteString(name) s.WriteString(a.Join) } return s.String() } type releaseJSON struct { ID string `json:"id"` Title string `json:"title"` Date string `json:"date"` Country string `json:"country"` Comment string `json:"disambiguation"` Credits []credit `json:"artist-credit"` Group struct { ID string `json:"id"` Type string `json:"primary-type"` } `json:"release-group"` Cover struct { Front bool `json:"front"` } `json:"cover-art-archive"` Media []struct { Position int `json:"position"` Tracks []struct { Position int `json:"position"` Title string `json:"title"` Length int `json:"length"` Credits []credit `json:"artist-credit"` Recording struct { ID string `json:"id"` Title string `json:"title"` Length int `json:"length"` Credits []credit `json:"artist-credit"` } `json:"recording"` } `json:"tracks"` } `json:"media"` } func (c *Client) release(ctx context.Context, id string) (Release, error) { if _, err := uuid.Parse(id); err != nil { return Release{}, errors.New("metadata: invalid release ID") } data, err := c.get(ctx, c.base+"/release/"+id+"?fmt=json&inc=recordings%2Bartist-credits%2Brelease-groups", true, 8<<20) if err != nil { return Release{}, err } var raw releaseJSON if err = json.Unmarshal(data, &raw); err != nil { return Release{}, err } r := Release{ID: raw.ID, GroupID: raw.Group.ID, Title: raw.Title, Artist: artist(raw.Credits), Type: raw.Group.Type, Date: raw.Date, Country: raw.Country, Comment: raw.Comment, HasCover: raw.Cover.Front, Tracks: []Track{}} if _, err = uuid.Parse(r.ID); err != nil { return Release{}, errors.New("metadata: invalid release response") } for _, m := range raw.Media { for _, t := range m.Tracks { title := t.Title if title == "" { title = t.Recording.Title } a := artist(t.Credits) if a == "" { a = artist(t.Recording.Credits) } if a == "" { a = r.Artist } length := t.Length if length == 0 { length = t.Recording.Length } r.Tracks = append(r.Tracks, Track{RecordingID: t.Recording.ID, Title: title, Artist: a, Disc: m.Position, Position: t.Position, DurationMs: length}) } } if len(r.Tracks) == 0 || len(r.Tracks) > 1000 { return Release{}, errors.New("metadata: invalid track list") } return r, nil } func quote(s string) string { return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` } func (c *Client) Search(ctx context.Context, q Query) ([]Release, error) { if len(q.RecordingIDs) > 0 { return c.byRecordings(ctx, q.RecordingIDs) } if q.ReleaseID != "" { r, err := c.release(ctx, q.ReleaseID) if errors.Is(err, ErrNotFound) { return []Release{}, nil } return []Release{r}, err } params := url.Values{"fmt": {"json"}, "limit": {"5"}, "query": {"release:" + quote(q.Album) + " AND artist:" + quote(q.Artist)}} data, err := c.get(ctx, c.base+"/release?"+params.Encode(), true, 4<<20) if err != nil { return nil, err } var found struct { Releases []struct { ID string `json:"id"` } `json:"releases"` } if err = json.Unmarshal(data, &found); err != nil { return nil, err } out := []Release{} for _, candidate := range found.Releases { r, err := c.release(ctx, candidate.ID) if err != nil { return nil, err } out = append(out, r) } return out, nil } // byRecordings discovers editions from a bounded sample; all batch files are matched afterwards. func (c *Client) byRecordings(ctx context.Context, ids []string) ([]Release, error) { votes := map[string]int{} seen := map[string]bool{} for _, id := range ids { if _, err := uuid.Parse(id); err != nil { return nil, errors.New("metadata: invalid recording ID") } if seen[id] { continue } if len(seen) == 8 { break } seen[id] = true params := url.Values{"fmt": {"json"}, "recording": {id}, "limit": {"100"}} data, err := c.get(ctx, c.base+"/release?"+params.Encode(), true, 4<<20) if err != nil { return nil, err } var found struct { Releases []struct { ID string `json:"id"` } `json:"releases"` } if err = json.Unmarshal(data, &found); err != nil { return nil, err } perRecording := map[string]bool{} for _, r := range found.Releases { if _, err := uuid.Parse(r.ID); err == nil && !perRecording[r.ID] { votes[r.ID]++ perRecording[r.ID] = true } } } candidates := []string{} for id := range votes { candidates = append(candidates, id) } sort.Slice(candidates, func(i, j int) bool { if votes[candidates[i]] == votes[candidates[j]] { return candidates[i] < candidates[j] } return votes[candidates[i]] > votes[candidates[j]] }) out := []Release{} for _, id := range candidates[:min(5, len(candidates))] { r, err := c.release(ctx, id) if err != nil { return nil, err } out = append(out, r) } return out, nil } func (c *Client) Cover(ctx context.Context, r Release) (Cover, error) { for i, id := range []string{r.ID, r.GroupID} { if _, err := uuid.Parse(id); err != nil { continue } kind := "release" if i == 1 { kind = "release-group" } address := c.coverBase + "/" + kind + "/" + id + "/front-1200" data, err := c.get(ctx, address, false, 8<<20) if errors.Is(err, ErrNotFound) { continue } if err != nil { return Cover{}, err } mime := http.DetectContentType(data) if mime != "image/jpeg" && mime != "image/png" && mime != "image/webp" { return Cover{}, errors.New("metadata: unsupported cover") } return Cover{Data: data, MIME: mime, URL: address, Exact: i == 0}, nil } return Cover{}, ErrNotFound }