test-go / test (push) Failing after 44s
test-web / test (push) Successful in 49s
test-go / integration (push) Failing after 2m42s
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 4m8s
Rule 25: the fingerprinting knobs move out of source into a DB-backed singleton (migration 0061), edited from a card on the Duplicates page and shared live with the scanner, the backfill and the duplicate sweep through one service instance, so a save needs no restart. The length is the knob that can silently break the library: prints taken at two lengths never match. Each track_fingerprints row now records the length it was taken at, and every reader filters on the current one — the backfill treats another length as stale, the gauge counts it pending, the sweep never streams it. Equivalent to a version bump, except that setting the length back makes rows not yet redone current again. The card warns before a length change re-fingerprints the library. Off stops every decode: the scan takes only the stream hash (a demux, and what recognises a moved file) and stores nothing, dropping a changed file's stale row; the backfill idles. A save also makes a sweep due, since a new threshold or length changes what the same prints group into, and the sweep interval gains slack so an hourly interval on an hourly tick doesn't skip every other tick. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
231 lines
7.9 KiB
Go
231 lines
7.9 KiB
Go
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)
|
|
}
|
|
}
|