From b11d0806c1951e547c224f78b94f9b7013921231 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 30 Sep 2026 08:29:17 -0700 Subject: [PATCH] Allow naming without an artist folder and per-folder naming rules The artist folder can be left empty for Album/01 Title (no artist tag needed), and folders such as Video Games can have their own formats. Files in a rule folder are only renamed inside it; other files are never moved into it. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 + internal/api/server.go | 3 +- internal/engine/naming.go | 249 +++++++++++++++++++++++++-------- internal/engine/naming_test.go | 109 ++++++++++++++- internal/engine/organize.go | 9 +- web/src/api.ts | 10 ++ web/src/pages/Organize.tsx | 177 ++++++++++++++++++----- 7 files changed, 462 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index 394f8ac..9a56502 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,11 @@ use the folder image. A replaced `cover.jpg` goes to quarantine, and every chang **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`. +Leave the artist folder empty for `Album/01 Title`; songs then don't need an artist tag. + +**Folders with their own naming.** Part of a library can use different formats, such as game soundtracks in +`Video Games/{Album Title}/{track:00} {Track Title}` while the rest stays `Artist/Album/01 Title`. Files inside such a folder are only +renamed within it, and other files are never moved into it (they are skipped with a reason instead). Folder rules can't be nested. 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}`, diff --git a/internal/api/server.go b/internal/api/server.go index 62dcc36..8cc09b1 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -824,7 +824,8 @@ func (s *Server) preAction(w http.ResponseWriter, r *http.Request, pairID int64, // ---- 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}) + writeJSON(w, 200, map[string]any{"defaults": engine.DefaultNaming(), "presets": engine.NamingPresets, "tokens": engine.TokenHelp, + "folder_default": engine.SoundtrackFolder}) } // handleNamingExamples renders sample songs with unsaved settings, using the diff --git a/internal/engine/naming.go b/internal/engine/naming.go index 2fbe28c..de3c09b 100644 --- a/internal/engine/naming.go +++ b/internal/engine/naming.go @@ -18,6 +18,7 @@ import ( // track format (with a separate format for multi-disc albums), built from // tokens such as {Artist Name}, {Album Title} and {track:00}. +// An empty ArtistFolder means no artist folder: Album/01 Title. type NamingConfig struct { ArtistFolder string `json:"artist_folder"` AlbumFolder string `json:"album_folder"` @@ -25,6 +26,45 @@ type NamingConfig struct { 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 + // Folders give part of a library its own formats, such as soundtracks in + // "Video Games" named Album/01 Title. Files inside such a folder stay + // inside it, and other files are never moved into it. + Folders []FolderNaming `json:"folders"` +} + +// FolderNaming is the naming for one folder of a library (relative to its root). +type FolderNaming struct { + Folder string `json:"folder"` + ArtistFolder string `json:"artist_folder"` + AlbumFolder string `json:"album_folder"` + TrackFormat string `json:"track_format"` + MultiDiscFormat string `json:"multi_disc_format"` +} + +// formats are the four path formats in effect for one file. +type formats struct{ artist, album, track, multiDisc string } + +func (c NamingConfig) formats() formats { + return formats{c.ArtistFolder, c.AlbumFolder, c.TrackFormat, c.MultiDiscFormat} +} + +func (f FolderNaming) formats() formats { + return formats{f.ArtistFolder, f.AlbumFolder, f.TrackFormat, f.MultiDiscFormat} +} + +// folderOf returns the folder rule a library path falls under, if any. +func (c NamingConfig) folderOf(rel string) (FolderNaming, bool) { + for _, f := range c.Folders { + if inFolder(rel, f.Folder) { + return f, true + } + } + return FolderNaming{}, false +} + +// inFolder reports whether rel is inside folder (compared case-insensitively). +func inFolder(rel, folder string) bool { + return strings.HasPrefix(strings.ToLower(rel)+"/", strings.ToLower(folder)+"/") && len(rel) > len(folder) } func DefaultNaming() NamingConfig { @@ -35,9 +75,14 @@ func DefaultNaming() NamingConfig { MultiDiscFormat: "{Medium Format} {medium:00}/{track:00} {Track Title}", Colon: "smart", Illegal: "replace", + Folders: []FolderNaming{}, } } +// SoundtrackFolder is the suggested folder rule for game and film soundtracks. +var SoundtrackFolder = FolderNaming{Folder: "Video Games", AlbumFolder: "{Album Title}", + TrackFormat: "{track:00} {Track Title}", MultiDiscFormat: "{Medium Format} {medium:00}/{track:00} {Track Title}"} + // NamingPresets are offered in the UI. var NamingPresets = []struct { Name string `json:"name"` @@ -51,6 +96,8 @@ var NamingPresets = []struct { 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"}}, + {"Album/01 Title (no artist folder)", NamingConfig{AlbumFolder: "{Album Title}", + TrackFormat: "{track:00} {Track Title}", MultiDiscFormat: "{Medium Format} {medium:00}/{track:00} {Track Title}", Colon: "smart", Illegal: "replace"}}, } // NamingOf returns a pair's naming, converting the settings of earlier @@ -58,9 +105,7 @@ var NamingPresets = []struct { 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) + if stored, err := parseNaming(p.OrganizeConfig); err == nil { return stored } } @@ -91,9 +136,29 @@ func NamingOf(p store.Pair) NamingConfig { return c } +// parseNaming reads stored settings. Fields missing from the JSON take the +// defaults; an artist folder that is present but empty stays empty. +func parseNaming(raw []byte) (NamingConfig, error) { + c := DefaultNaming() + if err := json.Unmarshal(raw, &c); err != nil { + return c, err + } + fill(&c, DefaultNaming()) + return c, nil +} + func fill(c *NamingConfig, d NamingConfig) { - if strings.TrimSpace(c.ArtistFolder) == "" { - c.ArtistFolder = d.ArtistFolder + c.ArtistFolder = strings.TrimSpace(c.ArtistFolder) + if c.Folders == nil { + c.Folders = []FolderNaming{} + } + for i := range c.Folders { + f := &c.Folders[i] + f.Folder = strings.Trim(strings.TrimSpace(f.Folder), "/") + f.ArtistFolder = strings.TrimSpace(f.ArtistFolder) + if strings.TrimSpace(f.MultiDiscFormat) == "" { + f.MultiDiscFormat = "{Medium Format} {medium:00}/" + f.TrackFormat + } } if strings.TrimSpace(c.AlbumFolder) == "" { c.AlbumFolder = d.AlbumFolder @@ -171,42 +236,32 @@ func styleOf(t string) (sep string, cased string) { // 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 { + if err := checkFormats("", c.formats()); 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 { + seen := map[string]bool{} + for _, f := range c.Folders { + label := fmt.Sprintf("Folder %q", f.Folder) + switch { + case f.Folder == "": + return errors.New("a folder rule has no folder") + case strings.Contains(f.Folder, "\\") || strings.HasPrefix(f.Folder, "/") || filepath.Clean(f.Folder) != f.Folder || + f.Folder == "." || strings.HasPrefix(f.Folder, ".."): + return fmt.Errorf("%s must be a folder inside the library, like Video Games", label) + case strings.HasPrefix(filepath.Base(f.Folder), "."): + return fmt.Errorf("%s: hidden folders can't have naming rules", label) + case seen[strings.ToLower(f.Folder)]: + return fmt.Errorf("%s is listed twice", label) + } + seen[strings.ToLower(f.Folder)] = true + for _, o := range c.Folders { + if inFolder(f.Folder, o.Folder) { + return fmt.Errorf("%s is inside %q; folder rules can't be nested", label, o.Folder) + } + } + if err := checkFormats(label+": ", f.formats()); 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": @@ -219,6 +274,51 @@ func ValidateNaming(c NamingConfig) error { return nil } +// checkFormats validates one set of formats. The artist folder may be empty. +func checkFormats(prefix string, f formats) error { + if f.artist != "" { + if err := checkFormat(prefix+"Artist folder format", f.artist, true); err != nil { + return err + } + } + if err := checkFormat(prefix+"Album folder format", f.album, true); err != nil { + return err + } + for _, t := range []struct{ label, v string }{{"Track format", f.track}, {"Multi-disc track format", f.multiDisc}} { + if err := checkFormat(prefix+t.label, t.v, false); err != nil { + return err + } + if !strings.Contains(tokenKeys(t.v), "|tracktitle|") && !strings.Contains(tokenKeys(t.v), "|trackcleantitle|") { + return fmt.Errorf("%s%s must contain {Track Title}", prefix, t.label) + } + } + return nil +} + +func checkFormat(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 +} + func tokenKeys(f string) string { var b strings.Builder b.WriteString("|") @@ -350,30 +450,62 @@ func valuesOf(f map[string]string) songValues { 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) { +// renderPath builds the organized relative path of a song at rel. Songs in +// a folder with its own naming stay in that folder. It returns why the song +// can't be renamed instead when a tag is missing or the new path would enter +// a folder that has its own naming. +func renderPath(c NamingConfig, rel string, f map[string]string) (string, string) { + ext := filepath.Ext(rel) + if rule, ok := c.folderOf(rel); ok { + to, why := renderFormats(c, rule.formats(), ext, f) + if why != "" { + return "", why + } + // Keep the folder as it is spelled on disk. + return filepath.Join(rel[:len(rule.Folder)], to), "" + } + to, why := renderFormats(c, c.formats(), ext, f) + if why != "" { + return "", why + } + for _, rule := range c.Folders { + if inFolder(to, rule.Folder) { + return "", "would move into " + rule.Folder + ", which has its own naming" + } + } + return to, "" +} + +var artistTokens = []string{"|artistname|", "|artistnamethe|", "|artistcleanname|", "|artistnamefirstcharacter|"} + +func renderFormats(c NamingConfig, fm formats, ext string, f map[string]string) (string, string) { v := valuesOf(f) - if v.artist == "" { - return "", "artist or album artist" + track := fm.track + if v.multiDisc() { + track = fm.multiDisc + } + used := tokenKeys(fm.artist + fm.album + track) + for _, t := range artistTokens { + if v.artist == "" && strings.Contains(used, t) { + return "", "missing artist or album artist tag" + } } if v.album == "" { - return "", "album" + return "", "missing album tag" } if v.title == "" { - return "", "title" - } - track := c.TrackFormat - if v.multiDisc() { - track = c.MultiDiscFormat + return "", "missing title tag" } var parts []string - for _, f := range []string{c.ArtistFolder, c.AlbumFolder, track} { + for _, f := range []string{fm.artist, fm.album, track} { + if f == "" { + continue // no artist folder + } 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 + return "", "missing tags for " + part } parts = append(parts, out) } @@ -465,12 +597,19 @@ func NamingExamples(c NamingConfig) []NamingExample { 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 + ")" + add := func(label, rel string, f map[string]string) { + p, why := renderPath(c, rel, f) + if why != "" { + p = "(skipped: " + why + ")" } - out = append(out, NamingExample{Label: s.label, Path: p}) + out = append(out, NamingExample{Label: label, Path: p}) + } + for _, s := range samples { + add(s.label, "x"+s.ext, s.f) + } + for _, rule := range c.Folders { + add("In "+rule.Folder, rule.Folder+"/x.flac", map[string]string{tags.Artist: "Koji Kondo", tags.Album: "Super Mario Galaxy", + tags.Title: "Gusty Garden Galaxy", tags.Track: "3", tags.Date: "2007"}) } return out } diff --git a/internal/engine/naming_test.go b/internal/engine/naming_test.go index 6e926a9..9696d17 100644 --- a/internal/engine/naming_test.go +++ b/internal/engine/naming_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" + "strings" "testing" "github.com/cdnutter/playlink/internal/store" @@ -57,7 +59,7 @@ func TestRenderPath(t *testing.T) { {"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) + got, missing := renderPath(c.c, "in/x"+c.ext, c.f) if got != c.want || missing != "" { t.Errorf("%s: got %q (missing %q), want %q", c.name, got, missing, c.want) } @@ -67,12 +69,82 @@ func TestRenderPath(t *testing.T) { "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) + if _, why := renderPath(d, "x.mp3", f); why != "missing "+field+" tag" { + t.Errorf("want missing %q, got %q", field, why) } } } +func TestNamingWithoutArtistFolder(t *testing.T) { + c := DefaultNaming() + c.ArtistFolder = "" + if err := ValidateNaming(c); err != nil { + t.Fatal(err) + } + // Soundtracks often have no artist tag; it isn't needed without the folder. + got, why := renderPath(c, "x.flac", song("album", "Super Mario Galaxy", "title", "Gusty Garden Galaxy", "track", "3")) + if got != "Super Mario Galaxy/03 Gusty Garden Galaxy.flac" || why != "" { + t.Fatalf("got %q %q", got, why) + } + got, _ = renderPath(c, "x.flac", song("album", "Halo 3", "title", "S", "track", "2", "disc", "2/2")) + if got != "Halo 3/Disc 02/02 S.flac" { + t.Fatalf("multi-disc: %q", got) + } + // Saving an empty artist folder keeps it empty; a missing one takes the default. + raw, _ := json.Marshal(c) + if got := NamingOf(store.Pair{OrganizeConfig: raw}); got.ArtistFolder != "" { + t.Fatalf("empty artist folder should stay empty: %+v", got) + } + p := store.Pair{OrganizeConfig: raw} + if err := normalizeOrganize(&p); err != nil || NamingOf(p).ArtistFolder != "" { + t.Fatalf("normalize: %v %s", err, p.OrganizeConfig) + } +} + +func TestNamingFolderRules(t *testing.T) { + c := DefaultNaming() + c.Folders = []FolderNaming{SoundtrackFolder} + if err := ValidateNaming(c); err != nil { + t.Fatal(err) + } + vgm := song("artist", "Koji Kondo", "album", "Super Mario Galaxy", "title", "Gusty Garden Galaxy", "track", "3") + cases := []struct{ rel, want, why string }{ + {"Video Games/mario/gusty.flac", "Video Games/Super Mario Galaxy/03 Gusty Garden Galaxy.flac", ""}, + {"video games/Mario/x.flac", "video games/Super Mario Galaxy/03 Gusty Garden Galaxy.flac", ""}, // spelled as on disk + {"Koji Kondo/x.flac", "Koji Kondo/Super Mario Galaxy/03 Gusty Garden Galaxy.flac", ""}, + {"Video Games Extra/x.flac", "Koji Kondo/Super Mario Galaxy/03 Gusty Garden Galaxy.flac", ""}, + } + for _, tc := range cases { + got, why := renderPath(c, tc.rel, vgm) + if got != tc.want || why != tc.why { + t.Errorf("%s: got %q %q, want %q", tc.rel, got, why, tc.want) + } + } + // A file outside the folder is never moved into it. + if _, why := renderPath(c, "x.flac", song("artist", "Video Games", "album", "B", "title", "S")); !strings.Contains(why, "own naming") { + t.Fatalf("should refuse to move into the rule folder: %q", why) + } + bad := []FolderNaming{ + {Folder: "", AlbumFolder: "{Album Title}", TrackFormat: "{Track Title}", MultiDiscFormat: "{Track Title}"}, + {Folder: "../x", AlbumFolder: "{Album Title}", TrackFormat: "{Track Title}", MultiDiscFormat: "{Track Title}"}, + {Folder: ".playlink-trash", AlbumFolder: "{Album Title}", TrackFormat: "{Track Title}", MultiDiscFormat: "{Track Title}"}, + {Folder: "Video Games/Nintendo", AlbumFolder: "{Album Title}", TrackFormat: "{Track Title}", MultiDiscFormat: "{Track Title}"}, + {Folder: "VIDEO GAMES", AlbumFolder: "{Album Title}", TrackFormat: "{Track Title}", MultiDiscFormat: "{Track Title}"}, + {Folder: "OST", AlbumFolder: "{Album Title}", TrackFormat: "{track:00}", MultiDiscFormat: "{Track Title}"}, + } + for _, b := range bad { + cc := c + cc.Folders = []FolderNaming{SoundtrackFolder, b} + if ValidateNaming(cc) == nil { + t.Errorf("rule %+v should be rejected", b) + } + } + ex := NamingExamples(c) + if last := ex[len(ex)-1]; last.Label != "In Video Games" || last.Path != "Video Games/Super Mario Galaxy/03 Gusty Garden Galaxy.flac" { + t.Fatalf("folder example: %+v", last) + } +} + func TestValidateNaming(t *testing.T) { bad := []func(*NamingConfig){ func(c *NamingConfig) { c.ArtistFolder = "{Artist Name}/{Album Title}" }, @@ -103,11 +175,10 @@ func TestNamingOfConvertsOldSettings(t *testing.T) { c.MultiDiscFormat != "{medium:0}-{track:00} - {Track Title}" { t.Fatalf("old settings not converted: %+v", c) } - if got := NamingOf(store.Pair{}); got != DefaultNaming() { + if got := NamingOf(store.Pair{}); !reflect.DeepEqual(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}) + got := NamingOf(store.Pair{OrganizeConfig: []byte(`{"track_format":"{track:0}. {Track Title}"}`)}) if got.TrackFormat != "{track:0}. {Track Title}" || got.ArtistFolder != "{Artist Name}" { t.Fatalf("stored settings should win and gaps take defaults, got %+v", got) } @@ -154,3 +225,29 @@ func TestRenameNowPreviewAndApply(t *testing.T) { t.Fatal("applying without a fresh preview must be refused") } } + +func TestOrganizeFolderRule(t *testing.T) { + ev := tagEnv(t, false) + c := DefaultNaming() + c.Folders = []FolderNaming{SoundtrackFolder} + ev.pair.OrganizeConfig, _ = json.Marshal(c) + if err := ev.st.SavePair(ctx, &ev.pair); err != nil { + t.Fatal(err) + } + mkTagged(t, filepath.Join(ev.slave, "Video Games/rip/g.flac"), "title", "Gusty Garden Galaxy", "album", "Super Mario Galaxy", "track", "3") + mkTagged(t, filepath.Join(ev.slave, "Video Games/Halo 3/01 Halo.flac"), "title", "Halo", "artist", "Martin O'Donnell", "album", "Halo 3", "track", "1") + mkTagged(t, filepath.Join(ev.slave, "in/a.mp3"), "title", "One", "artist", "Band", "album", "LP", "track", "1") + ev.e.StartOrganizePreview(ev.pair.ID, "slave") + ev.wait() + plan, items := ev.e.OrganizePreview(ev.pair.ID) + if plan == nil || plan.Moves != 2 || plan.Conforming != 1 { + t.Fatalf("preview: %+v %+v", plan, items) + } + ev.e.StartOrganizeApply(ev.pair.ID, nil) + ev.wait() + for _, rel := range []string{"Video Games/Super Mario Galaxy/03 Gusty Garden Galaxy.flac", "Video Games/Halo 3/01 Halo.flac", "Band/LP/01 One.mp3"} { + if _, err := os.Stat(filepath.Join(ev.slave, rel)); err != nil { + t.Error(err) + } + } +} diff --git a/internal/engine/organize.go b/internal/engine/organize.go index d835905..cfd1730 100644 --- a/internal/engine/organize.go +++ b/internal/engine/organize.go @@ -29,11 +29,10 @@ import ( // normalizeOrganize validates a pair's organize settings. func normalizeOrganize(p *store.Pair) error { if len(p.OrganizeConfig) > 0 && string(p.OrganizeConfig) != "{}" && string(p.OrganizeConfig) != "null" { - var c NamingConfig - if err := json.Unmarshal(p.OrganizeConfig, &c); err != nil { + c, err := parseNaming(p.OrganizeConfig) + if err != nil { return fmt.Errorf("naming settings: %w", err) } - fill(&c, DefaultNaming()) if err := ValidateNaming(c); err != nil { return err } @@ -171,8 +170,8 @@ 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 := renderPath(naming, filepath.Ext(rel), fields); missing != "" { - item.Reason = "missing " + missing + " tag" + } else if to, why := renderPath(naming, rel, fields); why != "" { + item.Reason = why } else if to == rel { plan.Conforming++ continue diff --git a/web/src/api.ts b/web/src/api.ts index e2b3264..2efb146 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -249,6 +249,16 @@ export type Naming = { multi_disc_format: string; colon: "smart" | "delete" | "dash" | "space_dash" | "space_dash_space"; illegal: "replace" | "remove"; + folders: FolderNaming[]; +}; + +/** Its own naming for one folder of a library, e.g. soundtracks in "Video Games". */ +export type FolderNaming = { + folder: string; + artist_folder: string; + album_folder: string; + track_format: string; + multi_disc_format: string; }; export type OrganizeItem = { id: number; side: Side; from: string; to?: string; move: boolean; reason?: string; sidecars?: string[] }; diff --git a/web/src/pages/Organize.tsx b/web/src/pages/Organize.tsx index 1364f0b..7b9b2df 100644 --- a/web/src/pages/Organize.tsx +++ b/web/src/pages/Organize.tsx @@ -1,18 +1,21 @@ 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 { AlertTriangle, ArrowRight, ChevronDown, ChevronRight, Folder, FolderPlus, FolderTree, Tags as TagsIcon, Trash2 } from "lucide-react"; +import { api, type FolderNaming, type Job, type Naming, type OrganizeItem, type OrganizePlan, type Pair, type Side } from "../api"; import { useInfo } from "../App"; import { Alert, Badge, Button, Card, Field, Modal, Spinner, cx, inputCls } from "../ui"; import { useToast } from "../toast"; import { TagEditor } from "./Tags"; -type NamingInfo = { defaults: Naming; presets: { name: string; config: Naming }[]; tokens: [string, string][] }; +type NamingInfo = { defaults: Naming; presets: { name: string; config: Naming }[]; tokens: [string, string][]; folder_default: FolderNaming }; +type FormatKey = "artist_folder" | "album_folder" | "track_format" | "multi_disc_format"; +// Where a token is inserted: a main format, or one of a folder rule's (rule = its index). +type FieldRef = { rule: number | null; key: FormatKey; el: HTMLInputElement }; 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." }, +const FORMAT_FIELDS: { key: FormatKey; label: string; hint: string }[] = [ + { key: "artist_folder", label: "Artist folder", hint: "One folder name. Leave empty for no artist folder (Album/01 Title)." }, + { key: "album_folder", label: "Album folder", hint: "One folder name, inside the artist folder if there is one." }, { 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." }, ]; @@ -38,7 +41,7 @@ function NamingCard({ pair }: { pair: Pair }) { const [cfg, setCfg] = useState(pair.naming); const [auto, setAuto] = useState(pair.auto_organize); const [showTokens, setShowTokens] = useState(false); - const focused = useRef<{ key: keyof Naming; el: HTMLInputElement } | null>(null); + const focused = useRef(null); const saved = JSON.stringify(pair.naming); useEffect(() => { setCfg(pair.naming); @@ -82,13 +85,16 @@ function NamingCard({ pair }: { pair: Pair }) { onError: (e) => toast("err", e.message), }); + const folders = cfg.folders ?? []; + const setField = (rule: number | null, key: FormatKey, v: string) => + setCfg(rule === null ? { ...cfg, [key]: v } : { ...cfg, folders: folders.map((r, i) => (i === rule ? { ...r, [key]: v } : r)) }); const insert = (token: string) => { const f = focused.current; if (!f) return; - const v = cfg[f.key] as string; + const v = f.rule === null ? cfg[f.key] : (folders[f.rule]?.[f.key] ?? ""); 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) }); + setField(f.rule, f.key, v.slice(0, start) + token + v.slice(end)); requestAnimationFrame(() => { f.el.focus(); f.el.setSelectionRange(start + token.length, start + token.length); @@ -118,9 +124,9 @@ function NamingCard({ pair }: { pair: Pair }) { type="button" className={cx( "rounded-md border px-2 py-0.5 mono cursor-pointer", - JSON.stringify(p.config) === JSON.stringify(cfg) ? "border-accent bg-accent/15 text-white" : "border-line bg-panel-2 text-gray-300 hover:border-accent", + sameFormats(p.config, cfg) ? "border-accent bg-accent/15 text-white" : "border-line bg-panel-2 text-gray-300 hover:border-accent", )} - onClick={() => setCfg(p.config)} + onClick={() => setCfg({ ...p.config, folders })} > {p.name} @@ -128,24 +134,68 @@ function NamingCard({ pair }: { pair: Pair }) { )} -
- {FORMAT_FIELDS.map((f) => ( - - (focused.current = { key: f.key, el: e.currentTarget })} - onChange={(e) => setCfg({ ...cfg, [f.key]: e.target.value })} + setField(null, k, v)} onFocus={(key, el) => (focused.current = { rule: null, key, el })} /> + +
+
+
+

Folders with their own naming

+

+ For part of a library that is organized differently, such as game soundtracks in{" "} + Video Games/Album/01 Title. Files in the folder stay in it, and other files are never moved into it. +

+
+ +
+ {folders.map((r, i) => ( +
+
+
+ + setCfg({ ...cfg, folders: folders.map((x, j) => (j === i ? { ...x, folder: e.target.value } : x)) })} + /> + +
+ +
+ setField(i, k, v)} + onFocus={(key, el) => (focused.current = { rule: i, key, el })} /> - +
))}
{showTokens && info.data && (
@@ -162,8 +212,8 @@ function NamingCard({ pair }: { pair: Pair }) { ))}

