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) } }