feat(library): backfill fingerprints for the existing library — M400 #3908
test-web / test (push) Failing after 50s
test-go / test (push) Successful in 1m7s
test-go / integration (push) Successful in 3m27s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 4m23s
test-web / test (push) Failing after 50s
test-go / test (push) Successful in 1m7s
test-go / integration (push) Successful in 3m27s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 4m23s
The scan fingerprints only bytes it has not seen, so everything imported before fingerprinting existed, and any row derived by an older fingerprintVersion, needs a pass of its own. That pass is a background worker, not a stage in RunScan. RunScan runs at boot and then every 12h, and an in-flight scan older than an hour is reaped and a second started beside it. A stage would have to stop inside the hour: a few hundred decodes a run, so about a month for a 50k-track library. It would also hold the run in flight and answer manual rescans with 409 while it worked. FingerprintBackfillWorker runs once at start, then hourly. Nothing a pass does (error or panic) can stop the next tick. A pass walks tracks with no fingerprint or a stale version, skipping missing tracks, keyset-paged on id. The cursor is what lets a pass end: an inconclusive attempt writes no row, so a file that keeps timing out would otherwise be re-listed and retried forever. Two decodes at a time, deliberately: they compete with transcoding for CPU and with streaming for the mount. storeFingerprint is now one package function shared by the scan and the worker, and reports whether the attempt was fingerprinted, rejected, inconclusive or failed to store. Progress is a live gauge on the Admin scan card, served by GET /api/admin/library/fingerprints: fingerprinted / rejected / pending of total, with missing tracks excluded so it can reach the end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"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,
|
||||
}); 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{}
|
||||
w := NewFingerprintBackfillWorker(pool, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
// A batch of one forces the keyset cursor across several queries in a pass.
|
||||
w.batch = 1
|
||||
w.fingerprint = func(_ context.Context, path string) fingerprintResult {
|
||||
name := filepath.Base(path)
|
||||
mu.Lock()
|
||||
calls[name]++
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user