diff --git a/README.md b/README.md index 5982e10..cb0c682 100644 --- a/README.md +++ b/README.md @@ -83,16 +83,23 @@ 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 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 02` folder (two-digit disc numbers by default, optionally `Disc 2`), and 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 how files are named, in the style of Lidarr: an **artist folder**, an +**album folder**, a **track format** and a separate **multi-disc track format**, with presets. The default is +`{Artist Name}/{Album Title}/{track:00} {Track Title}`, and multi-disc albums get `{Medium Format} {medium:00}/…` → `Disc 01`, `Disc 02`. -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. +Tokens: `{Artist Name}`, `{Artist NameThe}` (The Beatles → Beatles, The), `{Artist CleanName}`, `{Artist NameFirstCharacter}`, +`{Album Title}`, `{Album TitleThe}`, `{Album CleanTitle}`, `{Release Year}`, `{track:0}`/`{track:00}`, `{medium:0}`/`{medium:00}`, +`{Medium Format}`, `{Track Title}`, `{Track CleanTitle}`, `{Track ArtistName}`, `{Genre}`. Like Lidarr, `{Artist.Name}` or `{Artist_Name}` +puts dots or underscores between words and `{ARTIST NAME}` / `{artist name}` change the case. The artist is the album artist, falling back +to the artist. Colons can be replaced smartly (`A: B` → `A - B`), with a dash, space dash, space dash space, or deleted; other characters +Windows/SMB rejects are replaced (`AC/DC` → `AC-DC`) or removed. Brackets and separators left empty by a missing value are cleaned up +(`Album ({Release Year})` without a year → `Album`). Examples are rendered by the same code that renames files. + +**Rename now…** checks the slave, the master or both, then shows every change grouped by destination folder (old → new, selectable per +file or folder), plus skipped files with the reason and a *Fix tags* button. 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. Each +rename is in History and can be undone, and the library is rescanned afterwards. **Organize automatically** (off by default) applies the +same renames during every scan. A slave file that is a hardlink of a master file at a different path is still recognised as linked. **Backups** (Settings → Backups), similar to Sonarr. A backup is a zip of playlink's database (settings, library pairs, scan results and history, not music files) taken safely while the app runs. Choose a folder (default `/config/backups`), a schedule (off, every 1, 3, 7 diff --git a/internal/api/server.go b/internal/api/server.go index a1ab149..25d02d7 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -79,6 +79,8 @@ func (s *Server) Handler() http.Handler { r.Get("/settings", s.handleGetSettings) r.Put("/settings", s.handlePutSettings) r.Get("/browse", s.handleBrowse) + r.Get("/naming", s.handleNamingInfo) + r.Post("/naming/examples", s.handleNamingExamples) if s.cfg.Backups != nil { s.backupRoutes(r) } @@ -101,6 +103,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) @@ -303,13 +308,14 @@ func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) { type pairView struct { store.Pair - Summary store.Summary `json:"summary"` - Job *engine.Job `json:"job"` + Naming engine.NamingConfig `json:"naming"` + Summary store.Summary `json:"summary"` + Job *engine.Job `json:"job"` } func (s *Server) view(r *http.Request, p store.Pair) pairView { sum, _ := s.st.Summary(r.Context(), p.ID) - return pairView{Pair: p, Summary: sum, Job: s.eng.Job(p.ID)} + return pairView{Pair: p, Naming: engine.NamingOf(p), Summary: sum, Job: s.eng.Job(p.ID)} } func (s *Server) handleListPairs(w http.ResponseWriter, r *http.Request) { @@ -347,10 +353,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"` - OrganizeDiscDigits int `json:"organize_disc_digits"` + // Naming is optional: when absent, the pair keeps its current naming. + Naming *engine.NamingConfig `json:"naming,omitempty"` } func (in pairInput) apply(p *store.Pair) { @@ -358,8 +363,10 @@ 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 - p.OrganizeDiscDigits = in.OrganizeDiscDigits + p.AutoOrganize = in.AutoOrganize + if in.Naming != nil { + p.OrganizeConfig, _ = json.Marshal(in.Naming) + } } func (s *Server) handleValidatePair(w http.ResponseWriter, r *http.Request) { @@ -800,3 +807,69 @@ func (s *Server) preAction(w http.ResponseWriter, r *http.Request, pairID int64, } return true } + +// ---- organizer ---- + +func (s *Server) handleNamingInfo(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{"defaults": engine.DefaultNaming(), "presets": engine.NamingPresets, "tokens": engine.TokenHelp}) +} + +// handleNamingExamples renders sample songs with unsaved settings, using the +// same code as real renames. +func (s *Server) handleNamingExamples(w http.ResponseWriter, r *http.Request) { + var c engine.NamingConfig + if !readJSON(w, r, &c) { + return + } + if err := engine.ValidateNaming(c); err != nil { + writeJSON(w, 200, map[string]any{"error": err.Error(), "examples": []engine.NamingExample{}}) + return + } + writeJSON(w, 200, map[string]any{"examples": engine.NamingExamples(c)}) +} + +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.StartOrganizePreview(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) + plan, items := s.eng.OrganizePreview(id) + if plan == nil { + writeJSON(w, 200, map[string]any{"plan": nil, "items": []any{}}) + return + } + writeJSON(w, 200, map[string]any{"plan": plan, "items": items}) +} + +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 + } + if !s.preAction(w, r, id, "rename files") { + return + } + j, err := s.eng.StartOrganizeApply(id, body.IDs) + if err != nil { + opErr(w, err) + return + } + s.log.Info("rename now started", "pair", id, "selected", len(body.IDs)) + writeJSON(w, 202, j) +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index b3a5895..703fd90 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -46,6 +46,9 @@ type Engine struct { beforeAction func(ctx context.Context, reason string) error tagCache tagCache + plansMu sync.Mutex + plans map[int64]*organizePlan // latest "rename now" preview per pair + jobsMu sync.Mutex jobs map[int64]*Job nextID int64 diff --git a/internal/engine/naming.go b/internal/engine/naming.go new file mode 100644 index 0000000..2fbe28c --- /dev/null +++ b/internal/engine/naming.go @@ -0,0 +1,476 @@ +package engine + +import ( + "encoding/json" + "errors" + "fmt" + "path/filepath" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + "github.com/cdnutter/playlink/internal/store" + "github.com/cdnutter/playlink/internal/tags" +) + +// Naming follows Lidarr's model: an artist folder, an album folder and a +// track format (with a separate format for multi-disc albums), built from +// tokens such as {Artist Name}, {Album Title} and {track:00}. + +type NamingConfig struct { + ArtistFolder string `json:"artist_folder"` + AlbumFolder string `json:"album_folder"` + TrackFormat string `json:"track_format"` + MultiDiscFormat string `json:"multi_disc_format"` + Colon string `json:"colon"` // smart, delete, dash, space_dash, space_dash_space + Illegal string `json:"illegal"` // replace or remove +} + +func DefaultNaming() NamingConfig { + return NamingConfig{ + ArtistFolder: "{Artist Name}", + AlbumFolder: "{Album Title}", + TrackFormat: "{track:00} {Track Title}", + MultiDiscFormat: "{Medium Format} {medium:00}/{track:00} {Track Title}", + Colon: "smart", + Illegal: "replace", + } +} + +// NamingPresets are offered in the UI. +var NamingPresets = []struct { + Name string `json:"name"` + Config NamingConfig `json:"config"` +}{ + {"Artist/Album/01 Title", DefaultNaming()}, + {"Artist/Album (Year)/01 - Title", NamingConfig{ArtistFolder: "{Artist Name}", AlbumFolder: "{Album Title} ({Release Year})", + TrackFormat: "{track:00} - {Track Title}", MultiDiscFormat: "{Medium Format} {medium:00}/{track:00} - {Track Title}", Colon: "smart", Illegal: "replace"}}, + {"Artist/Album/Artist - Album - 01 - Title", NamingConfig{ArtistFolder: "{Artist Name}", AlbumFolder: "{Album Title}", + TrackFormat: "{Artist Name} - {Album Title} - {track:00} - {Track Title}", + MultiDiscFormat: "{Artist Name} - {Album Title} - {medium:00}-{track:00} - {Track Title}", Colon: "smart", Illegal: "replace"}}, + {"Artist/Album/1-01 Title (disc in name)", NamingConfig{ArtistFolder: "{Artist Name}", AlbumFolder: "{Album Title}", + TrackFormat: "{track:00} {Track Title}", MultiDiscFormat: "{medium:0}-{track:00} {Track Title}", Colon: "smart", Illegal: "replace"}}, +} + +// NamingOf returns a pair's naming, converting the settings of earlier +// versions (one path pattern plus a disc style) when none is stored yet. +func NamingOf(p store.Pair) NamingConfig { + c := DefaultNaming() + if len(p.OrganizeConfig) > 0 && string(p.OrganizeConfig) != "{}" { + var stored NamingConfig + if json.Unmarshal(p.OrganizeConfig, &stored) == nil { + fill(&stored, c) + return stored + } + } + old := map[string]string{"{artist}": "{Artist Name}", "{album}": "{Album Title}", "{track}": "{track:00}", "{title}": "{Track Title}", + "{year}": "{Release Year}", "{track_artist}": "{Track ArtistName}", "{genre}": "{Genre}"} + medium := "{medium:00}" + if p.OrganizeDiscDigits == 1 { + medium = "{medium:0}" + } + old["{disc}"] = medium + if parts := strings.Split(p.OrganizeTemplate, "/"); len(parts) == 3 { + conv := func(s string) string { + for k, v := range old { + s = strings.ReplaceAll(s, k, v) + } + return s + } + c.ArtistFolder, c.AlbumFolder, c.TrackFormat = conv(parts[0]), conv(parts[1]), conv(parts[2]) + } + switch p.OrganizeMultiDisc { + case "prefix": + c.MultiDiscFormat = strings.Replace(c.TrackFormat, "{track:00}", medium+"-{track:00}", 1) + case "none": + c.MultiDiscFormat = c.TrackFormat + default: + c.MultiDiscFormat = "{Medium Format} " + medium + "/" + c.TrackFormat + } + return c +} + +func fill(c *NamingConfig, d NamingConfig) { + if strings.TrimSpace(c.ArtistFolder) == "" { + c.ArtistFolder = d.ArtistFolder + } + if strings.TrimSpace(c.AlbumFolder) == "" { + c.AlbumFolder = d.AlbumFolder + } + if strings.TrimSpace(c.TrackFormat) == "" { + c.TrackFormat = d.TrackFormat + } + if strings.TrimSpace(c.MultiDiscFormat) == "" { + c.MultiDiscFormat = d.MultiDiscFormat + } + if c.Colon == "" { + c.Colon = d.Colon + } + if c.Illegal == "" { + c.Illegal = d.Illegal + } +} + +// ---- tokens ---- + +var ( + tokenPat = regexp.MustCompile(`\{([^{}]+)\}`) + numberTok = regexp.MustCompile(`(?i)^(track|medium):(0+)$`) +) + +// TokenHelp documents the tokens (with example values) for the UI. +var TokenHelp = [][2]string{ + {"{Artist Name}", "Avenged Sevenfold"}, {"{Artist NameThe}", "Beatles, The"}, {"{Artist CleanName}", "ACDC"}, + {"{Artist NameFirstCharacter}", "A"}, {"{Album Title}", "Nightmare"}, {"{Album TitleThe}", "White Album, The"}, + {"{Album CleanTitle}", "Nightmare"}, {"{Release Year}", "2010"}, {"{track:00}", "01"}, {"{track:0}", "1"}, + {"{medium:00}", "01"}, {"{medium:0}", "1"}, {"{Medium Format}", "Disc"}, {"{Track Title}", "Nightmare"}, + {"{Track CleanTitle}", "Nightmare"}, {"{Track ArtistName}", "Avenged Sevenfold"}, {"{Genre}", "Metal"}, +} + +var knownTokens = map[string]bool{ + "artistname": true, "artistnamethe": true, "artistcleanname": true, "artistnamefirstcharacter": true, + "albumtitle": true, "albumtitlethe": true, "albumcleantitle": true, "releaseyear": true, + "tracktitle": true, "trackcleantitle": true, "trackartistname": true, "mediumformat": true, "genre": true, +} + +// tokenKey normalizes "Artist Name", "Artist.Name" and "ARTIST_NAME". +func tokenKey(t string) string { + var b strings.Builder + for _, r := range strings.ToLower(t) { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + } + } + return b.String() +} + +// styleOf returns the word separator and case a token is written in. +func styleOf(t string) (sep string, cased string) { + sep = " " + switch { + case strings.Contains(t, "."): + sep = "." + case strings.Contains(t, "_"): + sep = "_" + } + letters := strings.Map(func(r rune) rune { + if unicode.IsLetter(r) { + return r + } + return -1 + }, t) + switch { + case letters != "" && letters == strings.ToUpper(letters): + cased = "upper" + case letters != "" && letters == strings.ToLower(letters): + cased = "lower" + } + return +} + +// ValidateNaming checks the formats. +func ValidateNaming(c NamingConfig) error { + check := func(label, f string, folder bool) error { + if strings.TrimSpace(f) == "" { + return fmt.Errorf("%s is empty", label) + } + if strings.Contains(f, "..") || strings.Contains(f, "\\") || strings.HasPrefix(f, "/") || strings.HasSuffix(f, "/") { + return fmt.Errorf("%s must be a relative name without .. or backslashes", label) + } + if folder && strings.Contains(f, "/") { + return fmt.Errorf("%s must be a single folder name (no /)", label) + } + for _, m := range tokenPat.FindAllStringSubmatch(f, -1) { + if numberTok.MatchString(m[1]) { + continue + } + if !knownTokens[tokenKey(m[1])] { + return fmt.Errorf("%s: unknown token {%s}", label, m[1]) + } + } + if strings.Count(f, "{") != strings.Count(f, "}") { + return fmt.Errorf("%s has an unclosed {", label) + } + return nil + } + if err := check("Artist folder format", c.ArtistFolder, true); err != nil { + return err + } + if err := check("Album folder format", c.AlbumFolder, true); err != nil { + return err + } + for _, f := range []struct{ label, v string }{{"Track format", c.TrackFormat}, {"Multi-disc track format", c.MultiDiscFormat}} { + if err := check(f.label, f.v, false); err != nil { + return err + } + if !strings.Contains(tokenKeys(f.v), "|tracktitle|") && !strings.Contains(tokenKeys(f.v), "|trackcleantitle|") { + return fmt.Errorf("%s must contain {Track Title}", f.label) + } + } + switch c.Colon { + case "smart", "delete", "dash", "space_dash", "space_dash_space": + default: + return errors.New("unknown colon replacement") + } + if c.Illegal != "replace" && c.Illegal != "remove" { + return errors.New("illegal characters must be replace or remove") + } + return nil +} + +func tokenKeys(f string) string { + var b strings.Builder + b.WriteString("|") + for _, m := range tokenPat.FindAllStringSubmatch(f, -1) { + b.WriteString(tokenKey(m[1]) + "|") + } + return b.String() +} + +// ---- value transforms ---- + +var articles = []string{"The ", "A ", "An "} + +// nameThe moves a leading article to the end: "The Beatles" -> "Beatles, The". +func nameThe(s string) string { + for _, a := range articles { + if len(s) > len(a) && strings.EqualFold(s[:len(a)], a) { + return strings.TrimSpace(s[len(a):]) + ", " + strings.TrimSpace(s[:len(a)]) + } + } + return s +} + +// cleanName keeps letters, digits and spaces: "AC/DC" -> "ACDC". +func cleanName(s string) string { + s = strings.Map(func(r rune) rune { + switch { + case unicode.IsLetter(r), unicode.IsDigit(r), r == ' ': + return r + case r == '&': + return r + } + return -1 + }, s) + return strings.TrimSpace(spaces.ReplaceAllString(s, " ")) +} + +func firstChar(s string) string { + for _, r := range nameThe(s) { + if unicode.IsLetter(r) { + return string(unicode.ToUpper(r)) + } + if unicode.IsDigit(r) { + return "#" + } + } + return "" +} + +func colonReplace(s, mode string) string { + switch mode { + case "delete": + return strings.ReplaceAll(s, ":", "") + case "dash": + return strings.ReplaceAll(s, ":", "-") + case "space_dash": + return strings.ReplaceAll(s, ":", " -") + case "space_dash_space": + return strings.ReplaceAll(strings.ReplaceAll(s, " :", ":"), ":", " - ") + } + // smart: "Title: Sub" -> "Title - Sub", "10:30" -> "10-30" + s = strings.ReplaceAll(s, ": ", " - ") + return strings.ReplaceAll(s, ":", "-") +} + +var ( + illegalReplace = strings.NewReplacer("/", "-", "\\", "-", "|", "-", "*", "-", "?", "", "\"", "'", "<", "", ">", "") + illegalRemove = strings.NewReplacer("/", "", "\\", "", "|", "", "*", "", "?", "", "\"", "", "<", "", ">", "") +) + +// safeValue makes a tag value safe inside a file or folder name. +func safeValue(s string, c NamingConfig) string { + s = colonReplace(s, c.Colon) + if c.Illegal == "remove" { + s = illegalRemove.Replace(s) + } else { + s = illegalReplace.Replace(s) + } + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, s) +} + +var emptyBrackets = regexp.MustCompile(`\(\s*\)|\[\s*\]|\{\s*\}`) + +// finishComponent tidies a rendered name: empty brackets and separators left +// by missing optional values are removed, and leading dots (hidden files) +// and trailing dots/spaces (invalid on Windows) are trimmed. +func finishComponent(s string) string { + s = emptyBrackets.ReplaceAllString(s, "") + s = spaces.ReplaceAllString(s, " ") + for _, dup := range []string{" - - ", " - - "} { + for strings.Contains(s, dup) { + s = strings.ReplaceAll(s, dup, " - ") + } + } + s = strings.Trim(s, " -._") + s = strings.TrimLeft(s, ".") + for len(s) > maxComponent { + _, size := utf8.DecodeLastRuneInString(s) + s = strings.TrimRight(s[:len(s)-size], ". ") + } + return s +} + +// songValues are the raw values tokens draw from. +type songValues struct { + artist, trackArtist, album, title, year, genre string + track, disc, discTotal int +} + +func valuesOf(f map[string]string) songValues { + v := songValues{ + artist: strings.TrimSpace(f[tags.AlbumArtist]), trackArtist: strings.TrimSpace(f[tags.Artist]), + album: strings.TrimSpace(f[tags.Album]), title: strings.TrimSpace(f[tags.Title]), genre: strings.TrimSpace(f[tags.Genre]), + track: leadingNumber(f[tags.Track]), disc: leadingNumber(f[tags.Disc]), discTotal: leadingNumber(f[tags.DiscTotal]), + } + if v.artist == "" { + v.artist = v.trackArtist + } + if d := strings.TrimSpace(f[tags.Date]); len(d) >= 4 && leadingNumber(d[:4]) > 0 { + v.year = d[:4] + } + return v +} + +func (v songValues) multiDisc() bool { return v.discTotal > 1 || v.disc > 1 } + +// renderPath builds the organized relative path of a song. It returns the +// missing tag when a required one is empty. +func renderPath(c NamingConfig, ext string, f map[string]string) (string, string) { + v := valuesOf(f) + if v.artist == "" { + return "", "artist or album artist" + } + if v.album == "" { + return "", "album" + } + if v.title == "" { + return "", "title" + } + track := c.TrackFormat + if v.multiDisc() { + track = c.MultiDiscFormat + } + var parts []string + for _, f := range []string{c.ArtistFolder, c.AlbumFolder, track} { + for _, part := range strings.Split(f, "/") { + out := tokenPat.ReplaceAllStringFunc(part, func(tok string) string { return renderToken(tok[1:len(tok)-1], v, c) }) + out = finishComponent(out) + if out == "" { + return "", "tags for " + part + } + parts = append(parts, out) + } + } + return filepath.Join(parts...) + strings.ToLower(ext), "" +} + +func renderToken(t string, v songValues, c NamingConfig) string { + if m := numberTok.FindStringSubmatch(t); m != nil { + n := v.track + if strings.EqualFold(m[1], "medium") { + n = v.disc + if n == 0 { + n = 1 + } + } + if n <= 0 { + return "" + } + width := len(m[2]) + if strings.EqualFold(m[1], "track") && n >= 100 && width < 3 { + width = 3 + } + return fmt.Sprintf("%0*d", width, n) + } + var val string + switch tokenKey(t) { + case "artistname": + val = v.artist + case "artistnamethe": + val = nameThe(v.artist) + case "artistcleanname": + val = cleanName(v.artist) + case "artistnamefirstcharacter": + val = firstChar(v.artist) + case "albumtitle": + val = v.album + case "albumtitlethe": + val = nameThe(v.album) + case "albumcleantitle": + val = cleanName(v.album) + case "releaseyear": + val = v.year + case "tracktitle": + val = v.title + case "trackcleantitle": + val = cleanName(v.title) + case "trackartistname": + val = v.trackArtist + case "mediumformat": + val = "Disc" + case "genre": + val = v.genre + } + val = safeValue(val, c) + sep, cased := styleOf(t) + if sep != " " { + val = strings.ReplaceAll(val, " ", sep) + } + switch cased { + case "upper": + val = strings.ToUpper(val) + case "lower": + val = strings.ToLower(val) + } + return val +} + +// NamingExample is one sample rendering for the settings page. +type NamingExample struct { + Label string `json:"label"` + Path string `json:"path"` +} + +// NamingExamples renders sample songs with a naming config, using exactly +// the code that renames real files. +func NamingExamples(c NamingConfig) []NamingExample { + samples := []struct { + label, ext string + f map[string]string + }{ + {"Album track", ".flac", map[string]string{tags.Artist: "Avenged Sevenfold", tags.Album: "Nightmare", tags.Title: "Nightmare", + tags.Track: "1", tags.Date: "2010", tags.Genre: "Metal"}}, + {"Multi-disc album, disc 1", ".flac", map[string]string{tags.Artist: "The Beatles", tags.Album: "The Beatles (White Album)", + tags.Title: "Back in the U.S.S.R.", tags.Track: "1", tags.Disc: "1", tags.DiscTotal: "2", tags.Date: "1968"}}, + {"Multi-disc album, disc 2", ".flac", map[string]string{tags.Artist: "The Beatles", tags.Album: "The Beatles (White Album)", + tags.Title: "Birthday", tags.Track: "1", tags.Disc: "2", tags.DiscTotal: "2", tags.Date: "1968"}}, + {"Colon and slash in names", ".mp3", map[string]string{tags.Artist: "AC/DC", tags.Album: "Who Made Who", tags.Title: "Hells Bells: Live", + tags.Track: "7", tags.Date: "1986"}}, + } + out := []NamingExample{} + for _, s := range samples { + p, missing := renderPath(c, s.ext, s.f) + if missing != "" { + p = "(skipped: missing " + missing + ")" + } + out = append(out, NamingExample{Label: s.label, Path: p}) + } + return out +} diff --git a/internal/engine/naming_test.go b/internal/engine/naming_test.go new file mode 100644 index 0000000..6e926a9 --- /dev/null +++ b/internal/engine/naming_test.go @@ -0,0 +1,156 @@ +package engine + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/cdnutter/playlink/internal/store" +) + +func song(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 +} + +func TestRenderPath(t *testing.T) { + d := DefaultNaming() + with := func(mod func(*NamingConfig)) NamingConfig { c := DefaultNaming(); mod(&c); return c } + nightmare := song("artist", "Avenged Sevenfold", "album", "Nightmare", "title", "Nightmare", "track", "1", "date", "2010-07-27") + cases := []struct { + name string + c NamingConfig + f map[string]string + ext string + want string + }{ + {"default", d, nightmare, ".FLAC", "Avenged Sevenfold/Nightmare/01 Nightmare.flac"}, + {"multi-disc disc 1 also gets its folder", d, song("artist", "The Beatles", "album", "White Album", "title", "Back in the USSR", "track", "1", "disc", "1", "disc_total", "2"), ".flac", + "The Beatles/White Album/Disc 01/01 Back in the USSR.flac"}, + {"multi-disc disc 2", d, song("artist", "The Beatles", "album", "White Album", "title", "Birthday", "track", "1", "disc", "2/2"), ".flac", + "The Beatles/White Album/Disc 02/01 Birthday.flac"}, + {"album artist wins", d, song("artist", "Queen", "album_artist", "Various Artists", "album", "Hits", "title", "Bohemian Rhapsody", "track", "12"), ".mp3", + "Various Artists/Hits/12 Bohemian Rhapsody.mp3"}, + {"year with empty brackets removed", with(func(c *NamingConfig) { c.AlbumFolder = "{Album Title} ({Release Year})" }), + song("artist", "A", "album", "B", "title", "S", "track", "3"), ".mp3", "A/B/03 S.mp3"}, + {"year present", with(func(c *NamingConfig) { c.AlbumFolder = "{Album Title} ({Release Year})" }), nightmare, ".flac", "Avenged Sevenfold/Nightmare (2010)/01 Nightmare.flac"}, + {"no track number", d, song("artist", "A", "album", "B", "title", "Song"), ".mp3", "A/B/Song.mp3"}, + {"separator left by missing number", with(func(c *NamingConfig) { c.TrackFormat = "{track:00} - {Track Title}" }), song("artist", "A", "album", "B", "title", "Song"), ".mp3", "A/B/Song.mp3"}, + {"three digit tracks", d, song("artist", "A", "album", "B", "title", "S", "track", "105"), ".mp3", "A/B/105 S.mp3"}, + {"NameThe and first character", with(func(c *NamingConfig) { + c.ArtistFolder = "{Artist NameFirstCharacter}" + c.AlbumFolder = "{Artist NameThe}" + }), + song("artist", "The Beatles", "album", "X", "title", "S", "track", "1"), ".mp3", "B/Beatles, The/01 S.mp3"}, + {"dotted and uppercase tokens", with(func(c *NamingConfig) { c.ArtistFolder = "{Artist.Name}"; c.TrackFormat = "{track:0} {TRACK TITLE}" }), nightmare, ".flac", + "Avenged.Sevenfold/Nightmare/1 NIGHTMARE.flac"}, + {"clean name", with(func(c *NamingConfig) { c.ArtistFolder = "{Artist CleanName}" }), song("artist", "AC/DC", "album", "B", "title", "S", "track", "1"), ".mp3", "ACDC/B/01 S.mp3"}, + {"illegal characters replaced, smart colon", d, song("artist", "AC/DC", "album", "Who Made Who?", "title", "Hells Bells: Live \"1986\"", "track", "7"), ".mp3", + "AC-DC/Who Made Who/07 Hells Bells - Live '1986'.mp3"}, + {"illegal characters removed, colon deleted", with(func(c *NamingConfig) { c.Illegal = "remove"; c.Colon = "delete" }), + song("artist", "AC/DC", "album", "B", "title", "Hells Bells: Live", "track", "7"), ".mp3", "ACDC/B/07 Hells Bells Live.mp3"}, + {"colon space-dash-space", with(func(c *NamingConfig) { c.Colon = "space_dash_space" }), song("artist", "A", "album", "B", "title", "Part:Two", "track", "1"), ".mp3", "A/B/01 Part - Two.mp3"}, + {"no hidden names", d, song("artist", "...And Justice", "album", "B", "title", "S", "track", "1"), ".mp3", "And Justice/B/01 S.mp3"}, + } + for _, c := range cases { + got, missing := renderPath(c.c, c.ext, c.f) + if got != c.want || missing != "" { + t.Errorf("%s: got %q (missing %q), want %q", c.name, got, missing, c.want) + } + } + for field, f := range map[string]map[string]string{ + "artist or album artist": song("album", "B", "title", "S"), + "album": song("artist", "A", "title", "S"), + "title": song("artist", "A", "album", "B"), + } { + if _, missing := renderPath(d, ".mp3", f); missing != field { + t.Errorf("want missing %q, got %q", field, missing) + } + } +} + +func TestValidateNaming(t *testing.T) { + bad := []func(*NamingConfig){ + func(c *NamingConfig) { c.ArtistFolder = "{Artist Name}/{Album Title}" }, + func(c *NamingConfig) { c.TrackFormat = "{track:00}" }, + func(c *NamingConfig) { c.TrackFormat = "{Bogus} {Track Title}" }, + func(c *NamingConfig) { c.MultiDiscFormat = "../{Track Title}" }, + func(c *NamingConfig) { c.AlbumFolder = "" }, + func(c *NamingConfig) { c.Colon = "weird" }, + func(c *NamingConfig) { c.AlbumFolder = "{Album Title" }, + } + for i, mod := range bad { + c := DefaultNaming() + mod(&c) + if ValidateNaming(c) == nil { + t.Errorf("case %d should be rejected: %+v", i, c) + } + } + for _, p := range NamingPresets { + if err := ValidateNaming(p.Config); err != nil { + t.Errorf("preset %q invalid: %v", p.Name, err) + } + } +} + +func TestNamingOfConvertsOldSettings(t *testing.T) { + c := NamingOf(store.Pair{OrganizeTemplate: "{artist}/{album} ({year})/{track} - {title}", OrganizeMultiDisc: "prefix", OrganizeDiscDigits: 1}) + if c.ArtistFolder != "{Artist Name}" || c.AlbumFolder != "{Album Title} ({Release Year})" || c.TrackFormat != "{track:00} - {Track Title}" || + c.MultiDiscFormat != "{medium:0}-{track:00} - {Track Title}" { + t.Fatalf("old settings not converted: %+v", c) + } + if got := NamingOf(store.Pair{}); got != DefaultNaming() { + t.Fatalf("pairs without settings should use the defaults, got %+v", got) + } + raw, _ := json.Marshal(NamingConfig{TrackFormat: "{track:0}. {Track Title}"}) + got := NamingOf(store.Pair{OrganizeConfig: raw}) + if got.TrackFormat != "{track:0}. {Track Title}" || got.ArtistFolder != "{Artist Name}" { + t.Fatalf("stored settings should win and gaps take defaults, got %+v", got) + } + ex := NamingExamples(DefaultNaming()) + if len(ex) != 4 || ex[1].Path != "The Beatles/The Beatles (White Album)/Disc 01/01 Back in the U.S.S.R.flac" { + t.Fatalf("examples: %+v", ex) + } +} + +func TestRenameNowPreviewAndApply(t *testing.T) { + ev := tagEnv(t, false) + mkTagged(t, filepath.Join(ev.slave, "in/a.mp3"), "title", "One", "artist", "Band", "album", "LP", "track", "1") + mkTagged(t, filepath.Join(ev.slave, "in/b.mp3"), "title", "Two", "artist", "Band", "album", "LP", "track", "2") + if _, err := ev.e.StartOrganizePreview(ev.pair.ID, "slave"); err != nil { + t.Fatal(err) + } + ev.wait() + plan, items := ev.e.OrganizePreview(ev.pair.ID) + if plan == nil || plan.Moves != 2 || len(items) != 2 { + t.Fatalf("preview: %+v %+v", plan, items) + } + var first int + for _, it := range items { + if it.From == "in/a.mp3" { + first = it.ID + } + } + if _, err := ev.e.StartOrganizeApply(ev.pair.ID, []int{first}); err != nil { + t.Fatal(err) + } + if j := ev.wait(); j.Results["renamed"] != 1 { + t.Fatalf("only the selected file should be renamed: %+v", j.Results) + } + if _, err := os.Stat(filepath.Join(ev.slave, "Band/LP/01 One.mp3")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(ev.slave, "in/b.mp3")); err != nil { + t.Fatal("unselected files stay") + } + if p, _ := ev.e.OrganizePreview(ev.pair.ID); p != nil { + t.Fatal("the preview is stale after renaming and must be cleared") + } + if _, err := ev.e.StartOrganizeApply(ev.pair.ID, nil); err == nil { + t.Fatal("applying without a fresh preview must be refused") + } +} diff --git a/internal/engine/organize.go b/internal/engine/organize.go index 99a7ef3..d835905 100644 --- a/internal/engine/organize.go +++ b/internal/engine/organize.go @@ -14,8 +14,7 @@ import ( "strconv" "strings" "sync" - "unicode" - "unicode/utf8" + "time" "github.com/cdnutter/playlink/internal/fsops" "github.com/cdnutter/playlink/internal/store" @@ -27,61 +26,20 @@ import ( // (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 -} - +// normalizeOrganize validates a pair's organize settings. 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.OrganizeDiscDigits { - case 0: - p.OrganizeDiscDigits = 2 - case 1, 2: - default: - return errors.New("disc numbers must use 1 or 2 digits") + if len(p.OrganizeConfig) > 0 && string(p.OrganizeConfig) != "{}" && string(p.OrganizeConfig) != "null" { + var c NamingConfig + if err := json.Unmarshal(p.OrganizeConfig, &c); err != nil { + return fmt.Errorf("naming settings: %w", err) + } + fill(&c, DefaultNaming()) + if err := ValidateNaming(c); err != nil { + return err + } + p.OrganizeConfig, _ = json.Marshal(c) + } else { + p.OrganizeConfig = nil // derived from older settings or defaults (NamingOf) } switch p.AutoOrganize { case "": @@ -93,38 +51,11 @@ func normalizeOrganize(p *store.Pair) error { 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 @@ -135,75 +66,6 @@ func leadingNumber(s string) int { 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 string, discDigits int, ext string, f map[string]string) (string, string) { - if discDigits < 1 { - discDigits = 2 - } - discStr := func(d int) string { return fmt.Sprintf("%0*d", discDigits, d) } - 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 = discStr(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": discStr(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], "Disc "+discStr(disc), parts[last]) - } - return filepath.Join(parts...) + strings.ToLower(ext), "" -} - // ---- tag cache ---- type cachedTags struct { @@ -245,12 +107,13 @@ func (c *tagCache) get(ctx context.Context, t tags.Tools, abs string, in fsops.I // 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 + 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 @@ -258,11 +121,13 @@ type organizeItem struct { // organizePlan is what organizing would do right now. type organizePlan struct { - Scanned int - Conforming int - Moves int - Skips map[string]int - Items []organizeItem + Sides []string `json:"sides"` + 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. @@ -283,7 +148,9 @@ func sidesOf(s string) []string { } func (e *Engine) planOrganize(ctx context.Context, j *Job, l *libs, sides []string) (*organizePlan, error) { - plan := &organizePlan{Skips: map[string]int{}} + plan := &organizePlan{Sides: sides, CreatedAt: time.Now().Unix(), Skips: map[string]int{}} + naming := NamingOf(l.pair) + id := 0 for _, side := range sides { root, _ := sideRoot(l, side) files := map[string]fsops.Info{} @@ -304,7 +171,7 @@ func (e *Engine) planOrganize(ctx context.Context, j *Job, l *libs, sides []stri 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, l.pair.OrganizeDiscDigits, filepath.Ext(rel), fields); missing != "" { + } else if to, missing := renderPath(naming, filepath.Ext(rel), fields); missing != "" { item.Reason = "missing " + missing + " tag" } else if to == rel { plan.Conforming++ @@ -323,6 +190,8 @@ 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 { @@ -633,3 +502,88 @@ func (e *Engine) autoOrganize(ctx context.Context, j *Job, l *libs) error { e.log.Info("auto-organizing", "pair", l.pair.Name, "files", len(moves)) return e.applyOrganize(ctx, j, l, moves, "auto-organize") } + +// ---- rename now (preview, then apply) ---- + +// StartOrganizePreview checks which files would be renamed. +func (e *Engine) StartOrganizePreview(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 + }) +} + +// OrganizePreview returns the latest preview and its items. +func (e *Engine) OrganizePreview(pairID int64) (*organizePlan, []organizeItem) { + e.plansMu.Lock() + defer e.plansMu.Unlock() + p := e.plans[pairID] + if p == nil { + return nil, nil + } + return p, append([]organizeItem{}, p.Items...) +} + +// StartOrganizeApply renames the selected files of the latest preview (all +// when ids is empty), then rescans. Each file is checked again 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: check the files 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") + }) +} diff --git a/internal/engine/organize_test.go b/internal/engine/organize_test.go index 324e41a..47bb927 100644 --- a/internal/engine/organize_test.go +++ b/internal/engine/organize_test.go @@ -10,47 +10,6 @@ import ( "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 - digits int - fields map[string]string - want, missing string - }{ - {"basic", "prefix", ".mp3", 0, f("artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1", "title", "Nightmare"), "Avenged Sevenfold/Nightmare/01 Nightmare.mp3", ""}, - {"album artist wins", "prefix", ".flac", 0, 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", 0, f("artist", "A", "album", "B", "title", "Song"), "A/B/Song.mp3", ""}, - {"missing album", "prefix", ".mp3", 0, f("artist", "A", "title", "Song"), "", "album"}, - {"missing title", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "track", "1"), "", "title"}, - {"multi-disc prefix", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "track", "4", "disc", "2", "disc_total", "2", "title", "S"), "A/B/02-04 S.mp3", ""}, - {"multi-disc folder", "folder", ".mp3", 0, f("artist", "A", "album", "B", "track", "4", "disc", "2", "title", "S"), "A/B/Disc 02/04 S.mp3", ""}, - {"single disc of one", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "track", "4", "disc", "1", "disc_total", "1", "title", "S"), "A/B/04 S.mp3", ""}, - {"unsafe characters", "prefix", ".MP3", 0, 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", 0, f("artist", ".hidden", "album", "B", "title", "S"), "hidden/B/S.mp3", ""}, - {"unpadded disc prefix", "prefix", ".mp3", 1, f("artist", "A", "album", "B", "track", "4", "disc", "2", "disc_total", "2", "title", "S"), "A/B/2-04 S.mp3", ""}, - {"unpadded disc folder", "folder", ".mp3", 1, f("artist", "A", "album", "B", "track", "4", "disc", "2", "title", "S"), "A/B/Disc 2/04 S.mp3", ""}, - {"big albums use 3 digits", "prefix", ".mp3", 0, 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.digits, 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) diff --git a/internal/store/models.go b/internal/store/models.go index 58f615c..589aceb 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -137,42 +137,43 @@ func decodeWarnings(raw string) []Warning { // ---- pairs ---- type Pair struct { - ID int64 `json:"id"` - Name string `json:"name"` - MasterRoot string `json:"master_root"` - SlaveRoot string `json:"slave_root"` - QuarantineDir string `json:"quarantine_dir"` - Extensions []string `json:"extensions"` - AutoLinkIdentical bool `json:"auto_link_identical"` - ScanIntervalMinutes int `json:"scan_interval_minutes"` - RetentionDays int `json:"retention_days"` - 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 - OrganizeDiscDigits int `json:"organize_disc_digits"` // 2: Disc 01, 1: Disc 1 - LastScanAt *int64 `json:"last_scan_at"` - LastScanError string `json:"last_scan_error"` - LastScanWarnings []Warning `json:"last_scan_warnings"` - CreatedAt int64 `json:"created_at"` + ID int64 `json:"id"` + Name string `json:"name"` + MasterRoot string `json:"master_root"` + SlaveRoot string `json:"slave_root"` + QuarantineDir string `json:"quarantine_dir"` + Extensions []string `json:"extensions"` + AutoLinkIdentical bool `json:"auto_link_identical"` + ScanIntervalMinutes int `json:"scan_interval_minutes"` + RetentionDays int `json:"retention_days"` + 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 + OrganizeDiscDigits int `json:"-"` // superseded by OrganizeConfig; read to convert old settings + OrganizeConfig json.RawMessage `json:"-"` // naming settings (engine.NamingConfig) + LastScanAt *int64 `json:"last_scan_at"` + LastScanError string `json:"last_scan_error"` + LastScanWarnings []Warning `json:"last_scan_warnings"` + CreatedAt int64 `json:"created_at"` } 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, organize_template, - organize_multidisc, auto_organize, organize_disc_digits, last_scan_at, last_scan_error, + organize_multidisc, auto_organize, organize_disc_digits, organize_config, last_scan_at, last_scan_error, last_scan_warnings, created_at` type scanner interface{ Scan(...any) error } func scanPair(r scanner) (Pair, error) { var p Pair - var exts, warns string + var exts, warns, orgCfg 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, &p.OrganizeTemplate, - &p.OrganizeMultiDisc, &p.AutoOrganize, &p.OrganizeDiscDigits, &last, &p.LastScanError, + &p.OrganizeMultiDisc, &p.AutoOrganize, &p.OrganizeDiscDigits, &orgCfg, &last, &p.LastScanError, &warns, &p.CreatedAt) if err != nil { return p, err @@ -182,6 +183,9 @@ func scanPair(r scanner) (Pair, error) { } p.Extensions = strings.Split(exts, ",") p.LastScanWarnings = decodeWarnings(warns) + if orgCfg != "" { + p.OrganizeConfig = json.RawMessage(orgCfg) + } return p, nil } @@ -215,11 +219,11 @@ func (s *Store) SavePair(ctx context.Context, p *Pair) error { 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, - organize_template, organize_multidisc, auto_organize, organize_disc_digits, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + organize_template, organize_multidisc, auto_organize, organize_disc_digits, organize_config, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical, p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold, - p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, p.OrganizeDiscDigits, now()) + p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, p.OrganizeDiscDigits, string(p.OrganizeConfig), now()) if err != nil { return err } @@ -229,10 +233,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 = ?, organize_template = ?, organize_multidisc = ?, auto_organize = ?, - organize_disc_digits = ? WHERE id = ?`, + organize_disc_digits = ?, organize_config = ? WHERE id = ?`, p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical, p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold, - p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, p.OrganizeDiscDigits, p.ID) + p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, p.OrganizeDiscDigits, string(p.OrganizeConfig), p.ID) return err } diff --git a/internal/store/store.go b/internal/store/store.go index 32dd2df..447d61e 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -129,6 +129,9 @@ var migrations = []string{ // 8: zero-padded disc numbers (Disc 01) by default. `ALTER TABLE pairs ADD COLUMN organize_disc_digits INTEGER NOT NULL DEFAULT 2;`, + + // 9: Lidarr-style naming (artist/album folder and track formats) as JSON. + `ALTER TABLE pairs ADD COLUMN organize_config TEXT NOT NULL DEFAULT '';`, } // SchemaVersion is the newest database schema this build knows. diff --git a/web/src/api.ts b/web/src/api.ts index 64738b5..fe4fa1d 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -76,10 +76,8 @@ 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"; - organize_disc_digits: number; + naming: Naming; last_scan_at: number | null; last_scan_error: string; last_scan_warnings: ScanWarning[]; @@ -100,10 +98,7 @@ export type PairInput = Pick< | "settle_minutes" | "approx_matching" | "approx_threshold" - | "organize_template" - | "organize_multidisc" | "auto_organize" - | "organize_disc_digits" >; export type ProbeInfo = { @@ -245,3 +240,16 @@ export type TagFile = { export type BackupConfig = { folder: string; interval_days: number; retention_days: number; before_actions: boolean }; export type Backup = { name: string; type: "manual" | "scheduled" | "action"; size: number; created: number }; + +export type Naming = { + artist_folder: string; + album_folder: string; + track_format: string; + multi_disc_format: string; + colon: "smart" | "delete" | "dash" | "space_dash" | "space_dash_space"; + illegal: "replace" | "remove"; +}; + +export type OrganizeItem = { id: number; side: Side; from: string; to?: string; move: boolean; reason?: string; sidecars?: string[] }; + +export type OrganizePlan = { sides: Side[]; created_at: number; scanned: number; conforming: number; moves: number; skips: Record }; diff --git a/web/src/pages/Organize.tsx b/web/src/pages/Organize.tsx index 3414a7d..1364f0b 100644 --- a/web/src/pages/Organize.tsx +++ b/web/src/pages/Organize.tsx @@ -1,70 +1,61 @@ -import { useEffect, useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { AlertTriangle, FolderTree } from "lucide-react"; -import { api, type Pair } from "../api"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertTriangle, ArrowRight, ChevronDown, ChevronRight, Folder, FolderTree, Tags as TagsIcon } from "lucide-react"; +import { api, type Job, type Naming, type OrganizeItem, type OrganizePlan, type Pair, type Side } from "../api"; import { useInfo } from "../App"; -import { Alert, Button, Card, Field, inputCls } from "../ui"; +import { Alert, Badge, Button, Card, Field, Modal, Spinner, cx, 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"], +type NamingInfo = { defaults: Naming; presets: { name: string; config: Naming }[]; tokens: [string, string][] }; +type Scope = Side | "both"; + +const FORMAT_FIELDS: { key: keyof Naming; label: string; hint: string }[] = [ + { key: "artist_folder", label: "Artist folder", hint: "One folder name." }, + { key: "album_folder", label: "Album folder", hint: "One folder name, inside the artist folder." }, + { key: "track_format", label: "Track", hint: "File name inside the album folder. May add subfolders with /." }, + { key: "multi_disc_format", label: "Multi-disc track", hint: "Used when an album has more than one disc." }, ]; -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"], digits = 2) { - const d = digits === 1 ? "2" : "02"; - const values: Record = multi - ? { ...SAMPLE, track: multi === "prefix" ? `${d}-04` : "04", title: "Victim", disc: d } - : { ...SAMPLE, disc: digits === 1 ? "1" : "01" }; - 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 ${d}`, parts[parts.length - 1]]; - return parts.join("/") + ".flac"; -} - 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 }) { +// ---- naming settings ---- + +function NamingCard({ pair }: { pair: Pair }) { const qc = useQueryClient(); const toast = useToast(); - const [tpl, setTpl] = useState(pair.organize_template); - const [multi, setMulti] = useState(pair.organize_multidisc); + const info = useQuery({ queryKey: ["naming-info"], queryFn: () => api.get("/naming"), staleTime: Infinity }); + const [cfg, setCfg] = useState(pair.naming); const [auto, setAuto] = useState(pair.auto_organize); - const [digits, setDigits] = useState(pair.organize_disc_digits || 2); + const [showTokens, setShowTokens] = useState(false); + const focused = useRef<{ key: keyof Naming; el: HTMLInputElement } | null>(null); + const saved = JSON.stringify(pair.naming); useEffect(() => { - setTpl(pair.organize_template); - setMulti(pair.organize_multidisc); + setCfg(pair.naming); setAuto(pair.auto_organize); - setDigits(pair.organize_disc_digits || 2); - }, [pair.id, pair.organize_template, pair.organize_multidisc, pair.auto_organize, pair.organize_disc_digits]); - const dirty = - tpl !== pair.organize_template || multi !== pair.organize_multidisc || auto !== pair.auto_organize || digits !== (pair.organize_disc_digits || 2); + }, [pair.id, saved, pair.auto_organize]); // eslint-disable-line react-hooks/exhaustive-deps + const dirty = JSON.stringify(cfg) !== saved || auto !== pair.auto_organize; + + // Examples are rendered by the server with the same code as real renames. + const [debounced, setDebounced] = useState(cfg); + useEffect(() => { + const t = setTimeout(() => setDebounced(cfg), 250); + return () => clearTimeout(t); + }, [cfg]); + const examples = useQuery({ + queryKey: ["naming-examples", JSON.stringify(debounced)], + queryFn: () => api.post<{ examples: { label: string; path: string }[]; error?: string }>("/naming/examples", debounced), + }); const save = useMutation({ mutationFn: () => @@ -80,19 +71,30 @@ function NamingSettings({ pair }: { pair: Pair }) { settle_minutes: pair.settle_minutes, approx_matching: pair.approx_matching, approx_threshold: pair.approx_threshold, - organize_template: tpl, - organize_multidisc: multi, auto_organize: auto, - organize_disc_digits: digits, + naming: cfg, }), onSuccess: () => { - toast("ok", "Naming settings saved. Preview again to see the effect."); + toast("ok", "Naming saved"); qc.invalidateQueries({ queryKey: ["pair", pair.id] }); qc.invalidateQueries({ queryKey: ["pairs"] }); }, onError: (e) => toast("err", e.message), }); + const insert = (token: string) => { + const f = focused.current; + if (!f) return; + const v = cfg[f.key] as string; + const start = f.el.selectionStart ?? v.length; + const end = f.el.selectionEnd ?? v.length; + setCfg({ ...cfg, [f.key]: v.slice(0, start) + token + v.slice(end) }); + requestAnimationFrame(() => { + f.el.focus(); + f.el.setSelectionRange(start + token.length, start + token.length); + }); + }; + return (
@@ -100,57 +102,104 @@ function NamingSettings({ pair }: { pair: Pair }) {

File naming

-

- Files that don't follow this layout are renamed from their tags during scans, when Organize automatically is on. Nothing else about - a file changes. -

+

How files are named from their tags, in the style of Lidarr. Only the name and folder change.

- - Folders are separated by /. The file extension is added automatically.}> - setTpl(e.target.value)} spellCheck={false} /> - -
- {TOKENS.map(([t, help]) => ( - + + {info.data && ( +
+ Presets: + {info.data.presets.map((p) => ( + + ))} +
+ )} + +
+ {FORMAT_FIELDS.map((f) => ( + + (focused.current = { key: f.key, el: e.currentTarget })} + onChange={(e) => setCfg({ ...cfg, [f.key]: e.target.value })} + /> + ))}
-
-
Example
-
{example(tpl, undefined, digits)}
- {multi !== "none" && ( -
- {example(tpl, multi, digits)} (disc 2 of a multi-disc album) + +
+ + {showTokens && info.data && ( +
+ {info.data.tokens.map(([tok, ex]) => ( + + ))} +

+ Write a token with dots or underscores to use them between words ({"{Artist.Name}"} → + Avenged.Sevenfold), or in capitals or lower case to change the case ({"{ARTIST NAME}"}). +

)}
-
- - setCfg({ ...cfg, colon: e.target.value as Naming["colon"] })}> + + + + + - - setCfg({ ...cfg, illegal: e.target.value as Naming["illegal"] })}> + + - + setScope(e.target.value as Scope)}> + + + + + +
+ {open && setOpen(false)} />} + + ); +} + +type Stage = { kind: "checking"; job?: Job } | { kind: "ready" } | { kind: "error"; message: string }; + +function RenameFilesDialog({ pair, scope, onClose }: { pair: Pair; scope: Scope; onClose: () => void }) { + const qc = useQueryClient(); + const toast = useToast(); + const dry = useInfo().data?.dry_run ?? true; + const [stage, setStage] = useState({ kind: "checking" }); + const [data, setData] = useState<{ plan: OrganizePlan; items: OrganizeItem[] } | null>(null); + const [view, setView] = useState<"changes" | "skipped">("changes"); + const [sel, setSel] = useState>(new Set()); + const [editTags, setEditTags] = useState(null); + + // Start a check and follow its job until it finishes. + const check = async () => { + setStage({ kind: "checking" }); + try { + const job = await api.post(`/pairs/${pair.id}/organize/preview`, { side: scope }); + for (;;) { + await new Promise((r) => setTimeout(r, 600)); + const j = await api.get(`/pairs/${pair.id}/job`); + if (!j || j.id !== job.id) break; + if (j.state !== "running") { + if (j.state !== "done") throw new Error(j.error || j.state); + break; + } + setStage({ kind: "checking", job: j }); + } + const res = await api.get<{ plan: OrganizePlan | null; items: OrganizeItem[] }>(`/pairs/${pair.id}/organize/plan`); + if (!res.plan) throw new Error("The check did not produce a result. Try again."); + setData({ plan: res.plan, items: res.items }); + setSel(new Set(res.items.filter((i) => i.move).map((i) => i.id))); + setStage({ kind: "ready" }); + } catch (e) { + setStage({ kind: "error", message: (e as Error).message }); + } + }; + useEffect(() => { + check(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const moves = useMemo(() => (data?.items ?? []).filter((i) => i.move), [data]); + const skipped = useMemo(() => (data?.items ?? []).filter((i) => !i.move), [data]); + // Group the changes by the folder they end up in. + const groups = useMemo(() => { + const m = new Map(); + for (const it of moves) { + const key = `${it.side}\u0000${it.to!.split("/").slice(0, -1).join("/")}`; + m.set(key, [...(m.get(key) ?? []), it]); + } + return [...m.entries()].sort(([a], [b]) => a.localeCompare(b)); + }, [moves]); + const both = (data?.plan.sides.length ?? 0) > 1; + const toggle = (ids: number[], on: boolean) => + setSel((s) => { + const n = new Set(s); + ids.forEach((id) => (on ? n.add(id) : n.delete(id))); + return n; + }); + + const apply = useMutation({ + mutationFn: () => api.post(`/pairs/${pair.id}/organize/apply`, { ids: sel.size === moves.length ? [] : [...sel] }), + onSuccess: () => { + toast("ok", dry ? "Started (dry run): renames are recorded as planned only" : `Renaming ${sel.size} files. The library is rescanned afterwards.`); + qc.invalidateQueries({ queryKey: ["pair", pair.id] }); + onClose(); + }, + onError: (e) => toast("err", e.message), + }); + + return ( + + {stage.kind === "checking" && ( +
+
+ {stage.job?.phase || "Checking files"}… +
+ {stage.job && stage.job.total > 0 && ( +
+
+
+ )} +

Reading each file's tags. Nothing is renamed until you confirm.

+
+ )} + {stage.kind === "error" && ( +
+ {stage.message} + +
+ )} + {stage.kind === "ready" && data && ( +
+
+ + + + +
+ + {moves.length === 0 && skipped.length === 0 && Everything already follows the naming.} + + {(moves.length > 0 || skipped.length > 0) && ( +
+ {(["changes", "skipped"] as const).map((v) => ( + + ))} +
+ )} + + {view === "changes" && moves.length > 0 && ( +
+ +
+ {groups.map(([key, items]) => { + const [side, folder] = key.split("\u0000"); + const ids = items.map((i) => i.id); + return ( +
+ + {items.map((it) => ( + + ))} +
+ ); + })} +
+
+ )} + + {view === "skipped" && ( +
+
    + {Object.entries(data.plan.skips).map(([why, n]) => ( +
  • + {n} {why} +
  • + ))} +
+
+ {skipped.map((it) => ( +
+ {both && {it.side}} +
+
{it.from}
+
{it.reason}
+
+ {it.reason?.startsWith("missing") && ( + + )} +
+ ))} +
+

After fixing tags, run Rename now again to include those files.

+
+ )} + + {moves.length > 0 && ( +
    +
  • Files move inside their own 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. +
  • + {!both &&
  • Only the {data.plan.sides[0]} library is renamed; paths may then differ from the other library (hardlinked songs stay paired).
  • } +
  • Each rename is in History and can be undone. The library is rescanned when it finishes.
  • +
+ )} + {dry && moves.length > 0 && Dry run is on, so the renames are recorded as planned in Activity. No files move.} +
+ )} +
+ + {stage.kind === "ready" && moves.length > 0 && ( + + )} +
+ {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 f29accf..7f45de5 100644 --- a/web/src/pages/PairForm.tsx +++ b/web/src/pages/PairForm.tsx @@ -25,10 +25,7 @@ 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", - organize_disc_digits: 2, }; const [f, setF] = useState(pair ?? blank); const [exts, setExts] = useState((pair?.extensions ?? []).join(", ")); @@ -56,11 +53,8 @@ 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, + // Edited on the Organize tab; sent unchanged so saving here keeps it. auto_organize: f.auto_organize, - organize_disc_digits: f.organize_disc_digits, extensions: exts.split(",").map((s) => s.trim()).filter(Boolean), }); const set = (k: K, v: PairInput[K]) => {