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>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-22 17:25:21 -07:00
1 parent 381b804742
commit 5f895cab6a
12 files changed
+717 -128

No files matched your search

+178 -2
View File
@@ -1,10 +1,14 @@
package engine
import (
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/cdnutter/playlink/internal/probe"
"github.com/cdnutter/playlink/internal/store"
)
@@ -41,11 +45,12 @@ func TestApproxDiscFolderUpgrade(t *testing.T) {
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.
// 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/07 Memory.flac")
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")
}
@@ -124,3 +129,174 @@ func TestTitleScore(t *testing.T) {
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)
}
}