package musicbrainz import ( "context" "net/url" "strconv" "strings" ) // Artist is a MusicBrainz artist. type Artist struct { MBID string Name string SortName string Disambiguation string Type string // Person, Group, Orchestra, …; may be empty Country string } // ReleaseGroup is what a person calls an album: one work, released any // number of times. type ReleaseGroup struct { MBID string Title string PrimaryType string // Album, Single, EP, Broadcast, Other; may be empty SecondaryTypes []string // Compilation, Live, Remix, Soundtrack, … // FirstReleaseDate is MusicBrainz's partial date: "", "1997", // "1997-05" or "1997-05-21". FirstReleaseDate string ArtistCredit string // the credit as printed, joinphrases included ArtistMBIDs []string } // Year is the first release's year, or "". func (g ReleaseGroup) Year() string { if len(g.FirstReleaseDate) >= 4 { return g.FirstReleaseDate[:4] } return "" } // Release is one issue of a release group: a pressing, an edition, a // digital release, each with its own track list. type Release struct { MBID string Title string Status string // Official, Promotion, Bootleg, Pseudo-Release; may be empty Date string // partial, like ReleaseGroup.FirstReleaseDate Country string Disambiguation string Media []Medium } // TrackCount totals the tracks across every medium. func (r Release) TrackCount() int { n := 0 for _, m := range r.Media { n += len(m.Tracks) } return n } // Formats summarises the media, e.g. "CD", "2×CD" or "CD + DVD". func (r Release) Formats() string { var parts []string counts := map[string]int{} for _, m := range r.Media { f := m.Format if f == "" { f = "Unknown" } if counts[f] == 0 { parts = append(parts, f) } counts[f]++ } for i, f := range parts { if counts[f] > 1 { parts[i] = strconv.Itoa(counts[f]) + "×" + f } } return strings.Join(parts, " + ") } // Medium is one disc (or side, or file set) of a release. type Medium struct { Position int Format string Tracks []Track } // Track is a position on a medium, pointing at the recording heard there. type Track struct { Position int Number string // as printed: "1", "A1", … Title string LengthMs int // 0 when unknown RecordingMBID string } // Recording is a piece of audio, as a search returns it. type Recording struct { MBID string Title string LengthMs int ArtistCredit string ArtistMBIDs []string // ReleaseGroup is the group the recording is best known from: the first // official album it appears on, else the first release listed. ReleaseGroup ReleaseGroup } // --- wire shapes --- type wireCredit struct { Name string `json:"name"` JoinPhrase string `json:"joinphrase"` Artist struct { ID string `json:"id"` Name string `json:"name"` } `json:"artist"` } func credit(cs []wireCredit) (string, []string) { var b strings.Builder ids := make([]string, 0, len(cs)) for _, c := range cs { name := c.Name if name == "" { name = c.Artist.Name } b.WriteString(name) b.WriteString(c.JoinPhrase) if c.Artist.ID != "" { ids = append(ids, c.Artist.ID) } } return b.String(), ids } type wireArtist struct { ID string `json:"id"` Name string `json:"name"` SortName string `json:"sort-name"` Disambiguation string `json:"disambiguation"` Type string `json:"type"` Country string `json:"country"` } func (w wireArtist) artist() Artist { return Artist{MBID: w.ID, Name: w.Name, SortName: w.SortName, Disambiguation: w.Disambiguation, Type: w.Type, Country: w.Country} } type wireReleaseGroup struct { ID string `json:"id"` Title string `json:"title"` PrimaryType string `json:"primary-type"` SecondaryTypes []string `json:"secondary-types"` FirstReleaseDate string `json:"first-release-date"` ArtistCredit []wireCredit `json:"artist-credit"` } func (w wireReleaseGroup) group() ReleaseGroup { name, ids := credit(w.ArtistCredit) return ReleaseGroup{ MBID: w.ID, Title: w.Title, PrimaryType: w.PrimaryType, SecondaryTypes: append([]string{}, w.SecondaryTypes...), FirstReleaseDate: w.FirstReleaseDate, ArtistCredit: name, ArtistMBIDs: ids, } } type wireTrack struct { Position int `json:"position"` Number string `json:"number"` Title string `json:"title"` Length *int `json:"length"` Recording struct { ID string `json:"id"` Length *int `json:"length"` } `json:"recording"` } type wireRelease struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` Date string `json:"date"` Country string `json:"country"` Disambiguation string `json:"disambiguation"` Media []struct { Position int `json:"position"` Format string `json:"format"` Tracks []wireTrack `json:"tracks"` } `json:"media"` ReleaseGroup *wireReleaseGroup `json:"release-group"` } func (w wireRelease) release() Release { r := Release{MBID: w.ID, Title: w.Title, Status: w.Status, Date: w.Date, Country: w.Country, Disambiguation: w.Disambiguation} for _, m := range w.Media { med := Medium{Position: m.Position, Format: m.Format} for _, t := range m.Tracks { length := 0 switch { case t.Length != nil: length = *t.Length case t.Recording.Length != nil: length = *t.Recording.Length } med.Tracks = append(med.Tracks, Track{ Position: t.Position, Number: t.Number, Title: t.Title, LengthMs: length, RecordingMBID: t.Recording.ID, }) } r.Media = append(r.Media, med) } return r } // --- searches --- // SearchArtists finds artists by name or alias. func (c *Client) SearchArtists(ctx context.Context, text string, limit int) ([]Artist, error) { var resp struct { Artists []wireArtist `json:"artists"` } q := url.Values{"query": {fieldQuery(text, "artist", "alias")}, "limit": {searchLimit(limit)}} if err := c.get(ctx, "/artist", q, &resp); err != nil { return nil, err } out := make([]Artist, 0, len(resp.Artists)) for _, a := range resp.Artists { out = append(out, a.artist()) } return out, nil } // SearchReleaseGroups finds albums by title or by artist, so "radiohead ok // computer" and "ok computer" both find OK Computer. func (c *Client) SearchReleaseGroups(ctx context.Context, text string, limit int) ([]ReleaseGroup, error) { var resp struct { ReleaseGroups []wireReleaseGroup `json:"release-groups"` } q := url.Values{"query": {fieldQuery(text, "releasegroup", "artist")}, "limit": {searchLimit(limit)}} if err := c.get(ctx, "/release-group", q, &resp); err != nil { return nil, err } out := make([]ReleaseGroup, 0, len(resp.ReleaseGroups)) for _, g := range resp.ReleaseGroups { out = append(out, g.group()) } return out, nil } // SearchReleaseGroupsBy finds an artist's albums by title: both must // match, where SearchReleaseGroups takes either. It is for naming an album // already in hand, not for a person's search box. func (c *Client) SearchReleaseGroupsBy(ctx context.Context, artist, title string, limit int) ([]ReleaseGroup, error) { var resp struct { ReleaseGroups []wireReleaseGroup `json:"release-groups"` } query := "releasegroup:(" + Escape(strings.TrimSpace(title)) + ") AND artist:(" + Escape(strings.TrimSpace(artist)) + ")" q := url.Values{"query": {query}, "limit": {searchLimit(limit)}} if err := c.get(ctx, "/release-group", q, &resp); err != nil { return nil, err } out := make([]ReleaseGroup, 0, len(resp.ReleaseGroups)) for _, g := range resp.ReleaseGroups { out = append(out, g.group()) } return out, nil } // SearchRecordings finds songs by title or by artist. func (c *Client) SearchRecordings(ctx context.Context, text string, limit int) ([]Recording, error) { var resp struct { Recordings []struct { ID string `json:"id"` Title string `json:"title"` Length *int `json:"length"` ArtistCredit []wireCredit `json:"artist-credit"` Releases []wireRelease `json:"releases"` } `json:"recordings"` } q := url.Values{"query": {fieldQuery(text, "recording", "artist")}, "limit": {searchLimit(limit)}} if err := c.get(ctx, "/recording", q, &resp); err != nil { return nil, err } out := make([]Recording, 0, len(resp.Recordings)) for _, r := range resp.Recordings { name, ids := credit(r.ArtistCredit) rec := Recording{MBID: r.ID, Title: r.Title, ArtistCredit: name, ArtistMBIDs: ids} if r.Length != nil { rec.LengthMs = *r.Length } rec.ReleaseGroup = bestKnownFrom(r.Releases) out = append(out, rec) } return out, nil } // bestKnownFrom picks the release group a recording should be filed under // in a search result: the first official album, else the first release. func bestKnownFrom(releases []wireRelease) ReleaseGroup { var first *wireReleaseGroup for _, r := range releases { if r.ReleaseGroup == nil || r.ReleaseGroup.ID == "" { continue } if first == nil { first = r.ReleaseGroup } if r.Status == "Official" && r.ReleaseGroup.PrimaryType == "Album" && len(r.ReleaseGroup.SecondaryTypes) == 0 { return r.ReleaseGroup.group() } } if first == nil { return ReleaseGroup{} } return first.group() } // --- lookups and browses --- // Artist looks one artist up by MBID. func (c *Client) Artist(ctx context.Context, mbid string) (Artist, error) { var w wireArtist if err := c.get(ctx, "/artist/"+url.PathEscape(mbid), nil, &w); err != nil { return Artist{}, err } if w.ID == "" { return Artist{}, ErrBadResponse } return w.artist(), nil } // ArtistReleaseGroups lists every release group the artist is credited on, // paging through MusicBrainz's browse. func (c *Client) ArtistReleaseGroups(ctx context.Context, artistMBID string) ([]ReleaseGroup, error) { var out []ReleaseGroup for page := 0; page < maxPages; page++ { var resp struct { Count int `json:"release-group-count"` ReleaseGroups []wireReleaseGroup `json:"release-groups"` } q := url.Values{ "artist": {artistMBID}, "inc": {"artist-credits"}, "limit": {strconv.Itoa(pageSize)}, "offset": {strconv.Itoa(len(out))}, } if err := c.get(ctx, "/release-group", q, &resp); err != nil { return nil, err } for _, g := range resp.ReleaseGroups { out = append(out, g.group()) } if len(resp.ReleaseGroups) == 0 || len(out) >= resp.Count { break } } return out, nil } // ReleaseGroupReleases lists every release of a release group with its // media and track lists. func (c *Client) ReleaseGroupReleases(ctx context.Context, groupMBID string) ([]Release, error) { var out []Release for page := 0; page < maxPages; page++ { var resp struct { Count int `json:"release-count"` Releases []wireRelease `json:"releases"` } q := url.Values{ "release-group": {groupMBID}, "inc": {"media recordings"}, "limit": {strconv.Itoa(pageSize)}, "offset": {strconv.Itoa(len(out))}, } if err := c.get(ctx, "/release", q, &resp); err != nil { return nil, err } for _, r := range resp.Releases { out = append(out, r.release()) } // Advance by what came back: MusicBrainz may return fewer than the // page size when track lists are large. if len(resp.Releases) == 0 || len(out) >= resp.Count { break } } return out, nil } // ReleaseGroup looks one release group up by MBID, with its artist credit. func (c *Client) ReleaseGroup(ctx context.Context, mbid string) (ReleaseGroup, error) { var w wireReleaseGroup q := url.Values{"inc": {"artist-credits"}} if err := c.get(ctx, "/release-group/"+url.PathEscape(mbid), q, &w); err != nil { return ReleaseGroup{}, err } if w.ID == "" { return ReleaseGroup{}, ErrBadResponse } return w.group(), nil }