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