Remove the organizer's Preview and apply section
The preview/apply flow on the Organize tab did not work as expected, so it is removed along with its backend: the stored per-pair preview, the /organize/preview, /organize/plan and /organize/apply endpoints and their UI and types. The File naming card stays, and renaming now happens only through "Organize automatically" during scans, which keeps the underlying planning/moving code (now internal), undo, crash recovery and dry run. The rename-folders warning is unchanged. Tests now drive the kept planning and moving code directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dbcf562f7e
commit
96ea856dfd
8 files changed
+76
-462
No files matched your search
@@ -83,20 +83,16 @@ 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`).
|
||||
**Organize (rename files from tags).** The *Organize* tab sets a naming 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, and 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.
|
||||
With **Organize automatically** set to the slave, the master or both (off by default), every scan renames files that don't follow the
|
||||
layout. Files are only moved within their library, so contents and hardlinks never change; lyrics (`.lrc`) move with their song, cover
|
||||
art follows a whole folder, and emptied folders are removed. Files with missing tags or clashing names are left alone (fix their tags
|
||||
in the *Tags* tab). Each rename is in History and can be undone, and dry run applies. A slave file that is a hardlink of a master file
|
||||
at a different path is still recognised as linked.
|
||||
|
||||
**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,
|
||||
|
||||
@@ -91,9 +91,6 @@ 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)
|
||||
@@ -746,54 +743,3 @@ 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)
|
||||
}
|
||||
@@ -45,9 +45,6 @@ type Engine struct {
|
||||
tagTools tags.Tools
|
||||
tagCache tagCache
|
||||
|
||||
plansMu sync.Mutex
|
||||
plans map[int64]*OrganizePlan // latest organize preview per pair
|
||||
|
||||
jobsMu sync.Mutex
|
||||
jobs map[int64]*Job
|
||||
nextID int64
|
||||
|
||||
+22
-130
@@ -14,7 +14,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -233,31 +232,26 @@ func (c *tagCache) get(ctx context.Context, t tags.Tools, abs string, in fsops.I
|
||||
|
||||
// ---- 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
|
||||
// organizeItem is one file the organizer looked at.
|
||||
type organizeItem struct {
|
||||
Side string
|
||||
From string
|
||||
To string
|
||||
Move bool
|
||||
Reason string // why it is skipped
|
||||
Sidecars []string // 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:"-"`
|
||||
// organizePlan is what organizing would do right now.
|
||||
type organizePlan struct {
|
||||
Scanned int
|
||||
Conforming int
|
||||
Moves int
|
||||
Skips map[string]int
|
||||
Items []organizeItem
|
||||
}
|
||||
|
||||
// sidecarExts move together with the song of the same name.
|
||||
@@ -277,9 +271,8 @@ func sidesOf(s string) []string {
|
||||
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
|
||||
func (e *Engine) planOrganize(ctx context.Context, j *Job, l *libs, sides []string) (*organizePlan, error) {
|
||||
plan := &organizePlan{Skips: map[string]int{}}
|
||||
for _, side := range sides {
|
||||
root, _ := sideRoot(l, side)
|
||||
files := map[string]fsops.Info{}
|
||||
@@ -295,7 +288,7 @@ func (e *Engine) planOrganize(ctx context.Context, j *Job, l *libs, sides []stri
|
||||
j.tick()
|
||||
plan.Scanned++
|
||||
in := files[rel]
|
||||
item := OrganizeItem{Side: side, From: rel, ino: in.Ino, size: in.Size, mtime: in.MtimeNs}
|
||||
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 {
|
||||
@@ -319,8 +312,6 @@ func (e *Engine) planOrganize(ctx context.Context, j *Job, l *libs, sides []stri
|
||||
item.Sidecars = sidecarsOf(root, rel)
|
||||
}
|
||||
}
|
||||
id++
|
||||
item.ID = id
|
||||
if item.Move {
|
||||
plan.Moves++
|
||||
} else {
|
||||
@@ -361,112 +352,13 @@ func sidecarsOf(root fsops.Root, rel string) []string {
|
||||
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 {
|
||||
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 {
|
||||
@@ -527,7 +419,7 @@ func (e *Engine) moveFolderArt(ctx context.Context, l *libs, root fsops.Root, si
|
||||
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())}
|
||||
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 {
|
||||
@@ -552,7 +444,7 @@ func setSidePath(o *store.Operation, side, rel string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) moveOne(ctx context.Context, l *libs, it OrganizeItem, trigger string) error {
|
||||
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)
|
||||
@@ -715,7 +607,7 @@ func (e *Engine) autoOrganize(ctx context.Context, j *Job, l *libs) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var moves []OrganizeItem
|
||||
var moves []organizeItem
|
||||
for _, it := range plan.Items {
|
||||
if it.Move {
|
||||
moves = append(moves, it)
|
||||
|
||||
@@ -60,14 +60,34 @@ func mkTagged(t *testing.T, path string, kv ...string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (ev *env) organize(t *testing.T, side string) (*OrganizePlan, []OrganizeItem) {
|
||||
// plan and apply run the same code as automatic organizing.
|
||||
func (ev *env) plan(t *testing.T, sides ...string) *organizePlan {
|
||||
t.Helper()
|
||||
if _, err := ev.e.StartOrganizePlan(ev.pair.ID, side); err != nil {
|
||||
l, err := ev.e.loadPair(ctx, ev.pair.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev.wait()
|
||||
plan, items, _ := ev.e.OrganizePlanFor(ev.pair.ID, "", 0, 1000)
|
||||
return plan, items
|
||||
p, err := ev.e.planOrganize(ctx, &Job{e: ev.e, Results: map[string]int{}}, l, sides)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (ev *env) apply(t *testing.T, p *organizePlan) *Job {
|
||||
t.Helper()
|
||||
l, _ := ev.e.loadPair(ctx, ev.pair.ID)
|
||||
j := &Job{e: ev.e, Results: map[string]int{}}
|
||||
var moves []organizeItem
|
||||
for _, it := range p.Items {
|
||||
if it.Move {
|
||||
moves = append(moves, it)
|
||||
}
|
||||
}
|
||||
if err := ev.e.applyOrganize(ctx, j, l, moves, "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
func TestOrganizeSlaveLibrary(t *testing.T) {
|
||||
@@ -84,17 +104,14 @@ func TestOrganizeSlaveLibrary(t *testing.T) {
|
||||
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)
|
||||
plan := ev.plan(t, SideSlave)
|
||||
if plan.Conforming != 1 || plan.Moves != 2 {
|
||||
t.Fatalf("want 1 conforming and 2 moves, got %+v %+v", plan, items)
|
||||
t.Fatalf("want 1 conforming and 2 moves, got %+v", plan)
|
||||
}
|
||||
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()
|
||||
j := ev.apply(t, plan)
|
||||
if j.Results["renamed"] != 2 {
|
||||
t.Fatalf("apply: %+v %v", j.Results, j.Skipped)
|
||||
}
|
||||
@@ -139,11 +156,8 @@ func TestOrganizeKeepsHardlinks(t *testing.T) {
|
||||
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()
|
||||
ev.apply(t, ev.plan(t, SideSlave))
|
||||
ev.scan()
|
||||
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")
|
||||
@@ -157,11 +171,7 @@ 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 {
|
||||
if j := ev.apply(t, ev.plan(t, SideSlave)); j.Results["renamed"] != 1 {
|
||||
t.Fatalf("dry run should plan the rename: %+v", j.Results)
|
||||
}
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
@@ -169,11 +179,10 @@ func TestOrganizeDryRunAndChangedFile(t *testing.T) {
|
||||
}
|
||||
|
||||
ev.st.SetSetting(ctx, "dry_run", "0")
|
||||
ev.organize(t, SideSlave)
|
||||
plan := ev.plan(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)
|
||||
if j := ev.apply(t, plan); j.Results["renamed"] != 0 || j.Results["skipped"] != 1 {
|
||||
t.Fatalf("a file changed after planning must be skipped: %+v", j.Results)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -240,16 +240,3 @@ export type TagFile = {
|
||||
links: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type OrganizeItem = { id: number; side: Side; from: string; to?: string; move: boolean; reason?: string; sidecars?: string[] };
|
||||
|
||||
export type OrganizePlan = {
|
||||
pair_id: number;
|
||||
sides: Side[];
|
||||
template: string;
|
||||
created_at: number;
|
||||
scanned: number;
|
||||
conforming: number;
|
||||
moves: number;
|
||||
skips: Record<string, number>;
|
||||
};
|
||||
+11
-223
@@ -1,11 +1,10 @@
|
||||
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 { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, FolderTree } from "lucide-react";
|
||||
import { api, type Pair } from "../api";
|
||||
import { useInfo } from "../App";
|
||||
import { Alert, Badge, Button, Card, Empty, Field, Modal, Spinner, cx, formatAgo, inputCls } from "../ui";
|
||||
import { Alert, Button, Card, Field, inputCls } from "../ui";
|
||||
import { useToast } from "../toast";
|
||||
import { TagEditor } from "./Tags";
|
||||
|
||||
const TOKENS: [string, string][] = [
|
||||
["artist", "album artist, or artist"],
|
||||
@@ -39,8 +38,6 @@ function example(tpl: string, multi?: Pair["organize_multidisc"]) {
|
||||
return parts.join("/") + ".flac";
|
||||
}
|
||||
|
||||
type Scope = Side | "both";
|
||||
|
||||
export function OrganizeTab({ pair }: { pair: Pair }) {
|
||||
const info = useInfo();
|
||||
if (info.data && !info.data.tag_editing)
|
||||
@@ -48,7 +45,6 @@ export function OrganizeTab({ pair }: { pair: Pair }) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<NamingSettings pair={pair} />
|
||||
<Preview pair={pair} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -99,7 +95,10 @@ function NamingSettings({ pair }: { pair: Pair }) {
|
||||
<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>
|
||||
<p className="text-sm text-muted">
|
||||
Files that don't follow this layout are renamed from their tags during scans, when <b>Organize automatically</b> is on. Nothing else about
|
||||
a file changes.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" size="sm" disabled={!dirty} loading={save.isPending} onClick={() => save.mutate()}>
|
||||
Save
|
||||
@@ -138,9 +137,9 @@ function NamingSettings({ pair }: { pair: Pair }) {
|
||||
<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.">
|
||||
<Field label="Organize automatically" hint="Renames run at the start of every scan.">
|
||||
<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="off">Off: nothing is renamed</option>
|
||||
<option value="slave">Slave library</option>
|
||||
<option value="master">Master library</option>
|
||||
<option value="both">Both libraries</option>
|
||||
@@ -150,221 +149,10 @@ function NamingSettings({ pair }: { pair: Pair }) {
|
||||
{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.
|
||||
clashing names are left alone (fix their tags in the Tags tab). Lyrics files and cover art move along. 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>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,6 @@ 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}` : ""}`);
|
||||
|
||||
Reference in new issue
Block a user