Files
minstrel/internal/library/fingerprint_settings_test.go
T
bvandeusenandClaude Opus 5.5 4ecff52f19
release / govulncheck (push) Successful in 45s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 4m25s
release / android (push) Successful in 6m17s
release / Build signed APK (releases and dev) (push) Successful in 5m57s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m54s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: duplicates resolve themselves where Lidarr says it is safe (M498)
The duplicate sweep proposed 4,197 groups and every one waited for the
operator. Most are safe to settle, and Lidarr defines what safe means: it
maps one file to each track of the release it monitors and downloads any
mapped file that disappears. Deleting a mapped copy opens exactly the hole
the operator saw Lidarr fill.

Classify (#5435)
- Migration 0075: duplicate_groups.class (same_release, cross_release,
  mismatch, review), resolve_note, resolved_automatically;
  duplicate_group_members.lidarr_state (tracked, unmapped);
  fingerprint_settings.auto_resolve; notification kind
  duplicates_resolved with both kind CHECKs swapped (rule 36).
- library.ClassifyDuplicateGroup, with MatchTitleKey dropping featuring
  credits, remaster notes and video-rip markers, and keeping live, demo,
  remix and instrumental. The rip markers move from api to library.

Choose the copy to keep (#5436)
- ProposeSurvivor ranks the copy Lidarr maps first, then tag fit (a
  clash-free track number, no rip marker in the name, an MBID), then the
  quality rules. File size picked the wrong Humanz copy in 6 of 21 groups.

Act (#5437)
- An hourly resolver pass reads Lidarr's unmapped files, matched by the
  last three path components, and records each copy's state.
- Same album, with at most one copy mapped: merged into the mapped copy.
  The merge is guarded, so a mapped copy can never be removed
  (MergeDuplicateGroupGuarded, ErrCopyTrackedByLidarr).
- Same album, every copy mapped: the monitored release lists the song
  twice (Humanz's 14x12" box set). The pass moves Lidarr to the release
  that lists each song once and best covers what is on disk. It never
  picks one covering less, and is capped at 10 albums per pass.
  - Fixed point (lesson #4183): the chosen release no longer repeats.
  - The album is left alone for 24h while Lidarr rescans, so "every copy
    unmapped" mid-rescan is never read as licence to merge.
- Both actions are audited with no actor and summarised to admins. The
  operator can switch them off in the Fingerprinting card (rule 25).
- Manual merges use the same guard: 409 copy_tracked_by_lidarr, or 503
  lidarr_unavailable when Lidarr cannot say.

Web
- Duplicates gets tabs: Needs review, Across releases, Resolved
  automatically. Each loads as you scroll (rule 172), replacing the
  pager.
- Each copy says whether Lidarr uses it.
- The resolver's note shows on each group.
- The merge confirm blocks, before sending, a merge that would remove
  the copy Lidarr uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:49:37 -04:00

149 lines
6.3 KiB
Go

package library
import (
"context"
"errors"
"math"
"testing"
"time"
)
func TestValidateFingerprintSettings(t *testing.T) {
with := func(edit func(*FingerprintSettings)) FingerprintSettings {
s := DefaultFingerprintSettings
edit(&s)
return s
}
valid := map[string]FingerprintSettings{
"defaults": DefaultFingerprintSettings,
"shortest length": with(func(s *FingerprintSettings) { s.ChromaprintLengthSec = minChromaprintLengthSec }),
"longest length": with(func(s *FingerprintSettings) { s.ChromaprintLengthSec = maxChromaprintLengthSec }),
"strictest match": with(func(s *FingerprintSettings) { s.AcousticMaxBitErrorRate = minAcousticMaxBitErrorRate }),
"loosest match": with(func(s *FingerprintSettings) { s.AcousticMaxBitErrorRate = maxAcousticMaxBitErrorRate }),
"fewest at once": with(func(s *FingerprintSettings) { s.BackfillConcurrency = minBackfillConcurrency }),
"most at once": with(func(s *FingerprintSettings) { s.BackfillConcurrency = maxBackfillConcurrency }),
"shortest interval": with(func(s *FingerprintSettings) { s.SweepIntervalHours = minSweepIntervalHours }),
"longest interval": with(func(s *FingerprintSettings) { s.SweepIntervalHours = maxSweepIntervalHours }),
"switched off": with(func(s *FingerprintSettings) { s.Enabled = false }),
}
for name, s := range valid {
if err := validateFingerprintSettings(s); err != nil {
t.Errorf("%s: rejected: %v", name, err)
}
}
invalid := map[string]FingerprintSettings{
"length too short": with(func(s *FingerprintSettings) { s.ChromaprintLengthSec = minChromaprintLengthSec - 1 }),
"length too long": with(func(s *FingerprintSettings) { s.ChromaprintLengthSec = maxChromaprintLengthSec + 1 }),
"match too strict": with(func(s *FingerprintSettings) { s.AcousticMaxBitErrorRate = minAcousticMaxBitErrorRate - 0.001 }),
"match too loose": with(func(s *FingerprintSettings) { s.AcousticMaxBitErrorRate = maxAcousticMaxBitErrorRate + 0.001 }),
"match not a number": with(func(s *FingerprintSettings) { s.AcousticMaxBitErrorRate = math.NaN() }),
"none at once": with(func(s *FingerprintSettings) { s.BackfillConcurrency = 0 }),
"too many at once": with(func(s *FingerprintSettings) { s.BackfillConcurrency = maxBackfillConcurrency + 1 }),
"no interval": with(func(s *FingerprintSettings) { s.SweepIntervalHours = 0 }),
"interval too long": with(func(s *FingerprintSettings) { s.SweepIntervalHours = maxSweepIntervalHours + 1 }),
}
for name, s := range invalid {
if err := validateFingerprintSettings(s); !errors.Is(err, ErrFingerprintSettingOutOfRange) {
t.Errorf("%s: err = %v, want ErrFingerprintSettingOutOfRange", name, err)
}
}
}
func TestFingerprintSettingsService_NilServesDefaults(t *testing.T) {
var s *FingerprintSettingsService
if got := s.Get(); got != DefaultFingerprintSettings {
t.Fatalf("nil service Get = %+v, want the defaults", got)
}
// Validation still runs first, so a bad value is named rather than hidden
// behind the missing service.
bad := DefaultFingerprintSettings
bad.BackfillConcurrency = 0
if _, err := s.Set(context.Background(), bad); !errors.Is(err, ErrFingerprintSettingOutOfRange) {
t.Fatalf("nil service Set of a bad value: err = %v, want ErrFingerprintSettingOutOfRange", err)
}
}
func TestFingerprintSettingsService_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
reload := func(step string) FingerprintSettings {
t.Helper()
fresh, err := NewFingerprintSettingsService(ctx, pool)
if err != nil {
t.Fatalf("%s: load: %v", step, err)
}
return fresh.Get()
}
withoutTime := func(s FingerprintSettings) FingerprintSettings {
s.UpdatedAt = time.Time{}
return s
}
svc, err := NewFingerprintSettingsService(ctx, pool)
if err != nil {
t.Fatalf("load: %v", err)
}
// ResetDB puts every column back to its migration default, so this pins the
// Go defaults to migration 0061's. Were they to drift, a database that could
// not be read would fingerprint differently from one that could.
loaded := svc.Get()
if loaded.UpdatedAt.IsZero() {
t.Fatal("loaded settings carry no updated_at")
}
if withoutTime(loaded) != DefaultFingerprintSettings {
t.Fatalf("stored defaults = %+v, want the Go defaults %+v", withoutTime(loaded), DefaultFingerprintSettings)
}
// Every bound the service accepts, the table accepts too. A CHECK tighter
// than validate would turn a value the card allows into a 500.
lowest := FingerprintSettings{
Enabled: false,
ChromaprintLengthSec: minChromaprintLengthSec,
AcousticMaxBitErrorRate: minAcousticMaxBitErrorRate,
BackfillConcurrency: minBackfillConcurrency,
SweepIntervalHours: minSweepIntervalHours,
AutoResolve: false,
}
highest := FingerprintSettings{
Enabled: true,
ChromaprintLengthSec: maxChromaprintLengthSec,
AcousticMaxBitErrorRate: maxAcousticMaxBitErrorRate,
BackfillConcurrency: maxBackfillConcurrency,
SweepIntervalHours: maxSweepIntervalHours,
AutoResolve: true,
}
for _, step := range []struct {
name string
want FingerprintSettings
}{{"lowest", lowest}, {"highest", highest}} {
saved, err := svc.Set(ctx, step.want)
if err != nil {
t.Fatalf("%s: save: %v", step.name, err)
}
// A save must move updated_at forward: it is what makes a sweep due.
if !saved.UpdatedAt.After(loaded.UpdatedAt) {
t.Fatalf("%s: updated_at %v did not move past %v", step.name, saved.UpdatedAt, loaded.UpdatedAt)
}
if withoutTime(saved) != step.want || withoutTime(svc.Get()) != step.want {
t.Fatalf("%s: saved %+v, cached %+v, want %+v", step.name, saved, svc.Get(), step.want)
}
if got := withoutTime(reload(step.name)); got != step.want {
t.Fatalf("%s: table holds %+v, want %+v", step.name, got, step.want)
}
}
// An out-of-range save changes nothing, in the cache or the table.
before := svc.Get()
bad := highest
bad.ChromaprintLengthSec = maxChromaprintLengthSec + 1
if _, err := svc.Set(ctx, bad); !errors.Is(err, ErrFingerprintSettingOutOfRange) {
t.Fatalf("out-of-range save: err = %v, want ErrFingerprintSettingOutOfRange", err)
}
if svc.Get() != before {
t.Fatalf("out-of-range save changed the cache to %+v", svc.Get())
}
if got := reload("after out-of-range save"); got != before {
t.Fatalf("out-of-range save changed the table to %+v", got)
}
}