Add opt-in approximate matching and upgrade recommendations
A per-pair "Approximate matching" setting adds a second pass for slave files the strict rules could not place. It searches the same album with disc folders (Disc 02, CD2, ...) ignored, then the top-level folder, and scores titles by edit distance and word containment against a configurable threshold (default 85%), with a small track-number bonus. Guards against false matches: version words (live, remix, reprise, instrumental, ...) must agree, near-tied candidates are left unmatched, and approximate matches always go to manual review, never auto-link. Each match records a human-readable reason. Review now flags matches where the master copy is better as upgrades, with an Upgrades filter and count and an "Upgrade to master copy" action. The redundant "master is lossless" warning note is dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5b088bb4a6
commit
381b804742
12 files changed
+552
-51
No files matched your search
@@ -0,0 +1,126 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cdnutter/playlink/internal/store"
|
||||
)
|
||||
|
||||
func enableApprox(t *testing.T, ev *env) {
|
||||
t.Helper()
|
||||
ev.pair.ApproxMatching = true
|
||||
ev.pair.ApproxThreshold = 85
|
||||
if err := ev.st.SavePair(ctx, &ev.pair); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// The user's example: the master album has disc folders, the slave does not.
|
||||
func TestApproxDiscFolderUpgrade(t *testing.T) {
|
||||
ev := setup(t, false)
|
||||
master := ev.write(ev.master, "Andrew Lloyd Webber/Cats/Disc 02/07 Memory.flac", "lossless")
|
||||
ev.write(ev.master, "Andrew Lloyd Webber/Cats/Disc 01/07 Macavity.flac", "other")
|
||||
ev.write(ev.master, "Andrew Lloyd Webber/Evita/03 Memory.flac", "same title, other album")
|
||||
ev.write(ev.slave, "Andrew Lloyd Webber/Cats/07 Memory.mp3", "lossy")
|
||||
|
||||
// Off by default: the title exists on two albums, so nothing matches.
|
||||
ev.scan()
|
||||
if m := ev.match("Andrew Lloyd Webber/Cats/07 Memory.mp3"); m.Status != store.StatusUnmatched {
|
||||
t.Fatalf("approximate matching must be opt-in, got %s/%s", m.Status, m.Kind)
|
||||
}
|
||||
|
||||
enableApprox(t, ev)
|
||||
ev.scan()
|
||||
m := ev.match("Andrew Lloyd Webber/Cats/07 Memory.mp3")
|
||||
if m.Kind != store.KindApprox || m.Status != store.StatusSimilar || m.RelMaster != "Andrew Lloyd Webber/Cats/Disc 02/07 Memory.flac" {
|
||||
t.Fatalf("unexpected match %s %s -> %s", m.Status, m.Kind, m.RelMaster)
|
||||
}
|
||||
if !strings.Contains(m.Reason, "disc folder ignored") || !strings.Contains(m.Reason, "track 07 matches") {
|
||||
t.Fatalf("reason should explain the match, got %q", m.Reason)
|
||||
}
|
||||
|
||||
// Linking upgrades the slave: it gets the master's FLAC in its own folder.
|
||||
if _, err := ev.e.Link(ctx, m.ID, LinkOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
upgraded := filepath.Join(ev.slave, "Andrew Lloyd Webber/Cats/07 Memory.flac")
|
||||
if !sameInode(t, master, upgraded) {
|
||||
t.Fatal("slave should now hold a hardlink of the master FLAC")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproxTitleVariations(t *testing.T) {
|
||||
ev := setup(t, false)
|
||||
enableApprox(t, ev)
|
||||
ev.write(ev.master, "Band/Album/CD1/03 - Dont Stop Believin.flac", "a")
|
||||
ev.write(ev.slave, "Band/Album/3 Don't Stop Believing.mp3", "b") // typo-level difference
|
||||
ev.write(ev.master, "Band/Album/CD1/04 - Song.flac", "c")
|
||||
ev.write(ev.slave, "Band/Album/04 Song (Live).mp3", "d") // different version: must not match
|
||||
ev.write(ev.master, "Band/Album/CD2/05 Totally Different.flac", "e")
|
||||
ev.write(ev.slave, "Band/Album/05 Something Else.mp3", "f") // too dissimilar
|
||||
ev.scan()
|
||||
|
||||
if m := ev.match("Band/Album/3 Don't Stop Believing.mp3"); m.Kind != store.KindApprox {
|
||||
t.Fatalf("near-identical title should match approximately, got %s", m.Kind)
|
||||
}
|
||||
if m := ev.match("Band/Album/04 Song (Live).mp3"); m.Status != store.StatusUnmatched {
|
||||
t.Fatalf("live version must not match the studio track, got %s -> %s", m.Kind, m.RelMaster)
|
||||
}
|
||||
if m := ev.match("Band/Album/05 Something Else.mp3"); m.Status != store.StatusUnmatched {
|
||||
t.Fatalf("dissimilar titles must not match, got %s -> %s", m.Kind, m.RelMaster)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproxAmbiguousIsLeftAlone(t *testing.T) {
|
||||
ev := setup(t, false)
|
||||
enableApprox(t, ev)
|
||||
// Two discs each carry an equally good candidate: refuse to guess.
|
||||
ev.write(ev.master, "Band/Album/Disc 1/Anthem.flac", "a")
|
||||
ev.write(ev.master, "Band/Album/Disc 2/Anthem.flac", "b")
|
||||
ev.write(ev.slave, "Band/Album/Anthem.mp3", "c")
|
||||
ev.scan()
|
||||
if m := ev.match("Band/Album/Anthem.mp3"); m.Status != store.StatusUnmatched {
|
||||
t.Fatalf("ambiguous candidates must not be matched, got %s -> %s", m.Kind, m.RelMaster)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproxAlbumFolderRename(t *testing.T) {
|
||||
ev := setup(t, false)
|
||||
enableApprox(t, ev)
|
||||
ev.write(ev.master, "Cast/Cats (Original London Cast)/Disc 2/07 Memory.flac", "a")
|
||||
ev.write(ev.slave, "Cast/Cats/07. Memory.mp3", "b")
|
||||
ev.scan()
|
||||
m := ev.match("Cast/Cats/07. Memory.mp3")
|
||||
if m.Kind != store.KindApprox || !strings.Contains(m.Reason, "different album folder") {
|
||||
t.Fatalf("should match across album folder names, got %s %q", m.Kind, m.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitleScore(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
min float64
|
||||
max float64
|
||||
}{
|
||||
{"07 Memory.flac", "07 Memory.mp3", 1, 1},
|
||||
{"07 Memory.flac", "08 Memory.mp3", 0.9, 0.96},
|
||||
{"Memory.flac", "Memory (Live).flac", -1, -1},
|
||||
{"Song (Remix).flac", "Song (Remix).mp3", 1, 1},
|
||||
{"Love.flac", "Love Story.flac", 0, 0.6},
|
||||
{"Memory from Cats.flac", "Memory from Cats Remastered.flac", 0.85, 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := titleScore(parseTitle(c.a), parseTitle(c.b))
|
||||
if got < c.min-1e-9 || got > c.max+1e-9 {
|
||||
t.Errorf("titleScore(%q, %q) = %.3f, want [%.2f, %.2f]", c.a, c.b, got, c.min, c.max)
|
||||
}
|
||||
}
|
||||
if k := albumKey("A/Cats/Disc 02/07 Memory.flac"); k != "a/cats" {
|
||||
t.Errorf("albumKey = %q", k)
|
||||
}
|
||||
if k := albumKey("A/Cats/CD2 - Bonus/x.flac"); k != "a/cats" {
|
||||
t.Errorf("albumKey = %q", k)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user