From dbcf562f7eee6e3adc1ddd9e39847ee520a82cdf Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 29 Sep 2026 12:23:41 -0700 Subject: [PATCH] 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 --- README.md | 15 + internal/api/export.go | 1 + internal/api/server.go | 58 +++ internal/engine/engine.go | 7 + internal/engine/ops.go | 18 + internal/engine/organize.go | 729 +++++++++++++++++++++++++++++++ internal/engine/organize_test.go | 196 +++++++++ internal/engine/scan.go | 19 + internal/store/models.go | 24 +- internal/store/store.go | 5 + web/src/api.ts | 31 +- web/src/pages/Activity.tsx | 5 +- web/src/pages/Organize.tsx | 370 ++++++++++++++++ web/src/pages/PairForm.tsx | 7 + web/src/pages/PairPage.tsx | 5 + web/src/pages/Review.tsx | 1 + 16 files changed, 1480 insertions(+), 11 deletions(-) create mode 100644 internal/engine/organize.go create mode 100644 internal/engine/organize_test.go create mode 100644 web/src/pages/Organize.tsx diff --git a/README.md b/README.md index 93fa1b2..0707d4b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/api/export.go b/internal/api/export.go index d5fc324..4c86834 100644 --- a/internal/api/export.go +++ b/internal/api/export.go @@ -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", } diff --git a/internal/api/server.go b/internal/api/server.go index c547f93..a331a03 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index ef7146c..18ef710 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -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 } diff --git a/internal/engine/ops.go b/internal/engine/ops.go index be88b76..5b76711 100644 --- a/internal/engine/ops.go +++ b/internal/engine/ops.go @@ -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) diff --git a/internal/engine/organize.go b/internal/engine/organize.go new file mode 100644 index 0000000..789c3b0 --- /dev/null +++ b/internal/engine/organize.go @@ -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") +} diff --git a/internal/engine/organize_test.go b/internal/engine/organize_test.go new file mode 100644 index 0000000..a1892af --- /dev/null +++ b/internal/engine/organize_test.go @@ -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") + } +} diff --git a/internal/engine/scan.go b/internal/engine/scan.go index 04cc22d..66a11c1 100644 --- a/internal/engine/scan.go +++ b/internal/engine/scan.go @@ -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 diff --git a/internal/store/models.go b/internal/store/models.go index c6d0f57..5784f67 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -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. diff --git a/internal/store/store.go b/internal/store/store.go index 445b464..dc47c5f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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. diff --git a/web/src/api.ts b/web/src/api.ts index 605a2ff..9a1d0bc 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -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 }; + detail?: { + side?: "master" | "slave"; + mode?: "replace" | "inplace"; + changes?: Record; + 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; +}; diff --git a/web/src/pages/Activity.tsx b/web/src/pages/Activity.tsx index 8d5a862..8bf4ec0 100644 --- a/web/src/pages/Activity.tsx +++ b/web/src/pages/Activity.tsx @@ -38,6 +38,7 @@ const actionLabel: Record = { remove: "Removed from slave", rename: "Renamed slave folder", tags: "Edited tags", + move: "Renamed file", }; const statusTone: Record = { @@ -97,7 +98,7 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:
- {o.action === "tags" && {o.detail?.side}} + {(o.action === "tags" || o.action === "move") && {o.detail?.side}} {o.rel_slave || o.rel_master}
{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." diff --git a/web/src/pages/Organize.tsx b/web/src/pages/Organize.tsx new file mode 100644 index 0000000..d9145a1 --- /dev/null +++ b/web/src/pages/Organize.tsx @@ -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 = { + 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 = 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 Organizing reads tags with ffmpeg and ffprobe, which were not found. The Docker image includes both.; + return ( +
+ + +
+ ); +} + +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 ( + +
+
+

+ File naming +

+

Files are renamed from their tags into this layout. Nothing else about a file changes.

+
+ +
+ Folders are separated by /. The file extension is added automatically.}> + setTpl(e.target.value)} spellCheck={false} /> + +
+ {TOKENS.map(([t, help]) => ( + + ))} +
+
+
Example
+
{example(tpl)}
+ {multi !== "none" && ( +
+ {example(tpl, multi)} (disc 2 of a multi-disc album) +
+ )} +
+
+ + + + + + +
+ {auto !== "off" && ( + }> + 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."} + + )} +
+ ); +} + +function Preview({ pair }: { pair: Pair }) { + const qc = useQueryClient(); + const toast = useToast(); + const dry = useInfo().data?.dry_run ?? true; + const [scope, setScope] = useState("both"); + const [view, setView] = useState<"move" | "skip">("move"); + const [sel, setSel] = useState | null>(null); // null = all + const [confirm, setConfirm] = useState(false); + const [editTags, setEditTags] = useState(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 ( + +
+
+

Preview and apply

+

See what would be renamed before anything changes.

+
+
+ + +
+
+ + {running && pair.job?.kind.startsWith("organize") && ( +
+ {pair.job.phase || "Working"}… {pair.job.total > 0 && `${pair.job.done} / ${pair.job.total}`} +
+ )} + + {!p && !running && ( + } title="No preview yet"> + Choose which library to organize and click Preview renames. Organizing both keeps their paths in step, so songs keep matching. + + )} + + {p && ( + <> +
+ + + + +
+
+ Preview of the {p.sides.join(" and ")} {both ? "libraries" : "library"} · {formatAgo(p.created_at)} · pattern{" "} + {p.template} +
+ +
+ {(["move", "skip"] as const).map((v) => ( + + ))} +
+ + {view === "skip" && skipped > 0 && ( +
    + {Object.entries(p.skips).map(([why, n]) => ( +
  • + {n} {why} +
  • + ))} +
+ )} + +
+ {view === "move" && items.length > 0 && ( + + )} +
+ {items.length === 0 &&
Nothing here.
} + {items.map((it) => ( +
+ {it.move && ( + + 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 && {it.side}} +
+ {it.move ? ( +
+ {it.from} + + {it.to} +
+ ) : ( +
{it.from}
+ )} + {it.sidecars?.length ?
+ lyrics file moves along
: null} + {!it.move &&
{it.reason}
} +
+ {!it.move && it.reason?.startsWith("missing") && ( + + )} +
+ ))} +
+
+ {(plan.data?.total ?? 0) > items.length && ( +

Showing the first {items.length} of {plan.data?.total}. Applying all includes every file.

+ )} + + {p.moves > 0 && ( +
+ +
+ )} + + )} + + setConfirm(false)} title={`Rename ${chosen} file${chosen !== 1 ? "s" : ""}?`}> +
    +
  • Files are moved to their new names inside the same library. Their contents and hardlinks don't change.
  • +
  • Lyrics files with the same name move along; cover art follows when a whole folder moves. Emptied folders are removed.
  • +
  • + Playlists or apps that point at the old paths will need updating. +
  • + {p && !both &&
  • Only the {p.sides[0]} library is renamed. Hardlinked songs stay paired, but paths may differ from the other library.
  • } +
  • Each rename is listed in History and can be undone. The library is rescanned when it finishes.
  • +
