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>
298 lines
11 KiB
Go
298 lines
11 KiB
Go
package library
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseFpcalcRaw(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
out string
|
|
want []int32
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "signed output with negatives",
|
|
out: "DURATION=213\nFINGERPRINT=-1453821711,17,0,2147483647,-2147483648\n",
|
|
want: []int32{-1453821711, 17, 0, 2147483647, -2147483648},
|
|
},
|
|
{
|
|
name: "fingerprint line need not come second",
|
|
out: "FINGERPRINT=5,6\nDURATION=1\n",
|
|
want: []int32{5, 6},
|
|
},
|
|
{
|
|
// fpcalc's default is uint32. This value only appears when -signed
|
|
// is missing, and storing it would need a reinterpretation nothing
|
|
// performs.
|
|
name: "unsigned output is refused",
|
|
out: "DURATION=213\nFINGERPRINT=2841145585,17\n",
|
|
wantErr: "item 0",
|
|
},
|
|
{name: "empty fingerprint", out: "DURATION=0\nFINGERPRINT=\n", wantErr: "empty fingerprint"},
|
|
{name: "no fingerprint line", out: "DURATION=213\n", wantErr: "no FINGERPRINT= line"},
|
|
{name: "non-numeric item", out: "FINGERPRINT=1,x,3\n", wantErr: "item 1"},
|
|
{name: "trailing comma", out: "FINGERPRINT=1,2,\n", wantErr: "item 2"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := parseFpcalcRaw([]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 !slices.Equal(got, tc.want) {
|
|
t.Fatalf("got %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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"
|
|
|
|
got, err := parseStreamHash([]byte("SHA256=" + www + "\n"))
|
|
if err != nil {
|
|
t.Fatalf("unexpected err: %v", err)
|
|
}
|
|
if len(got) != 32 || got[0] != 0x24 || got[31] != 0x8e {
|
|
t.Fatalf("decoded %x, want %s", got, www)
|
|
}
|
|
|
|
for name, out := range map[string]string{
|
|
"no hash line": "",
|
|
"other hash": "MD5=" + www[:32] + "\n",
|
|
"not hex": "SHA256=" + strings.Repeat("zz", 32) + "\n",
|
|
"short digest": "SHA256=" + www[:62] + "\n",
|
|
"odd hex chars": "SHA256=" + www[:63] + "\n",
|
|
} {
|
|
if _, err := parseStreamHash([]byte(out)); err == nil {
|
|
t.Errorf("%s: parsed %q without error", name, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// followedBy reports whether flag appears in args immediately followed by value.
|
|
func followedBy(args []string, flag, value string) bool {
|
|
for i := 0; i+1 < len(args); i++ {
|
|
if args[i] == flag && args[i+1] == value {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// The exact tier's stored hashes must stay comparable across ffmpeg upgrades,
|
|
// which only holds while the packets are copied rather than decoded. A decoded
|
|
// hash still matches within one ffmpeg build, so nothing else would notice the
|
|
// change until an image upgrade silently broke every stored value.
|
|
func TestStreamHashArgs_HashPacketsNotSamples(t *testing.T) {
|
|
args := streamHashArgs("/music/a.mp3")
|
|
for _, pair := range [][2]string{
|
|
{"-c:a", "copy"}, // no decode
|
|
{"-map", "0:a"}, // audio only: cover art stays out of the hash
|
|
{"-f", "hash"}, // the hash muxer, not a file
|
|
{"-hash", "sha256"},
|
|
{"-i", "/music/a.mp3"},
|
|
} {
|
|
if !followedBy(args, pair[0], pair[1]) {
|
|
t.Errorf("streamHashArgs lacks %s %s: %v", pair[0], pair[1], args)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFpcalcArgs_RequestSignedRawOutput(t *testing.T) {
|
|
args := fpcalcArgs("/music/a.flac", 90)
|
|
for _, flag := range []string{"-raw", "-signed"} {
|
|
if !slices.Contains(args, flag) {
|
|
t.Errorf("fpcalcArgs lacks %s: %v", flag, args)
|
|
}
|
|
}
|
|
if !followedBy(args, "-length", "90") {
|
|
t.Errorf("fpcalcArgs does not pass the requested length: %v", args)
|
|
}
|
|
// fpcalc takes the file as its trailing positional argument.
|
|
if args[len(args)-1] != "/music/a.flac" {
|
|
t.Errorf("path is not last: %v", args)
|
|
}
|
|
}
|
|
|
|
func TestStderrTail_KeepsTheCauseNotTheBanner(t *testing.T) {
|
|
banner := bytes.Repeat([]byte("warning: skipping frame\n"), fpcalcStderrTail)
|
|
got := stderrTail(append(banner, []byte("ERROR: could not decode\n")...))
|
|
if len(got) != fpcalcStderrTail {
|
|
t.Fatalf("tail is %d bytes, want the %d-byte cap", len(got), fpcalcStderrTail)
|
|
}
|
|
if !bytes.HasSuffix(got, []byte("ERROR: could not decode")) {
|
|
t.Fatalf("tail dropped the final line: ...%q", got[len(got)-40:])
|
|
}
|
|
|
|
if got := stderrTail([]byte(" short \n")); string(got) != "short" {
|
|
t.Fatalf("short stderr = %q, want it trimmed and whole", got)
|
|
}
|
|
}
|
|
|
|
// A stored failure is permanent until the file changes, so the classification
|
|
// decides whether a track is ever retried. Every inconclusive case here would,
|
|
// if misfiled as a verdict, silently exclude that track from duplicate
|
|
// detection for good.
|
|
func TestIsInconclusive(t *testing.T) {
|
|
notInstalled := fmt.Errorf("fpcalc: %w", &exec.Error{Name: "fpcalc", Err: exec.ErrNotFound})
|
|
cases := []struct {
|
|
name string
|
|
err error
|
|
want bool
|
|
}{
|
|
{"timeout", fmt.Errorf("fpcalc: no result: %w", errFingerprintTimeout), true},
|
|
{"scan cancelled", fmt.Errorf("ffmpeg: %w", context.Canceled), true},
|
|
{"caller deadline", fmt.Errorf("ffmpeg: %w", context.DeadlineExceeded), true},
|
|
{"tool not installed", notInstalled, true},
|
|
{"tool rejected the file", errors.New("fpcalc exited 2: could not decode"), false},
|
|
{"unparseable output", errors.New("fpcalc printed no FINGERPRINT= line"), false},
|
|
{"success", nil, false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := isInconclusive(tc.err); got != tc.want {
|
|
t.Errorf("%s: isInconclusive = %v, want %v", tc.name, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Either half being inconclusive taints the whole result: storing the half that
|
|
// succeeded would stamp the row at the current version with the other half
|
|
// NULL, and that NULL would then read as a verdict.
|
|
func TestFingerprintResult_InconclusiveIfEitherHalfIs(t *testing.T) {
|
|
stall := fmt.Errorf("fpcalc: %w", errFingerprintTimeout)
|
|
rejected := errors.New("fpcalc exited 2")
|
|
for name, tc := range map[string]struct {
|
|
r fingerprintResult
|
|
want bool
|
|
}{
|
|
"both succeeded": {fingerprintResult{streamSHA256: []byte{1}, chromaprint: []int32{1}}, false},
|
|
"hash ok, print stalled": {fingerprintResult{streamSHA256: []byte{1}, printErr: stall}, true},
|
|
"hash stalled, print ok": {fingerprintResult{hashErr: stall, chromaprint: []int32{1}}, true},
|
|
"hash ok, print rejected": {fingerprintResult{streamSHA256: []byte{1}, printErr: rejected}, false},
|
|
"both rejected by the file": {fingerprintResult{hashErr: rejected, printErr: rejected}, false},
|
|
} {
|
|
if got := tc.r.inconclusive(); got != tc.want {
|
|
t.Errorf("%s: inconclusive = %v, want %v", name, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// fpcalc 1.5.1 exits 3 with "(End of file)" on every file shorter than the
|
|
// span asked for (#6178). That print covers the whole file and is kept; a file
|
|
// whose declared length runs past the span stopped early, and is not.
|
|
func TestFpcalcReadToEnd(t *testing.T) {
|
|
eof := []byte("ERROR: Error decoding audio frame (End of file)")
|
|
out := func(sec int) []byte { return []byte(fmt.Sprintf("DURATION=%d\nFINGERPRINT=AQAB\n", sec)) }
|
|
for name, tc := range map[string]struct {
|
|
err error
|
|
out []byte
|
|
want bool
|
|
}{
|
|
"short file read to its end": {&toolExitError{name: "fpcalc", code: 3, stderr: eof}, out(37), true},
|
|
"exactly the span": {&toolExitError{name: "fpcalc", code: 3, stderr: eof}, out(120), true},
|
|
"declared longer than read": {&toolExitError{name: "fpcalc", code: 3, stderr: eof}, out(150), false},
|
|
"partial for another reason": {&toolExitError{name: "fpcalc", code: 3, stderr: []byte("ERROR: invalid data")}, out(37), false},
|
|
"a different exit": {&toolExitError{name: "fpcalc", code: 2, stderr: eof}, out(37), false},
|
|
"no duration printed": {&toolExitError{name: "fpcalc", code: 3, stderr: eof}, []byte("FINGERPRINT=AQAB\n"), false},
|
|
"not a tool exit (a stall)": {fmt.Errorf("fpcalc: %w", errFingerprintTimeout), out(37), false},
|
|
"wrapped exit is still judged": {fmt.Errorf("lookup: %w", &toolExitError{name: "fpcalc", code: 3, stderr: eof}), out(37), true},
|
|
} {
|
|
if got := fpcalcReadToEnd(tc.err, tc.out, 120); got != tc.want {
|
|
t.Errorf("%s: fpcalcReadToEnd = %v, want %v", name, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The output of a tool that exited non-zero reaches the caller, or
|
|
// fpcalcReadToEnd has nothing to read; the message is unchanged.
|
|
func TestRunFingerprintTool_KeepsOutputOnExit(t *testing.T) {
|
|
out, err := runFingerprintTool(context.Background(), "sh",
|
|
[]string{"-c", "echo DURATION=37; echo 'ERROR: Error decoding audio frame (End of file)' >&2; exit 3"})
|
|
if err == nil {
|
|
t.Fatal("exit 3 returned no error")
|
|
}
|
|
if want := "sh exited 3: ERROR: Error decoding audio frame (End of file)"; err.Error() != want {
|
|
t.Errorf("err = %q, want %q", err, want)
|
|
}
|
|
if d, ok := fpcalcDuration(out); !ok || d != 37 {
|
|
t.Errorf("output = %q, want the DURATION line kept", out)
|
|
}
|
|
}
|