test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 3m28s
release / Build signed APK (releases and dev) (push) Successful in 4m38s
release / Build + push container image (push) Successful in 1m26s
release / Verify release artifacts (tag releases only) (push) Skipped
Two identities per track, because they answer different questions: - audio_stream_sha256: SHA-256 of the ENCODED audio packets (ffmpeg -map 0:a -c:a copy -f hash). Equal means identical audio whatever the tags say. Measured against the #3885 pair: the two WWW files hash identically here and differently as whole files. Packets rather than decoded samples, so an ffmpeg upgrade cannot silently change every stored hash, and nothing is decoded. - chromaprint: fpcalc -raw -signed. The same recording at another bitrate or codec, for the acoustic tier. fpcalc ships in the image (libchromaprint-tools); shelled out because CGO_ENABLED=0 rules out bindings. Stored in a track_fingerprints table rather than on tracks: eight queries read tracks with SELECT *, including album pages, search and the Subsonic surface, and a ~4 KB array there would be de-TOASTed on every one of them. The scan fingerprints only bytes it has not seen (a new path, or mtime past the row's). A tag-repair pass leaves fingerprints alone, and unchanged files with no fingerprint are the backfill's job (#3908). Folding that into the skip check would re-decode the whole library on the first scan after upgrade and push a sync change per track. A failure that says nothing about the file (timeout, cancelled scan, tool not installed) is never stored, and on changed bytes it removes the old row. A tool that rejects the file stores NULL at the current version, so the backfill does not retry it every boot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
195 lines
6.6 KiB
Go
195 lines
6.6 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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|