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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user