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>
232 lines
7.9 KiB
Go
232 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 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)
|
|
}
|
|
}
|