Files
playlink/internal/engine/organize.go
T
cdnutterandClaude Opus 5.5 b11d0806c1 Allow naming without an artist folder and per-folder naming rules
The artist folder can be left empty for Album/01 Title (no artist tag
needed), and folders such as Video Games can have their own formats.
Files in a rule folder are only renamed inside it; other files are never
moved into it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 08:29:17 -07:00

589 lines
16 KiB
Go

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