release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m28s
release / android (push) Successful in 5m44s
release / Build signed APK (releases and dev) (push) Successful in 5m58s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
The one-time listing took only albums whose tags already named their release group and artist, and left 340 off the list. The rest are now matched: the release their tags name, else the artist's release groups called what the album is called, else a MusicBrainz search by artist and title. A group found by name is taken only when its tracks agree with the album's (by disc and number, then title; length and title, or recording), so a partial copy still matches and a same-titled other album does not. library_album_matches records each album the listing decided, listed or unmatched with why, so an album taken off the list is never re-listed. The albums no match was found for are named on the Held tab. Album rows with no tracks are no longer counted. The length and title checks move from the importer to release, shared by both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
403 lines
12 KiB
Go
403 lines
12 KiB
Go
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. Lidarr's albums were release groups too.
|
||
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
|
||
}
|