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>
234 lines
7.1 KiB
Go
234 lines
7.1 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/cdnutter/playlink/internal/fsops"
|
|
"github.com/cdnutter/playlink/internal/store"
|
|
)
|
|
|
|
// Folder renames fix the "named differently" warning: slave folders that are
|
|
// the same as a master folder under another spelling ("City Of Evil" vs
|
|
// "City of Evil", "AC-DC" vs "AC_DC") are renamed to the master's spelling so
|
|
// their songs match by path. Only the slave library is ever renamed.
|
|
|
|
// FolderRename is one proposed rename of a slave folder.
|
|
type FolderRename struct {
|
|
Slave string `json:"slave"` // current slave folder (relative)
|
|
Master string `json:"master"` // the master's spelling = the new slave name
|
|
Ready bool `json:"ready"`
|
|
Reason string `json:"reason,omitempty"` // why it cannot be renamed
|
|
}
|
|
|
|
// maxRenamePasses bounds the loop that picks up folders revealed by renaming
|
|
// their parent (an artist rename can expose album folders that also differ).
|
|
const maxRenamePasses = 3
|
|
|
|
// planRenames lists the renames needed right now, marking conflicts.
|
|
func planRenames(l *libs) []FolderRename {
|
|
var out []FolderRename
|
|
for _, p := range renamedFolders(l) {
|
|
r := FolderRename{Slave: p[0], Master: p[1], Ready: true}
|
|
r.Reason = checkRename(l, r.Slave, r.Master)
|
|
r.Ready = r.Reason == ""
|
|
out = append(out, r)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// checkRename returns why a slave folder cannot be renamed, or "".
|
|
func checkRename(l *libs, from, to string) string {
|
|
src, err := l.slave.Resolve(from)
|
|
if err != nil {
|
|
return err.Error()
|
|
}
|
|
dst, err := l.slave.Resolve(to)
|
|
if err != nil {
|
|
return err.Error()
|
|
}
|
|
fi, err := os.Lstat(src)
|
|
switch {
|
|
case err != nil:
|
|
return "folder no longer exists (rescan)"
|
|
case fi.Mode()&os.ModeSymlink != 0:
|
|
return "is a symlink"
|
|
case !fi.IsDir():
|
|
return "is not a folder"
|
|
case src == l.quarantine || fsops.Within(src, l.quarantine):
|
|
return "contains the quarantine folder"
|
|
}
|
|
if filepath.Dir(from) != filepath.Dir(to) {
|
|
return "rename would move the folder"
|
|
}
|
|
// A case-only rename "exists" on case-insensitive filesystems; that is fine.
|
|
if di, err := os.Lstat(dst); err == nil && !(strings.EqualFold(from, to) && os.SameFile(fi, di)) {
|
|
return fmt.Sprintf("the slave already has a folder named %q; merge the two by hand", filepath.Base(to))
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// PreviewRenames lists the folder renames that would be made.
|
|
func (e *Engine) PreviewRenames(ctx context.Context, pairID int64) ([]FolderRename, error) {
|
|
l, err := e.loadPair(ctx, pairID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := planRenames(l)
|
|
if out == nil {
|
|
out = []FolderRename{}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// StartRenames renames slave folders to the master's spelling, then rescans.
|
|
// With only set, just those renames are made (by slave path); they must be
|
|
// among the renames the server computes itself, so a request can never name
|
|
// arbitrary paths. Later passes (folders revealed inside renamed ones) only
|
|
// run when renaming everything.
|
|
func (e *Engine) StartRenames(pairID int64, only []string) (*Job, error) {
|
|
return e.startJob(pairID, "rename-folders", func(ctx context.Context, j *Job) error {
|
|
l, err := e.loadPair(ctx, pairID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
want := map[string]bool{}
|
|
for _, s := range only {
|
|
want[filepath.Clean(s)] = true
|
|
}
|
|
done := map[string]bool{}
|
|
for pass := 0; pass < maxRenamePasses; pass++ {
|
|
var todo []FolderRename
|
|
for _, r := range planRenames(l) {
|
|
if done[r.Slave] || (len(want) > 0 && !want[r.Slave]) {
|
|
continue
|
|
}
|
|
todo = append(todo, r)
|
|
}
|
|
if len(todo) == 0 {
|
|
break
|
|
}
|
|
j.phase(fmt.Sprintf("Renaming slave folders (pass %d)", pass+1), int64(len(todo)))
|
|
for _, r := range todo {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
done[r.Slave] = true
|
|
if !r.Ready {
|
|
j.skip(r.Slave + ": " + r.Reason)
|
|
} else if err := e.renameOne(ctx, j, l, r); err != nil {
|
|
j.skip(r.Slave + ": " + err.Error())
|
|
}
|
|
j.tick()
|
|
}
|
|
if len(want) > 0 || e.st.DryRun(ctx) {
|
|
break // a dry run renames nothing, so there is nothing new to find
|
|
}
|
|
}
|
|
if e.st.DryRun(ctx) {
|
|
j.update(func(j *Job) { j.Message = "Dry run: renames recorded as planned in Activity" })
|
|
return nil
|
|
}
|
|
// Paths changed, so the stored matches are stale.
|
|
return e.scan(ctx, j, pairID, "rename")
|
|
})
|
|
}
|
|
|
|
func (e *Engine) renameOne(ctx context.Context, j *Job, l *libs, r FolderRename) error {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
if why := checkRename(l, r.Slave, r.Master); why != "" { // re-check under the lock
|
|
return errors.New(why)
|
|
}
|
|
src, _ := l.slave.Resolve(r.Slave)
|
|
dst, _ := l.slave.Resolve(r.Master)
|
|
o := store.Operation{PairID: l.pair.ID, Action: store.ActRename, RelSlave: r.Slave, RelNew: r.Master, Trigger: "rename"}
|
|
if err := e.begin(ctx, &o); err != nil {
|
|
return err
|
|
}
|
|
if o.DryRun {
|
|
j.count("planned")
|
|
return nil
|
|
}
|
|
err := renameDir(src, dst)
|
|
if err = e.finish(ctx, &o, err); err != nil {
|
|
return err
|
|
}
|
|
j.count("renamed")
|
|
return nil
|
|
}
|
|
|
|
// renameDir renames a folder. Case-only renames go through a temporary name
|
|
// so they also work on case-insensitive filesystems. The destination must not
|
|
// exist (checked by the caller under the engine lock).
|
|
func renameDir(src, dst string) error {
|
|
if strings.EqualFold(src, dst) {
|
|
var b [6]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return err
|
|
}
|
|
tmp := filepath.Join(filepath.Dir(src), fsops.TmpPrefix+"dir-"+hex.EncodeToString(b[:]))
|
|
if err := os.Rename(src, tmp); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp, dst); err != nil {
|
|
_ = os.Rename(tmp, src) // put it back
|
|
return err
|
|
}
|
|
} else if err := os.Rename(src, dst); err != nil {
|
|
return err
|
|
}
|
|
return fsops.SyncDir(filepath.Dir(dst))
|
|
}
|
|
|
|
// undoRename renames a folder back to its old name.
|
|
func undoRename(l *libs, o store.Operation) error {
|
|
cur, err := l.slave.Resolve(o.RelNew)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
old, err := l.slave.Resolve(o.RelSlave)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fi, err := os.Lstat(cur)
|
|
if err != nil || !fi.IsDir() {
|
|
return fmt.Errorf("the folder %q no longer exists", o.RelNew)
|
|
}
|
|
if di, err := os.Lstat(old); err == nil && !(strings.EqualFold(o.RelNew, o.RelSlave) && os.SameFile(fi, di)) {
|
|
return fmt.Errorf("%q exists again: %w", o.RelSlave, fsops.ErrExists)
|
|
}
|
|
return renameDir(cur, old)
|
|
}
|
|
|
|
// recoverRename finishes a case-only folder rename cut short by a crash: the
|
|
// folder was moved to a temporary name and never reached its new name. The
|
|
// folder holds user data, so it is renamed, never removed.
|
|
func recoverRename(p store.Pair, o store.Operation, log *slog.Logger) {
|
|
dir := filepath.Join(p.SlaveRoot, filepath.Dir(o.RelSlave))
|
|
from := filepath.Join(p.SlaveRoot, o.RelSlave)
|
|
to := filepath.Join(p.SlaveRoot, o.RelNew)
|
|
if _, err := os.Lstat(from); err == nil {
|
|
return // never moved
|
|
}
|
|
if _, err := os.Lstat(to); err == nil {
|
|
return // completed
|
|
}
|
|
ents, _ := os.ReadDir(dir)
|
|
for _, en := range ents {
|
|
if en.IsDir() && strings.HasPrefix(en.Name(), fsops.TmpPrefix+"dir-") {
|
|
if err := os.Rename(filepath.Join(dir, en.Name()), to); err == nil {
|
|
log.Warn("finished interrupted folder rename", "op", o.ID, "folder", o.RelNew)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|