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:
2026-10-06 23:16:52 -04:00
co-authored by Claude Opus 5.5
parent b28cbe0600
commit f13da62797
4 changed files with 621 additions and 0 deletions
+73
View File
@@ -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
}