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>
221 lines
7.9 KiB
Go
221 lines
7.9 KiB
Go
package acoustid
|
|
|
|
import (
|
|
"compress/gzip"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// docSample is the meta=recordings response printed in AcoustID's own
|
|
// documentation (https://acoustid.org/webservice), artists and release groups
|
|
// as given.
|
|
const docSample = `{
|
|
"status": "ok",
|
|
"results": [{
|
|
"score": 1.0,
|
|
"id": "9ff43b6a-4f16-427c-93c2-92307ca505e0",
|
|
"recordings": [{
|
|
"duration": 639,
|
|
"releasegroups": [{"type": "Album", "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", "title": "Before the Dawn Heals Us"}],
|
|
"title": "Lower Your Eyelids to Die With the Sun",
|
|
"id": "cd2e7c47-16f5-46c6-a37c-a1eb7bf599ff",
|
|
"artists": [{"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"}]
|
|
}]
|
|
}]
|
|
}`
|
|
|
|
// severalSample is the shape the worker has to disambiguate: one audio linked
|
|
// to two recordings (an album and a single release), the album recording
|
|
// reached again through a weaker second result, and a third result that
|
|
// AcoustID knows but nobody has tied to MusicBrainz.
|
|
const severalSample = `{
|
|
"status": "ok",
|
|
"results": [
|
|
{"score": 0.71, "id": "a2", "recordings": [{"id": "rec-album", "title": "Song", "duration": 200.4}]},
|
|
{"score": 0.93, "id": "a1", "recordings": [
|
|
{"id": "rec-album", "title": "Song", "duration": 200.4, "artists": [{"name": "A"}, {"name": "B"}]},
|
|
{"id": "rec-single", "title": "Song (radio edit)", "duration": 181}
|
|
]},
|
|
{"score": 0.88, "id": "a3"}
|
|
]
|
|
}`
|
|
|
|
func TestParseLookup(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
want []Candidate
|
|
}{
|
|
{"single match", docSample, []Candidate{{
|
|
Recording: Recording{
|
|
ID: "cd2e7c47-16f5-46c6-a37c-a1eb7bf599ff", Title: "Lower Your Eyelids to Die With the Sun",
|
|
DurationSec: 639, Artists: []string{"M83"},
|
|
},
|
|
Score: 1.0,
|
|
}}},
|
|
{"several recordings", severalSample, []Candidate{
|
|
{Recording: Recording{ID: "rec-album", Title: "Song", DurationSec: 200}, Score: 0.93},
|
|
{Recording: Recording{ID: "rec-single", Title: "Song (radio edit)", DurationSec: 181}, Score: 0.93},
|
|
}},
|
|
// Choosing a threshold is the worker's; the parser carries the score.
|
|
{"below threshold", `{"status":"ok","results":[{"score":0.31,"id":"x","recordings":[{"id":"rec"}]}]}`,
|
|
[]Candidate{{Recording: Recording{ID: "rec"}, Score: 0.31}}},
|
|
{"no results", `{"status":"ok","results":[]}`, []Candidate{}},
|
|
{"results with no recordings", `{"status":"ok","results":[{"score":0.99,"id":"x"}]}`, []Candidate{}},
|
|
}
|
|
for _, c := range cases {
|
|
got, err := parseLookup(http.StatusOK, []byte(c.body))
|
|
if err != nil {
|
|
t.Errorf("%s: err = %v", c.name, err)
|
|
continue
|
|
}
|
|
if !sameCandidates(got, c.want) {
|
|
t.Errorf("%s:\n got %+v\nwant %+v", c.name, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The first result's recording is the album one with both artists; the
|
|
// weaker repeat must not replace its fields, only fail to lower its score.
|
|
func TestParseLookup_RepeatedRecordingKeepsBestScoreAndFirstFields(t *testing.T) {
|
|
got, err := parseLookup(http.StatusOK, []byte(severalSample))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got[0].ID != "rec-album" || got[0].Score != 0.93 {
|
|
t.Errorf("album recording = %+v, want score 0.93", got[0])
|
|
}
|
|
}
|
|
|
|
func TestParseLookup_Errors(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
status int
|
|
body string
|
|
want error
|
|
}{
|
|
{"invalid key", 400, `{"status":"error","error":{"code":4,"message":"invalid API key"}}`, ErrInvalidKey},
|
|
{"unknown application", 400, `{"status":"error","error":{"code":17,"message":"unknown application"}}`, ErrInvalidKey},
|
|
{"bad fingerprint", 400, `{"status":"error","error":{"code":3,"message":"invalid fingerprint"}}`, ErrInvalidFingerprint},
|
|
{"rate limited", 429, `{"status":"error","error":{"code":14,"message":"rate limit (3.000000 requests per second) exceeded, try again later"}}`, ErrUnavailable},
|
|
{"down", 503, `{"status":"error","error":{"code":13,"message":"service currently unavailable, try again later"}}`, ErrUnavailable},
|
|
{"internal error", 500, `{"status":"error","error":{"code":5,"message":"internal error"}}`, ErrUnavailable},
|
|
{"proxy page", 502, `<html>Bad Gateway</html>`, ErrUnavailable},
|
|
}
|
|
for _, c := range cases {
|
|
_, err := parseLookup(c.status, []byte(c.body))
|
|
if !errors.Is(err, c.want) {
|
|
t.Errorf("%s: err = %v, want %v", c.name, err, c.want)
|
|
}
|
|
}
|
|
|
|
// A code this client caused (here a missing parameter) is neither the
|
|
// key nor the service, so it must not read as either: retrying repeats it.
|
|
_, err := parseLookup(400, []byte(`{"status":"error","error":{"code":2,"message":"missing required parameter \"fingerprint\""}}`))
|
|
if err == nil || errors.Is(err, ErrUnavailable) || errors.Is(err, ErrInvalidKey) || !strings.Contains(err.Error(), "missing required parameter") {
|
|
t.Errorf("missing parameter: err = %v", err)
|
|
}
|
|
}
|
|
|
|
// The request is what AcoustID documents: a gzip form POST carrying the key,
|
|
// whole-second duration, fingerprint and meta=recordings.
|
|
func TestLookup_SendsACompressedForm(t *testing.T) {
|
|
var form url.Values
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.Header.Get("Content-Encoding") != "gzip" {
|
|
t.Errorf("request %s with Content-Encoding %q, want a gzip POST", r.Method, r.Header.Get("Content-Encoding"))
|
|
}
|
|
zr, err := gzip.NewReader(r.Body)
|
|
if err != nil {
|
|
t.Fatalf("body is not gzip: %v", err)
|
|
}
|
|
raw, _ := io.ReadAll(zr)
|
|
form, _ = url.ParseQuery(string(raw))
|
|
_, _ = io.WriteString(w, docSample)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
got, err := New(srv.URL).Lookup(context.Background(), "key123", "AQADtE", 241)
|
|
if err != nil || len(got) != 1 {
|
|
t.Fatalf("Lookup = %+v, %v", got, err)
|
|
}
|
|
for k, want := range map[string]string{
|
|
"client": "key123", "duration": "241", "fingerprint": "AQADtE", "meta": "recordings", "format": "json",
|
|
} {
|
|
if form.Get(k) != want {
|
|
t.Errorf("form %s = %q, want %q", k, form.Get(k), want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLookup_DeadlineIsUnavailable(t *testing.T) {
|
|
release := make(chan struct{})
|
|
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { <-release }))
|
|
defer srv.Close()
|
|
defer close(release)
|
|
|
|
c := New(srv.URL)
|
|
c.http.Timeout = 50 * time.Millisecond
|
|
_, err := c.Lookup(context.Background(), "k", "f", 1)
|
|
if !errors.Is(err, ErrUnavailable) {
|
|
t.Errorf("err = %v, want ErrUnavailable", err)
|
|
}
|
|
}
|
|
|
|
// A cancelled caller is reported as such, never as the service being down.
|
|
func TestLookup_CancelledCallerIsNotUnavailable(t *testing.T) {
|
|
release := make(chan struct{})
|
|
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { <-release }))
|
|
defer srv.Close()
|
|
defer close(release)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
defer cancel()
|
|
_, err := New(srv.URL).Lookup(ctx, "k", "f", 1)
|
|
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, ErrUnavailable) {
|
|
t.Errorf("err = %v, want the caller's own deadline", err)
|
|
}
|
|
}
|
|
|
|
func TestLookup_KeepsToTheRateLimit(t *testing.T) {
|
|
var at []time.Time
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
at = append(at, time.Now())
|
|
_, _ = io.WriteString(w, `{"status":"ok","results":[]}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := New(srv.URL)
|
|
for range 3 {
|
|
if _, err := c.Lookup(context.Background(), "k", "f", 1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for i := 1; i < len(at); i++ {
|
|
// A little slack for the server's own clock reading.
|
|
if gap := at[i].Sub(at[i-1]); gap < minInterval-10*time.Millisecond {
|
|
t.Errorf("requests %d and %d were %s apart, want at least %s", i-1, i, gap, minInterval)
|
|
}
|
|
}
|
|
}
|
|
|
|
func sameCandidates(a, b []Candidate) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i].ID != b[i].ID || a[i].Title != b[i].Title || a[i].DurationSec != b[i].DurationSec ||
|
|
a[i].Score != b[i].Score || strings.Join(a[i].Artists, "|") != strings.Join(b[i].Artists, "|") {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|