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