internal/acoustid posts AcoustID's v2 lookup as a gzip form with meta=recordings, one request per 400ms (their limit is 3/s) and a 20s deadline. It returns every linked recording with its best score; choosing among them is the worker's job (D5). The server's error codes map to an invalid key (stop and say so), a rejected fingerprint (a verdict on the track) or unavailable (try again later). A cancelled caller stays a cancellation. fpcalcLookupArgs and parseFpcalcCompressed read fpcalc's default output, the compressed string the lookup takes (D1), always over the first 120s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
268 lines
8.4 KiB
Go
268 lines
8.4 KiB
Go
// 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
|
|
}
|