- 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}"}). + 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}"}).

)} @@ -208,8 +258,8 @@ function NamingCard({ pair }: { pair: Pair }) {
{auto !== "off" && ( }> - Files are renamed during each scan without asking. Every rename is in History and can be undone; files with missing tags or clashing - names are left alone. Dry run still applies. + Files are renamed during each scan without asking. Every rename is in History and can be undone; files with missing tags or clashing names + are left alone. Dry run still applies. )} @@ -305,7 +355,10 @@ function RenameFilesDialog({ pair, scope, onClose }: { pair: Pair; scope: Scope; 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.`); + 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(); }, @@ -350,7 +403,10 @@ function RenameFilesDialog({ pair, scope, onClose }: { pair: Pair; scope: Scope; @@ -365,7 +421,12 @@ function RenameFilesDialog({ pair, scope, onClose }: { pair: Pair; scope: Scope; type="checkbox" className="accent-violet-500" checked={sel.size === moves.length} - onChange={(e) => toggle(moves.map((m) => m.id), e.target.checked)} + onChange={(e) => + toggle( + moves.map((m) => m.id), + e.target.checked, + ) + } /> {sel.size} of {moves.length} selected, in {groups.length} folder{groups.length !== 1 && "s"} @@ -387,8 +448,16 @@ function RenameFilesDialog({ pair, scope, onClose }: { pair: Pair; scope: Scope; {both && {side}} {items.map((it) => ( -