package library import ( "bytes" "context" "errors" "fmt" "io" "log/slog" "os" "path/filepath" "slices" "testing" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db" ) // TestScanner_FingerprintsOnlyNewOrChangedBytes_Integration pins WHEN the scan // fingerprints. The cost of getting it wrong is asymmetric and invisible: a // scan that re-fingerprints unchanged files still produces correct rows, just // by decoding the entire library on every tag-repair pass. // // The fingerprinter is stubbed. CI has no real audio, and the tools' output is // covered by the parser tests; this covers the scan's decisions. func TestScanner_FingerprintsOnlyNewOrChangedBytes_Integration(t *testing.T) { if testing.Short() { t.Skip("skipping scanner integration in -short mode") } dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") if dsn == "" { t.Skip("MINSTREL_TEST_DATABASE_URL not set") } ctx := context.Background() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) if err := db.Migrate(dsn, logger); err != nil { t.Fatalf("migrate: %v", err) } pool, err := pgxpool.New(ctx, dsn) if err != nil { t.Fatalf("pool: %v", err) } t.Cleanup(pool.Close) if _, err := pool.Exec(ctx, "TRUNCATE tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { t.Fatalf("truncate: %v", err) } root := t.TempDir() a := filepath.Join(root, "artist/album/01.mp3") b := filepath.Join(root, "artist/album/02.mp3") writeTestMP3(t, a, map[string]string{"TIT2": "One", "TPE1": "Artist", "TALB": "Album", "TRCK": "1"}) writeTestMP3(t, b, map[string]string{"TIT2": "Two", "TPE1": "Artist", "TALB": "Album", "TRCK": "2"}) sum := bytes.Repeat([]byte{0xAB}, 32) chroma := []int32{7, -7, 2147483647} result := fingerprintResult{streamSHA256: sum, chromaprint: chroma} calls := map[string]int{} settings, err := NewFingerprintSettingsService(ctx, pool) if err != nil { t.Fatalf("fingerprint settings: %v", err) } if _, err := settings.Set(ctx, DefaultFingerprintSettings); err != nil { t.Fatalf("reset fingerprint settings: %v", err) } t.Cleanup(func() { if _, err := settings.Set(context.Background(), DefaultFingerprintSettings); err != nil { t.Errorf("restore fingerprint settings: %v", err) } }) var lastOpts fingerprintOptions scanner := New(pool, logger, []string{root}, settings) scanner.fingerprint = func(_ context.Context, path string, opts fingerprintOptions) fingerprintResult { calls[path]++ lastOpts = opts return result } scan := func(step string) Stats { t.Helper() st, err := scanner.Scan(ctx, nil) if err != nil { t.Fatalf("%s: scan: %v", step, err) } return st } type row struct { sha []byte chroma []int32 version int16 length int32 } stored := func(path string) (row, bool) { t.Helper() var r row err := pool.QueryRow(ctx, ` SELECT f.audio_stream_sha256, f.chromaprint, f.fingerprint_version, f.chromaprint_length_sec FROM track_fingerprints f JOIN tracks t ON t.id = f.track_id WHERE t.file_path = $1`, path).Scan(&r.sha, &r.chroma, &r.version, &r.length) if errors.Is(err, pgx.ErrNoRows) { return row{}, false } if err != nil { t.Fatalf("read fingerprint for %s: %v", path, err) } return r, true } // A later step moves mtime forward past the row's updated_at, which is // what the scan reads as "these bytes changed". touch := func(path string, ahead time.Duration) { t.Helper() when := time.Now().Add(ahead) if err := os.Chtimes(path, when, when); err != nil { t.Fatalf("chtimes %s: %v", path, err) } } // 1. New files are fingerprinted, and stored at the current version. scan("first scan") if calls[a] != 1 || calls[b] != 1 { t.Fatalf("first scan fingerprint calls = %v, want one per file", calls) } got, ok := stored(a) if !ok { t.Fatal("first scan stored no fingerprint") } if !bytes.Equal(got.sha, sum) || !slices.Equal(got.chroma, chroma) || got.version != fingerprintVersion || got.length != defaultChromaprintLengthSec { t.Fatalf("stored %+v, want sha %x chromaprint %v version %d length %d", got, sum, chroma, fingerprintVersion, defaultChromaprintLengthSec) } if !lastOpts.chromaprint || lastOpts.lengthSec != defaultChromaprintLengthSec { t.Fatalf("first scan asked for %+v, want a chromaprint at the default length", lastOpts) } // 2. A tag-repair pass re-reads every unchanged file and must not // fingerprint any of them again. // // The Updated count is what makes this able to fail. Without it, a scan // that simply SKIPPED both files would also leave the call counts at one, // and the assertion would pass without the re-read path ever running. if _, err := pool.Exec(ctx, "UPDATE tracks SET duration_ms = 1000, tag_read_version = 0"); err != nil { t.Fatalf("force tag re-read: %v", err) } if st := scan("tag-repair scan"); st.Updated != 2 || st.Skipped != 0 { t.Fatalf("tag-repair scan stats = %+v, want both files re-read (Updated=2 Skipped=0)", st) } if calls[a] != 1 || calls[b] != 1 { t.Fatalf("tag-repair scan re-fingerprinted unchanged files: calls = %v", calls) } if _, ok := stored(a); !ok { t.Fatal("tag-repair scan dropped a stored fingerprint") } // 3. Bytes that changed are fingerprinted again, and only those. touch(a, time.Hour) scan("changed-file scan") if calls[a] != 2 || calls[b] != 1 { t.Fatalf("changed-file scan calls = %v, want a=2 b=1", calls) } // 4. A changed file whose attempt is inconclusive loses its old row: that // row describes the previous bytes, and a stall says nothing about the new // ones. result = fingerprintResult{streamSHA256: sum, printErr: fmt.Errorf("fpcalc: %w", errFingerprintTimeout)} touch(a, 2*time.Hour) scan("inconclusive scan") if _, ok := stored(a); ok { t.Fatal("inconclusive attempt left the previous bytes' fingerprint in place") } if _, ok := stored(b); !ok { t.Fatal("inconclusive attempt on one file removed another file's fingerprint") } // 5. A file the tools reject gets a row at the current version with both // halves NULL — a verdict, so the backfill does not retry it every boot. result = fingerprintResult{ hashErr: errors.New("ffmpeg exited 1"), printErr: errors.New("fpcalc exited 2"), } touch(a, 3*time.Hour) scan("rejected scan") got, ok = stored(a) if !ok { t.Fatal("a file the tools rejected got no row, so the backfill would retry it forever") } if got.sha != nil || got.chroma != nil || got.version != fingerprintVersion { t.Fatalf("rejected file stored %+v, want both halves NULL at version %d", got, fingerprintVersion) } // 6. With fingerprinting off (#3913) the scan decodes nothing. It still asks // for the stream hash — a demux, and what recognises a moved file — but stores // no row, and a changed file's old row goes: it describes bytes that are gone. result = fingerprintResult{streamSHA256: sum, chromaprint: chroma} off := DefaultFingerprintSettings off.Enabled = false if _, err := settings.Set(ctx, off); err != nil { t.Fatalf("switch fingerprinting off: %v", err) } touch(a, 4*time.Hour) scan("fingerprinting-off scan") if calls[a] != 5 || lastOpts.chromaprint { t.Fatalf("fingerprinting-off scan: calls = %v, last options %+v; want a fifth call asking for no chromaprint", calls, lastOpts) } if _, ok := stored(a); ok { t.Fatal("with fingerprinting off, a changed file kept the previous bytes' fingerprint") } if _, ok := stored(b); !ok { t.Fatal("with fingerprinting off, an unchanged file lost its fingerprint") } // 7. The length setting reaches the scan, and is stored with the row. longer := DefaultFingerprintSettings longer.ChromaprintLengthSec = 90 if _, err := settings.Set(ctx, longer); err != nil { t.Fatalf("change length: %v", err) } touch(a, 5*time.Hour) scan("new-length scan") got, ok = stored(a) if !ok || got.length != 90 || lastOpts.lengthSec != 90 || !lastOpts.chromaprint { t.Fatalf("new-length scan stored %+v (present %v) after asking for %+v; want a chromaprint at 90s", got, ok, lastOpts) } }