Rewrites the internal/api comment blocks to rule 32: task, milestone and rule ids gone, the stories behind them restated as the constraint they left, stale references corrected (the stream secret is wired from config, the timezone handler already refreshes the scheduler, the Android app is the native client, the design docs cited are not in the repo). Also rewraps comment lines over 80 columns left by the earlier batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
267 lines
8.4 KiB
Go
267 lines
8.4 KiB
Go
// Package acoustid looks up MusicBrainz recording ids by acoustic fingerprint
|
|
// through the AcoustID web service.
|
|
//
|
|
// What it relies on, from https://acoustid.org/webservice:
|
|
// - 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.
|
|
// - "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. 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 configured 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
|
|
}
|