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) }