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 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-30 08:29:17 -07:00
1 parent d8ac25a55d
commit b11d0806c1
7 files changed
+462 -100

No files matched your search

+5
View File
@@ -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 **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 **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`. `{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}`, 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}`, `{Album Title}`, `{Album TitleThe}`, `{Album CleanTitle}`, `{Release Year}`, `{track:0}`/`{track:00}`, `{medium:0}`/`{medium:00}`,
+2 -1
View File
@@ -824,7 +824,8 @@ func (s *Server) preAction(w http.ResponseWriter, r *http.Request, pairID int64,
// ---- organizer ---- // ---- organizer ----
func (s *Server) handleNamingInfo(w http.ResponseWriter, r *http.Request) { 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 // handleNamingExamples renders sample songs with unsaved settings, using the
+194 -55
View File
@@ -18,6 +18,7 @@ import (
// track format (with a separate format for multi-disc albums), built from // track format (with a separate format for multi-disc albums), built from
// tokens such as {Artist Name}, {Album Title} and {track:00}. // tokens such as {Artist Name}, {Album Title} and {track:00}.
// An empty ArtistFolder means no artist folder: Album/01 Title.
type NamingConfig struct { type NamingConfig struct {
ArtistFolder string `json:"artist_folder"` ArtistFolder string `json:"artist_folder"`
AlbumFolder string `json:"album_folder"` AlbumFolder string `json:"album_folder"`
@@ -25,6 +26,45 @@ type NamingConfig struct {
MultiDiscFormat string `json:"multi_disc_format"` MultiDiscFormat string `json:"multi_disc_format"`
Colon string `json:"colon"` // smart, delete, dash, space_dash, space_dash_space Colon string `json:"colon"` // smart, delete, dash, space_dash, space_dash_space
Illegal string `json:"illegal"` // replace or remove 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 { func DefaultNaming() NamingConfig {
@@ -35,9 +75,14 @@ func DefaultNaming() NamingConfig {
MultiDiscFormat: "{Medium Format} {medium:00}/{track:00} {Track Title}", MultiDiscFormat: "{Medium Format} {medium:00}/{track:00} {Track Title}",
Colon: "smart", Colon: "smart",
Illegal: "replace", 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. // NamingPresets are offered in the UI.
var NamingPresets = []struct { var NamingPresets = []struct {
Name string `json:"name"` 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"}}, 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}", {"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"}}, 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 // NamingOf returns a pair's naming, converting the settings of earlier
@@ -58,9 +105,7 @@ var NamingPresets = []struct {
func NamingOf(p store.Pair) NamingConfig { func NamingOf(p store.Pair) NamingConfig {
c := DefaultNaming() c := DefaultNaming()
if len(p.OrganizeConfig) > 0 && string(p.OrganizeConfig) != "{}" { if len(p.OrganizeConfig) > 0 && string(p.OrganizeConfig) != "{}" {
var stored NamingConfig if stored, err := parseNaming(p.OrganizeConfig); err == nil {
if json.Unmarshal(p.OrganizeConfig, &stored) == nil {
fill(&stored, c)
return stored return stored
} }
} }
@@ -91,9 +136,29 @@ func NamingOf(p store.Pair) NamingConfig {
return c 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) { func fill(c *NamingConfig, d NamingConfig) {
if strings.TrimSpace(c.ArtistFolder) == "" { c.ArtistFolder = strings.TrimSpace(c.ArtistFolder)
c.ArtistFolder = d.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) == "" { if strings.TrimSpace(c.AlbumFolder) == "" {
c.AlbumFolder = d.AlbumFolder c.AlbumFolder = d.AlbumFolder
@@ -171,42 +236,32 @@ func styleOf(t string) (sep string, cased string) {
// ValidateNaming checks the formats. // ValidateNaming checks the formats.
func ValidateNaming(c NamingConfig) error { func ValidateNaming(c NamingConfig) error {
check := func(label, f string, folder bool) error { if err := checkFormats("", c.formats()); err != nil {
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 return err
} }
if err := check("Album folder format", c.AlbumFolder, true); err != nil { seen := map[string]bool{}
return err for _, f := range c.Folders {
} label := fmt.Sprintf("Folder %q", f.Folder)
for _, f := range []struct{ label, v string }{{"Track format", c.TrackFormat}, {"Multi-disc track format", c.MultiDiscFormat}} { switch {
if err := check(f.label, f.v, false); err != nil { 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 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 { switch c.Colon {
case "smart", "delete", "dash", "space_dash", "space_dash_space": case "smart", "delete", "dash", "space_dash", "space_dash_space":
@@ -219,6 +274,51 @@ func ValidateNaming(c NamingConfig) error {
return nil 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 { func tokenKeys(f string) string {
var b strings.Builder var b strings.Builder
b.WriteString("|") 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 } func (v songValues) multiDisc() bool { return v.discTotal > 1 || v.disc > 1 }
// renderPath builds the organized relative path of a song. It returns the // renderPath builds the organized relative path of a song at rel. Songs in
// missing tag when a required one is empty. // a folder with its own naming stay in that folder. It returns why the song
func renderPath(c NamingConfig, ext string, f map[string]string) (string, string) { // 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) v := valuesOf(f)
if v.artist == "" { track := fm.track
return "", "artist or album artist" 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 == "" { if v.album == "" {
return "", "album" return "", "missing album tag"
} }
if v.title == "" { if v.title == "" {
return "", "title" return "", "missing title tag"
}
track := c.TrackFormat
if v.multiDisc() {
track = c.MultiDiscFormat
} }
var parts []string 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, "/") { for _, part := range strings.Split(f, "/") {
out := tokenPat.ReplaceAllStringFunc(part, func(tok string) string { return renderToken(tok[1:len(tok)-1], v, c) }) out := tokenPat.ReplaceAllStringFunc(part, func(tok string) string { return renderToken(tok[1:len(tok)-1], v, c) })
out = finishComponent(out) out = finishComponent(out)
if out == "" { if out == "" {
return "", "tags for " + part return "", "missing tags for " + part
} }
parts = append(parts, out) parts = append(parts, out)
} }
@@ -465,12 +597,19 @@ func NamingExamples(c NamingConfig) []NamingExample {
tags.Track: "7", tags.Date: "1986"}}, tags.Track: "7", tags.Date: "1986"}},
} }
out := []NamingExample{} out := []NamingExample{}
for _, s := range samples { add := func(label, rel string, f map[string]string) {
p, missing := renderPath(c, s.ext, s.f) p, why := renderPath(c, rel, f)
if missing != "" { if why != "" {
p = "(skipped: missing " + missing + ")" 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 return out
} }
+103 -6
View File
@@ -4,6 +4,8 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"strings"
"testing" "testing"
"github.com/cdnutter/playlink/internal/store" "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"}, {"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 { 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 != "" { if got != c.want || missing != "" {
t.Errorf("%s: got %q (missing %q), want %q", c.name, got, missing, c.want) 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"), "album": song("artist", "A", "title", "S"),
"title": song("artist", "A", "album", "B"), "title": song("artist", "A", "album", "B"),
} { } {
if _, missing := renderPath(d, ".mp3", f); missing != field { if _, why := renderPath(d, "x.mp3", f); why != "missing "+field+" tag" {
t.Errorf("want missing %q, got %q", field, missing) 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) { func TestValidateNaming(t *testing.T) {
bad := []func(*NamingConfig){ bad := []func(*NamingConfig){
func(c *NamingConfig) { c.ArtistFolder = "{Artist Name}/{Album Title}" }, 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}" { c.MultiDiscFormat != "{medium:0}-{track:00} - {Track Title}" {
t.Fatalf("old settings not converted: %+v", c) 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) 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: []byte(`{"track_format":"{track:0}. {Track Title}"}`)})
got := NamingOf(store.Pair{OrganizeConfig: raw})
if got.TrackFormat != "{track:0}. {Track Title}" || got.ArtistFolder != "{Artist Name}" { if got.TrackFormat != "{track:0}. {Track Title}" || got.ArtistFolder != "{Artist Name}" {
t.Fatalf("stored settings should win and gaps take defaults, got %+v", got) 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") 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)
}
}
}
+4 -5
View File
@@ -29,11 +29,10 @@ import (
// normalizeOrganize validates a pair's organize settings. // normalizeOrganize validates a pair's organize settings.
func normalizeOrganize(p *store.Pair) error { func normalizeOrganize(p *store.Pair) error {
if len(p.OrganizeConfig) > 0 && string(p.OrganizeConfig) != "{}" && string(p.OrganizeConfig) != "null" { if len(p.OrganizeConfig) > 0 && string(p.OrganizeConfig) != "{}" && string(p.OrganizeConfig) != "null" {
var c NamingConfig c, err := parseNaming(p.OrganizeConfig)
if err := json.Unmarshal(p.OrganizeConfig, &c); err != nil { if err != nil {
return fmt.Errorf("naming settings: %w", err) return fmt.Errorf("naming settings: %w", err)
} }
fill(&c, DefaultNaming())
if err := ValidateNaming(c); err != nil { if err := ValidateNaming(c); err != nil {
return err 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" 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 { } 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" item.Reason = "tags could not be read"
} else if to, missing := renderPath(naming, filepath.Ext(rel), fields); missing != "" { } else if to, why := renderPath(naming, rel, fields); why != "" {
item.Reason = "missing " + missing + " tag" item.Reason = why
} else if to == rel { } else if to == rel {
plan.Conforming++ plan.Conforming++
continue continue
+10
View File
@@ -249,6 +249,16 @@ export type Naming = {
multi_disc_format: string; multi_disc_format: string;
colon: "smart" | "delete" | "dash" | "space_dash" | "space_dash_space"; colon: "smart" | "delete" | "dash" | "space_dash" | "space_dash_space";
illegal: "replace" | "remove"; 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[] }; export type OrganizeItem = { id: number; side: Side; from: string; to?: string; move: boolean; reason?: string; sidecars?: string[] };
+144 -33
View File
@@ -1,18 +1,21 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowRight, ChevronDown, ChevronRight, Folder, FolderTree, Tags as TagsIcon } from "lucide-react"; import { AlertTriangle, ArrowRight, ChevronDown, ChevronRight, Folder, FolderPlus, FolderTree, Tags as TagsIcon, Trash2 } from "lucide-react";
import { api, type Job, type Naming, type OrganizeItem, type OrganizePlan, type Pair, type Side } from "../api"; import { api, type FolderNaming, type Job, type Naming, type OrganizeItem, type OrganizePlan, type Pair, type Side } from "../api";
import { useInfo } from "../App"; import { useInfo } from "../App";
import { Alert, Badge, Button, Card, Field, Modal, Spinner, cx, inputCls } from "../ui"; import { Alert, Badge, Button, Card, Field, Modal, Spinner, cx, inputCls } from "../ui";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { TagEditor } from "./Tags"; 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"; type Scope = Side | "both";
const FORMAT_FIELDS: { key: keyof Naming; label: string; hint: string }[] = [ const FORMAT_FIELDS: { key: FormatKey; label: string; hint: string }[] = [
{ key: "artist_folder", label: "Artist folder", hint: "One folder name." }, { 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." }, { 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: "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." }, { 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<Naming>(pair.naming); const [cfg, setCfg] = useState<Naming>(pair.naming);
const [auto, setAuto] = useState(pair.auto_organize); const [auto, setAuto] = useState(pair.auto_organize);
const [showTokens, setShowTokens] = useState(false); const [showTokens, setShowTokens] = useState(false);
const focused = useRef<{ key: keyof Naming; el: HTMLInputElement } | null>(null); const focused = useRef<FieldRef | null>(null);
const saved = JSON.stringify(pair.naming); const saved = JSON.stringify(pair.naming);
useEffect(() => { useEffect(() => {
setCfg(pair.naming); setCfg(pair.naming);
@@ -82,13 +85,16 @@ function NamingCard({ pair }: { pair: Pair }) {
onError: (e) => toast("err", e.message), 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 insert = (token: string) => {
const f = focused.current; const f = focused.current;
if (!f) return; 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 start = f.el.selectionStart ?? v.length;
const end = f.el.selectionEnd ?? 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(() => { requestAnimationFrame(() => {
f.el.focus(); f.el.focus();
f.el.setSelectionRange(start + token.length, start + token.length); f.el.setSelectionRange(start + token.length, start + token.length);
@@ -118,9 +124,9 @@ function NamingCard({ pair }: { pair: Pair }) {
type="button" type="button"
className={cx( className={cx(
"rounded-md border px-2 py-0.5 mono cursor-pointer", "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} {p.name}
</button> </button>
@@ -128,24 +134,68 @@ function NamingCard({ pair }: { pair: Pair }) {
</div> </div>
)} )}
<div className="grid gap-4 md:grid-cols-2"> <FormatFields values={cfg} onChange={(k, v) => setField(null, k, v)} onFocus={(key, el) => (focused.current = { rule: null, key, el })} />
{FORMAT_FIELDS.map((f) => (
<Field key={f.key} label={f.label} hint={f.hint}> <div className="space-y-3">
<input <div className="flex flex-wrap items-center justify-between gap-2">
className={inputCls + " mono text-xs"} <div>
value={cfg[f.key] as string} <h3 className="text-sm font-medium">Folders with their own naming</h3>
spellCheck={false} <p className="text-xs text-muted">
onFocus={(e) => (focused.current = { key: f.key, el: e.currentTarget })} For part of a library that is organized differently, such as game soundtracks in{" "}
onChange={(e) => setCfg({ ...cfg, [f.key]: e.target.value })} <span className="mono">Video Games/Album/01 Title</span>. Files in the folder stay in it, and other files are never moved into it.
</p>
</div>
<Button
size="sm"
onClick={() =>
info.data &&
setCfg({
...cfg,
folders: [...folders, { ...info.data.folder_default, folder: folders.length ? "" : info.data.folder_default.folder }],
})
}
>
<FolderPlus className="size-4" /> Add folder
</Button>
</div>
{folders.map((r, i) => (
<div key={i} className="space-y-3 rounded-lg border border-line bg-panel-2/40 p-3">
<div className="flex items-start gap-2">
<div className="flex-1">
<Field label="Folder" hint="Relative to the library, e.g. Video Games or Music/Soundtracks.">
<input
className={inputCls + " mono text-xs"}
value={r.folder}
placeholder="Video Games"
spellCheck={false}
onChange={(e) => setCfg({ ...cfg, folders: folders.map((x, j) => (j === i ? { ...x, folder: e.target.value } : x)) })}
/>
</Field>
</div>
<Button
className="mt-7"
variant="ghost"
size="sm"
title="Remove this folder rule"
onClick={() => setCfg({ ...cfg, folders: folders.filter((_, j) => j !== i) })}
>
<Trash2 className="size-4" />
</Button>
</div>
<FormatFields
values={r}
inside={r.folder || "the folder"}
onChange={(k, v) => setField(i, k, v)}
onFocus={(key, el) => (focused.current = { rule: i, key, el })}
/> />
</Field> </div>
))} ))}
</div> </div>
<div> <div>
<button className="flex items-center gap-1 text-xs text-muted hover:text-white cursor-pointer" onClick={() => setShowTokens(!showTokens)}> <button className="flex items-center gap-1 text-xs text-muted hover:text-white cursor-pointer" onClick={() => setShowTokens(!showTokens)}>
{showTokens ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />} Tokens (click one to insert it into the field you {showTokens ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />} Tokens (click one to insert it into the field
last clicked) you last clicked)
</button> </button>
{showTokens && info.data && ( {showTokens && info.data && (
<div className="mt-2 grid gap-1 sm:grid-cols-2 lg:grid-cols-3"> <div className="mt-2 grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
@@ -162,8 +212,8 @@ function NamingCard({ pair }: { pair: Pair }) {
</button> </button>
))} ))}
<p className="text-[11px] text-muted sm:col-span-2 lg:col-span-3"> <p className="text-[11px] text-muted sm:col-span-2 lg:col-span-3">
Write a token with dots or underscores to use them between words (<code className="mono">{"{Artist.Name}"}</code> → Write a token with dots or underscores to use them between words (<code className="mono">{"{Artist.Name}"}</code> → Avenged.Sevenfold),
Avenged.Sevenfold), or in capitals or lower case to change the case (<code className="mono">{"{ARTIST NAME}"}</code>). or in capitals or lower case to change the case (<code className="mono">{"{ARTIST NAME}"}</code>).
</p> </p>
</div> </div>
)} )}
@@ -208,8 +258,8 @@ function NamingCard({ pair }: { pair: Pair }) {
</div> </div>
{auto !== "off" && ( {auto !== "off" && (
<Alert tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}> <Alert tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
Files are renamed during each scan without asking. Every rename is in History and can be undone; files with missing tags or clashing Files are renamed during each scan without asking. Every rename is in History and can be undone; files with missing tags or clashing names
names are left alone. Dry run still applies. are left alone. Dry run still applies.
</Alert> </Alert>
)} )}
</Card> </Card>
@@ -305,7 +355,10 @@ function RenameFilesDialog({ pair, scope, onClose }: { pair: Pair; scope: Scope;
const apply = useMutation({ const apply = useMutation({
mutationFn: () => api.post(`/pairs/${pair.id}/organize/apply`, { ids: sel.size === moves.length ? [] : [...sel] }), mutationFn: () => api.post(`/pairs/${pair.id}/organize/apply`, { ids: sel.size === moves.length ? [] : [...sel] }),
onSuccess: () => { 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] }); qc.invalidateQueries({ queryKey: ["pair", pair.id] });
onClose(); onClose();
}, },
@@ -350,7 +403,10 @@ function RenameFilesDialog({ pair, scope, onClose }: { pair: Pair; scope: Scope;
<button <button
key={v} key={v}
onClick={() => setView(v)} onClick={() => setView(v)}
className={cx("-mb-px border-b-2 px-3 py-1.5 text-sm cursor-pointer", view === v ? "border-accent text-white" : "border-transparent text-muted hover:text-white")} className={cx(
"-mb-px border-b-2 px-3 py-1.5 text-sm cursor-pointer",
view === v ? "border-accent text-white" : "border-transparent text-muted hover:text-white",
)}
> >
{v === "changes" ? `Changes (${moves.length})` : `Skipped (${skipped.length})`} {v === "changes" ? `Changes (${moves.length})` : `Skipped (${skipped.length})`}
</button> </button>
@@ -365,7 +421,12 @@ function RenameFilesDialog({ pair, scope, onClose }: { pair: Pair; scope: Scope;
type="checkbox" type="checkbox"
className="accent-violet-500" className="accent-violet-500"
checked={sel.size === moves.length} 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"} {sel.size} of {moves.length} selected, in {groups.length} folder{groups.length !== 1 && "s"}
</label> </label>
@@ -387,8 +448,16 @@ function RenameFilesDialog({ pair, scope, onClose }: { pair: Pair; scope: Scope;
{both && <Badge tone={side === "master" ? "violet" : "gray"}>{side}</Badge>} {both && <Badge tone={side === "master" ? "violet" : "gray"}>{side}</Badge>}
</label> </label>
{items.map((it) => ( {items.map((it) => (
<label key={it.id} className={cx("flex items-start gap-2 py-1 pl-9 pr-3 text-xs cursor-pointer", !sel.has(it.id) && "opacity-50")}> <label
<input type="checkbox" className="mt-0.5 accent-violet-500" checked={sel.has(it.id)} onChange={(e) => toggle([it.id], e.target.checked)} /> key={it.id}
className={cx("flex items-start gap-2 py-1 pl-9 pr-3 text-xs cursor-pointer", !sel.has(it.id) && "opacity-50")}
>
<input
type="checkbox"
className="mt-0.5 accent-violet-500"
checked={sel.has(it.id)}
onChange={(e) => toggle([it.id], e.target.checked)}
/>
<div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-start gap-2 mono"> <div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-start gap-2 mono">
<span className="break-all text-gray-500">{it.from}</span> <span className="break-all text-gray-500">{it.from}</span>
<ArrowRight className="mt-0.5 size-3 text-muted" /> <ArrowRight className="mt-0.5 size-3 text-muted" />
@@ -442,7 +511,11 @@ function RenameFilesDialog({ pair, scope, onClose }: { pair: Pair; scope: Scope;
<li> <li>
<b className="text-gray-300">Playlists or apps that point at the old paths will need updating.</b> <b className="text-gray-300">Playlists or apps that point at the old paths will need updating.</b>
</li> </li>
{!both && <li>Only the {data.plan.sides[0]} library is renamed; paths may then differ from the other library (hardlinked songs stay paired).</li>} {!both && (
<li>
Only the {data.plan.sides[0]} library is renamed; paths may then differ from the other library (hardlinked songs stay paired).
</li>
)}
<li>Each rename is in History and can be undone. The library is rescanned when it finishes.</li> <li>Each rename is in History and can be undone. The library is rescanned when it finishes.</li>
</ul> </ul>
)} )}
@@ -472,3 +545,41 @@ function Stat({ label, value, tone }: { label: string; value: number; tone?: str
</div> </div>
); );
} }
function sameFormats(a: Naming, b: Naming) {
return (["artist_folder", "album_folder", "track_format", "multi_disc_format", "colon", "illegal"] as const).every((k) => a[k] === b[k]);
}
/** The four path formats, for the library or for one folder rule. */
function FormatFields({
values,
inside,
onChange,
onFocus,
}: {
values: Record<FormatKey, string>;
inside?: string;
onChange: (key: FormatKey, v: string) => void;
onFocus: (key: FormatKey, el: HTMLInputElement) => void;
}) {
return (
<div className="grid gap-4 md:grid-cols-2">
{FORMAT_FIELDS.map((f) => (
<Field
key={f.key}
label={f.label}
hint={inside && f.key === "artist_folder" ? `Inside ${inside}. Leave empty for no artist folder.` : f.hint}
>
<input
className={inputCls + " mono text-xs"}
value={values[f.key]}
placeholder={f.key === "artist_folder" ? "(none)" : undefined}
spellCheck={false}
onFocus={(e) => onFocus(f.key, e.currentTarget)}
onChange={(e) => onChange(f.key, e.target.value)}
/>
</Field>
))}
</div>
);
}