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
+451 -89

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
**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}`,
+2 -1
View File
@@ -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
+187 -48
View File
@@ -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,7 +236,66 @@ 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 err := checkFormats("", c.formats()); err != nil {
return err
}
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
}
}
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
}
// 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)
}
@@ -194,30 +318,6 @@ func ValidateNaming(c NamingConfig) error {
}
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
@@ -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
}
+103 -6
View File
@@ -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)
}
}
}
+4 -5
View File
@@ -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
+10
View File
@@ -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[] };
+140 -29
View File
@@ -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<Naming>(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<FieldRef | null>(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}
</button>
@@ -128,24 +134,68 @@ function NamingCard({ pair }: { pair: Pair }) {
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
{FORMAT_FIELDS.map((f) => (
<Field key={f.key} label={f.label} hint={f.hint}>
<FormatFields values={cfg} onChange={(k, v) => setField(null, k, v)} onFocus={(key, el) => (focused.current = { rule: null, key, el })} />
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-medium">Folders with their own naming</h3>
<p className="text-xs text-muted">
For part of a library that is organized differently, such as game soundtracks in{" "}
<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={cfg[f.key] as string}
value={r.folder}
placeholder="Video Games"
spellCheck={false}
onFocus={(e) => (focused.current = { key: f.key, el: e.currentTarget })}
onChange={(e) => setCfg({ ...cfg, [f.key]: e.target.value })}
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 })}
/>
</div>
))}
</div>
<div>
<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
last clicked)
{showTokens ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />} Tokens (click one to insert it into the field
you last clicked)
</button>
{showTokens && info.data && (
<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>
))}
<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> →
Avenged.Sevenfold), or in capitals or lower case to change the case (<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),
or in capitals or lower case to change the case (<code className="mono">{"{ARTIST NAME}"}</code>).
</p>
</div>
)}
@@ -208,8 +258,8 @@ function NamingCard({ pair }: { pair: Pair }) {
</div>
{auto !== "off" && (
<Alert tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
Files are renamed during each scan without asking. Every rename is 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.
</Alert>
)}
</Card>
@@ -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;
<button
key={v}
onClick={() => setView(v)}
className={cx("-mb-px border-b-2 px-3 py-1.5 text-sm cursor-pointer", view === v ? "border-accent text-white" : "border-transparent text-muted hover:text-white")}
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})`}
</button>
@@ -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"}
</label>
@@ -387,8 +448,16 @@ function RenameFilesDialog({ pair, scope, onClose }: { pair: Pair; scope: Scope;
{both && <Badge tone={side === "master" ? "violet" : "gray"}>{side}</Badge>}
</label>
{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")}>
<input type="checkbox" className="mt-0.5 accent-violet-500" checked={sel.has(it.id)} onChange={(e) => toggle([it.id], e.target.checked)} />
<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")}
>
<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">
<span className="break-all text-gray-500">{it.from}</span>
<ArrowRight className="mt-0.5 size-3 text-muted" />
@@ -442,7 +511,11 @@ function RenameFilesDialog({ pair, scope, onClose }: { pair: Pair; scope: Scope;
<li>
<b className="text-gray-300">Playlists or apps that point at the old paths will need updating.</b>
</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>
</ul>
)}
@@ -472,3 +545,41 @@ function Stat({ label, value, tone }: { label: string; value: number; tone?: str
</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>
);
}