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>
253 lines
9.5 KiB
Go
253 lines
9.5 KiB
Go
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. 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)
|
|
}
|
|
}
|
|
}
|