package library import ( "bytes" "context" "io" "log/slog" "path/filepath" "sort" "strings" "testing" "time" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" ) func TestCoveredByDismissal(t *testing.T) { dismissed := []map[string]struct{}{{"a": {}, "b": {}, "c": {}}} for _, tc := range []struct { name string members []string want bool }{ {"the same set", []string{"a", "b", "c"}, true}, {"a subset of it", []string{"a", "b"}, true}, // A new copy joining is new evidence: ask again. {"a superset of it", []string{"a", "b", "c", "d"}, false}, {"overlapping only in part", []string{"a", "d"}, false}, {"unrelated", []string{"x", "y"}, false}, } { if got := coveredByDismissal(tc.members, dismissed); got != tc.want { t.Errorf("%s: coveredByDismissal = %v, want %v", tc.name, got, tc.want) } } } // TestDuplicateSweep_Integration pins what the sweep proposes, what it leaves out, // and how re-sweeping treats a dismissal and a proposal that no longer holds. func TestDuplicateSweep_Integration(t *testing.T) { pool := newPool(t) ctx := context.Background() q := dbq.New(pool) dir := t.TempDir() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) // seedTrack's own track has no fingerprint row: it must be absent from the // report, not grouped with every other track lacking one. _, album, artist := seedTrack(t, pool, filepath.Join(dir, "unfingerprinted.mp3")) hash := func(b byte) []byte { return bytes.Repeat([]byte{b}, 32) } add := func(name string, durationMs int32, sum []byte, print []int32) string { t.Helper() tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ Title: name, AlbumID: album.ID, ArtistID: artist.ID, DurationMs: durationMs, FilePath: filepath.Join(dir, name+".mp3"), FileSize: 100, FileFormat: "mp3", }) if err != nil { t.Fatalf("track %s: %v", name, err) } if err := q.UpsertTrackFingerprint(ctx, dbq.UpsertTrackFingerprintParams{ TrackID: tr.ID, AudioStreamSha256: sum, Chromaprint: print, FingerprintVersion: fingerprintVersion, ChromaprintLengthSec: defaultChromaprintLengthSec, }); err != nil { t.Fatalf("fingerprint %s: %v", name, err) } return syncpkg.FormatUUID(tr.ID) } key := func(ids ...string) string { sorted := append([]string(nil), ids...) sort.Strings(sorted) return strings.Join(sorted, ",") } recording := randomPrint(200, printLen) onAlbum := add("recording-album", 240000, hash(1), recording) onCompilation := add("recording-compilation", 241000, hash(2), withBitNoise(recording, 0.05, 201)) www1 := add("www-01", 215000, hash(9), randomPrint(210, printLen)) www2 := add("www-02", 215000, hash(9), randomPrint(210, printLen)) // Near-identical duration to the recording, different audio. add("different-song", 240500, hash(3), randomPrint(220, printLen)) // Identical to the album copy, but its file is gone: nothing to compare. missing := add("missing-copy", 240000, hash(4), recording) if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE file_path LIKE '%missing-copy.mp3'"); err != nil { t.Fatalf("mark missing: %v", err) } type stored struct { tier, status string } groups := func() map[string]stored { t.Helper() rows, err := pool.Query(ctx, `SELECT member_key, tier, status FROM duplicate_groups`) if err != nil { t.Fatalf("read groups: %v", err) } defer rows.Close() out := map[string]stored{} for rows.Next() { var k string var s stored if err := rows.Scan(&k, &s.tier, &s.status); err != nil { t.Fatalf("scan group: %v", err) } out[k] = s } return out } // 1. A page size of one forces the keyset cursor across every candidate. res, err := runDuplicateSweep(ctx, pool, logger, DefaultFingerprintSettings, 1) if err != nil { t.Fatalf("first sweep: %v", err) } // Five tracks carry a chromaprint and a present file. if res.Candidates != 5 || res.Groups != 2 || res.Proposed != 2 { t.Fatalf("first sweep = %+v, want 5 candidates, 2 groups, 2 proposed", res) } acousticKey, exactKey := key(onAlbum, onCompilation), key(www1, www2) got := groups() want := map[string]stored{ acousticKey: {"acoustic", "pending"}, exactKey: {"exact", "pending"}, } if len(got) != len(want) || got[acousticKey] != want[acousticKey] || got[exactKey] != want[exactKey] { t.Fatalf("groups = %+v, want %+v", got, want) } for k := range got { if strings.Contains(k, missing) { t.Fatalf("a missing track was proposed: %s", k) } } // 2. A dismissed group is not proposed again, and the pending one is // refreshed in place rather than duplicated. if _, err := pool.Exec(ctx, "UPDATE duplicate_groups SET status = 'dismissed' WHERE member_key = $1", acousticKey); err != nil { t.Fatalf("dismiss: %v", err) } res, err = runDuplicateSweep(ctx, pool, logger, DefaultFingerprintSettings, duplicateCandidatePage) if err != nil { t.Fatalf("second sweep: %v", err) } if res.Proposed != 1 || res.Suppressed != 1 { t.Fatalf("second sweep = %+v, want 1 proposed, 1 suppressed", res) } got = groups() if len(got) != 2 || got[acousticKey].status != "dismissed" || got[exactKey].status != "pending" { t.Fatalf("after dismissal groups = %+v, want the dismissal kept and one pending group", got) } // 3. A proposal that no longer holds is retired; the dismissal survives it. if _, err := pool.Exec(ctx, "DELETE FROM track_fingerprints f USING tracks t WHERE f.track_id = t.id AND t.file_path LIKE '%www-02.mp3'"); err != nil { t.Fatalf("drop fingerprint: %v", err) } res, err = runDuplicateSweep(ctx, pool, logger, DefaultFingerprintSettings, duplicateCandidatePage) if err != nil { t.Fatalf("third sweep: %v", err) } if res.Retired != 1 { t.Fatalf("third sweep = %+v, want 1 retired", res) } got = groups() if len(got) != 1 || got[acousticKey].status != "dismissed" { t.Fatalf("after retiring groups = %+v, want only the dismissal", got) } // 4. The sweep record reflects the last run. last, err := q.GetLatestDuplicateSweep(ctx) if err != nil { t.Fatalf("latest sweep: %v", err) } if !last.FinishedAt.Valid || last.ErrorMessage != nil { t.Fatalf("latest sweep = %+v, want finished without error", last) } // 5. Prints taken at another length are never compared (#3913). Every print // here was taken at the default length, so a sweep at 60s has nothing to read, // rather than scoring 120s prints against each other as if they were 60s ones. atOtherLength := DefaultFingerprintSettings atOtherLength.ChromaprintLengthSec = 60 res, err = runDuplicateSweep(ctx, pool, logger, atOtherLength, duplicateCandidatePage) if err != nil { t.Fatalf("sweep at another length: %v", err) } if res.Candidates != 0 || res.Groups != 0 { t.Fatalf("sweep at another length = %+v, want no candidates and no groups", res) } // 6. The sweep applies the threshold it is given. The recording's two copies // disagree on about 5% of their bits: grouped at the default, not at 1%. if _, err := pool.Exec(ctx, "DELETE FROM duplicate_groups"); err != nil { t.Fatalf("clear groups: %v", err) } strict := DefaultFingerprintSettings strict.AcousticMaxBitErrorRate = 0.01 res, err = runDuplicateSweep(ctx, pool, logger, strict, duplicateCandidatePage) if err != nil { t.Fatalf("strict sweep: %v", err) } if res.Groups != 0 { t.Fatalf("sweep at a 1%% threshold = %+v, want the copies 5%% apart left ungrouped", res) } res, err = runDuplicateSweep(ctx, pool, logger, DefaultFingerprintSettings, duplicateCandidatePage) if err != nil { t.Fatalf("default sweep: %v", err) } if got := groups(); res.Groups != 1 || got[acousticKey] != (stored{"acoustic", "pending"}) { t.Fatalf("sweep at the default threshold = %+v, groups %+v; want the recording's copies proposed", res, got) } } func TestSweepIsDue(t *testing.T) { now := time.Date(2026, 9, 11, 12, 0, 0, 0, time.UTC) at := func(ago time.Duration) pgtype.Timestamptz { return pgtype.Timestamptz{Time: now.Add(-ago), Valid: true} } never := pgtype.Timestamptz{} hourly := DefaultFingerprintSettings daily := DefaultFingerprintSettings daily.SweepIntervalHours = 24 savedAgo := func(ago time.Duration) FingerprintSettings { s := DefaultFingerprintSettings s.UpdatedAt = now.Add(-ago) return s } for _, tc := range []struct { name string latestPrint, lastSweep pgtype.Timestamptz cfg FingerprintSettings want bool }{ {"nothing fingerprinted", never, never, hourly, false}, {"never swept", at(time.Minute), never, hourly, true}, {"new fingerprints since the last sweep", at(10 * time.Minute), at(2 * time.Hour), hourly, true}, {"nothing new since the last sweep", at(3 * time.Hour), at(2 * time.Hour), hourly, false}, // The sweep started a moment after the previous tick, so one tick later // it is a moment under an hour old. Without the slack this is false. {"one tick after an hourly sweep", at(time.Minute), at(time.Hour - 2*time.Second), hourly, true}, {"new fingerprints inside the interval", at(time.Minute), at(3 * time.Hour), daily, false}, {"new fingerprints past the interval", at(time.Minute), at(25 * time.Hour), daily, true}, {"settings saved since the last sweep", at(3 * time.Hour), at(2 * time.Hour), savedAgo(time.Hour), true}, {"settings saved before the last sweep", at(3 * time.Hour), at(2 * time.Hour), savedAgo(4 * time.Hour), false}, } { if got := sweepIsDue(tc.latestPrint, tc.lastSweep, tc.cfg, now); got != tc.want { t.Errorf("%s: sweepIsDue = %v, want %v", tc.name, got, tc.want) } } }