Files
cdnutterandClaude Opus 5.5 5f895cab6a Loosen approximate matching and link to the album version's folders
Matching:
- Prefer the studio album over singles, EPs and compilations when a song
  exists on several releases, including when the master also has the
  single at the slave's exact folder.
- Ignore release labels (Single Version, Remastered, Explicit, Deluxe)
  inside brackets or a trailing " - ..." part; "Clean" stays a version.
- Treat live/acoustic/remix/... album folders as different recordings;
  the strict top-folder fallback now respects this too.
- Add a last-resort library-wide search that only accepts candidates whose
  artist tag agrees with the slave file.

Linking:
- Cross-folder matches now mirror the master's path in the slave (e.g. a
  single moves into the album folder) and prune the emptied folder; a
  "keep in current folder" option preserves the old behaviour. Undo
  recreates and prunes folders accordingly.
- Operations record the match kind and reason, so undo (and promotes that
  rename the master file) restore the correct pairing instead of leaving
  the file unmatched until the next scan.

UI: rewrite the approximate matching description with Avenged Sevenfold
examples and clear safeguards, and show where linking will place a file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 17:25:21 -07:00

303 lines
12 KiB
Go

package engine
import (
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/cdnutter/playlink/internal/probe"
"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 to the master's FLAC, mirroring the master's
// folder structure (including the disc folder).
if _, err := ev.e.Link(ctx, m.ID, LinkOptions{}); err != nil {
t.Fatal(err)
}
upgraded := filepath.Join(ev.slave, "Andrew Lloyd Webber/Cats/Disc 02/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)
}
}
// A single matched to the album version is linked into the album's folders.
func TestApproxSingleToAlbumVersion(t *testing.T) {
ev := setup(t, false)
enableApprox(t, ev)
a7x := "Avenged Sevenfold"
album := a7x + "/Hail to the King/01 Hail to the King.flac"
for i, title := range []string{"Hail to the King", "Shepherd of Fire", "Doing Time", "This Means War", "Requiem"} {
rel := a7x + "/Hail to the King/" + []string{"01", "02", "03", "04", "05"}[i] + " " + title + ".flac"
ev.write(ev.master, rel, "album:"+title)
}
ev.write(ev.master, a7x+"/Hail to the King - Single/01 Hail to the King.flac", "single release")
ev.write(ev.master, a7x+"/Live in the LBC/Hail to the King.flac", "live recording")
slaveRel := a7x + "/Hail to the King (Single)/Hail to the King (Single Version).mp3"
ev.write(ev.slave, slaveRel, "slave mp3")
ev.scan()
m := ev.match(slaveRel)
if m.Kind != store.KindApprox || m.RelMaster != album {
t.Fatalf("want the album version, got %s -> %q (%s)", m.Kind, m.RelMaster, m.Reason)
}
if !strings.Contains(m.Reason, "single → album version") {
t.Fatalf("reason should mention the album preference, got %q", m.Reason)
}
if got := LinkTarget(m.RelSlave, m.RelMaster, false); got != album {
t.Fatalf("link target should mirror the master's folders, got %q", got)
}
op, err := ev.e.Link(ctx, m.ID, LinkOptions{})
if err != nil {
t.Fatal(err)
}
if !sameInode(t, filepath.Join(ev.master, album), filepath.Join(ev.slave, album)) {
t.Fatal("slave should now have the album version at the album path")
}
if _, err := os.Stat(filepath.Join(ev.slave, a7x, "Hail to the King (Single)")); !errors.Is(err, os.ErrNotExist) {
t.Fatal("the single's emptied folder should be removed")
}
if err := ev.e.Undo(ctx, op.ID); err != nil {
t.Fatal(err)
}
if ev.read(filepath.Join(ev.slave, slaveRel)) != "slave mp3" {
t.Fatal("undo should restore the single in its original folder")
}
if _, err := os.Stat(filepath.Join(ev.slave, a7x, "Hail to the King")); !errors.Is(err, os.ErrNotExist) {
t.Fatal("undo should remove the album folder it no longer needs")
}
if m := ev.match(slaveRel); m.Kind != store.KindApprox || m.RelMaster != album || m.Reason == "" {
t.Fatalf("undo should restore the approximate match, got %s/%s -> %q", m.Status, m.Kind, m.RelMaster)
}
}
func TestApproxKeepFolderOption(t *testing.T) {
ev := setup(t, false)
enableApprox(t, ev)
ev.write(ev.master, "Avenged Sevenfold/Nightmare/01 Nightmare.flac", "album")
ev.write(ev.slave, "Avenged Sevenfold/Favorites/Nightmare.mp3", "mp3")
ev.scan()
m := ev.match("Avenged Sevenfold/Favorites/Nightmare.mp3")
if m.Kind != store.KindApprox {
t.Fatalf("want approximate match, got %s", m.Kind)
}
if _, err := ev.e.Link(ctx, m.ID, LinkOptions{KeepFolder: true}); err != nil {
t.Fatal(err)
}
if !sameInode(t, filepath.Join(ev.master, "Avenged Sevenfold/Nightmare/01 Nightmare.flac"),
filepath.Join(ev.slave, "Avenged Sevenfold/Favorites/01 Nightmare.flac")) {
t.Fatal("KeepFolder should link into the slave's own folder")
}
}
func TestApproxLiveAlbumIsDifferentRecording(t *testing.T) {
ev := setup(t, false)
enableApprox(t, ev)
ev.write(ev.master, "Avenged Sevenfold/Live in the LBC/04 Nightmare.flac", "live")
ev.write(ev.slave, "Avenged Sevenfold/Nightmare/04 Nightmare.mp3", "studio")
ev.scan()
if m := ev.match("Avenged Sevenfold/Nightmare/04 Nightmare.mp3"); m.Status != store.StatusUnmatched {
t.Fatalf("studio track must not match a live album, got %s -> %s", m.Kind, m.RelMaster)
}
}
func TestApproxCompilationLosesToAlbum(t *testing.T) {
ev := setup(t, false)
enableApprox(t, ev)
ev.write(ev.master, "Avenged Sevenfold/City of Evil/03 Bat Country.flac", "album")
ev.write(ev.master, "Avenged Sevenfold/City of Evil/04 Trashed and Scattered.flac", "album2")
ev.write(ev.master, "Avenged Sevenfold/The Best of 2005-2013/02 Bat Country.flac", "comp")
ev.write(ev.slave, "Avenged Sevenfold/Misc/Bat Country (Remastered).mp3", "mp3")
ev.scan()
m := ev.match("Avenged Sevenfold/Misc/Bat Country (Remastered).mp3")
if m.RelMaster != "Avenged Sevenfold/City of Evil/03 Bat Country.flac" {
t.Fatalf("album should win over compilation, got %q", m.RelMaster)
}
}
func TestApproxLibraryWideNeedsArtistTag(t *testing.T) {
ffmpeg, err1 := exec.LookPath("ffmpeg")
ffprobe, err2 := exec.LookPath("ffprobe")
if err1 != nil || err2 != nil {
t.Skip("ffmpeg/ffprobe not installed")
}
ev := setup(t, false)
ev.e.prober = probe.Prober{Bin: ffprobe}
enableApprox(t, ev)
mk := func(base, rel, artist string) {
p := filepath.Join(base, rel)
os.MkdirAll(filepath.Dir(p), 0o755)
if out, err := exec.Command(ffmpeg, "-v", "error", "-y", "-f", "lavfi", "-i", "sine=d=1",
"-metadata", "artist="+artist, p).CombinedOutput(); err != nil {
t.Fatalf("ffmpeg: %v %s", err, out)
}
}
mk(ev.master, "Avenged Sevenfold/Nightmare/01 Nightmare.flac", "Avenged Sevenfold")
mk(ev.master, "Other Band/Dreams/05 Nightmare.flac", "Other Band")
mk(ev.slave, "Chris Picks/Nightmare.mp3", "Avenged Sevenfold")
mk(ev.slave, "Chris Picks/Nightmare (untagged).mp3", "")
ev.scan()
m := ev.match("Chris Picks/Nightmare.mp3")
if m.Kind != store.KindApprox || m.RelMaster != "Avenged Sevenfold/Nightmare/01 Nightmare.flac" {
t.Fatalf("artist tag should pick the A7X track, got %s -> %q", m.Kind, m.RelMaster)
}
if !strings.Contains(m.Reason, "artist tag matches") {
t.Fatalf("reason = %q", m.Reason)
}
}
func TestReleaseLabels(t *testing.T) {
cases := map[string]string{
"Nightmare (Single Version).flac": "nightmare",
"Nightmare - Remastered 2013.flac": "nightmare",
"Nightmare [Explicit].flac": "nightmare",
"Single Ladies.flac": "singleladies",
"Nightmare (Clean).flac": "nightmareclean",
"04 Nightmare (Deluxe Edition).flac": "nightmare",
"Afterlife (Live at Wembley).flac": "afterlifeliveatwembley",
}
for in, want := range cases {
if got := parseTitle(in).norm; got != want {
t.Errorf("parseTitle(%q).norm = %q, want %q", in, got, want)
}
}
}
// The master has both the single and the album; the slave's single sits at
// the same path as the master's single. The album version is still preferred.
func TestApproxPrefersAlbumOverMatchingSingle(t *testing.T) {
ev := setup(t, false)
a7x := "Avenged Sevenfold"
for i, title := range []string{"Shepherd of Fire", "Hail to the King", "Doing Time"} {
ev.write(ev.master, a7x+"/Hail to the King/0"+string(rune('1'+i))+" "+title+".flac", "album:"+title)
}
ev.write(ev.master, a7x+"/Hail to the King - Single/01 Hail to the King.flac", "single")
ev.write(ev.slave, a7x+"/Hail to the King - Single/Hail to the King.mp3", "mp3")
ev.scan() // approximate matching off: the strict same-folder match stands
if m := ev.match(a7x + "/Hail to the King - Single/Hail to the King.mp3"); m.RelMaster != a7x+"/Hail to the King - Single/01 Hail to the King.flac" {
t.Fatalf("without approximate matching the single should match, got %q", m.RelMaster)
}
enableApprox(t, ev)
ev.scan()
m := ev.match(a7x + "/Hail to the King - Single/Hail to the King.mp3")
if m.Kind != store.KindApprox || m.RelMaster != a7x+"/Hail to the King/02 Hail to the King.flac" {
t.Fatalf("want the album version, got %s -> %q", m.Kind, m.RelMaster)
}
if !strings.Contains(m.Reason, `album version preferred over the single in "Hail to the King - Single"`) {
t.Fatalf("reason = %q", m.Reason)
}
}