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>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-30 19:44:03 -07:00
1 parent 491d679233
commit 206b2ab5db
13 files changed
+1123 -11

No files matched your search

+178
View File
@@ -0,0 +1,178 @@
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)
}
}