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
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user