Add renaming slave folders to the master's spelling
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>
This commit is contained in:
1 parent
176a527ec7
commit
42161e4287
14 files changed
+684
-21
No files matched your search
@@ -0,0 +1,233 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user