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:
@@ -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