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

+58
View File
@@ -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)
}