Files
minstrel/internal/musicbrainz/client.go
T
bvandeusenandClaude Opus 5.5 d191a28217 docs: server comments say what the code does, not how it came to be (#6277)
Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.

Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
  it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
  not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
  today rather than a milestone that would change it.

Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:15:05 -04:00

210 lines
5.4 KiB
Go

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. 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: they say
// MusicBrainz is unreachable rather than show an empty answer.
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)
}