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) } }) } } 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) } } }