The "N folders are named differently" warning now offers to rename the slave folders to match the master, marked as the recommended first step after a scan (also linked from the dashboard card). - A preview lists each rename with the differing characters highlighted; renames are selectable, and conflicts (the slave already has a folder with the master's name, symlinks, the quarantine) are skipped with a reason. - Only renames the server computes itself are applied; a request can only narrow them down, never name arbitrary paths. Only slave folders are renamed. - Case-only renames go through a temporary name so they also work on case-insensitive filesystems; a rename interrupted by a crash is completed on startup (the folder is never removed). - Renaming everything repeats until folders revealed inside renamed ones (an album under a renamed artist) are fixed too, then rescans. - Each rename is an operation in History that can be undone (which also rescans). Dry run records renames as planned. Also fixes the Review list not refreshing after a job that started and finished between two status polls, and logs the new path of operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
164 lines
5.7 KiB
Go
164 lines
5.7 KiB
Go
package engine
|
|
|
|
import (
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/cdnutter/playlink/internal/store"
|
|
)
|
|
|
|
func renameLibrary(t *testing.T, dryRun bool) *env {
|
|
ev := setup(t, dryRun)
|
|
ev.write(ev.master, "AC_DC/Back in Black/01 Hells Bells.flac", "hb")
|
|
ev.write(ev.slave, "AC-DC/Back In Black/01 Hells Bells.flac", "hb")
|
|
ev.write(ev.master, "Avenged Sevenfold/City of Evil/03 Bat Country.flac", "bc")
|
|
ev.write(ev.slave, "Avenged Sevenfold/City Of Evil/03 Bat Country.flac", "bc")
|
|
ev.scan()
|
|
return ev
|
|
}
|
|
|
|
func TestRenameFoldersToMasterSpelling(t *testing.T) {
|
|
ev := renameLibrary(t, false)
|
|
plan, err := ev.e.PreviewRenames(ctx, ev.pair.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(plan) != 2 || !plan[0].Ready || !plan[1].Ready {
|
|
t.Fatalf("want 2 ready renames, got %+v", plan)
|
|
}
|
|
if _, err := ev.e.StartRenames(ev.pair.ID, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
j := ev.wait()
|
|
// The artist rename reveals "Back In Black" inside it: a second pass fixes it.
|
|
if j.Results["renamed"] != 3 {
|
|
t.Fatalf("want 3 renames over two passes, got %+v %v", j.Results, j.Skipped)
|
|
}
|
|
for _, rel := range []string{"AC_DC/Back in Black/01 Hells Bells.flac", "Avenged Sevenfold/City of Evil/03 Bat Country.flac"} {
|
|
if ev.read(filepath.Join(ev.slave, rel)) == "" {
|
|
t.Fatalf("%s missing after rename", rel)
|
|
}
|
|
// The job rescans, so the songs now match by path.
|
|
if m := ev.match(rel); m.Status != store.StatusIdentical || m.Kind != store.KindPath {
|
|
t.Fatalf("%s should match by path after the rename, got %s/%s", rel, m.Status, m.Kind)
|
|
}
|
|
}
|
|
p, _ := ev.st.Pair(ctx, ev.pair.ID)
|
|
for _, w := range p.LastScanWarnings {
|
|
if w.Code == store.WarnRenamedFolders {
|
|
t.Fatalf("renamed-folders warning should be gone, got %+v", w)
|
|
}
|
|
}
|
|
if ev.read(filepath.Join(ev.master, "AC_DC/Back in Black/01 Hells Bells.flac")) != "hb" {
|
|
t.Fatal("the master library must not change")
|
|
}
|
|
|
|
// Undo the artist rename from History.
|
|
ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID})
|
|
var artist store.Operation
|
|
for _, o := range ops {
|
|
if o.Action == store.ActRename && o.RelSlave == "AC-DC" {
|
|
artist = o
|
|
}
|
|
}
|
|
if artist.ID == 0 || artist.RelNew != "AC_DC" {
|
|
t.Fatalf("rename not recorded in history: %+v", ops)
|
|
}
|
|
if err := ev.e.Undo(ctx, artist.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ev.wait() // undo starts a rescan
|
|
if _, err := os.Stat(filepath.Join(ev.slave, "AC-DC/Back in Black/01 Hells Bells.flac")); err != nil {
|
|
t.Fatalf("undo should rename the folder back: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenameConflictIsSkipped(t *testing.T) {
|
|
ev := setup(t, false)
|
|
ev.write(ev.master, "Band/Album Name/01 a.flac", "a")
|
|
ev.write(ev.slave, "Band/Album name/01 a.flac", "a")
|
|
ev.write(ev.slave, "Band/Album Name/02 b.flac", "b") // the master spelling already exists too
|
|
ev.scan()
|
|
plan, _ := ev.e.PreviewRenames(ctx, ev.pair.ID)
|
|
if len(plan) != 1 || plan[0].Ready || plan[0].Reason == "" {
|
|
t.Fatalf("want one conflicting rename, got %+v", plan)
|
|
}
|
|
if _, err := ev.e.StartRenames(ev.pair.ID, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
j := ev.wait()
|
|
if j.Results["renamed"] != 0 || j.Results["skipped"] != 1 {
|
|
t.Fatalf("conflict must be skipped, got %+v", j.Results)
|
|
}
|
|
if ev.read(filepath.Join(ev.slave, "Band/Album name/01 a.flac")) != "a" || ev.read(filepath.Join(ev.slave, "Band/Album Name/02 b.flac")) != "b" {
|
|
t.Fatal("nothing may change on a conflict")
|
|
}
|
|
}
|
|
|
|
func TestRenameOnlySelectedAndNeverArbitraryPaths(t *testing.T) {
|
|
ev := renameLibrary(t, false)
|
|
ev.write(ev.slave, "Unrelated/x.flac", "x")
|
|
if _, err := ev.e.StartRenames(ev.pair.ID, []string{"Avenged Sevenfold/City Of Evil", "Unrelated"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
j := ev.wait()
|
|
if j.Results["renamed"] != 1 {
|
|
t.Fatalf("only the selected, computed rename may run, got %+v", j.Results)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(ev.slave, "AC-DC")); err != nil {
|
|
t.Fatal("unselected folders must not be renamed")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(ev.slave, "Unrelated/x.flac")); err != nil {
|
|
t.Fatal("a folder that is not a computed rename must never be touched")
|
|
}
|
|
}
|
|
|
|
func TestRenameDryRun(t *testing.T) {
|
|
ev := renameLibrary(t, true)
|
|
if _, err := ev.e.StartRenames(ev.pair.ID, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
j := ev.wait()
|
|
if j.Results["planned"] != 2 {
|
|
t.Fatalf("dry run should plan 2 renames, got %+v", j.Results)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(ev.slave, "AC-DC")); err != nil {
|
|
t.Fatal("dry run must not rename anything")
|
|
}
|
|
}
|
|
|
|
func TestRecoverInterruptedCaseRename(t *testing.T) {
|
|
root := t.TempDir()
|
|
os.MkdirAll(filepath.Join(root, "Band", ".playlink-tmp-dir-abc123"), 0o755)
|
|
os.WriteFile(filepath.Join(root, "Band", ".playlink-tmp-dir-abc123", "song.flac"), []byte("data"), 0o644)
|
|
p := store.Pair{SlaveRoot: root}
|
|
o := store.Operation{ID: 1, Action: store.ActRename, RelSlave: "Band/City Of Evil", RelNew: "Band/City of Evil"}
|
|
recoverRename(p, o, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
if b, err := os.ReadFile(filepath.Join(root, "Band", "City of Evil", "song.flac")); err != nil || string(b) != "data" {
|
|
t.Fatalf("interrupted rename should be completed, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenameWarningCountsFixable(t *testing.T) {
|
|
ev := setup(t, false)
|
|
ev.write(ev.master, "Band/Album Name/01 a.flac", "a")
|
|
ev.write(ev.slave, "Band/Album name/01 a.flac", "a")
|
|
ev.write(ev.slave, "Band/Album Name/02 b.flac", "b") // conflict
|
|
ev.write(ev.master, "Other/Record/01 c.flac", "c")
|
|
ev.write(ev.slave, "Other/record/01 c.flac", "c") // renameable
|
|
ev.scan()
|
|
p, _ := ev.st.Pair(ctx, ev.pair.ID)
|
|
for _, w := range p.LastScanWarnings {
|
|
if w.Code == store.WarnRenamedFolders {
|
|
if w.Total != 2 || w.Fixable != 1 {
|
|
t.Fatalf("want 2 renamed folders, 1 fixable; got total=%d fixable=%d", w.Total, w.Fixable)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Fatal("renamed-folders warning missing")
|
|
}
|