Files
playlink/internal/engine/dupes_test.go
T
cdnutterandClaude Opus 5.5 206b2ab5db Find and remove duplicate songs within the slave library
A new Duplicates filter in Review finds songs filed more than once in the
slave library, such as a single next to its album version, and recommends
which copy to keep. Removed copies go to the quarantine and can be restored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:44:03 -07:00

179 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package engine
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/probe"
"github.com/cdnutter/playlink/internal/store"
)
func (ev *env) dupes(t *testing.T) *DupeScan {
t.Helper()
if _, err := ev.e.StartDupeScan(ev.pair.ID); err != nil {
t.Fatal(err)
}
ev.wait()
s := ev.e.DupeResult(ev.pair.ID)
if s == nil {
t.Fatal("no search result")
}
return s
}
func groupRels(g DupeGroup) string {
var out []string
for _, f := range g.Files {
out = append(out, f.Rel)
}
return strings.Join(out, ",")
}
func TestFindDuplicates(t *testing.T) {
ev := setup(t, false)
s := ev.slave
// A single next to the album version: the album copy is kept.
ev.write(s, "A7X/Hail to the King - Single/Hail to the King.mp3", "single")
ev.write(s, "A7X/Hail to the King/01 Hail to the King.flac", "album")
ev.write(s, "A7X/Hail to the King/02 Shepherd of Fire.flac", "album")
// Release labels and disc folders don't matter; a live version is a different song.
ev.write(s, "A7X/City of Evil/Disc 1/03 Bat Country.flac", "album")
ev.write(s, "A7X/Greatest Hits/07 Bat Country (Remastered).mp3", "comp")
ev.write(s, "A7X/Live in the LBC/05 Bat Country.flac", "live")
ev.write(s, "A7X/Singles/Nightmare (Live).mp3", "live single")
ev.write(s, "A7X/Nightmare/01 Nightmare.flac", "album")
// The same title by another artist is not a duplicate.
ev.write(s, "Other/Album/Nightmare.flac", "other")
// One song in two formats in one folder.
ev.write(s, "Band/Album/Song.mp3", "mp3")
ev.write(s, "Band/Album/Song.flac", "flac")
scan := ev.dupes(t)
var got []string
for _, g := range scan.Groups {
got = append(got, groupRels(g))
if !g.Files[0].Keep || g.Files[1].Keep {
t.Errorf("%s: only the first copy should be kept", g.Title)
}
}
want := []string{
"A7X/City of Evil/Disc 1/03 Bat Country.flac,A7X/Greatest Hits/07 Bat Country (Remastered).mp3",
"A7X/Hail to the King/01 Hail to the King.flac,A7X/Hail to the King - Single/Hail to the King.mp3",
"Band/Album/Song.flac,Band/Album/Song.mp3",
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("got\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
}
if scan.Groups[1].Files[1].Release != "single" || scan.Groups[0].Files[1].Release != "compilation" {
t.Fatalf("releases: %+v", scan.Groups)
}
if scan.Groups[1].Title != "A7X – Hail to the King" {
t.Fatalf("title %q", scan.Groups[1].Title)
}
}
func TestDuplicatesSplitByArtistTag(t *testing.T) {
files := map[string]fsops.Info{
"Rock/Song.mp3": {Ino: 1},
"Rock/Album/Song.mp3": {Ino: 2},
"Rock/Other/Song.mp3": {Ino: 3},
}
artist := map[string]string{"Rock/Song.mp3": "Band", "Rock/Album/Song.mp3": "Band feat. Guest", "Rock/Other/Song.mp3": "Someone Else"}
probeFn := func(rel string) *probe.Info { return &probe.Info{Codec: "mp3", Artist: artist[rel]} }
groups, err := findDupes(ctx, files, nil, nil, probeFn)
if err != nil {
t.Fatal(err)
}
if len(groups) != 1 || len(groups[0].Files) != 2 || strings.Contains(groupRels(groups[0]), "Other") {
t.Fatalf("got %+v", groups)
}
}
func TestRemoveDuplicates(t *testing.T) {
ev := setup(t, false)
s := ev.slave
ev.write(ev.master, "A/Album/01 Song.flac", "album")
keep := ev.write(s, "A/Album/01 Song.flac", "album")
single := ev.write(s, "A/Song - Single/Song.mp3", "single")
ev.write(s, "B/X/Tune.mp3", "1")
ev.write(s, "B/Y/Tune.mp3", "2")
ev.scan()
scan := ev.dupes(t)
if len(scan.Groups) != 2 {
t.Fatalf("groups %+v", scan.Groups)
}
if _, err := ev.e.StartRemoveDupes(ev.pair.ID, []string{"A/Album/02 Nope.flac"}); err == nil {
t.Fatal("a file that wasn't listed must be refused")
}
if _, err := ev.e.StartRemoveDupes(ev.pair.ID, []string{"B/X/Tune.mp3", "B/Y/Tune.mp3"}); err == nil {
t.Fatal("removing every copy must be refused")
}
// A file changed since the search is skipped.
ev.write(s, "B/Y/Tune.mp3", "changed!")
if _, err := ev.e.StartRemoveDupes(ev.pair.ID, []string{"A/Song - Single/Song.mp3", "B/Y/Tune.mp3"}); err != nil {
t.Fatal(err)
}
j := ev.wait()
if j.Results["removed"] != 1 || len(j.Skipped) != 1 || !strings.Contains(j.Skipped[0], "changed") {
t.Fatalf("results %+v skipped %v", j.Results, j.Skipped)
}
if exists(single) || exists(filepath.Dir(single)) || !exists(keep) {
t.Fatal("the single (and its emptied folder) should be gone, the album copy kept")
}
if g := ev.e.DupeResult(ev.pair.ID).Groups; len(g) != 1 || !strings.HasPrefix(g[0].Files[0].Rel, "B/") {
t.Fatalf("the removed song should leave the list: %+v", g)
}
if _, err := ev.st.MatchBySlave(ctx, ev.pair.ID, "A/Song - Single/Song.mp3"); err == nil {
t.Fatal("the scan result for the removed file should be gone")
}
ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID})
var op store.Operation
for _, o := range ops {
if o.Action == store.ActRemoveDupe {
op = o
}
}
if op.ID == 0 || ev.read(op.QuarantinePath) != "single" {
t.Fatalf("the single should be in the quarantine: %+v", op)
}
if err := ev.e.Undo(ctx, op.ID); err != nil {
t.Fatal(err)
}
if ev.read(single) != "single" || exists(op.QuarantinePath) {
t.Fatal("undo should move the file back")
}
if _, err := ev.st.MatchBySlave(ctx, ev.pair.ID, "A/Song - Single/Song.mp3"); err != nil {
t.Fatal("undo should restore the scan result")
}
}
func TestRemoveDuplicatesKeepsACopy(t *testing.T) {
ev := setup(t, false)
ev.write(ev.slave, "A/X/Song.mp3", "1")
ev.write(ev.slave, "A/Y/Song.mp3", "2")
ev.dupes(t)
// The copy being kept disappeared after the search.
os.Remove(filepath.Join(ev.slave, "A/X/Song.mp3"))
ev.e.StartRemoveDupes(ev.pair.ID, []string{"A/Y/Song.mp3"})
j := ev.wait()
if j.Results["removed"] != 0 || !exists(filepath.Join(ev.slave, "A/Y/Song.mp3")) {
t.Fatalf("the last copy must never be removed: %+v %v", j.Results, j.Skipped)
}
}
func TestRemoveDuplicatesDryRun(t *testing.T) {
ev := setup(t, true)
ev.write(ev.slave, "A/X/Song.mp3", "1")
p := ev.write(ev.slave, "A/Y/Song.mp3", "2")
ev.dupes(t)
ev.e.StartRemoveDupes(ev.pair.ID, []string{"A/Y/Song.mp3"})
if j := ev.wait(); j.Results["planned"] != 1 || !exists(p) {
t.Fatalf("dry run must only record: %+v", j.Results)
}
}