Files
minstrel/internal/library/fingerprint_backfill_test.go
T
bvandeusenandClaude Opus 5.5 7e6228c13f
release / govulncheck (push) Successful in 20s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m26s
release / Build signed APK (releases and dev) (push) Successful in 5m45s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
docs: library comments say what the scanner and resolver do, not how they came to be (#6277)
Rewrites the internal/library comment blocks to rule 32. Task, milestone,
decision and rule ids are gone; the incidents behind them are restated as
the constraint they left (why a file is deleted before its row, why the
missing mark is a mark, why duplicates block on duration). Counts and
album names from one library are dropped from production comments; test
comments keep the names their fixtures use. The tagReadVersion log keeps
each version's before and after, without the measurements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:27:34 -04:00

227 lines
7.3 KiB
Go

package library
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"maps"
"path/filepath"
"sync"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// TestFingerprintBackfill_Integration pins which tracks a pass touches, that a
// pass ends, and that the coverage gauge counts what the pass wrote.
func TestFingerprintBackfill_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
dir := t.TempDir()
_, album, artist := seedTrack(t, pool, filepath.Join(dir, "unfingerprinted.mp3"))
addTrack := func(name string) dbq.Track {
t.Helper()
tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: name, AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: filepath.Join(dir, name+".mp3"), FileSize: 100, FileFormat: "mp3",
})
if err != nil {
t.Fatalf("track %s: %v", name, err)
}
return tr
}
current := addTrack("current")
stale := addTrack("stale")
missing := addTrack("missing")
sum := bytes.Repeat([]byte{0xCD}, 32)
for _, seed := range []struct {
track dbq.Track
version int16
}{
{current, fingerprintVersion},
{stale, fingerprintVersion - 1},
} {
if err := q.UpsertTrackFingerprint(ctx, dbq.UpsertTrackFingerprintParams{
TrackID: seed.track.ID, AudioStreamSha256: sum, Chromaprint: []int32{1},
FingerprintVersion: seed.version, ChromaprintLengthSec: defaultChromaprintLengthSec,
}); err != nil {
t.Fatalf("seed fingerprint: %v", err)
}
}
if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", missing.ID); err != nil {
t.Fatalf("mark missing: %v", err)
}
var mu sync.Mutex
calls := map[string]int{}
settings, err := NewFingerprintSettingsService(ctx, pool)
if err != nil {
t.Fatalf("fingerprint settings: %v", err)
}
t.Cleanup(func() {
if _, err := settings.Set(context.Background(), DefaultFingerprintSettings); err != nil {
t.Errorf("restore fingerprint settings: %v", err)
}
})
w := NewFingerprintBackfillWorker(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), settings)
// A batch of one forces the keyset cursor across several queries in a pass.
w.batch = 1
lengths := map[int32]int{}
w.fingerprint = func(_ context.Context, path string, opts fingerprintOptions) fingerprintResult {
name := filepath.Base(path)
mu.Lock()
calls[name]++
lengths[opts.lengthSec]++
mu.Unlock()
switch name {
case "stall.mp3":
return fingerprintResult{streamSHA256: sum, printErr: fmt.Errorf("fpcalc: %w", errFingerprintTimeout)}
case "rejected.mp3":
return fingerprintResult{hashErr: errors.New("ffmpeg exited 1"), printErr: errors.New("fpcalc exited 2")}
default:
return fingerprintResult{streamSHA256: sum, chromaprint: []int32{7, -7}}
}
}
callCount := func(name string) int {
mu.Lock()
defer mu.Unlock()
return calls[name]
}
// 1. Only the track with no row and the stale one are fingerprinted — never
// the current one, never the missing one.
res, err := w.pass(ctx)
if err != nil {
t.Fatalf("first pass: %v", err)
}
if res.Processed != 2 || res.Fingerprinted != 2 {
t.Fatalf("first pass = %+v, want 2 processed, 2 fingerprinted", res)
}
for name, want := range map[string]int{
"unfingerprinted.mp3": 1, "stale.mp3": 1, "current.mp3": 0, "missing.mp3": 0,
} {
if got := callCount(name); got != want {
t.Errorf("%s fingerprinted %d times, want %d", name, got, want)
}
}
// 2. A pass after a complete one is a no-op. A backfill that redoes its work
// every hour is the expensive way this could be wrong.
res, err = w.pass(ctx)
if err != nil {
t.Fatalf("second pass: %v", err)
}
if res.Processed != 0 {
t.Fatalf("second pass processed %d tracks, want 0", res.Processed)
}
// 3. An inconclusive file is tried exactly once and the pass ENDS. Without the
// keyset cursor it would be re-listed immediately and this call would never
// return.
addTrack("stall")
addTrack("rejected")
res, err = w.pass(ctx)
if err != nil {
t.Fatalf("third pass: %v", err)
}
if res.Processed != 2 || res.Inconclusive != 1 || res.Rejected != 1 {
t.Fatalf("third pass = %+v, want 2 processed, 1 inconclusive, 1 rejected", res)
}
if got := callCount("stall.mp3"); got != 1 {
t.Fatalf("stalling file tried %d times in one pass, want exactly 1", got)
}
// 4. The gauge counts what the passes wrote, and its buckets add up.
cov, err := FingerprintCoverage(ctx, pool, settings.Get())
if err != nil {
t.Fatalf("coverage: %v", err)
}
// Five present tracks: unfingerprinted, current, stale, stall, rejected.
// The missing track is not counted.
if cov.Total != 5 || cov.Fingerprinted != 3 || cov.Rejected != 1 || cov.Pending != 1 {
t.Errorf("coverage = %+v, want total 5, fingerprinted 3, rejected 1, pending 1", cov)
}
if cov.Fingerprinted+cov.Rejected+cov.Pending != cov.Total {
t.Errorf("coverage buckets %+v do not sum to the total", cov)
}
// 5. Changing the length makes every stored row stale at once — the
// gauge shows the whole library pending before the backfill has touched a
// file — and the next pass re-derives each at the new length. The failure
// this prevents is invisible from the UI: prints at two lengths that
// silently never match.
shorter := DefaultFingerprintSettings
shorter.ChromaprintLengthSec = 60
if _, err := settings.Set(ctx, shorter); err != nil {
t.Fatalf("change length: %v", err)
}
cov, err = FingerprintCoverage(ctx, pool, settings.Get())
if err != nil {
t.Fatalf("coverage after length change: %v", err)
}
if cov.Total != 5 || cov.Pending != 5 {
t.Fatalf("coverage after length change = %+v, want all 5 tracks pending", cov)
}
mu.Lock()
clear(lengths)
mu.Unlock()
res, err = w.pass(ctx)
if err != nil {
t.Fatalf("new-length pass: %v", err)
}
if res.Processed != 5 {
t.Fatalf("new-length pass = %+v, want all 5 present tracks re-derived", res)
}
mu.Lock()
asked := maps.Clone(lengths)
mu.Unlock()
if len(asked) != 1 || asked[60] != 5 {
t.Fatalf("new-length pass asked for lengths %v, want 60s for all 5", asked)
}
var atOldLength int
if err := pool.QueryRow(ctx,
"SELECT count(*) FROM track_fingerprints WHERE chromaprint_length_sec <> 60").Scan(&atOldLength); err != nil {
t.Fatalf("count old-length rows: %v", err)
}
if atOldLength != 0 {
t.Fatalf("%d fingerprints are still at the old length after a complete pass", atOldLength)
}
// 6. Switched off, the backfill does nothing, even with work waiting.
off := DefaultFingerprintSettings
off.Enabled = false
if _, err := settings.Set(ctx, off); err != nil {
t.Fatalf("switch fingerprinting off: %v", err)
}
addTrack("later")
res, err = w.pass(ctx)
if err != nil {
t.Fatalf("pass with fingerprinting off: %v", err)
}
if res.Processed != 0 || callCount("later.mp3") != 0 {
t.Fatalf("pass with fingerprinting off = %+v (later.mp3 tried %d times), want nothing done",
res, callCount("later.mp3"))
}
}
func TestBackfillFingerprintsResult_Add(t *testing.T) {
var r BackfillFingerprintsResult
for _, o := range []fingerprintOutcome{
outcomeFingerprinted, outcomeFingerprinted, outcomeRejected, outcomeInconclusive, outcomeStoreFailed,
} {
r.add(o)
}
// A failed write stored nothing, so like an inconclusive attempt it is
// tried again next pass — and counts as such.
want := BackfillFingerprintsResult{Processed: 5, Fingerprinted: 2, Rejected: 1, Inconclusive: 2}
if r != want {
t.Errorf("tally = %+v, want %+v", r, want)
}
}