// Package acoustid looks up MusicBrainz recording ids by acoustic fingerprint // through the AcoustID web service (M401). // // What it relies on, from https://acoustid.org/webservice (read 2026-10-06): // - POST https://api.acoustid.org/v2/lookup with client, duration (whole // seconds), fingerprint (fpcalc's compressed string) and meta. A gzip body // with Content-Encoding: gzip is accepted and preferred, because // fingerprints are long. // - meta=recordings returns each recording's title, duration and artists, // which the worker needs to tell several candidates apart (M401 D5). // - "Do not make more than 3 requests per second." // - The service is free for non-commercial use only. // // Error codes are the server's own, from acoustid-server's // acoustid/api/errors.py. package acoustid import ( "bytes" "cmp" "compress/gzip" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "slices" "strconv" "sync" "time" ) // DefaultBaseURL is the lookup endpoint. const DefaultBaseURL = "https://api.acoustid.org/v2/lookup" const ( // minInterval keeps to AcoustID's 3 requests a second with headroom: one // every 400ms is 2.5 a second. minInterval = 400 * time.Millisecond // lookupTimeout bounds one request, connection included (rule 156). A // lookup answers in well under a second; 20s is the line past which slow // has become never. lookupTimeout = 20 * time.Second // bodyLimit caps the response read. A lookup with meta=recordings for a // popular song runs to tens of kilobytes. bodyLimit = 2 << 20 userAgent = "Minstrel ( https://git.fabledsword.com/bvandeusen/minstrel )" ) // The server's error codes that the worker treats differently. const ( codeInvalidFingerprint = 3 codeInvalidAPIKey = 4 codeServiceUnavailable = 13 codeTooManyRequests = 14 codeUnknownApplication = 17 ) var ( // ErrInvalidKey: the operator's application key was refused. No lookup // can succeed until it is changed, so the worker stops and says so. ErrInvalidKey = errors.New("acoustid: the API key was refused") // ErrInvalidFingerprint: AcoustID rejected this track's fingerprint. A // verdict on the track, not on the service. ErrInvalidFingerprint = errors.New("acoustid: the fingerprint was rejected") // ErrUnavailable: the service could not answer now (unreachable, timed // out, overloaded or rate limiting). Says nothing about the track, so the // lookup is tried again later. ErrUnavailable = errors.New("acoustid: the service is unavailable") ) // Recording is one MusicBrainz recording a fingerprint matched. type Recording struct { ID string Title string // DurationSec is MusicBrainz's length for the recording; 0 when unknown. DurationSec int Artists []string } // Candidate is a recording with the score of the best AcoustID result that // linked to it. type Candidate struct { Recording Score float64 } // Client calls the lookup endpoint, at most one request per minInterval. type Client struct { baseURL string http *http.Client mu sync.Mutex lastCall time.Time } // New returns a client for baseURL (DefaultBaseURL in production). func New(baseURL string) *Client { return &Client{baseURL: baseURL, http: &http.Client{Timeout: lookupTimeout}} } // Lookup asks which recordings match fingerprint, a compressed chromaprint // of audio durationSec long. It returns every candidate, best score first; // choosing among them is the caller's job. No match is an empty slice and // no error. func (c *Client) Lookup(ctx context.Context, apiKey, fingerprint string, durationSec int) ([]Candidate, error) { if err := c.wait(ctx); err != nil { return nil, err } body, err := gzipForm(url.Values{ "client": {apiKey}, "duration": {strconv.Itoa(durationSec)}, "fingerprint": {fingerprint}, "meta": {"recordings"}, "format": {"json"}, }) if err != nil { return nil, err } reqCtx, cancel := context.WithTimeout(ctx, lookupTimeout) defer cancel() req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.baseURL, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("acoustid: build request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Encoding", "gzip") req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", userAgent) resp, err := c.http.Do(req) if err != nil { // The caller giving up is not the service failing. if ctx.Err() != nil { return nil, ctx.Err() } return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit)) if err != nil { return nil, fmt.Errorf("%w: read body: %v", ErrUnavailable, err) } return parseLookup(resp.StatusCode, raw) } // wait blocks until minInterval has passed since the last request, then // claims the slot. func (c *Client) wait(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() if d := time.Until(c.lastCall.Add(minInterval)); d > 0 { t := time.NewTimer(d) defer t.Stop() select { case <-ctx.Done(): return ctx.Err() case <-t.C: } } c.lastCall = time.Now() return nil } func gzipForm(v url.Values) ([]byte, error) { var buf bytes.Buffer zw := gzip.NewWriter(&buf) if _, err := zw.Write([]byte(v.Encode())); err != nil { return nil, fmt.Errorf("acoustid: compress request: %w", err) } if err := zw.Close(); err != nil { return nil, fmt.Errorf("acoustid: compress request: %w", err) } return buf.Bytes(), nil } type lookupResponse struct { Status string `json:"status"` Error *struct { Code int `json:"code"` Message string `json:"message"` } `json:"error"` Results []struct { ID string `json:"id"` Score float64 `json:"score"` Recordings []struct { ID string `json:"id"` Title string `json:"title"` Duration float64 `json:"duration"` Artists []struct { Name string `json:"name"` } `json:"artists"` } `json:"recordings"` } `json:"results"` } // parseLookup reads a lookup response. AcoustID reports its errors in the // body with a code, alongside a 4xx or 5xx status, so the body is read first // and the status only decides when the body says nothing usable. func parseLookup(status int, raw []byte) ([]Candidate, error) { var r lookupResponse if err := json.Unmarshal(raw, &r); err != nil { if status != http.StatusOK { return nil, fmt.Errorf("%w: status %d", ErrUnavailable, status) } return nil, fmt.Errorf("%w: unreadable response: %v", ErrUnavailable, err) } if r.Status != "ok" { return nil, lookupError(status, r) } return candidates(r), nil } func lookupError(status int, r lookupResponse) error { if r.Error == nil { return fmt.Errorf("%w: status %d", ErrUnavailable, status) } switch r.Error.Code { case codeInvalidAPIKey, codeUnknownApplication: return fmt.Errorf("%w: %s", ErrInvalidKey, r.Error.Message) case codeInvalidFingerprint: return fmt.Errorf("%w: %s", ErrInvalidFingerprint, r.Error.Message) case codeServiceUnavailable, codeTooManyRequests: return fmt.Errorf("%w: %s", ErrUnavailable, r.Error.Message) } if status >= http.StatusInternalServerError { return fmt.Errorf("%w: %s", ErrUnavailable, r.Error.Message) } // Any other code is a request this client built wrongly. Retrying would // repeat it, so it is named rather than folded into unavailable. return fmt.Errorf("acoustid: error %d: %s", r.Error.Code, r.Error.Message) } // candidates flattens the results into one entry per recording. The same // recording can hang off several AcoustID results (one audio, fingerprinted // from different sources); it keeps the best score it was seen with. // Results with no linked recording are fingerprints AcoustID knows but nobody // has tied to MusicBrainz, and carry nothing to fill. func candidates(r lookupResponse) []Candidate { out := []Candidate{} at := map[string]int{} for _, res := range r.Results { for _, rec := range res.Recordings { if rec.ID == "" { continue } if i, ok := at[rec.ID]; ok { out[i].Score = max(out[i].Score, res.Score) continue } c := Candidate{ Recording: Recording{ID: rec.ID, Title: rec.Title, DurationSec: int(rec.Duration + 0.5)}, Score: res.Score, } for _, a := range rec.Artists { c.Artists = append(c.Artists, a.Name) } at[rec.ID] = len(out) out = append(out, c) } } // Best first, keeping AcoustID's order among equals. slices.SortStableFunc(out, func(a, b Candidate) int { return cmp.Compare(b.Score, a.Score) }) return out }