Files
playlink/internal/engine/organize.go
T
cdnutterandClaude Opus 5.5 96ea856dfd Remove the organizer's Preview and apply section
The preview/apply flow on the Organize tab did not work as expected, so
it is removed along with its backend: the stored per-pair preview, the
/organize/preview, /organize/plan and /organize/apply endpoints and
their UI and types.

The File naming card stays, and renaming now happens only through
"Organize automatically" during scans, which keeps the underlying
planning/moving code (now internal), undo, crash recovery and dry run.
The rename-folders warning is unchanged. Tests now drive the kept
planning and moving code directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:18:40 -07:00

622 lines
17 KiB
Go

package engine
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"unicode"
"unicode/utf8"
"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.
const DefaultOrganizeTemplate = "{artist}/{album}/{track} {title}"
var organizeTokens = []string{"artist", "track_artist", "album", "track", "title", "disc", "year", "genre"}
var tokenRe = regexp.MustCompile(`\{([a-z_]+)\}`)
// ValidateTemplate checks a naming template.
func ValidateTemplate(t string) error {
if strings.TrimSpace(t) == "" {
return errors.New("the naming pattern is empty")
}
if !strings.Contains(t, "{title}") {
return errors.New("the naming pattern must contain {title}")
}
if strings.HasPrefix(t, "/") || strings.Contains(t, "..") || strings.Contains(t, "\\") {
return errors.New("the naming pattern must be a relative path without .. or backslashes")
}
known := map[string]bool{}
for _, k := range organizeTokens {
known[k] = true
}
for _, m := range tokenRe.FindAllStringSubmatch(t, -1) {
if !known[m[1]] {
return fmt.Errorf("unknown token {%s}; use %s", m[1], "{"+strings.Join(organizeTokens, "}, {")+"}")
}
}
for _, part := range strings.Split(t, "/") {
if strings.TrimSpace(part) == "" {
return errors.New("the naming pattern has an empty folder name")
}
}
return nil
}
func normalizeOrganize(p *store.Pair) error {
if strings.TrimSpace(p.OrganizeTemplate) == "" {
p.OrganizeTemplate = DefaultOrganizeTemplate
}
p.OrganizeTemplate = strings.TrimSpace(p.OrganizeTemplate)
if err := ValidateTemplate(p.OrganizeTemplate); err != nil {
return err
}
switch p.OrganizeMultiDisc {
case "":
p.OrganizeMultiDisc = "prefix"
case "prefix", "folder", "none":
default:
return errors.New("multi-disc style must be prefix, folder or none")
}
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
}
// ---- name cleanup ----
var unsafeChars = strings.NewReplacer(
"/", "-", "\\", "-", "|", "-", ":", " -", "*", "", "?", "", "\"", "'", "<", "", ">", "",
)
var spaces = regexp.MustCompile(`\s+`)
// maxComponent keeps names well below the 255-byte filesystem limit.
const maxComponent = 150
// cleanComponent makes a tag value safe as a file or folder name, including
// on Windows/SMB shares: path separators and reserved characters are
// replaced, control characters dropped, and leading/trailing dots and spaces
// removed (a leading dot would hide the file).
func cleanComponent(s string) string {
s = unsafeChars.Replace(s)
s = strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, s)
s = strings.TrimSpace(spaces.ReplaceAllString(s, " "))
s = strings.Trim(s, ". ")
for len(s) > maxComponent {
_, size := utf8.DecodeLastRuneInString(s)
s = strings.TrimRight(s[:len(s)-size], ". ")
}
return s
}
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
}
// renderName builds the organized path for a file from its tags. It returns
// the missing tag when a required one is empty.
func renderName(tpl, multiDisc, ext string, f map[string]string) (string, string) {
artist := f[tags.AlbumArtist]
if strings.TrimSpace(artist) == "" {
artist = f[tags.Artist]
}
track := leadingNumber(f[tags.Track])
total := leadingNumber(f[tags.TrackTotal])
disc, discTotal := leadingNumber(f[tags.Disc]), leadingNumber(f[tags.DiscTotal])
multi := discTotal > 1 || disc > 1
trackStr := ""
if track > 0 {
width := 2
if total >= 100 || track >= 100 {
width = 3
}
trackStr = fmt.Sprintf("%0*d", width, track)
if multi && multiDisc == "prefix" && disc > 0 {
trackStr = fmt.Sprintf("%d-%s", disc, trackStr)
}
}
year := ""
if d := strings.TrimSpace(f[tags.Date]); len(d) >= 4 && leadingNumber(d[:4]) > 0 {
year = d[:4]
}
values := map[string]string{
"artist": artist, "track_artist": f[tags.Artist], "album": f[tags.Album], "track": trackStr,
"title": f[tags.Title], "disc": strconv.Itoa(disc), "year": year, "genre": f[tags.Genre],
}
if disc == 0 {
values["disc"] = ""
}
optional := map[string]bool{"track": true}
parts := strings.Split(tpl, "/")
for i, part := range parts {
missing := ""
out := tokenRe.ReplaceAllStringFunc(part, func(tok string) string {
name := tok[1 : len(tok)-1]
v := cleanComponent(values[name])
if v == "" && !optional[name] && missing == "" {
missing = name
}
return v
})
if missing != "" {
if missing == "artist" {
missing = "artist or album artist"
}
return "", missing
}
// A missing optional token must not leave "- " or double spaces.
out = strings.Trim(spaces.ReplaceAllString(out, " "), " -.")
if out == "" {
return "", "tags for " + part
}
parts[i] = out
}
if multi && multiDisc == "folder" && disc > 0 {
last := len(parts) - 1
parts = append(parts[:last], fmt.Sprintf("Disc %d", disc), parts[last])
}
return filepath.Join(parts...) + strings.ToLower(ext), ""
}
// ---- 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 {
Side string
From string
To string
Move bool
Reason string // why it is skipped
Sidecars []string // e.g. lyrics files moving along
ino uint64
size, mtime int64
}
// organizePlan is what organizing would do right now.
type organizePlan struct {
Scanned int
Conforming int
Moves int
Skips map[string]int
Items []organizeItem
}
// 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{Skips: map[string]int{}}
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, missing := renderName(l.pair.OrganizeTemplate, l.pair.OrganizeMultiDisc, filepath.Ext(rel), fields); missing != "" {
item.Reason = "missing " + missing + " tag"
} 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)
}
}
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
}
e.log.Info("auto-organizing", "pair", l.pair.Name, "files", len(moves))
return e.applyOrganize(ctx, j, l, moves, "auto-organize")
}