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
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>
227 lines
7.3 KiB
Go
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)
|
|
}
|
|
}
|