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
@@ -83,6 +83,21 @@ tags** buttons. Edits are safe with hardlinks:
|
||||
- Each edit is listed in History (old → new values) and can be undone; an in-place edit interrupted by a crash is rolled back on
|
||||
startup. Dry run applies.
|
||||
|
||||
**Organize (rename files from tags).** The *Organize* tab renames files into one layout, by default
|
||||
`{artist}/{album}/{track} {title}` → `Avenged Sevenfold/Nightmare/01 Nightmare.flac` (`{artist}` is the album artist, falling back to
|
||||
the artist). Tokens: `{artist}`, `{album}`, `{track}`, `{title}`, `{year}`, `{disc}`, `{track_artist}`, `{genre}`. Multi-disc albums get
|
||||
a disc prefix (`2-04 Title`) or a `Disc 2` folder. Names are made safe for Windows/SMB shares (`AC/DC` → `AC-DC`, `Title: Sub` →
|
||||
`Title - Sub`).
|
||||
|
||||
- **Preview first:** every file with its new name, what is already organized, and what is skipped and why (missing tags, with a
|
||||
*Fix tags* button that opens the tag editor, or two files that would get the same name).
|
||||
- Files are only moved within their library, so contents and hardlinks never change. Lyrics (`.lrc`) move with their song, cover art
|
||||
follows when a whole folder moves, and emptied folders are removed.
|
||||
- Organizing **both libraries** keeps paths in step. A slave file that is a hardlink of a master file at a different path is still
|
||||
recognised as linked.
|
||||
- Each rename is in History and can be undone; the library is rescanned afterwards. Optionally, a pair can organize the slave, the
|
||||
master or both automatically during every scan (off by default). Dry run applies.
|
||||
|
||||
**Export.** The Review tab's **Export CSV** button downloads either the current view (filter and search applied) or the entire scan.
|
||||
Each row has the match status and type, a recommendation, both paths, where linking would place the file, codec, bitrate, sample rate,
|
||||
bit depth and duration for both sides, the quality verdict and any notes. The file opens cleanly in Excel, Numbers and LibreOffice
|
||||
|
||||
@@ -23,6 +23,7 @@ var kindLabels = map[string]string{
|
||||
store.KindFuzzy: "similar title",
|
||||
store.KindElsewhere: "same top folder, other subfolder",
|
||||
store.KindApprox: "approximate match",
|
||||
store.KindInode: "same file, other path",
|
||||
store.KindNone: "no match",
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,9 @@ func (s *Server) Handler() http.Handler {
|
||||
r.Get("/files", s.handleFiles)
|
||||
r.Post("/tags/read", s.handleReadTags)
|
||||
r.Post("/tags", s.handleEditTags)
|
||||
r.Post("/organize/preview", s.handleOrganizePreview)
|
||||
r.Get("/organize/plan", s.handleOrganizePlan)
|
||||
r.Post("/organize/apply", s.handleOrganizeApply)
|
||||
r.Get("/renames", s.handleRenamePreview)
|
||||
r.Post("/renames", s.handleRenames)
|
||||
r.Post("/link-identical", s.handleLinkIdentical)
|
||||
@@ -337,6 +340,9 @@ type pairInput struct {
|
||||
SettleMinutes int `json:"settle_minutes"`
|
||||
ApproxMatching bool `json:"approx_matching"`
|
||||
ApproxThreshold int `json:"approx_threshold"`
|
||||
OrganizeTemplate string `json:"organize_template"`
|
||||
OrganizeMultiDisc string `json:"organize_multidisc"`
|
||||
AutoOrganize string `json:"auto_organize"`
|
||||
}
|
||||
|
||||
func (in pairInput) apply(p *store.Pair) {
|
||||
@@ -344,6 +350,7 @@ func (in pairInput) apply(p *store.Pair) {
|
||||
p.Extensions, p.AutoLinkIdentical = in.Extensions, in.AutoLinkIdentical
|
||||
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes = in.ScanIntervalMinutes, in.RetentionDays, in.SettleMinutes
|
||||
p.ApproxMatching, p.ApproxThreshold = in.ApproxMatching, in.ApproxThreshold
|
||||
p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize = in.OrganizeTemplate, in.OrganizeMultiDisc, in.AutoOrganize
|
||||
}
|
||||
|
||||
func (s *Server) handleValidatePair(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -739,3 +746,54 @@ func (s *Server) handleEditTags(w http.ResponseWriter, r *http.Request) {
|
||||
s.log.Info("tag edit started", "pair", id, "side", body.Side, "files", len(body.Paths), "fields", len(body.Set), "separate", body.Separate)
|
||||
writeJSON(w, 202, j)
|
||||
}
|
||||
|
||||
// ---- organizer ----
|
||||
|
||||
func (s *Server) handleOrganizePreview(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
var body struct {
|
||||
Side string `json:"side"` // slave, master or both
|
||||
}
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
j, err := s.eng.StartOrganizePlan(id, body.Side)
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 202, j)
|
||||
}
|
||||
|
||||
func (s *Server) handleOrganizePlan(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
q := r.URL.Query()
|
||||
offset, _ := strconv.Atoi(q.Get("offset"))
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
plan, items, total := s.eng.OrganizePlanFor(id, q.Get("filter"), offset, limit)
|
||||
if plan == nil {
|
||||
writeJSON(w, 200, map[string]any{"plan": nil})
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []engine.OrganizeItem{}
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"plan": plan, "items": items, "total": total})
|
||||
}
|
||||
|
||||
func (s *Server) handleOrganizeApply(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
var body struct {
|
||||
IDs []int `json:"ids"` // empty: every rename in the preview
|
||||
}
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
j, err := s.eng.StartOrganizeApply(id, body.IDs)
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
return
|
||||
}
|
||||
s.log.Info("organize started", "pair", id, "selected", len(body.IDs))
|
||||
writeJSON(w, 202, j)
|
||||
}
|
||||
@@ -43,6 +43,10 @@ type Engine struct {
|
||||
mu sync.Mutex
|
||||
|
||||
tagTools tags.Tools
|
||||
tagCache tagCache
|
||||
|
||||
plansMu sync.Mutex
|
||||
plans map[int64]*OrganizePlan // latest organize preview per pair
|
||||
|
||||
jobsMu sync.Mutex
|
||||
jobs map[int64]*Job
|
||||
@@ -290,6 +294,9 @@ func (e *Engine) NormalizePair(p *store.Pair) ([]store.Warning, error) {
|
||||
if p.RetentionDays < 0 || p.ScanIntervalMinutes < 0 || p.SettleMinutes < 0 {
|
||||
return nil, errors.New("intervals must not be negative")
|
||||
}
|
||||
if err := normalizeOrganize(p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.ApproxThreshold == 0 {
|
||||
p.ApproxThreshold = 85
|
||||
}
|
||||
|
||||
@@ -625,6 +625,17 @@ func (e *Engine) Undo(ctx context.Context, opID int64) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if o.Action == store.ActMove {
|
||||
if err := undoMove(l, o); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.st.MarkUndone(ctx, o.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
e.log.Info("file move undone", "op", o.ID)
|
||||
_, _ = e.StartScan(o.PairID, "undo")
|
||||
return nil
|
||||
}
|
||||
if o.Action == store.ActTags {
|
||||
if err := e.undoTags(ctx, l, o); err != nil {
|
||||
return err
|
||||
@@ -858,6 +869,13 @@ func (e *Engine) Recover(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
for _, o := range ops {
|
||||
if o.Action == store.ActMove {
|
||||
if l, err := e.loadPair(ctx, o.PairID); err == nil {
|
||||
recoverMove(l, o)
|
||||
}
|
||||
_ = e.st.FinishOp(ctx, o.ID, store.OpInterrupted, "interrupted by shutdown; rescan to verify state")
|
||||
continue
|
||||
}
|
||||
if o.Action == store.ActTags {
|
||||
msg := e.recoverTags(ctx, o)
|
||||
_ = e.st.FinishOp(ctx, o.ID, store.OpInterrupted, msg)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/cdnutter/playlink/internal/store"
|
||||
)
|
||||
|
||||
func TestRenderName(t *testing.T) {
|
||||
f := func(kv ...string) map[string]string {
|
||||
m := map[string]string{}
|
||||
for i := 0; i < len(kv); i += 2 {
|
||||
m[kv[i]] = kv[i+1]
|
||||
}
|
||||
return m
|
||||
}
|
||||
cases := []struct {
|
||||
name, multi, ext string
|
||||
fields map[string]string
|
||||
want, missing string
|
||||
}{
|
||||
{"basic", "prefix", ".mp3", f("artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1", "title", "Nightmare"), "Avenged Sevenfold/Nightmare/01 Nightmare.mp3", ""},
|
||||
{"album artist wins", "prefix", ".flac", f("artist", "Avenged Sevenfold feat. X", "album_artist", "Avenged Sevenfold", "album", "A", "track", "3", "title", "T"), "Avenged Sevenfold/A/03 T.flac", ""},
|
||||
{"no track number", "prefix", ".mp3", f("artist", "A", "album", "B", "title", "Song"), "A/B/Song.mp3", ""},
|
||||
{"missing album", "prefix", ".mp3", f("artist", "A", "title", "Song"), "", "album"},
|
||||
{"missing title", "prefix", ".mp3", f("artist", "A", "album", "B", "track", "1"), "", "title"},
|
||||
{"multi-disc prefix", "prefix", ".mp3", f("artist", "A", "album", "B", "track", "4", "disc", "2", "disc_total", "2", "title", "S"), "A/B/2-04 S.mp3", ""},
|
||||
{"multi-disc folder", "folder", ".mp3", f("artist", "A", "album", "B", "track", "4", "disc", "2", "title", "S"), "A/B/Disc 2/04 S.mp3", ""},
|
||||
{"single disc of one", "prefix", ".mp3", f("artist", "A", "album", "B", "track", "4", "disc", "1", "disc_total", "1", "title", "S"), "A/B/04 S.mp3", ""},
|
||||
{"unsafe characters", "prefix", ".MP3", f("artist", "AC/DC", "album", "Who Made Who?", "track", "7", "title", "Title: Sub \"Live\"..."), "AC-DC/Who Made Who/07 Title - Sub 'Live'.mp3", ""},
|
||||
{"no hidden names", "prefix", ".mp3", f("artist", ".hidden", "album", "B", "title", "S"), "hidden/B/S.mp3", ""},
|
||||
{"big albums use 3 digits", "prefix", ".mp3", f("artist", "A", "album", "B", "track", "7", "track_total", "120", "title", "S"), "A/B/007 S.mp3", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, missing := renderName(DefaultOrganizeTemplate, c.multi, c.ext, c.fields)
|
||||
if got != c.want || missing != c.missing {
|
||||
t.Errorf("%s: got %q (missing %q), want %q (missing %q)", c.name, got, missing, c.want, c.missing)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "{artist}/{album}", "/{title}", "../{title}", "{artist}/{bogus} {title}", "{artist}//{title}"} {
|
||||
if ValidateTemplate(bad) == nil {
|
||||
t.Errorf("template %q should be rejected", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mkTagged(t *testing.T, path string, kv ...string) {
|
||||
t.Helper()
|
||||
os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
args := []string{"-v", "error", "-y", "-f", "lavfi", "-i", "sine=d=1"}
|
||||
for i := 0; i < len(kv); i += 2 {
|
||||
args = append(args, "-metadata", kv[i]+"="+kv[i+1])
|
||||
}
|
||||
if out, err := exec.Command("ffmpeg", append(args, path)...).CombinedOutput(); err != nil {
|
||||
t.Fatal(err, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func (ev *env) organize(t *testing.T, side string) (*OrganizePlan, []OrganizeItem) {
|
||||
t.Helper()
|
||||
if _, err := ev.e.StartOrganizePlan(ev.pair.ID, side); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev.wait()
|
||||
plan, items, _ := ev.e.OrganizePlanFor(ev.pair.ID, "", 0, 1000)
|
||||
return plan, items
|
||||
}
|
||||
|
||||
func TestOrganizeSlaveLibrary(t *testing.T) {
|
||||
ev := tagEnv(t, false)
|
||||
s := ev.slave
|
||||
// The user's example: a song named "Name - Artist.mp3".
|
||||
mkTagged(t, filepath.Join(s, "Random/Hail to the King - Avenged Sevenfold.mp3"),
|
||||
"title", "Hail to the King", "artist", "Avenged Sevenfold", "album", "Hail to the King", "track", "2/10")
|
||||
os.WriteFile(filepath.Join(s, "Random/Hail to the King - Avenged Sevenfold.lrc"), []byte("[00:01]lyrics"), 0o644)
|
||||
os.WriteFile(filepath.Join(s, "Random/cover.jpg"), []byte("jpg"), 0o644)
|
||||
mkTagged(t, filepath.Join(s, "Avenged Sevenfold/Nightmare/01 Nightmare.mp3"), // already organized
|
||||
"title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1")
|
||||
mkTagged(t, filepath.Join(s, "loose/untagged.mp3"), "title", "No Album")
|
||||
mkTagged(t, filepath.Join(s, "dupes/a.mp3"), "title", "Same", "artist", "X", "album", "Y", "track", "1")
|
||||
mkTagged(t, filepath.Join(s, "dupes/b.mp3"), "title", "Same", "artist", "X", "album", "Y", "track", "1")
|
||||
|
||||
plan, items := ev.organize(t, SideSlave)
|
||||
if plan.Conforming != 1 || plan.Moves != 2 {
|
||||
t.Fatalf("want 1 conforming and 2 moves, got %+v %+v", plan, items)
|
||||
}
|
||||
if plan.Skips["missing artist or album artist tag"] != 1 || len(plan.Skips) != 2 {
|
||||
t.Fatalf("unexpected skips %+v", plan.Skips)
|
||||
}
|
||||
if _, err := ev.e.StartOrganizeApply(ev.pair.ID, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := ev.wait()
|
||||
if j.Results["renamed"] != 2 {
|
||||
t.Fatalf("apply: %+v %v", j.Results, j.Skipped)
|
||||
}
|
||||
for _, rel := range []string{"Avenged Sevenfold/Hail to the King/02 Hail to the King.mp3", "Avenged Sevenfold/Hail to the King/02 Hail to the King.lrc",
|
||||
"Avenged Sevenfold/Hail to the King/cover.jpg", "X/Y/01 Same.mp3", "dupes/b.mp3"} {
|
||||
if _, err := os.Stat(filepath.Join(s, rel)); err != nil {
|
||||
t.Errorf("expected %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(s, "Random")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Error("the emptied folder should be removed")
|
||||
}
|
||||
|
||||
// Undo the song move: it goes back, lyrics too.
|
||||
ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID})
|
||||
for _, o := range ops {
|
||||
if o.Action == store.ActMove && o.RelSlave == "Random/Hail to the King - Avenged Sevenfold.mp3" {
|
||||
if err := ev.e.Undo(ctx, o.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev.wait()
|
||||
}
|
||||
}
|
||||
for _, rel := range []string{"Random/Hail to the King - Avenged Sevenfold.mp3", "Random/Hail to the King - Avenged Sevenfold.lrc"} {
|
||||
if _, err := os.Stat(filepath.Join(s, rel)); err != nil {
|
||||
t.Errorf("undo should restore %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Moving a hardlinked file keeps the link, and the scan still pairs it.
|
||||
func TestOrganizeKeepsHardlinks(t *testing.T) {
|
||||
ev := tagEnv(t, false)
|
||||
m := filepath.Join(ev.master, "Avenged Sevenfold/Nightmare/01 Nightmare.flac")
|
||||
mkTagged(t, m, "title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1")
|
||||
s := filepath.Join(ev.slave, "Chris Picks/Nightmare.flac")
|
||||
os.MkdirAll(filepath.Dir(s), 0o755)
|
||||
if err := os.Link(m, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev.scan()
|
||||
if got := ev.match("Chris Picks/Nightmare.flac"); got.Status != store.StatusLinked || got.Kind != store.KindInode {
|
||||
t.Fatalf("a hardlink at another path should be recognised, got %s/%s", got.Status, got.Kind)
|
||||
}
|
||||
ev.organize(t, SideSlave)
|
||||
if _, err := ev.e.StartOrganizeApply(ev.pair.ID, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev.wait()
|
||||
moved := filepath.Join(ev.slave, "Avenged Sevenfold/Nightmare/01 Nightmare.flac")
|
||||
if !sameInode(t, m, moved) {
|
||||
t.Fatal("the moved slave file must still be the master's file")
|
||||
}
|
||||
if got := ev.match("Avenged Sevenfold/Nightmare/01 Nightmare.flac"); got.Status != store.StatusLinked || got.Kind != store.KindPath {
|
||||
t.Fatalf("after organizing, the paths agree again: got %s/%s", got.Status, got.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrganizeDryRunAndChangedFile(t *testing.T) {
|
||||
ev := tagEnv(t, true)
|
||||
p := filepath.Join(ev.slave, "x/song.mp3")
|
||||
mkTagged(t, p, "title", "Song", "artist", "A", "album", "B", "track", "1")
|
||||
ev.organize(t, SideSlave)
|
||||
if _, err := ev.e.StartOrganizeApply(ev.pair.ID, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if j := ev.wait(); j.Results["renamed"] != 1 {
|
||||
t.Fatalf("dry run should plan the rename: %+v", j.Results)
|
||||
}
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Fatal("dry run must not move files")
|
||||
}
|
||||
|
||||
ev.st.SetSetting(ctx, "dry_run", "0")
|
||||
ev.organize(t, SideSlave)
|
||||
mkTagged(t, p, "title", "Song", "artist", "A", "album", "B", "track", "1", "genre", "changed")
|
||||
ev.e.StartOrganizeApply(ev.pair.ID, nil)
|
||||
if j := ev.wait(); j.Results["renamed"] != 0 || j.Results["skipped"] != 1 {
|
||||
t.Fatalf("a file changed after the preview must be skipped: %+v", j.Results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoOrganizeDuringScan(t *testing.T) {
|
||||
ev := tagEnv(t, false)
|
||||
ev.pair.AutoOrganize = "slave"
|
||||
if err := ev.st.SavePair(ctx, &ev.pair); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mkTagged(t, filepath.Join(ev.slave, "incoming/Nightmare - Avenged Sevenfold.mp3"),
|
||||
"title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1")
|
||||
ev.scan()
|
||||
rel := "Avenged Sevenfold/Nightmare/01 Nightmare.mp3"
|
||||
if _, err := os.Stat(filepath.Join(ev.slave, rel)); err != nil {
|
||||
t.Fatalf("the scan should organize the file: %v", err)
|
||||
}
|
||||
if got := ev.match(rel); got.RelSlave != rel {
|
||||
t.Fatal("the scan should see the organized path")
|
||||
}
|
||||
}
|
||||
@@ -287,6 +287,11 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string)
|
||||
_ = e.st.FinishScan(ctx, pairID, started.Unix(), err.Error(), nil)
|
||||
return err
|
||||
}
|
||||
if l.pair.AutoOrganize != "" && l.pair.AutoOrganize != "off" {
|
||||
if err := e.autoOrganize(ctx, j, l); err != nil {
|
||||
e.log.Warn("auto-organize failed", "pair", l.pair.Name, "err", err)
|
||||
}
|
||||
}
|
||||
warns := layoutWarnings(l)
|
||||
|
||||
j.phase("Indexing master library", 0)
|
||||
@@ -304,6 +309,15 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string)
|
||||
return err
|
||||
}
|
||||
ix := buildIndex(masterFiles, l.pair)
|
||||
// The same file (hardlink) can sit at another path, e.g. after one library
|
||||
// was organized; it is recognised as linked rather than unmatched.
|
||||
byInode := map[[2]uint64]string{}
|
||||
for _, rel := range sortedKeys(masterFiles) {
|
||||
in := masterFiles[rel]
|
||||
if _, dup := byInode[[2]uint64{in.Dev, in.Ino}]; !dup {
|
||||
byInode[[2]uint64{in.Dev, in.Ino}] = rel
|
||||
}
|
||||
}
|
||||
if ix.approx != nil {
|
||||
ix.verify = e.artistVerifier(ctx, l)
|
||||
}
|
||||
@@ -322,6 +336,11 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string)
|
||||
si := slaveFiles[rel]
|
||||
m := store.Match{PairID: pairID, RelSlave: rel}
|
||||
m.RelMaster, m.Kind, m.Reason = ix.find(rel)
|
||||
if m.Kind != store.KindPath {
|
||||
if mrel, ok := byInode[[2]uint64{si.Dev, si.Ino}]; ok {
|
||||
m.RelMaster, m.Kind, m.Reason = mrel, store.KindInode, ""
|
||||
}
|
||||
}
|
||||
var pm *store.Match
|
||||
if p, ok := prev[rel]; ok {
|
||||
pm = &p
|
||||
|
||||
@@ -149,6 +149,9 @@ type Pair struct {
|
||||
SettleMinutes int `json:"settle_minutes"`
|
||||
ApproxMatching bool `json:"approx_matching"`
|
||||
ApproxThreshold int `json:"approx_threshold"` // percent
|
||||
OrganizeTemplate string `json:"organize_template"`
|
||||
OrganizeMultiDisc string `json:"organize_multidisc"` // prefix, folder or none
|
||||
AutoOrganize string `json:"auto_organize"` // off, slave, master or both
|
||||
LastScanAt *int64 `json:"last_scan_at"`
|
||||
LastScanError string `json:"last_scan_error"`
|
||||
LastScanWarnings []Warning `json:"last_scan_warnings"`
|
||||
@@ -156,7 +159,8 @@ type Pair struct {
|
||||
}
|
||||
|
||||
const pairCols = `id, name, master_root, slave_root, quarantine_dir, extensions, auto_link_identical,
|
||||
scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold, last_scan_at, last_scan_error,
|
||||
scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold, organize_template,
|
||||
organize_multidisc, auto_organize, last_scan_at, last_scan_error,
|
||||
last_scan_warnings, created_at`
|
||||
|
||||
type scanner interface{ Scan(...any) error }
|
||||
@@ -166,7 +170,8 @@ func scanPair(r scanner) (Pair, error) {
|
||||
var exts, warns string
|
||||
var last sql.NullInt64
|
||||
err := r.Scan(&p.ID, &p.Name, &p.MasterRoot, &p.SlaveRoot, &p.QuarantineDir, &exts, &p.AutoLinkIdentical,
|
||||
&p.ScanIntervalMinutes, &p.RetentionDays, &p.SettleMinutes, &p.ApproxMatching, &p.ApproxThreshold, &last, &p.LastScanError,
|
||||
&p.ScanIntervalMinutes, &p.RetentionDays, &p.SettleMinutes, &p.ApproxMatching, &p.ApproxThreshold, &p.OrganizeTemplate,
|
||||
&p.OrganizeMultiDisc, &p.AutoOrganize, &last, &p.LastScanError,
|
||||
&warns, &p.CreatedAt)
|
||||
if err != nil {
|
||||
return p, err
|
||||
@@ -208,10 +213,12 @@ func (s *Store) SavePair(ctx context.Context, p *Pair) error {
|
||||
exts := strings.Join(p.Extensions, ",")
|
||||
if p.ID == 0 {
|
||||
res, err := s.DB.ExecContext(ctx, `INSERT INTO pairs (name, master_root, slave_root, quarantine_dir, extensions,
|
||||
auto_link_identical, scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
auto_link_identical, scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold,
|
||||
organize_template, organize_multidisc, auto_organize, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical,
|
||||
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold, now())
|
||||
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold,
|
||||
p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -220,9 +227,10 @@ func (s *Store) SavePair(ctx context.Context, p *Pair) error {
|
||||
}
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE pairs SET name = ?, master_root = ?, slave_root = ?, quarantine_dir = ?,
|
||||
extensions = ?, auto_link_identical = ?, scan_interval_minutes = ?, retention_days = ?, settle_minutes = ?,
|
||||
approx_matching = ?, approx_threshold = ? WHERE id = ?`,
|
||||
approx_matching = ?, approx_threshold = ?, organize_template = ?, organize_multidisc = ?, auto_organize = ? WHERE id = ?`,
|
||||
p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical,
|
||||
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold, p.ID)
|
||||
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold,
|
||||
p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, p.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -266,6 +274,7 @@ const (
|
||||
KindFuzzy = "fuzzy" // same directory, normalized title matches
|
||||
KindElsewhere = "elsewhere" // same top-level folder and title, different subfolder
|
||||
KindApprox = "approximate" // opt-in: similar title, disc folders ignored
|
||||
KindInode = "inode" // the same file (hardlink) at another path
|
||||
KindNone = "none"
|
||||
)
|
||||
|
||||
@@ -655,6 +664,7 @@ const (
|
||||
ActRemove = "remove" // slave hardlink removed (master copy kept)
|
||||
ActRename = "rename" // slave folder renamed to the master's spelling
|
||||
ActTags = "tags" // tags of a file edited
|
||||
ActMove = "move" // file renamed/moved by the organizer
|
||||
)
|
||||
|
||||
// Operation statuses.
|
||||
|
||||
@@ -121,6 +121,11 @@ var migrations = []string{
|
||||
|
||||
// 6: operation-specific details as JSON (e.g. which tags an edit changed).
|
||||
`ALTER TABLE operations ADD COLUMN detail TEXT NOT NULL DEFAULT '';`,
|
||||
|
||||
// 7: file organizing (renaming files from their tags).
|
||||
`ALTER TABLE pairs ADD COLUMN organize_template TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE pairs ADD COLUMN organize_multidisc TEXT NOT NULL DEFAULT 'prefix';
|
||||
ALTER TABLE pairs ADD COLUMN auto_organize TEXT NOT NULL DEFAULT 'off';`,
|
||||
}
|
||||
|
||||
// Open opens (and migrates) the database in dataDir.
|
||||
|
||||
+28
-3
@@ -76,6 +76,9 @@ export type Pair = {
|
||||
settle_minutes: number;
|
||||
approx_matching: boolean;
|
||||
approx_threshold: number;
|
||||
organize_template: string;
|
||||
organize_multidisc: "prefix" | "folder" | "none";
|
||||
auto_organize: "off" | "slave" | "master" | "both";
|
||||
last_scan_at: number | null;
|
||||
last_scan_error: string;
|
||||
last_scan_warnings: ScanWarning[];
|
||||
@@ -96,6 +99,9 @@ export type PairInput = Pick<
|
||||
| "settle_minutes"
|
||||
| "approx_matching"
|
||||
| "approx_threshold"
|
||||
| "organize_template"
|
||||
| "organize_multidisc"
|
||||
| "auto_organize"
|
||||
>;
|
||||
|
||||
export type ProbeInfo = {
|
||||
@@ -123,7 +129,7 @@ export type Match = {
|
||||
pair_id: number;
|
||||
rel_slave: string;
|
||||
rel_master: string;
|
||||
kind: "path" | "path_case" | "extension" | "fuzzy" | "elsewhere" | "approximate" | "none";
|
||||
kind: "path" | "path_case" | "extension" | "fuzzy" | "elsewhere" | "approximate" | "inode" | "none";
|
||||
status: Status;
|
||||
master_size: number;
|
||||
master_mtime: number;
|
||||
@@ -141,7 +147,7 @@ export type Match = {
|
||||
export type Operation = {
|
||||
id: number;
|
||||
pair_id: number;
|
||||
action: "link" | "promote" | "build" | "remove" | "rename" | "tags";
|
||||
action: "link" | "promote" | "build" | "remove" | "rename" | "tags" | "move";
|
||||
rel_master: string;
|
||||
rel_slave: string;
|
||||
rel_new: string;
|
||||
@@ -156,7 +162,13 @@ export type Operation = {
|
||||
finished_at: number | null;
|
||||
undone_at: number | null;
|
||||
purged_at: number | null;
|
||||
detail?: { side?: "master" | "slave"; mode?: "replace" | "inplace"; changes?: Record<string, [string, string]> };
|
||||
detail?: {
|
||||
side?: "master" | "slave";
|
||||
mode?: "replace" | "inplace";
|
||||
changes?: Record<string, [string, string]>;
|
||||
sidecars?: [string, string][];
|
||||
art?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type TreeEntry = {
|
||||
@@ -228,3 +240,16 @@ export type TagFile = {
|
||||
links: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type OrganizeItem = { id: number; side: Side; from: string; to?: string; move: boolean; reason?: string; sidecars?: string[] };
|
||||
|
||||
export type OrganizePlan = {
|
||||
pair_id: number;
|
||||
sides: Side[];
|
||||
template: string;
|
||||
created_at: number;
|
||||
scanned: number;
|
||||
conforming: number;
|
||||
moves: number;
|
||||
skips: Record<string, number>;
|
||||
};
|
||||
@@ -38,6 +38,7 @@ const actionLabel: Record<Operation["action"], string> = {
|
||||
remove: "Removed from slave",
|
||||
rename: "Renamed slave folder",
|
||||
tags: "Edited tags",
|
||||
move: "Renamed file",
|
||||
};
|
||||
|
||||
const statusTone: Record<Operation["status"], "green" | "red" | "blue" | "amber" | "gray"> = {
|
||||
@@ -97,7 +98,7 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate mono text-[13px]" title={o.rel_slave || o.rel_master}>
|
||||
{o.action === "tags" && <span className="mr-1 font-sans text-[11px] text-muted">{o.detail?.side}</span>}
|
||||
{(o.action === "tags" || o.action === "move") && <span className="mr-1 font-sans text-[11px] text-muted">{o.detail?.side}</span>}
|
||||
{o.rel_slave || o.rel_master}
|
||||
</div>
|
||||
{o.detail?.changes && (
|
||||
@@ -189,6 +190,8 @@ function undoText(o: Operation) {
|
||||
return "The hardlink is removed from the slave library. The master file is not affected.";
|
||||
case "remove":
|
||||
return "The hardlink is recreated in the slave library.";
|
||||
case "move":
|
||||
return `The file is moved back from "${o.rel_new}" to "${o.rel_slave || o.rel_master}"${o.detail?.sidecars?.length ? ", with its lyrics file" : ""}, and the library is rescanned.`;
|
||||
case "tags":
|
||||
return o.detail?.mode === "inplace"
|
||||
? "The previous version of the file is written back from quarantine, restoring the old tags everywhere the file is linked."
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, ArrowRight, FolderTree, Tags as TagsIcon } from "lucide-react";
|
||||
import { api, type OrganizeItem, type OrganizePlan, type Pair, type Side } from "../api";
|
||||
import { useInfo } from "../App";
|
||||
import { Alert, Badge, Button, Card, Empty, Field, Modal, Spinner, cx, formatAgo, inputCls } from "../ui";
|
||||
import { useToast } from "../toast";
|
||||
import { TagEditor } from "./Tags";
|
||||
|
||||
const TOKENS: [string, string][] = [
|
||||
["artist", "album artist, or artist"],
|
||||
["album", "album"],
|
||||
["track", "track number, 01"],
|
||||
["title", "title"],
|
||||
["year", "year"],
|
||||
["disc", "disc number"],
|
||||
["track_artist", "the song's artist"],
|
||||
["genre", "genre"],
|
||||
];
|
||||
|
||||
const SAMPLE: Record<string, string> = {
|
||||
artist: "Avenged Sevenfold",
|
||||
album: "Nightmare",
|
||||
track: "01",
|
||||
title: "Nightmare",
|
||||
year: "2010",
|
||||
disc: "1",
|
||||
track_artist: "Avenged Sevenfold",
|
||||
genre: "Metal",
|
||||
};
|
||||
|
||||
/** Renders the pattern for a sample song; disc 2 shows the multi-disc style. */
|
||||
function example(tpl: string, multi?: Pair["organize_multidisc"]) {
|
||||
const values: Record<string, string> = multi
|
||||
? { ...SAMPLE, track: multi === "prefix" ? "2-04" : "04", title: "Victim", disc: "2" }
|
||||
: SAMPLE;
|
||||
let parts = tpl.split("/").map((part) => part.replace(/\{([a-z_]+)\}/g, (_, k: string) => values[k] ?? `{${k}}`));
|
||||
if (multi === "folder") parts = [...parts.slice(0, -1), "Disc 2", parts[parts.length - 1]];
|
||||
return parts.join("/") + ".flac";
|
||||
}
|
||||
|
||||
type Scope = Side | "both";
|
||||
|
||||
export function OrganizeTab({ pair }: { pair: Pair }) {
|
||||
const info = useInfo();
|
||||
if (info.data && !info.data.tag_editing)
|
||||
return <Alert tone="amber">Organizing reads tags with ffmpeg and ffprobe, which were not found. The Docker image includes both.</Alert>;
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<NamingSettings pair={pair} />
|
||||
<Preview pair={pair} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NamingSettings({ pair }: { pair: Pair }) {
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [tpl, setTpl] = useState(pair.organize_template);
|
||||
const [multi, setMulti] = useState(pair.organize_multidisc);
|
||||
const [auto, setAuto] = useState(pair.auto_organize);
|
||||
useEffect(() => {
|
||||
setTpl(pair.organize_template);
|
||||
setMulti(pair.organize_multidisc);
|
||||
setAuto(pair.auto_organize);
|
||||
}, [pair.id, pair.organize_template, pair.organize_multidisc, pair.auto_organize]);
|
||||
const dirty = tpl !== pair.organize_template || multi !== pair.organize_multidisc || auto !== pair.auto_organize;
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
api.put(`/pairs/${pair.id}`, {
|
||||
name: pair.name,
|
||||
master_root: pair.master_root,
|
||||
slave_root: pair.slave_root,
|
||||
quarantine_dir: pair.quarantine_dir,
|
||||
extensions: pair.extensions,
|
||||
auto_link_identical: pair.auto_link_identical,
|
||||
scan_interval_minutes: pair.scan_interval_minutes,
|
||||
retention_days: pair.retention_days,
|
||||
settle_minutes: pair.settle_minutes,
|
||||
approx_matching: pair.approx_matching,
|
||||
approx_threshold: pair.approx_threshold,
|
||||
organize_template: tpl,
|
||||
organize_multidisc: multi,
|
||||
auto_organize: auto,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast("ok", "Naming settings saved. Preview again to see the effect.");
|
||||
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
|
||||
qc.invalidateQueries({ queryKey: ["pairs"] });
|
||||
},
|
||||
onError: (e) => toast("err", e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="space-y-4 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<FolderTree className="size-4 text-accent" /> File naming
|
||||
</h2>
|
||||
<p className="text-sm text-muted">Files are renamed from their tags into this layout. Nothing else about a file changes.</p>
|
||||
</div>
|
||||
<Button variant="primary" size="sm" disabled={!dirty} loading={save.isPending} onClick={() => save.mutate()}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<Field label="Naming pattern" hint={<>Folders are separated by <code className="mono">/</code>. The file extension is added automatically.</>}>
|
||||
<input className={inputCls + " mono"} value={tpl} onChange={(e) => setTpl(e.target.value)} spellCheck={false} />
|
||||
</Field>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{TOKENS.map(([t, help]) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
title={help}
|
||||
className="rounded-md border border-line bg-panel-2 px-2 py-0.5 text-xs mono text-gray-300 hover:border-accent cursor-pointer"
|
||||
onClick={() => setTpl((v) => v + `{${t}}`)}
|
||||
>
|
||||
{`{${t}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-lg border border-line bg-bg/60 p-3 text-xs">
|
||||
<div className="text-muted">Example</div>
|
||||
<div className="mono break-all text-gray-100">{example(tpl)}</div>
|
||||
{multi !== "none" && (
|
||||
<div className="mono break-all text-gray-400">
|
||||
{example(tpl, multi)} <span className="font-sans text-muted">(disc 2 of a multi-disc album)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Multi-disc albums" hint="Keeps disc 1 and disc 2 tracks from getting the same name.">
|
||||
<select className={inputCls} value={multi} onChange={(e) => setMulti(e.target.value as Pair["organize_multidisc"])}>
|
||||
<option value="prefix">Disc number before the track (2-04 Title)</option>
|
||||
<option value="folder">A folder per disc (Disc 2/04 Title)</option>
|
||||
<option value="none">Nothing (clashing names are skipped)</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Organize automatically" hint="During every scan, clean renames are applied without a preview.">
|
||||
<select className={inputCls} value={auto} onChange={(e) => setAuto(e.target.value as Pair["auto_organize"])}>
|
||||
<option value="off">Off: preview and apply by hand</option>
|
||||
<option value="slave">Slave library</option>
|
||||
<option value="master">Master library</option>
|
||||
<option value="both">Both libraries</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
{auto !== "off" && (
|
||||
<Alert tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
|
||||
Files are renamed during each scan without asking. Every rename is listed in History and can be undone, and files with missing tags or
|
||||
clashing names are left alone. Dry run still applies.
|
||||
{auto !== "both" && " Organizing only one library can make paths differ from the other; hardlinked songs stay paired either way."}
|
||||
</Alert>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Preview({ pair }: { pair: Pair }) {
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const dry = useInfo().data?.dry_run ?? true;
|
||||
const [scope, setScope] = useState<Scope>("both");
|
||||
const [view, setView] = useState<"move" | "skip">("move");
|
||||
const [sel, setSel] = useState<Set<number> | null>(null); // null = all
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const [editTags, setEditTags] = useState<OrganizeItem | null>(null);
|
||||
const running = pair.job?.state === "running";
|
||||
|
||||
const plan = useQuery({
|
||||
queryKey: ["organize", pair.id, view],
|
||||
queryFn: () =>
|
||||
api.get<{ plan: OrganizePlan | null; items?: OrganizeItem[]; total?: number }>(`/pairs/${pair.id}/organize/plan?filter=${view}&limit=500`),
|
||||
});
|
||||
useEffect(() => setSel(null), [plan.data?.plan?.created_at]);
|
||||
|
||||
const preview = useMutation({
|
||||
mutationFn: () => api.post(`/pairs/${pair.id}/organize/preview`, { side: scope }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["pair", pair.id] }),
|
||||
onError: (e) => toast("err", e.message),
|
||||
});
|
||||
const apply = useMutation({
|
||||
mutationFn: () => api.post(`/pairs/${pair.id}/organize/apply`, { ids: sel ? [...sel] : [] }),
|
||||
onSuccess: () => {
|
||||
setConfirm(false);
|
||||
toast("ok", dry ? "Started (dry run): renames are recorded as planned only" : "Renaming files. The library is rescanned afterwards.");
|
||||
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
|
||||
},
|
||||
onError: (e) => toast("err", e.message),
|
||||
});
|
||||
|
||||
const p = plan.data?.plan;
|
||||
const items = plan.data?.items ?? [];
|
||||
const skipped = p ? Object.values(p.skips).reduce((a, b) => a + b, 0) : 0;
|
||||
const chosen = sel ? sel.size : (p?.moves ?? 0);
|
||||
const both = (p?.sides.length ?? 0) > 1;
|
||||
|
||||
return (
|
||||
<Card className="space-y-4 p-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold">Preview and apply</h2>
|
||||
<p className="text-sm text-muted">See what would be renamed before anything changes.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select className={inputCls + " h-8 w-auto text-xs"} value={scope} onChange={(e) => setScope(e.target.value as Scope)}>
|
||||
<option value="both">Both libraries (recommended)</option>
|
||||
<option value="slave">Slave library</option>
|
||||
<option value="master">Master library</option>
|
||||
</select>
|
||||
<Button size="sm" onClick={() => preview.mutate()} loading={preview.isPending} disabled={running}>
|
||||
Preview renames
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{running && pair.job?.kind.startsWith("organize") && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted">
|
||||
<Spinner className="size-4" /> {pair.job.phase || "Working"}… {pair.job.total > 0 && `${pair.job.done} / ${pair.job.total}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!p && !running && (
|
||||
<Empty icon={<FolderTree className="size-8" />} title="No preview yet">
|
||||
Choose which library to organize and click <b>Preview renames</b>. Organizing both keeps their paths in step, so songs keep matching.
|
||||
</Empty>
|
||||
)}
|
||||
|
||||
{p && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<Stat label="checked" value={p.scanned} />
|
||||
<Stat label="already organized" value={p.conforming} tone="text-emerald-300" />
|
||||
<Stat label="to rename" value={p.moves} tone="text-accent" />
|
||||
<Stat label="skipped" value={skipped} tone={skipped ? "text-amber-300" : undefined} />
|
||||
</div>
|
||||
<div className="text-xs text-muted">
|
||||
Preview of the {p.sides.join(" and ")} {both ? "libraries" : "library"} · {formatAgo(p.created_at)} · pattern{" "}
|
||||
<code className="mono">{p.template}</code>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b border-line">
|
||||
{(["move", "skip"] as const).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setView(v)}
|
||||
className={cx("-mb-px border-b-2 px-3 py-1.5 text-sm cursor-pointer", view === v ? "border-accent text-white" : "border-transparent text-muted hover:text-white")}
|
||||
>
|
||||
{v === "move" ? `To rename (${p.moves})` : `Skipped (${skipped})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{view === "skip" && skipped > 0 && (
|
||||
<ul className="space-y-0.5 text-xs text-muted">
|
||||
{Object.entries(p.skips).map(([why, n]) => (
|
||||
<li key={why}>
|
||||
<b className="tabular-nums text-gray-300">{n}</b> {why}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-line">
|
||||
{view === "move" && items.length > 0 && (
|
||||
<label className="flex items-center gap-3 border-b border-line bg-panel-2 px-3 py-2 text-xs text-muted cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-violet-500"
|
||||
checked={sel === null}
|
||||
onChange={(e) => setSel(e.target.checked ? null : new Set())}
|
||||
/>
|
||||
{sel === null ? `All ${p.moves} selected` : `${sel.size} selected`}
|
||||
</label>
|
||||
)}
|
||||
<div className="max-h-[55vh] overflow-y-auto">
|
||||
{items.length === 0 && <div className="p-6 text-center text-sm text-muted">Nothing here.</div>}
|
||||
{items.map((it) => (
|
||||
<div key={it.id} className="flex items-start gap-3 border-b border-line px-3 py-2 last:border-0">
|
||||
{it.move && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 accent-violet-500"
|
||||
checked={sel === null || sel.has(it.id)}
|
||||
onChange={(e) =>
|
||||
setSel((s) => {
|
||||
const n = new Set(s ?? items.filter((x) => x.move).map((x) => x.id));
|
||||
if (e.target.checked) n.add(it.id);
|
||||
else n.delete(it.id);
|
||||
return n;
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{both && <Badge tone={it.side === "master" ? "violet" : "gray"}>{it.side}</Badge>}
|
||||
<div className="min-w-0 flex-1 text-xs mono">
|
||||
{it.move ? (
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-start gap-2">
|
||||
<span className="break-all text-gray-400">{it.from}</span>
|
||||
<ArrowRight className="mt-0.5 size-3 text-muted" />
|
||||
<span className="break-all text-gray-100">{it.to}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="break-all text-gray-300">{it.from}</div>
|
||||
)}
|
||||
{it.sidecars?.length ? <div className="mt-0.5 font-sans text-[11px] text-muted">+ lyrics file moves along</div> : null}
|
||||
{!it.move && <div className="font-sans text-[11px] text-amber-300">{it.reason}</div>}
|
||||
</div>
|
||||
{!it.move && it.reason?.startsWith("missing") && (
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditTags(it)}>
|
||||
<TagsIcon className="size-3.5" /> Fix tags
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{(plan.data?.total ?? 0) > items.length && (
|
||||
<p className="text-xs text-muted">Showing the first {items.length} of {plan.data?.total}. Applying all includes every file.</p>
|
||||
)}
|
||||
|
||||
{p.moves > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="primary" disabled={chosen === 0 || running} onClick={() => setConfirm(true)}>
|
||||
<FolderTree className="size-4" /> Rename {chosen} file{chosen !== 1 && "s"}…
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal open={confirm} onClose={() => setConfirm(false)} title={`Rename ${chosen} file${chosen !== 1 ? "s" : ""}?`}>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-gray-300">
|
||||
<li>Files are moved to their new names inside the same library. Their contents and hardlinks don't change.</li>
|
||||
<li>Lyrics files with the same name move along; cover art follows when a whole folder moves. Emptied folders are removed.</li>
|
||||
<li>
|
||||
<b>Playlists or apps that point at the old paths will need updating.</b>
|
||||
</li>
|
||||
{p && !both && <li>Only the {p.sides[0]} library is renamed. Hardlinked songs stay paired, but paths may differ from the other library.</li>}
|
||||
<li>Each rename is listed in History and can be undone. The library is rescanned when it finishes.</li>
|
||||
</ul>
|
||||
{dry && (
|
||||
<div className="mt-3">
|
||||
<Alert tone="blue">Dry run is on, so the renames are recorded as planned in Activity. No files move.</Alert>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setConfirm(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" loading={apply.isPending} onClick={() => apply.mutate()}>
|
||||
Rename {chosen} file{chosen !== 1 && "s"}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
{editTags && <TagEditor pairId={pair.id} side={editTags.side} paths={[editTags.from]} onClose={() => setEditTags(null)} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: number; tone?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-panel-2 px-3 py-2">
|
||||
<div className={cx("text-xl font-semibold tabular-nums", tone)}>{value}</div>
|
||||
<div className="text-xs text-muted">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,9 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
|
||||
settle_minutes: 10,
|
||||
approx_matching: false,
|
||||
approx_threshold: 85,
|
||||
organize_template: "",
|
||||
organize_multidisc: "prefix",
|
||||
auto_organize: "off",
|
||||
};
|
||||
const [f, setF] = useState<PairInput>(pair ?? blank);
|
||||
const [exts, setExts] = useState((pair?.extensions ?? []).join(", "));
|
||||
@@ -52,6 +55,10 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
|
||||
settle_minutes: f.settle_minutes,
|
||||
approx_matching: f.approx_matching,
|
||||
approx_threshold: f.approx_threshold,
|
||||
// Edited on the Organize tab; sent unchanged so saving here keeps them.
|
||||
organize_template: f.organize_template,
|
||||
organize_multidisc: f.organize_multidisc,
|
||||
auto_organize: f.auto_organize,
|
||||
extensions: exts.split(",").map((s) => s.trim()).filter(Boolean),
|
||||
});
|
||||
const set = <K extends keyof PairInput>(k: K, v: PairInput[K]) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Build } from "./Build";
|
||||
import { OpsTable } from "./Activity";
|
||||
import { WarningList } from "./Warnings";
|
||||
import { TagsTab } from "./Tags";
|
||||
import { OrganizeTab } from "./Organize";
|
||||
|
||||
export function usePair(id: number) {
|
||||
return useQuery({
|
||||
@@ -39,6 +40,8 @@ function useJobFinished(pair: Pair | undefined) {
|
||||
qc.invalidateQueries({ queryKey: ["tree"] });
|
||||
qc.invalidateQueries({ queryKey: ["ops"] });
|
||||
qc.invalidateQueries({ queryKey: ["pairs"] });
|
||||
qc.invalidateQueries({ queryKey: ["organize"] });
|
||||
qc.invalidateQueries({ queryKey: ["files"] });
|
||||
const j = pair.job!;
|
||||
if (st === "done") toast("ok", `${j.kind} finished${j.message ? `: ${j.message}` : ""}`);
|
||||
else toast("err", `${j.kind} ${st}: ${j.error}`);
|
||||
@@ -124,6 +127,7 @@ const tabs = [
|
||||
{ id: "review", label: "Review" },
|
||||
{ id: "build", label: "Build" },
|
||||
{ id: "tags", label: "Tags" },
|
||||
{ id: "organize", label: "Organize" },
|
||||
{ id: "history", label: "History" },
|
||||
];
|
||||
|
||||
@@ -205,6 +209,7 @@ export function PairPage({ id, tab }: { id: number; tab: string }) {
|
||||
{tab === "review" && <Review pair={pair} />}
|
||||
{tab === "build" && <Build pair={pair} />}
|
||||
{tab === "tags" && <TagsTab pair={pair} />}
|
||||
{tab === "organize" && <OrganizeTab pair={pair} />}
|
||||
{tab === "history" && <OpsTable pairId={pair.id} />}
|
||||
|
||||
<PairForm pair={pair} open={editing} onClose={() => setEditing(false)} />
|
||||
|
||||
@@ -25,6 +25,7 @@ const kindLabel: Record<Match["kind"], string> = {
|
||||
fuzzy: "similar title",
|
||||
elsewhere: "same top folder, other subfolder",
|
||||
approximate: "approximate match",
|
||||
inode: "same file, other path",
|
||||
none: "no match",
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user