fix: a track shorter than the fingerprint span keeps its print (#6178)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m26s
release / go (push) Successful in 1m43s
release / integration (push) Successful in 5m14s
release / android (push) Successful in 5m15s
release / Build signed APK (releases and dev) (push) Successful in 5m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m18s
release / Verify release artifacts (tag releases only) (push) Skipped

The image's fpcalc 1.5.1 exits 3 with "Error decoding audio frame (End
of file)" whenever it reaches the end of a file before -length, at any
length, so every track under two minutes failed both the AcoustID lookup
print and the duplicate-detection print: 471 lookups and all 1,979
present short tracks' chromaprints.

- runFingerprintTool returns the tool's stdout beside a toolExitError.
- fpcalcReadToEnd keeps a partial exit whose stderr is the end of file
  and whose declared DURATION is within the span asked for. A damaged
  file that stops early declares more than it read, and still fails.
- Migration 0097 clears the stored failures so the lookup worker and
  the fingerprint backfill take those tracks again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 11:01:23 -04:00
co-authored by Claude Opus 5.5
parent e44025435e
commit cb1f619a21
4 changed files with 118 additions and 3 deletions
+53 -3
View File
@@ -264,7 +264,7 @@ func computeAudioStreamSHA256(ctx context.Context, path string) ([]byte, error)
// lengthSec seconds of the file.
func computeChromaprint(ctx context.Context, path string, lengthSec int32) ([]int32, error) {
out, err := runFingerprintTool(ctx, "fpcalc", fpcalcArgs(path, int(lengthSec)))
if err != nil {
if err != nil && !fpcalcReadToEnd(err, out, int(lengthSec)) {
return nil, err
}
return parseFpcalcRaw(out)
@@ -276,7 +276,7 @@ func computeChromaprint(ctx context.Context, path string, lengthSec int32) ([]in
// rejects as a verdict.
func computeLookupFingerprint(ctx context.Context, path string) (lookupFingerprint, error) {
out, err := runFingerprintTool(ctx, "fpcalc", fpcalcLookupArgs(path))
if err != nil {
if err != nil && !fpcalcReadToEnd(err, out, lookupFingerprintLengthSec) {
return lookupFingerprint{}, err
}
return parseFpcalcCompressed(out)
@@ -289,6 +289,9 @@ func computeLookupFingerprint(ctx context.Context, path string) (lookupFingerpri
// fingerprint of a damaged file is not that file's identity. Stored, it would
// score against a healthy copy over whatever prefix survived, and could group
// or fail to group either way. Absent is better than wrong.
//
// A tool that ran and exited non-zero returns its stdout beside the
// *toolExitError, for the one exit 3 that is not damage (fpcalcReadToEnd).
func runFingerprintTool(ctx context.Context, name string, args []string) ([]byte, error) {
runCtx, cancel := context.WithTimeout(ctx, fingerprintTimeout)
defer cancel()
@@ -311,11 +314,58 @@ func runFingerprintTool(ctx context.Context, name string, args []string) ([]byte
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return nil, fmt.Errorf("%s exited %d: %s", name, exitErr.ExitCode(), stderrTail(exitErr.Stderr))
return out, &toolExitError{name: name, code: exitErr.ExitCode(), stderr: stderrTail(exitErr.Stderr)}
}
return nil, fmt.Errorf("%s: %w", name, err)
}
// toolExitError is a fingerprint tool that ran and exited non-zero: a verdict
// on the file, unlike a stall or a missing tool.
type toolExitError struct {
name string
code int
stderr []byte // the tail, as stderrTail keeps it
}
func (e *toolExitError) Error() string {
return fmt.Sprintf("%s exited %d: %s", e.name, e.code, e.stderr)
}
// fpcalcExitPartial is fpcalc's exit for "reading failed, but here is a
// fingerprint of what I got".
const fpcalcExitPartial = 3
// fpcalcReadToEnd reports whether fpcalc's partial exit only means it decoded
// the whole file (#6178). The image's fpcalc 1.5.1 reports the decoder's end of
// file as "Error decoding audio frame (End of file)" and exits 3 whenever it
// reaches the end before -length, so it failed every file shorter than the
// span asked for: every track under two minutes, at any length setting. That
// print covers the whole file, so it is the file's identity.
//
// A damaged file can stop at an end of file too. Its DURATION, the length the
// container declares, is then longer than the span asked for, and it stays a
// failure.
func fpcalcReadToEnd(err error, out []byte, lengthSec int) bool {
var exitErr *toolExitError
if !errors.As(err, &exitErr) || exitErr.code != fpcalcExitPartial ||
!bytes.Contains(exitErr.stderr, []byte("(End of file)")) {
return false
}
d, ok := fpcalcDuration(out)
return ok && d > 0 && d <= lengthSec
}
// fpcalcDuration reads the DURATION= line of fpcalc's output, in whole seconds.
func fpcalcDuration(out []byte) (int, bool) {
for _, line := range strings.Split(string(out), "\n") {
if v, ok := strings.CutPrefix(strings.TrimSpace(line), "DURATION="); ok {
d, err := strconv.Atoi(v)
return d, err == nil
}
}
return 0, false
}
// stderrTail keeps the END of a failing tool's stderr. ffmpeg and fpcalc print
// the actual reason last, after any banner or per-frame warnings, so a cap that
// kept the head would log the noise and drop the cause.