// Package meta looks up album information (track lists, titles, dates) on // MusicBrainz for the fixer's album tags step. package meta import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "regexp" "strconv" "strings" "sync" "time" "github.com/cdnutter/playlink/internal/art" ) // Client talks to MusicBrainz. Requests are spaced out to respect its limit // of one request per second. type Client struct { HTTP *http.Client UserAgent string BaseURL string Gap time.Duration mu sync.Mutex next time.Time } func New(version string) *Client { return &Client{ HTTP: art.SafeHTTPClient(20 * time.Second), UserAgent: "playlink/" + version + " (self-hosted music library tool)", BaseURL: "https://musicbrainz.org", Gap: 1100 * time.Millisecond, } } // Release is one search result. type Release struct { ID string `json:"id"` Title string `json:"title"` Artist string `json:"artist"` Date string `json:"date"` Country string `json:"country"` Status string `json:"status"` Type string `json:"type"` // Album, Single, EP, Soundtrack, ... Disambiguation string `json:"disambiguation,omitempty"` Formats string `json:"formats"` // "2×CD", "Digital Media" TrackCount int `json:"track_count"` Discs int `json:"discs"` Score int `json:"score"` } // Track is one track of a release. type Track struct { Disc int `json:"disc"` Position int `json:"position"` DiscTracks int `json:"disc_tracks"` Title string `json:"title"` Artist string `json:"artist"` LengthMs int `json:"length_ms"` } // ReleaseDetail is a release with its track list. type ReleaseDetail struct { Release Tracks []Track `json:"tracks"` } var mbid = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) // ValidID reports whether s is a MusicBrainz id. func ValidID(s string) bool { return mbid.MatchString(s) } type artistCredit []struct { Name string `json:"name"` JoinPhrase string `json:"joinphrase"` } func (a artistCredit) String() string { var b strings.Builder for _, c := range a { b.WriteString(c.Name + c.JoinPhrase) } return strings.TrimSpace(b.String()) } type mbRelease struct { ID string `json:"id"` Score int `json:"score"` Title string `json:"title"` Status string `json:"status"` Date string `json:"date"` Country string `json:"country"` Disambiguation string `json:"disambiguation"` TrackCount int `json:"track-count"` ArtistCredit artistCredit `json:"artist-credit"` ReleaseGroup struct { PrimaryType string `json:"primary-type"` SecondaryTypes []string `json:"secondary-types"` } `json:"release-group"` Media []struct { Position int `json:"position"` Format string `json:"format"` TrackCount int `json:"track-count"` Tracks []struct { Position int `json:"position"` Title string `json:"title"` Length int `json:"length"` ArtistCredit artistCredit `json:"artist-credit"` } `json:"tracks"` } `json:"media"` } func (r mbRelease) summary() Release { out := Release{ID: r.ID, Title: r.Title, Artist: r.ArtistCredit.String(), Date: r.Date, Country: r.Country, Status: r.Status, Disambiguation: r.Disambiguation, TrackCount: r.TrackCount, Discs: len(r.Media), Score: r.Score} out.Type = r.ReleaseGroup.PrimaryType if len(r.ReleaseGroup.SecondaryTypes) > 0 { out.Type = strings.TrimSpace(out.Type + " " + strings.Join(r.ReleaseGroup.SecondaryTypes, " ")) } formats := map[string]int{} var order []string total := 0 for _, m := range r.Media { f := m.Format if f == "" { f = "Unknown" } if formats[f] == 0 { order = append(order, f) } formats[f]++ total += m.TrackCount } var parts []string for _, f := range order { if formats[f] > 1 { parts = append(parts, fmt.Sprintf("%d×%s", formats[f], f)) } else { parts = append(parts, f) } } out.Formats = strings.Join(parts, " + ") if out.TrackCount == 0 { out.TrackCount = total } return out } func quote(s string) string { // Lucene syntax: escape quotes and backslashes inside a phrase. s = strings.ReplaceAll(s, `\`, `\\`) return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` } // SearchReleases finds releases by album title and (optionally) artist. func (c *Client) SearchReleases(ctx context.Context, artist, album string) ([]Release, error) { if strings.TrimSpace(album) == "" { return nil, errors.New("no album title to search for") } q := "release:" + quote(album) if strings.TrimSpace(artist) != "" { q += " AND artist:" + quote(artist) } var d struct { Releases []mbRelease `json:"releases"` } u := c.BaseURL + "/ws/2/release/?" + url.Values{"query": {q}, "fmt": {"json"}, "limit": {"15"}}.Encode() if err := c.get(ctx, u, &d); err != nil { return nil, err } out := make([]Release, 0, len(d.Releases)) for _, r := range d.Releases { out = append(out, r.summary()) } return out, nil } // Release fetches a release with its track list. func (c *Client) Release(ctx context.Context, id string) (*ReleaseDetail, error) { if !ValidID(id) { return nil, errors.New("not a MusicBrainz release id") } var r mbRelease u := c.BaseURL + "/ws/2/release/" + id + "?" + url.Values{"inc": {"recordings artist-credits release-groups"}, "fmt": {"json"}}.Encode() if err := c.get(ctx, u, &r); err != nil { return nil, err } d := &ReleaseDetail{Release: r.summary()} for _, m := range r.Media { for _, t := range m.Tracks { artist := t.ArtistCredit.String() if artist == "" { artist = d.Artist } d.Tracks = append(d.Tracks, Track{Disc: m.Position, Position: t.Position, DiscTracks: len(m.Tracks), Title: t.Title, Artist: artist, LengthMs: t.Length}) } } return d, nil } // get fetches JSON, waiting for its turn and retrying once when MusicBrainz // says it is busy. func (c *Client) get(ctx context.Context, u string, v any) error { for attempt := 0; ; attempt++ { if err := c.wait(ctx); err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return err } req.Header.Set("User-Agent", c.UserAgent) req.Header.Set("Accept", "application/json") res, err := c.HTTP.Do(req) if err != nil { return err } body, err := io.ReadAll(io.LimitReader(res.Body, 8<<20)) res.Body.Close() if err != nil { return err } if res.StatusCode == http.StatusServiceUnavailable && attempt < 2 { c.backoff(res.Header.Get("Retry-After")) continue } if res.StatusCode != http.StatusOK { return fmt.Errorf("MusicBrainz: %s", res.Status) } return json.Unmarshal(body, v) } } func (c *Client) wait(ctx context.Context) error { c.mu.Lock() now := time.Now() at := c.next if at.Before(now) { at = now } c.next = at.Add(c.Gap) c.mu.Unlock() if d := time.Until(at); d > 0 { t := time.NewTimer(d) defer t.Stop() select { case <-ctx.Done(): return ctx.Err() case <-t.C: } } return nil } func (c *Client) backoff(retryAfter string) { d := 2 * time.Second if n, err := strconv.Atoi(retryAfter); err == nil && n > 0 && n < 30 { d = time.Duration(n) * time.Second } c.mu.Lock() if t := time.Now().Add(d); t.After(c.next) { c.next = t } c.mu.Unlock() }