+ {dry && ( +
+ Dry run is on, so the renames are recorded as planned in Activity. No files move. +
+ )} +
+ + +
+
+ {editTags && setEditTags(null)} />} +
+ ); +} + +function Stat({ label, value, tone }: { label: string; value: number; tone?: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} diff --git a/web/src/pages/PairForm.tsx b/web/src/pages/PairForm.tsx index 880e40b..1b8741a 100644 --- a/web/src/pages/PairForm.tsx +++ b/web/src/pages/PairForm.tsx @@ -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(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: K, v: PairInput[K]) => { diff --git a/web/src/pages/PairPage.tsx b/web/src/pages/PairPage.tsx index 2696b94..ff8d3c9 100644 --- a/web/src/pages/PairPage.tsx +++ b/web/src/pages/PairPage.tsx @@ -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" && } {tab === "build" && } {tab === "tags" && } + {tab === "organize" && } {tab === "history" && } setEditing(false)} /> diff --git a/web/src/pages/Review.tsx b/web/src/pages/Review.tsx index d038949..98d604c 100644 --- a/web/src/pages/Review.tsx +++ b/web/src/pages/Review.tsx @@ -25,6 +25,7 @@ const kindLabel: Record = { fuzzy: "similar title", elsewhere: "same top folder, other subfolder", approximate: "approximate match", + inode: "same file, other path", none: "no match", };