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{} scanner := New(pool, logger, []string{root}) scanner.fingerprint = func(_ context.Context, path string) fingerprintResult { calls[path]++ 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 } 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 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) 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 { t.Fatalf("stored %+v, want sha %x chromaprint %v version %d", got, sum, chroma, fingerprintVersion) } // 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) } }