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>
179 lines
6.1 KiB
Go
179 lines
6.1 KiB
Go
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)
|
||
}
|
||
}
|