feat: a release catalogue from MusicBrainz; request search asks it directly (M502 #5519)
release / govulncheck (push) Successful in 22s
release / web (push) Failing after 24s
release / go (push) Successful in 1m57s
release / integration (push) Successful in 4m1s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / android (push) Canceled after 4m38s
release / Build signed APK (releases and dev) (push) Canceled after 4m37s
release / govulncheck (push) Successful in 22s
release / web (push) Failing after 24s
release / go (push) Successful in 1m57s
release / integration (push) Successful in 4m1s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / android (push) Canceled after 4m38s
release / Build signed APK (releases and dev) (push) Canceled after 4m37s
Minstrel keeps its own record of what artists have released, read from MusicBrainz, so the wanted list, search and import no longer need Lidarr. - internal/musicbrainz: searches (artists, release groups, recordings) and browses (an artist's release groups; a group's releases with track lists). One process-wide pacer at 1.1s, shared with tag enrichment, because MusicBrainz's limit is per address. A 503 throttle is retried. - Migration 0080: mb_artists, mb_release_groups, mb_artist_release_groups (a collaboration belongs to each credited artist), mb_releases, mb_release_tracks. - internal/catalogue: Discography and Releases serve the held copy and re-read it when older than 7 or 30 days. An unreachable MusicBrainz serves the stale copy, marked. An hourly pass re-reads due artists. - Art for what isn't held comes through /api/catalogue/art from the enabled cover-art sources (Cover Art Archive by release group, then the name-based sources) and is kept on disk, so the browser never fetches a third-party host. A miss is a 404 and the card shows its icon. - Request search moves from /api/lidarr/search to /api/catalogue/search, with the same response shape. Albums are release groups, and "in library" matches albums.release_group_mbid. A 10-minute cache absorbs search-as-you-type. Discover suggestions point at the art endpoint instead of looking up Lidarr. - Android calls the new path and loads Discover art through ServerImage. Installed builds older than this one lose Discover search until they update (no compatibility path, rule 22). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
package musicbrainz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultBaseURL is the public MusicBrainz web service.
|
||||
const DefaultBaseURL = "https://musicbrainz.org/ws/2"
|
||||
|
||||
// UserAgent identifies Minstrel. MusicBrainz refuses requests without a
|
||||
// descriptive one.
|
||||
const UserAgent = "Minstrel/1.0 ( https://git.fabledsword.com/bvandeusen/minstrel )"
|
||||
|
||||
// DefaultTimeout bounds one request (rule 156). A browse page with track
|
||||
// lists is the slowest thing asked for.
|
||||
const DefaultTimeout = 20 * time.Second
|
||||
|
||||
// pageSize is the most a browse returns per page.
|
||||
const pageSize = 100
|
||||
|
||||
// maxPages bounds a browse, so an artist with thousands of release groups
|
||||
// cannot hold the pacer for an hour. 25 pages is 2,500 entries.
|
||||
const maxPages = 25
|
||||
|
||||
var (
|
||||
// ErrUnreachable: MusicBrainz could not be asked, or kept answering with
|
||||
// its throttle or a server error. Callers degrade visibly (rule 164).
|
||||
ErrUnreachable = errors.New("musicbrainz: unreachable")
|
||||
// ErrNotFound: MusicBrainz has nothing under that id.
|
||||
ErrNotFound = errors.New("musicbrainz: not found")
|
||||
// ErrBadResponse: MusicBrainz answered with something that is not the
|
||||
// JSON asked for.
|
||||
ErrBadResponse = errors.New("musicbrainz: unexpected response")
|
||||
)
|
||||
|
||||
// Client asks MusicBrainz. The zero value is usable: it talks to the public
|
||||
// service through the shared pacer.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTP *http.Client
|
||||
// Pace spaces requests; nil means Shared(). Tests pass a zero Pacer.
|
||||
Pace *Pacer
|
||||
// Retries is how many times a throttled or failed request is tried
|
||||
// again. Zero means 2; negative means none.
|
||||
Retries int
|
||||
// Backoff is the first retry's wait, doubled each time. Zero means 1s.
|
||||
Backoff time.Duration
|
||||
}
|
||||
|
||||
func (c *Client) base() string {
|
||||
if c.BaseURL != "" {
|
||||
return strings.TrimRight(c.BaseURL, "/")
|
||||
}
|
||||
return DefaultBaseURL
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTP != nil {
|
||||
return c.HTTP
|
||||
}
|
||||
return &http.Client{Timeout: DefaultTimeout}
|
||||
}
|
||||
|
||||
func (c *Client) pacer() *Pacer {
|
||||
if c.Pace != nil {
|
||||
return c.Pace
|
||||
}
|
||||
return Shared()
|
||||
}
|
||||
|
||||
func (c *Client) retries() int {
|
||||
switch {
|
||||
case c.Retries < 0:
|
||||
return 0
|
||||
case c.Retries == 0:
|
||||
return 2
|
||||
}
|
||||
return c.Retries
|
||||
}
|
||||
|
||||
func (c *Client) backoff(attempt int) time.Duration {
|
||||
b := c.Backoff
|
||||
if b <= 0 {
|
||||
b = time.Second
|
||||
}
|
||||
return b << attempt
|
||||
}
|
||||
|
||||
// get fetches path?query (fmt=json added) into out, waiting on the pacer
|
||||
// before every attempt.
|
||||
func (c *Client) get(ctx context.Context, path string, query url.Values, out any) error {
|
||||
if query == nil {
|
||||
query = url.Values{}
|
||||
}
|
||||
query.Set("fmt", "json")
|
||||
full := c.base() + path + "?" + query.Encode()
|
||||
|
||||
var last error
|
||||
for attempt := 0; attempt <= c.retries(); attempt++ {
|
||||
if attempt > 0 {
|
||||
if err := sleep(ctx, c.backoff(attempt-1)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := c.pacer().Wait(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
retry, err := c.once(ctx, full, out)
|
||||
if !retry {
|
||||
return err
|
||||
}
|
||||
last = err
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
// once makes one request. retry reports whether trying again could help.
|
||||
func (c *Client) once(ctx context.Context, full string, out any) (retry bool, err error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("musicbrainz: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", UserAgent)
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return false, ctx.Err()
|
||||
}
|
||||
return true, fmt.Errorf("%w: %v", ErrUnreachable, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusNotFound:
|
||||
return false, ErrNotFound
|
||||
case resp.StatusCode == http.StatusServiceUnavailable,
|
||||
resp.StatusCode == http.StatusTooManyRequests,
|
||||
resp.StatusCode >= 500:
|
||||
// 503 is how MusicBrainz says "slow down".
|
||||
return true, fmt.Errorf("%w: HTTP %d", ErrUnreachable, resp.StatusCode)
|
||||
case resp.StatusCode != http.StatusOK:
|
||||
return false, fmt.Errorf("%w: HTTP %d", ErrBadResponse, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("%w: read: %v", ErrUnreachable, err)
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return false, fmt.Errorf("%w: %v", ErrBadResponse, err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func sleep(ctx context.Context, d time.Duration) error {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// luceneSpecial are the characters MusicBrainz's search syntax gives meaning.
|
||||
const luceneSpecial = `+-&|!(){}[]^"~*?:\/`
|
||||
|
||||
// Escape makes text typed by a person safe to put in a search query, so
|
||||
// "AC/DC" or "Sunn O)))" searches for what was typed rather than failing to
|
||||
// parse.
|
||||
func Escape(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if strings.ContainsRune(luceneSpecial, r) {
|
||||
b.WriteByte('\\')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// fieldQuery searches each field for the escaped text.
|
||||
func fieldQuery(text string, fields ...string) string {
|
||||
t := Escape(strings.TrimSpace(text))
|
||||
parts := make([]string, len(fields))
|
||||
for i, f := range fields {
|
||||
parts[i] = f + ":(" + t + ")"
|
||||
}
|
||||
return strings.Join(parts, " OR ")
|
||||
}
|
||||
|
||||
func searchLimit(limit int) string {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 25
|
||||
}
|
||||
return strconv.Itoa(limit)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package musicbrainz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// testClient talks to srv with no pacing and fast retries.
|
||||
func testClient(srv *httptest.Server) *Client {
|
||||
return &Client{BaseURL: srv.URL, Pace: &Pacer{}, Backoff: time.Millisecond}
|
||||
}
|
||||
|
||||
func TestEscape_KeepsWhatWasTyped(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"Radiohead": "Radiohead",
|
||||
"AC/DC": `AC\/DC`,
|
||||
"Sunn O)))": `Sunn O\)\)\)`,
|
||||
`"Weird" Al`: `\"Weird\" Al`,
|
||||
} {
|
||||
if got := Escape(in); got != want {
|
||||
t.Errorf("Escape(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchReleaseGroups_AsksTitleAndArtistAndReadsCredits(t *testing.T) {
|
||||
var query, agent atomic.Value
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
query.Store(r.URL.Query().Get("query"))
|
||||
agent.Store(r.Header.Get("User-Agent"))
|
||||
_, _ = w.Write([]byte(`{"release-groups": [{
|
||||
"id": "rg1", "title": "OK Computer", "primary-type": "Album", "secondary-types": [],
|
||||
"first-release-date": "1997-05-21",
|
||||
"artist-credit": [{"name": "Radiohead", "joinphrase": "", "artist": {"id": "a1", "name": "Radiohead"}}]
|
||||
}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := testClient(srv).SearchReleaseGroups(context.Background(), "ok computer", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q := query.Load(); q != "releasegroup:(ok computer) OR artist:(ok computer)" {
|
||||
t.Errorf("query = %q", q)
|
||||
}
|
||||
if agent.Load() != UserAgent {
|
||||
t.Errorf("User-Agent = %q", agent.Load())
|
||||
}
|
||||
if len(got) != 1 || got[0].MBID != "rg1" || got[0].Year() != "1997" || got[0].ArtistCredit != "Radiohead" || got[0].ArtistMBIDs[0] != "a1" {
|
||||
t.Errorf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchRecordings_FilesASongUnderItsAlbum(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"recordings": [{
|
||||
"id": "rec1", "title": "Karma Police", "length": 264000,
|
||||
"artist-credit": [{"name": "Radiohead", "artist": {"id": "a1"}}],
|
||||
"releases": [
|
||||
{"id": "r1", "status": "Official", "release-group": {"id": "rg-live", "title": "Live", "primary-type": "Album", "secondary-types": ["Live"]}},
|
||||
{"id": "r2", "status": "Official", "release-group": {"id": "rg1", "title": "OK Computer", "primary-type": "Album"}}
|
||||
]
|
||||
}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := testClient(srv).SearchRecordings(context.Background(), "karma police", 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ReleaseGroup.MBID != "rg1" || got[0].LengthMs != 264000 {
|
||||
t.Errorf("got %+v, want it filed under the studio album", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtistReleaseGroups_PagesUntilTheCount(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
if r.URL.Query().Get("artist") != "a1" {
|
||||
t.Errorf("artist = %q", r.URL.Query().Get("artist"))
|
||||
}
|
||||
// 3 in all, two per page.
|
||||
var items string
|
||||
for i := offset; i < offset+2 && i < 3; i++ {
|
||||
if items != "" {
|
||||
items += ","
|
||||
}
|
||||
items += fmt.Sprintf(`{"id": "rg%d", "title": "T%d", "primary-type": "Album"}`, i, i)
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `{"release-group-count": 3, "release-groups": [%s]}`, items)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := testClient(srv).ArtistReleaseGroups(context.Background(), "a1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 3 || calls.Load() != 2 {
|
||||
t.Errorf("got %d groups in %d calls, want 3 in 2", len(got), calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseGroupReleases_ReadsMediaAndTracks(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("inc") != "media recordings" {
|
||||
t.Errorf("inc = %q", r.URL.Query().Get("inc"))
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"release-count": 1, "releases": [{
|
||||
"id": "r1", "title": "OK Computer", "status": "Official", "date": "1997-06-16", "country": "GB",
|
||||
"media": [
|
||||
{"position": 1, "format": "CD", "tracks": [
|
||||
{"position": 1, "number": "1", "title": "Airbag", "length": 284000, "recording": {"id": "rec-a"}},
|
||||
{"position": 2, "number": "2", "title": "Paranoid Android", "length": null, "recording": {"id": "rec-b", "length": 383000}}
|
||||
]},
|
||||
{"position": 2, "format": "CD", "tracks": []}
|
||||
]
|
||||
}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := testClient(srv).ReleaseGroupReleases(context.Background(), "rg1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
r := got[0]
|
||||
if r.TrackCount() != 2 || r.Formats() != "2×CD" {
|
||||
t.Errorf("tracks %d, formats %q", r.TrackCount(), r.Formats())
|
||||
}
|
||||
if tr := r.Media[0].Tracks[1]; tr.LengthMs != 383000 || tr.RecordingMBID != "rec-b" {
|
||||
t.Errorf("a track with no length of its own should take the recording's: %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_ThrottleIsRetriedThenUnreachable(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if calls.Add(1) == 1 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"artists": [{"id": "a1", "name": "Radiohead"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := testClient(srv).SearchArtists(context.Background(), "radiohead", 5)
|
||||
if err != nil || len(got) != 1 || calls.Load() != 2 {
|
||||
t.Fatalf("a throttled first answer should be retried: %v, %+v, %d calls", err, got, calls.Load())
|
||||
}
|
||||
|
||||
down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
defer down.Close()
|
||||
if _, err := testClient(down).SearchArtists(context.Background(), "x", 5); !errors.Is(err, ErrUnreachable) {
|
||||
t.Errorf("err = %v, want ErrUnreachable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_NotFoundAndNoServer(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
if _, err := testClient(srv).Artist(context.Background(), "nope"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
srv.Close()
|
||||
c := testClient(srv)
|
||||
c.Retries = -1
|
||||
if _, err := c.Artist(context.Background(), "a1"); !errors.Is(err, ErrUnreachable) {
|
||||
t.Errorf("a closed server: err = %v, want ErrUnreachable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacer_SpacesCallers(t *testing.T) {
|
||||
p := &Pacer{Interval: 30 * time.Millisecond}
|
||||
start := time.Now()
|
||||
for range 3 {
|
||||
if err := p.Wait(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed < 60*time.Millisecond {
|
||||
t.Errorf("three waits took %v, want at least two intervals", elapsed)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := p.Wait(ctx); err == nil {
|
||||
t.Error("a cancelled wait should return the context's error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Package musicbrainz is Minstrel's client for the MusicBrainz web service:
|
||||
// searches and browses for the release catalogue (M502 #5519), and the one
|
||||
// pacer every MusicBrainz request in the process waits on.
|
||||
package musicbrainz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MinInterval honours MusicBrainz's 1 request/second ceiling, with headroom.
|
||||
// The limit is per client address, not per caller, which is why there is one
|
||||
// pacer for the whole process.
|
||||
const MinInterval = 1100 * time.Millisecond
|
||||
|
||||
// Pacer spaces requests at least Interval apart. Safe for concurrent use;
|
||||
// callers queue in the order they claim slots.
|
||||
type Pacer struct {
|
||||
Interval time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
next time.Time
|
||||
}
|
||||
|
||||
// Wait blocks until this caller's slot comes up, or ctx ends. A zero
|
||||
// Interval never waits.
|
||||
func (p *Pacer) Wait(ctx context.Context) error {
|
||||
if p == nil || p.Interval <= 0 {
|
||||
return ctx.Err()
|
||||
}
|
||||
p.mu.Lock()
|
||||
now := time.Now()
|
||||
slot := p.next
|
||||
if slot.Before(now) {
|
||||
slot = now
|
||||
}
|
||||
p.next = slot.Add(p.Interval)
|
||||
p.mu.Unlock()
|
||||
|
||||
wait := time.Until(slot)
|
||||
if wait <= 0 {
|
||||
return ctx.Err()
|
||||
}
|
||||
timer := time.NewTimer(wait)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var shared = &Pacer{Interval: MinInterval}
|
||||
|
||||
// Shared is the pacer every MusicBrainz request in the process goes through:
|
||||
// the catalogue's and tag enrichment's alike.
|
||||
func Shared() *Pacer { return shared }
|
||||
Reference in New Issue
Block a user