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>
589 lines
16 KiB
Go
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")
|
|
})
|
|
}
|