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>
210 lines
5.4 KiB
Go
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)
|
|
}
|