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