release / govulncheck (push) Successful in 15s
release / web (push) Failing after 17s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m9s
release / Build signed APK (releases and dev) (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Migration 0091 drops lidarr_config and renames the quarantine tables, types, indexes and the history column off the Lidarr name. The Integrations Lidarr card, the Reports "Delete via Lidarr" action, the merge and remove-track unmonitor options, the Lidarr audit actions and error codes go, on the server, web and Android. The README describes acquisition as it is now. 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.
|
||
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
|
||
}
|