Add organizing files into Artist/Album/01 Title from their tags
A new Organize tab renames audio files from their tags into a per-pair
naming pattern, by default {artist}/{album}/{track} {title} (album
artist, falling back to artist). Multi-disc albums get a disc prefix
(2-04) or a Disc N folder; names are made safe for Windows/SMB shares.
- Preview first: every rename, files already organized, and files
skipped with the reason (missing tags, with a button into the tag
editor; two files resolving to the same name; an existing file).
- Applying re-checks each file against the preview, moves it within its
library (hardlinks and contents untouched, case-only renames via a
temporary name, completed on startup after a crash), carries .lrc
lyrics and folder cover art along, and removes emptied folders. Each
move is an operation that can be undone; the library is rescanned.
- Optional automatic organizing of the slave, master or both during
scans (off by default). Dry run applies.
- Scans now recognise a slave file that is a hardlink of a master file
at a different path as linked ("same file, other path") instead of
unmatched.
Migration 7 adds the per-pair naming settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3eb02e2c8d
commit
dbcf562f7e
16 files changed
+1480
-11
No files matched your search
@@ -0,0 +1,729 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"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 in an organize plan.
|
||||
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 the result of a preview.
|
||||
type OrganizePlan struct {
|
||||
PairID int64 `json:"pair_id"`
|
||||
Sides []string `json:"sides"`
|
||||
Template string `json:"template"`
|
||||
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{PairID: l.pair.ID, Sides: sides, Template: l.pair.OrganizeTemplate, CreatedAt: time.Now().Unix(), Skips: map[string]int{}}
|
||||
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, 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)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// StartOrganizePlan previews organizing one or both libraries.
|
||||
func (e *Engine) StartOrganizePlan(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
|
||||
})
|
||||
}
|
||||
|
||||
// OrganizePlanFor returns the latest preview (items filtered by "move" or
|
||||
// "skip"), newest first.
|
||||
func (e *Engine) OrganizePlanFor(pairID int64, filter string, offset, limit int) (*OrganizePlan, []OrganizeItem, int) {
|
||||
e.plansMu.Lock()
|
||||
defer e.plansMu.Unlock()
|
||||
plan := e.plans[pairID]
|
||||
if plan == nil {
|
||||
return nil, nil, 0
|
||||
}
|
||||
var items []OrganizeItem
|
||||
for _, it := range plan.Items {
|
||||
if filter == "" || (filter == "move") == it.Move {
|
||||
items = append(items, it)
|
||||
}
|
||||
}
|
||||
total := len(items)
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 200
|
||||
}
|
||||
if offset > total {
|
||||
offset = total
|
||||
}
|
||||
end := min(offset+limit, total)
|
||||
out := append([]OrganizeItem{}, items[offset:end]...)
|
||||
return plan, out, total
|
||||
}
|
||||
|
||||
// StartOrganizeApply performs the moves of the latest preview (all, or only
|
||||
// the given ids), then rescans. Each move is re-checked 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: preview the changes 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")
|
||||
})
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
Reference in new issue
Block a user