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:
cdnutterandClaude Opus 5.5 committed 2026-09-29 12:23:41 -07:00
1 parent 3eb02e2c8d
commit dbcf562f7e
16 files changed
+1480 -11

No files matched your search

+15
View File
@@ -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 - 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. 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. **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, 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 bit depth and duration for both sides, the quality verdict and any notes. The file opens cleanly in Excel, Numbers and LibreOffice
+1
View File
@@ -23,6 +23,7 @@ var kindLabels = map[string]string{
store.KindFuzzy: "similar title", store.KindFuzzy: "similar title",
store.KindElsewhere: "same top folder, other subfolder", store.KindElsewhere: "same top folder, other subfolder",
store.KindApprox: "approximate match", store.KindApprox: "approximate match",
store.KindInode: "same file, other path",
store.KindNone: "no match", store.KindNone: "no match",
} }
+58
View File
@@ -91,6 +91,9 @@ func (s *Server) Handler() http.Handler {
r.Get("/files", s.handleFiles) r.Get("/files", s.handleFiles)
r.Post("/tags/read", s.handleReadTags) r.Post("/tags/read", s.handleReadTags)
r.Post("/tags", s.handleEditTags) 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.Get("/renames", s.handleRenamePreview)
r.Post("/renames", s.handleRenames) r.Post("/renames", s.handleRenames)
r.Post("/link-identical", s.handleLinkIdentical) r.Post("/link-identical", s.handleLinkIdentical)
@@ -337,6 +340,9 @@ type pairInput struct {
SettleMinutes int `json:"settle_minutes"` SettleMinutes int `json:"settle_minutes"`
ApproxMatching bool `json:"approx_matching"` ApproxMatching bool `json:"approx_matching"`
ApproxThreshold int `json:"approx_threshold"` 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) { 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.Extensions, p.AutoLinkIdentical = in.Extensions, in.AutoLinkIdentical
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes = in.ScanIntervalMinutes, in.RetentionDays, in.SettleMinutes p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes = in.ScanIntervalMinutes, in.RetentionDays, in.SettleMinutes
p.ApproxMatching, p.ApproxThreshold = in.ApproxMatching, in.ApproxThreshold 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) { 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) 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) 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)
}
+7
View File
@@ -43,6 +43,10 @@ type Engine struct {
mu sync.Mutex mu sync.Mutex
tagTools tags.Tools tagTools tags.Tools
tagCache tagCache
plansMu sync.Mutex
plans map[int64]*OrganizePlan // latest organize preview per pair
jobsMu sync.Mutex jobsMu sync.Mutex
jobs map[int64]*Job 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 { if p.RetentionDays < 0 || p.ScanIntervalMinutes < 0 || p.SettleMinutes < 0 {
return nil, errors.New("intervals must not be negative") return nil, errors.New("intervals must not be negative")
} }
if err := normalizeOrganize(p); err != nil {
return nil, err
}
if p.ApproxThreshold == 0 { if p.ApproxThreshold == 0 {
p.ApproxThreshold = 85 p.ApproxThreshold = 85
} }
+18
View File
@@ -625,6 +625,17 @@ func (e *Engine) Undo(ctx context.Context, opID int64) error {
if err != nil { if err != nil {
return err 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 o.Action == store.ActTags {
if err := e.undoTags(ctx, l, o); err != nil { if err := e.undoTags(ctx, l, o); err != nil {
return err return err
@@ -858,6 +869,13 @@ func (e *Engine) Recover(ctx context.Context) {
return return
} }
for _, o := range ops { 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 { if o.Action == store.ActTags {
msg := e.recoverTags(ctx, o) msg := e.recoverTags(ctx, o)
_ = e.st.FinishOp(ctx, o.ID, store.OpInterrupted, msg) _ = e.st.FinishOp(ctx, o.ID, store.OpInterrupted, msg)
+729
View File
@@ -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")
}
+196
View File
@@ -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")
}
}
+19
View File
@@ -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) _ = e.st.FinishScan(ctx, pairID, started.Unix(), err.Error(), nil)
return err 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) warns := layoutWarnings(l)
j.phase("Indexing master library", 0) 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 return err
} }
ix := buildIndex(masterFiles, l.pair) 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 { if ix.approx != nil {
ix.verify = e.artistVerifier(ctx, l) 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] si := slaveFiles[rel]
m := store.Match{PairID: pairID, RelSlave: rel} m := store.Match{PairID: pairID, RelSlave: rel}
m.RelMaster, m.Kind, m.Reason = ix.find(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 var pm *store.Match
if p, ok := prev[rel]; ok { if p, ok := prev[rel]; ok {
pm = &p pm = &p
+17 -7
View File
@@ -149,6 +149,9 @@ type Pair struct {
SettleMinutes int `json:"settle_minutes"` SettleMinutes int `json:"settle_minutes"`
ApproxMatching bool `json:"approx_matching"` ApproxMatching bool `json:"approx_matching"`
ApproxThreshold int `json:"approx_threshold"` // percent 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"` LastScanAt *int64 `json:"last_scan_at"`
LastScanError string `json:"last_scan_error"` LastScanError string `json:"last_scan_error"`
LastScanWarnings []Warning `json:"last_scan_warnings"` 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, 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` last_scan_warnings, created_at`
type scanner interface{ Scan(...any) error } type scanner interface{ Scan(...any) error }
@@ -166,7 +170,8 @@ func scanPair(r scanner) (Pair, error) {
var exts, warns string var exts, warns string
var last sql.NullInt64 var last sql.NullInt64
err := r.Scan(&p.ID, &p.Name, &p.MasterRoot, &p.SlaveRoot, &p.QuarantineDir, &exts, &p.AutoLinkIdentical, 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) &warns, &p.CreatedAt)
if err != nil { if err != nil {
return p, err return p, err
@@ -208,10 +213,12 @@ func (s *Store) SavePair(ctx context.Context, p *Pair) error {
exts := strings.Join(p.Extensions, ",") exts := strings.Join(p.Extensions, ",")
if p.ID == 0 { if p.ID == 0 {
res, err := s.DB.ExecContext(ctx, `INSERT INTO pairs (name, master_root, slave_root, quarantine_dir, extensions, 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) auto_link_identical, scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, organize_template, organize_multidisc, auto_organize, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical, 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 { if err != nil {
return err 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 = ?, _, 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 = ?, 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.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 return err
} }
@@ -266,6 +274,7 @@ const (
KindFuzzy = "fuzzy" // same directory, normalized title matches KindFuzzy = "fuzzy" // same directory, normalized title matches
KindElsewhere = "elsewhere" // same top-level folder and title, different subfolder KindElsewhere = "elsewhere" // same top-level folder and title, different subfolder
KindApprox = "approximate" // opt-in: similar title, disc folders ignored KindApprox = "approximate" // opt-in: similar title, disc folders ignored
KindInode = "inode" // the same file (hardlink) at another path
KindNone = "none" KindNone = "none"
) )
@@ -655,6 +664,7 @@ const (
ActRemove = "remove" // slave hardlink removed (master copy kept) ActRemove = "remove" // slave hardlink removed (master copy kept)
ActRename = "rename" // slave folder renamed to the master's spelling ActRename = "rename" // slave folder renamed to the master's spelling
ActTags = "tags" // tags of a file edited ActTags = "tags" // tags of a file edited
ActMove = "move" // file renamed/moved by the organizer
) )
// Operation statuses. // Operation statuses.
+5
View File
@@ -121,6 +121,11 @@ var migrations = []string{
// 6: operation-specific details as JSON (e.g. which tags an edit changed). // 6: operation-specific details as JSON (e.g. which tags an edit changed).
`ALTER TABLE operations ADD COLUMN detail TEXT NOT NULL DEFAULT '';`, `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. // Open opens (and migrates) the database in dataDir.
+28 -3
View File
@@ -76,6 +76,9 @@ export type Pair = {
settle_minutes: number; settle_minutes: number;
approx_matching: boolean; approx_matching: boolean;
approx_threshold: number; approx_threshold: number;
organize_template: string;
organize_multidisc: "prefix" | "folder" | "none";
auto_organize: "off" | "slave" | "master" | "both";
last_scan_at: number | null; last_scan_at: number | null;
last_scan_error: string; last_scan_error: string;
last_scan_warnings: ScanWarning[]; last_scan_warnings: ScanWarning[];
@@ -96,6 +99,9 @@ export type PairInput = Pick<
| "settle_minutes" | "settle_minutes"
| "approx_matching" | "approx_matching"
| "approx_threshold" | "approx_threshold"
| "organize_template"
| "organize_multidisc"
| "auto_organize"
>; >;
export type ProbeInfo = { export type ProbeInfo = {
@@ -123,7 +129,7 @@ export type Match = {
pair_id: number; pair_id: number;
rel_slave: string; rel_slave: string;
rel_master: 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; status: Status;
master_size: number; master_size: number;
master_mtime: number; master_mtime: number;
@@ -141,7 +147,7 @@ export type Match = {
export type Operation = { export type Operation = {
id: number; id: number;
pair_id: number; pair_id: number;
action: "link" | "promote" | "build" | "remove" | "rename" | "tags"; action: "link" | "promote" | "build" | "remove" | "rename" | "tags" | "move";
rel_master: string; rel_master: string;
rel_slave: string; rel_slave: string;
rel_new: string; rel_new: string;
@@ -156,7 +162,13 @@ export type Operation = {
finished_at: number | null; finished_at: number | null;
undone_at: number | null; undone_at: number | null;
purged_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 = { export type TreeEntry = {
@@ -228,3 +240,16 @@ export type TagFile = {
links: number; links: number;
size: 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>;
};
+4 -1
View File
@@ -38,6 +38,7 @@ const actionLabel: Record<Operation["action"], string> = {
remove: "Removed from slave", remove: "Removed from slave",
rename: "Renamed slave folder", rename: "Renamed slave folder",
tags: "Edited tags", tags: "Edited tags",
move: "Renamed file",
}; };
const statusTone: Record<Operation["status"], "green" | "red" | "blue" | "amber" | "gray"> = { const statusTone: Record<Operation["status"], "green" | "red" | "blue" | "amber" | "gray"> = {
@@ -97,7 +98,7 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate mono text-[13px]" title={o.rel_slave || o.rel_master}> <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} {o.rel_slave || o.rel_master}
</div> </div>
{o.detail?.changes && ( {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."; return "The hardlink is removed from the slave library. The master file is not affected.";
case "remove": case "remove":
return "The hardlink is recreated in the slave library."; 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": case "tags":
return o.detail?.mode === "inplace" 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." ? "The previous version of the file is written back from quarantine, restoring the old tags everywhere the file is linked."
+370
View File
@@ -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>
);
}
+7
View File
@@ -25,6 +25,9 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
settle_minutes: 10, settle_minutes: 10,
approx_matching: false, approx_matching: false,
approx_threshold: 85, approx_threshold: 85,
organize_template: "",
organize_multidisc: "prefix",
auto_organize: "off",
}; };
const [f, setF] = useState<PairInput>(pair ?? blank); const [f, setF] = useState<PairInput>(pair ?? blank);
const [exts, setExts] = useState((pair?.extensions ?? []).join(", ")); 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, settle_minutes: f.settle_minutes,
approx_matching: f.approx_matching, approx_matching: f.approx_matching,
approx_threshold: f.approx_threshold, 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), extensions: exts.split(",").map((s) => s.trim()).filter(Boolean),
}); });
const set = <K extends keyof PairInput>(k: K, v: PairInput[K]) => { const set = <K extends keyof PairInput>(k: K, v: PairInput[K]) => {
+5
View File
@@ -11,6 +11,7 @@ import { Build } from "./Build";
import { OpsTable } from "./Activity"; import { OpsTable } from "./Activity";
import { WarningList } from "./Warnings"; import { WarningList } from "./Warnings";
import { TagsTab } from "./Tags"; import { TagsTab } from "./Tags";
import { OrganizeTab } from "./Organize";
export function usePair(id: number) { export function usePair(id: number) {
return useQuery({ return useQuery({
@@ -39,6 +40,8 @@ function useJobFinished(pair: Pair | undefined) {
qc.invalidateQueries({ queryKey: ["tree"] }); qc.invalidateQueries({ queryKey: ["tree"] });
qc.invalidateQueries({ queryKey: ["ops"] }); qc.invalidateQueries({ queryKey: ["ops"] });
qc.invalidateQueries({ queryKey: ["pairs"] }); qc.invalidateQueries({ queryKey: ["pairs"] });
qc.invalidateQueries({ queryKey: ["organize"] });
qc.invalidateQueries({ queryKey: ["files"] });
const j = pair.job!; const j = pair.job!;
if (st === "done") toast("ok", `${j.kind} finished${j.message ? `: ${j.message}` : ""}`); if (st === "done") toast("ok", `${j.kind} finished${j.message ? `: ${j.message}` : ""}`);
else toast("err", `${j.kind} ${st}: ${j.error}`); else toast("err", `${j.kind} ${st}: ${j.error}`);
@@ -124,6 +127,7 @@ const tabs = [
{ id: "review", label: "Review" }, { id: "review", label: "Review" },
{ id: "build", label: "Build" }, { id: "build", label: "Build" },
{ id: "tags", label: "Tags" }, { id: "tags", label: "Tags" },
{ id: "organize", label: "Organize" },
{ id: "history", label: "History" }, { id: "history", label: "History" },
]; ];
@@ -205,6 +209,7 @@ export function PairPage({ id, tab }: { id: number; tab: string }) {
{tab === "review" && <Review pair={pair} />} {tab === "review" && <Review pair={pair} />}
{tab === "build" && <Build pair={pair} />} {tab === "build" && <Build pair={pair} />}
{tab === "tags" && <TagsTab pair={pair} />} {tab === "tags" && <TagsTab pair={pair} />}
{tab === "organize" && <OrganizeTab pair={pair} />}
{tab === "history" && <OpsTable pairId={pair.id} />} {tab === "history" && <OpsTable pairId={pair.id} />}
<PairForm pair={pair} open={editing} onClose={() => setEditing(false)} /> <PairForm pair={pair} open={editing} onClose={() => setEditing(false)} />
+1
View File
@@ -25,6 +25,7 @@ const kindLabel: Record<Match["kind"], string> = {
fuzzy: "similar title", fuzzy: "similar title",
elsewhere: "same top folder, other subfolder", elsewhere: "same top folder, other subfolder",
approximate: "approximate match", approximate: "approximate match",
inode: "same file, other path",
none: "no match", none: "no match",
}; };