feat(library): AcoustID lookup client and the compressed fpcalc print (M401 #3919)
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>
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
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
|
||||
}
|
||||
@@ -111,6 +111,32 @@ func fpcalcArgs(path string, lengthSec int) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// lookupFingerprintLengthSec is how much audio the AcoustID lookup print
|
||||
// covers (M401). Fixed at fpcalc's default rather than following the
|
||||
// duplicate-detection length setting: AcoustID's database is built from
|
||||
// prints that taggers such as Picard take at the default, and a lookup print
|
||||
// of a different span matches it less well.
|
||||
const lookupFingerprintLengthSec = 120
|
||||
|
||||
// fpcalcLookupArgs asks for the COMPRESSED fingerprint, fpcalc's default
|
||||
// output and the string AcoustID's lookup takes. A second decode beside the
|
||||
// raw print, chosen over re-encoding the stored raw array in Go (M401 D1):
|
||||
// fpcalc is the reference encoder, while a subtle bug in a port would still
|
||||
// produce a well-formed string that AcoustID answers with "no match".
|
||||
func fpcalcLookupArgs(path string) []string {
|
||||
return []string{
|
||||
"-length", strconv.Itoa(lookupFingerprintLengthSec),
|
||||
path,
|
||||
}
|
||||
}
|
||||
|
||||
// lookupFingerprint is what an AcoustID lookup sends for one file.
|
||||
type lookupFingerprint struct {
|
||||
fingerprint string
|
||||
// durationSec is the whole file's length, not the span fingerprinted.
|
||||
durationSec int
|
||||
}
|
||||
|
||||
// fingerprintResult is one attempt at both halves of a track's identity. They
|
||||
// fail independently: a file ffmpeg can demux may still defeat fpcalc.
|
||||
type fingerprintResult struct {
|
||||
@@ -337,3 +363,50 @@ func parseFpcalcRaw(out []byte) ([]int32, error) {
|
||||
}
|
||||
return nil, errors.New("fpcalc printed no FINGERPRINT= line")
|
||||
}
|
||||
|
||||
// parseFpcalcCompressed reads fpcalc's default text output:
|
||||
//
|
||||
// DURATION=<whole seconds>
|
||||
// FINGERPRINT=<URL-safe base64, unpadded>
|
||||
//
|
||||
// The fingerprint is kept as the opaque string fpcalc printed. It is checked
|
||||
// only for being non-empty and in fpcalc's alphabet, which catches a -raw
|
||||
// list or a truncated line; whether it is a GOOD print is AcoustID's to judge.
|
||||
func parseFpcalcCompressed(out []byte) (lookupFingerprint, error) {
|
||||
var fp lookupFingerprint
|
||||
var sawDuration bool
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if v, ok := strings.CutPrefix(line, "DURATION="); ok {
|
||||
d, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return lookupFingerprint{}, fmt.Errorf("fpcalc duration %q: %w", v, err)
|
||||
}
|
||||
fp.durationSec, sawDuration = d, true
|
||||
}
|
||||
if v, ok := strings.CutPrefix(line, "FINGERPRINT="); ok {
|
||||
fp.fingerprint = v
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case fp.fingerprint == "":
|
||||
return lookupFingerprint{}, errors.New("fpcalc printed no fingerprint")
|
||||
case strings.IndexFunc(fp.fingerprint, notURLBase64) >= 0:
|
||||
return lookupFingerprint{}, fmt.Errorf("fpcalc fingerprint %.20q… is not a compressed print", fp.fingerprint)
|
||||
case !sawDuration:
|
||||
return lookupFingerprint{}, errors.New("fpcalc printed no DURATION= line")
|
||||
// AcoustID refuses a duration that is not a positive integer, and a file
|
||||
// under a second long has nothing a lookup could match.
|
||||
case fp.durationSec <= 0:
|
||||
return lookupFingerprint{}, fmt.Errorf("fpcalc duration %d is too short to look up", fp.durationSec)
|
||||
}
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
func notURLBase64(r rune) bool {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -60,6 +60,67 @@ func TestParseFpcalcRaw(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The lookup print is fpcalc's default output, kept as the string it printed.
|
||||
func TestParseFpcalcCompressed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
out string
|
||||
want lookupFingerprint
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "default output",
|
||||
out: "DURATION=641\nFINGERPRINT=AQABz0qUkZK4oOfhL-CPc4e5C_wW2H2QH9uDL4cvoT8UNQ-eHtsE8cceeFJx\n",
|
||||
want: lookupFingerprint{fingerprint: "AQABz0qUkZK4oOfhL-CPc4e5C_wW2H2QH9uDL4cvoT8UNQ-eHtsE8cceeFJx", durationSec: 641},
|
||||
},
|
||||
{
|
||||
name: "line order does not matter",
|
||||
out: "FINGERPRINT=AQAB\nDURATION=3\n",
|
||||
want: lookupFingerprint{fingerprint: "AQAB", durationSec: 3},
|
||||
},
|
||||
// A -raw invocation by mistake: a list of numbers is not a compressed
|
||||
// print, and sent to AcoustID it would only come back as "no match".
|
||||
{name: "raw list is refused", out: "DURATION=213\nFINGERPRINT=-1453821711,17\n", wantErr: "not a compressed print"},
|
||||
{name: "standard base64 is refused", out: "DURATION=213\nFINGERPRINT=AQAB+/==\n", wantErr: "not a compressed print"},
|
||||
{name: "empty fingerprint", out: "DURATION=213\nFINGERPRINT=\n", wantErr: "no fingerprint"},
|
||||
{name: "no duration", out: "FINGERPRINT=AQAB\n", wantErr: "no DURATION= line"},
|
||||
{name: "zero duration", out: "DURATION=0\nFINGERPRINT=AQAB\n", wantErr: "too short"},
|
||||
{name: "fractional duration", out: "DURATION=12.5\nFINGERPRINT=AQAB\n", wantErr: "duration"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseFpcalcCompressed([]byte(tc.out))
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("err = %v, want one containing %q", err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("got %+v, want %+v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFpcalcLookupArgs_RequestCompressedOutput(t *testing.T) {
|
||||
args := fpcalcLookupArgs("/music/a.flac")
|
||||
for _, flag := range []string{"-raw", "-signed"} {
|
||||
if slices.Contains(args, flag) {
|
||||
t.Errorf("fpcalcLookupArgs has %s, so fpcalc would not print the compressed form: %v", flag, args)
|
||||
}
|
||||
}
|
||||
if !followedBy(args, "-length", "120") {
|
||||
t.Errorf("fpcalcLookupArgs does not fingerprint fpcalc's default 120s: %v", args)
|
||||
}
|
||||
if args[len(args)-1] != "/music/a.flac" {
|
||||
t.Errorf("path is not last: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStreamHash(t *testing.T) {
|
||||
// The real value ffmpeg printed for both files of the #3885 pair.
|
||||
const www = "24e2daa3b4a534ff1a8d1a76f67810205869daf89f728d83a16625da4d28a18e"
|
||||
|
||||
Reference in New Issue
Block a user