package engine import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "regexp" "strconv" "strings" "sync" "time" "github.com/cdnutter/playlink/internal/fsops" "github.com/cdnutter/playlink/internal/store" "github.com/cdnutter/playlink/internal/tags" ) // The organizer renames audio files from their tags into one layout, by // default Artist/Album/01 Title.ext. It only moves files within a library // (same inode, so hardlinks survive), never rewrites them, and every move is // an operation that can be undone. // normalizeOrganize validates a pair's organize settings. func normalizeOrganize(p *store.Pair) error { if len(p.OrganizeConfig) > 0 && string(p.OrganizeConfig) != "{}" && string(p.OrganizeConfig) != "null" { c, err := parseNaming(p.OrganizeConfig) if err != nil { return fmt.Errorf("naming settings: %w", err) } if err := ValidateNaming(c); err != nil { return err } p.OrganizeConfig, _ = json.Marshal(c) } else { p.OrganizeConfig = nil // derived from older settings or defaults (NamingOf) } switch p.AutoOrganize { case "": p.AutoOrganize = "off" case "off", "slave", "master", "both": default: return errors.New("automatic organizing must be off, slave, master or both") } return nil } var spaces = regexp.MustCompile(`\s+`) // maxComponent keeps names well below the 255-byte filesystem limit. const maxComponent = 150 func leadingNumber(s string) int { s = strings.TrimSpace(s) end := 0 for end < len(s) && s[end] >= '0' && s[end] <= '9' { end++ } n, _ := strconv.Atoi(s[:end]) return n } // ---- tag cache ---- type cachedTags struct { size, mtime int64 fields map[string]string err string } type tagCache struct { mu sync.Mutex m map[string]cachedTags } func (c *tagCache) get(ctx context.Context, t tags.Tools, abs string, in fsops.Info) (map[string]string, error) { c.mu.Lock() if c.m == nil { c.m = map[string]cachedTags{} } if v, ok := c.m[abs]; ok && v.size == in.Size && v.mtime == in.MtimeNs { c.mu.Unlock() if v.err != "" { return nil, errors.New(v.err) } return v.fields, nil } c.mu.Unlock() f, err := t.Read(ctx, abs) v := cachedTags{size: in.Size, mtime: in.MtimeNs, fields: f.Fields} if err != nil { v.err = err.Error() } c.mu.Lock() c.m[abs] = v c.mu.Unlock() return v.fields, err } // ---- planning ---- // organizeItem is one file the organizer looked at. type organizeItem struct { ID int `json:"id"` Side string `json:"side"` From string `json:"from"` To string `json:"to,omitempty"` Move bool `json:"move"` Reason string `json:"reason,omitempty"` // why it is skipped Sidecars []string `json:"sidecars,omitempty"` // e.g. lyrics files moving along ino uint64 size, mtime int64 } // organizePlan is what organizing would do right now. type organizePlan struct { Sides []string `json:"sides"` CreatedAt int64 `json:"created_at"` Scanned int `json:"scanned"` Conforming int `json:"conforming"` Moves int `json:"moves"` Skips map[string]int `json:"skips"` Items []organizeItem `json:"-"` } // sidecarExts move together with the song of the same name. var sidecarExts = []string{".lrc"} // folderArt follows an album folder whose songs all moved to one folder. var folderArt = map[string]bool{"cover.jpg": true, "cover.png": true, "folder.jpg": true, "folder.png": true, "front.jpg": true, "front.png": true, "albumart.jpg": true, "albumartsmall.jpg": true} func sidesOf(s string) []string { switch s { case "both": return []string{SideSlave, SideMaster} case SideMaster, SideSlave: return []string{s} } return nil } func (e *Engine) planOrganize(ctx context.Context, j *Job, l *libs, sides []string) (*organizePlan, error) { plan := &organizePlan{Sides: sides, CreatedAt: time.Now().Unix(), Skips: map[string]int{}} naming := NamingOf(l.pair) id := 0 for _, side := range sides { root, _ := sideRoot(l, side) files := map[string]fsops.Info{} if _, err := walk(ctx, l, root, files); err != nil { return nil, err } j.phase("Reading tags ("+side+")", int64(len(files))) claimed := map[string]string{} // lower(target) -> source for _, rel := range sortedKeys(files) { if ctx.Err() != nil { return nil, ctx.Err() } j.tick() plan.Scanned++ in := files[rel] item := organizeItem{Side: side, From: rel, ino: in.Ino, size: in.Size, mtime: in.MtimeNs} if _, ok := tags.FormatOf(rel); !ok { item.Reason = "file type without editable tags" } else if fields, err := e.tagCache.get(ctx, e.tagTools, filepath.Join(root.Path, rel), in); err != nil { item.Reason = "tags could not be read" } else if to, why := renderPath(naming, rel, fields); why != "" { item.Reason = why } else if to == rel { plan.Conforming++ continue } else { item.To = to key := strings.ToLower(to) switch { case claimed[key] != "": item.Reason = "another file gets the same name (" + claimed[key] + ")" case targetTaken(root, rel, to): item.Reason = "a different file already has that name" default: claimed[key] = rel item.Move = true item.Sidecars = sidecarsOf(root, rel) } } id++ item.ID = id if item.Move { plan.Moves++ } else { plan.Skips[item.Reason]++ } plan.Items = append(plan.Items, item) } } return plan, nil } // targetTaken reports whether moving rel to to would overwrite another file. // A case-only rename onto itself is fine. func targetTaken(root fsops.Root, rel, to string) bool { abs, err := root.Resolve(to) if err != nil { return true } ti, err := os.Lstat(abs) if err != nil { return !errors.Is(err, fs.ErrNotExist) } if strings.EqualFold(rel, to) { si, err := os.Lstat(filepath.Join(root.Path, rel)) return err != nil || !os.SameFile(si, ti) } return true } func sidecarsOf(root fsops.Root, rel string) []string { var out []string stem := strings.TrimSuffix(rel, filepath.Ext(rel)) for _, ext := range sidecarExts { if fi, err := os.Lstat(filepath.Join(root.Path, stem+ext)); err == nil && fi.Mode().IsRegular() { out = append(out, stem+ext) } } return out } type moveDetail struct { Side string `json:"side"` Sidecars [][2]string `json:"sidecars,omitempty"` // [from, to] Art bool `json:"art,omitempty"` // a folder art file following its album } func (e *Engine) applyOrganize(ctx context.Context, j *Job, l *libs, items []organizeItem, trigger string) error { j.phase("Renaming files", int64(len(items))) // Remember where each source folder's songs went, to move cover art along. type dirMove struct { targets map[string]bool side string } dirs := map[string]*dirMove{} for _, it := range items { if ctx.Err() != nil { return ctx.Err() } if err := e.moveOne(ctx, l, it, trigger); err != nil { j.skip(it.From + ": " + err.Error()) } else { j.count("renamed") key := it.Side + "\x00" + filepath.Dir(it.From) if dirs[key] == nil { dirs[key] = &dirMove{targets: map[string]bool{}, side: it.Side} } dirs[key].targets[filepath.Dir(it.To)] = true } j.tick() } if e.st.DryRun(ctx) { return nil } for key, dm := range dirs { from := strings.SplitN(key, "\x00", 2)[1] root, _ := sideRoot(l, dm.side) if len(dm.targets) == 1 { for to := range dm.targets { e.moveFolderArt(ctx, l, root, dm.side, from, to, trigger) } } if abs, err := root.Resolve(filepath.Join(from, "x")); err == nil { pruneEmpty(filepath.Dir(abs), root.Path) } } return nil } // moveFolderArt moves cover art out of a folder that has no songs left. func (e *Engine) moveFolderArt(ctx context.Context, l *libs, root fsops.Root, side, from, to, trigger string) { if from == to { return } src := filepath.Join(root.Path, from) ents, err := os.ReadDir(src) if err != nil { return } for _, en := range ents { if !en.IsDir() && l.hasExt(en.Name()) { return // songs remain: the art still belongs here } } for _, en := range ents { if !en.Type().IsRegular() || !folderArt[strings.ToLower(en.Name())] { continue } item := organizeItem{Side: side, From: filepath.Join(from, en.Name()), To: filepath.Join(to, en.Name())} e.mu.Lock() dst, err := root.Resolve(item.To) if err == nil { if _, err := os.Lstat(dst); errors.Is(err, fs.ErrNotExist) { o := store.Operation{PairID: l.pair.ID, Action: store.ActMove, RelNew: item.To, Trigger: trigger} setSidePath(&o, side, item.From) o.Detail, _ = json.Marshal(moveDetail{Side: side, Art: true}) if e.begin(ctx, &o) == nil && !o.DryRun { _ = e.finish(ctx, &o, moveFile(filepath.Join(root.Path, item.From), dst)) } } } e.mu.Unlock() } } func setSidePath(o *store.Operation, side, rel string) { if side == SideMaster { o.RelMaster = rel } else { o.RelSlave = rel } } func (e *Engine) moveOne(ctx context.Context, l *libs, it organizeItem, trigger string) error { e.mu.Lock() defer e.mu.Unlock() root, err := sideRoot(l, it.Side) if err != nil { return err } src, err := root.Resolve(it.From) if err != nil { return err } dst, err := root.Resolve(it.To) if err != nil { return err } in, err := fsops.RegularFile(src) if err != nil { return err } if in.Ino != it.ino || !in.Unchanged(it.size, it.mtime) { return fmt.Errorf("changed since the preview: %w", fsops.ErrChanged) } if targetTaken(root, it.From, it.To) { return fmt.Errorf("%s: %w", it.To, fsops.ErrExists) } d := moveDetail{Side: it.Side} for _, sc := range it.Sidecars { to := strings.TrimSuffix(it.To, filepath.Ext(it.To)) + filepath.Ext(sc) if !targetTaken(root, sc, to) { d.Sidecars = append(d.Sidecars, [2]string{sc, to}) } } o := store.Operation{PairID: l.pair.ID, Action: store.ActMove, RelNew: it.To, Trigger: trigger, Bytes: 0} setSidePath(&o, it.Side, it.From) o.Detail, _ = json.Marshal(d) if err := e.begin(ctx, &o); err != nil { return err } if o.DryRun { return nil } err = func() error { if err := fsops.MkdirAllLike(filepath.Dir(dst)); err != nil { return err } if err := moveFile(src, dst); err != nil { pruneEmpty(filepath.Dir(dst), root.Path) return err } for _, sc := range d.Sidecars { _ = moveFile(filepath.Join(root.Path, sc[0]), filepath.Join(root.Path, sc[1])) } return nil }() return e.finish(ctx, &o, err) } // moveFile renames a file within a library. Case-only renames go through a // temporary name so they also work on case-insensitive filesystems. func moveFile(src, dst string) error { if strings.EqualFold(src, dst) && src != dst { var b [6]byte _, _ = rand.Read(b[:]) tmp := filepath.Join(filepath.Dir(src), fsops.TmpPrefix+"mv-"+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) return err } } else if err := os.Rename(src, dst); err != nil { return err } if err := fsops.SyncDir(filepath.Dir(dst)); err != nil { return err } return fsops.SyncDir(filepath.Dir(src)) } // undoMove moves a file (and its sidecars) back. func undoMove(l *libs, o store.Operation) error { var d moveDetail if err := json.Unmarshal(o.Detail, &d); err != nil { return err } root, err := sideRoot(l, d.Side) if err != nil { return err } from := o.RelSlave if d.Side == SideMaster { from = o.RelMaster } cur, err := root.Resolve(o.RelNew) if err != nil { return err } old, err := root.Resolve(from) if err != nil { return err } if _, err := fsops.RegularFile(cur); err != nil { return fmt.Errorf("%s is no longer there", o.RelNew) } if targetTaken(root, o.RelNew, from) { return fmt.Errorf("%s: %w", from, fsops.ErrExists) } if err := fsops.MkdirAllLike(filepath.Dir(old)); err != nil { return err } if err := moveFile(cur, old); err != nil { return err } for _, sc := range d.Sidecars { _ = moveFile(filepath.Join(root.Path, sc[1]), filepath.Join(root.Path, sc[0])) } pruneEmpty(filepath.Dir(cur), root.Path) return nil } // recoverMove finishes a case-only file rename cut short by a crash. func recoverMove(l *libs, o store.Operation) { var d moveDetail if json.Unmarshal(o.Detail, &d) != nil { return } root, err := sideRoot(l, d.Side) if err != nil { return } from := o.RelSlave if d.Side == SideMaster { from = o.RelMaster } if _, err := os.Lstat(filepath.Join(root.Path, from)); err == nil { return // never moved } dst := filepath.Join(root.Path, o.RelNew) if _, err := os.Lstat(dst); err == nil { return // completed } dir := filepath.Dir(filepath.Join(root.Path, from)) ents, _ := os.ReadDir(dir) for _, en := range ents { if en.Type().IsRegular() && strings.HasPrefix(en.Name(), fsops.TmpPrefix+"mv-") { _ = os.Rename(filepath.Join(dir, en.Name()), dst) return } } } // autoOrganize runs during a scan when the pair asks for it: every clean // rename is applied, anything doubtful is left for the preview. func (e *Engine) autoOrganize(ctx context.Context, j *Job, l *libs) error { sides := sidesOf(l.pair.AutoOrganize) if sides == nil || !e.TagEditing() { return nil } plan, err := e.planOrganize(ctx, j, l, sides) if err != nil { return err } var moves []organizeItem for _, it := range plan.Items { if it.Move { moves = append(moves, it) } } if len(moves) == 0 { return nil } if err := e.BeforeAction(ctx, "auto-organize"); err != nil { return err } e.log.Info("auto-organizing", "pair", l.pair.Name, "files", len(moves)) return e.applyOrganize(ctx, j, l, moves, "auto-organize") } // ---- rename now (preview, then apply) ---- // StartOrganizePreview checks which files would be renamed. func (e *Engine) StartOrganizePreview(pairID int64, side string) (*Job, error) { if !e.TagEditing() { return nil, ErrNoTagTools } sides := sidesOf(side) if sides == nil { return nil, fmt.Errorf("%w: side must be slave, master or both", ErrInvalidBulk) } return e.startJob(pairID, "organize-preview", func(ctx context.Context, j *Job) error { l, err := e.loadPair(ctx, pairID) if err != nil { return err } plan, err := e.planOrganize(ctx, j, l, sides) if err != nil { return err } e.plansMu.Lock() if e.plans == nil { e.plans = map[int64]*organizePlan{} } e.plans[pairID] = plan e.plansMu.Unlock() j.update(func(j *Job) { j.Message = fmt.Sprintf("%d to rename, %d already organized, %d skipped", plan.Moves, plan.Conforming, len(plan.Items)-plan.Moves) }) return nil }) } // OrganizePreview returns the latest preview and its items. func (e *Engine) OrganizePreview(pairID int64) (*organizePlan, []organizeItem) { e.plansMu.Lock() defer e.plansMu.Unlock() p := e.plans[pairID] if p == nil { return nil, nil } return p, append([]organizeItem{}, p.Items...) } // StartOrganizeApply renames the selected files of the latest preview (all // when ids is empty), then rescans. Each file is checked again first. func (e *Engine) StartOrganizeApply(pairID int64, ids []int) (*Job, error) { e.plansMu.Lock() plan := e.plans[pairID] e.plansMu.Unlock() if plan == nil { return nil, fmt.Errorf("%w: check the files first", ErrInvalidBulk) } want := map[int]bool{} for _, id := range ids { want[id] = true } var items []organizeItem for _, it := range plan.Items { if it.Move && (len(want) == 0 || want[it.ID]) { items = append(items, it) } } if len(items) == 0 { return nil, fmt.Errorf("%w: nothing to rename", ErrInvalidBulk) } return e.startJob(pairID, "organize", func(ctx context.Context, j *Job) error { l, err := e.loadPair(ctx, pairID) if err != nil { return err } if err := e.applyOrganize(ctx, j, l, items, "organize"); err != nil { return err } e.plansMu.Lock() delete(e.plans, pairID) // paths changed: the preview is stale e.plansMu.Unlock() if e.st.DryRun(ctx) { j.update(func(j *Job) { j.Message = "Dry run: renames recorded as planned in Activity" }) return nil } return e.scan(ctx, j, pairID, "organize") }) }