Files
bvandeusenandClaude Opus 5.5 77389f88bc
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
feat: a release catalogue from MusicBrainz; request search asks it directly (M502 #5519)
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>
2026-10-09 21:08:22 -04:00

209 lines
5.3 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 (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)
}