Redo file naming in the style of Lidarr and add "Rename now"

Naming: separate artist folder, album folder, track and multi-disc track
formats with presets, Lidarr-style tokens ({Artist Name}, {Artist
NameThe}, {Artist CleanName}, {Artist NameFirstCharacter}, {Album
Title}, {Album TitleThe}, {Album CleanTitle}, {Release Year},
{track:00}, {medium:00}, {Medium Format}, {Track Title}, {Track
CleanTitle}, {Track ArtistName}, {Genre}) with Lidarr's word-separator
and case variants, colon replacement modes and replace/remove for other
illegal characters. Empty brackets and separators left by missing values
are cleaned up. Defaults to Artist/Album/01 Title with Disc 01 folders
for multi-disc albums; earlier pattern/disc settings are converted.

Examples are now rendered by the server with the same code as real
renames, fixing the settings page showing paths that differed from what
would happen (e.g. disc 1 of a multi-disc album outside its Disc folder).

"Rename now..." checks one or both libraries, then lists every change
grouped by destination folder with per-file and per-folder selection,
skipped files with reasons and a Fix tags button, and warnings; applying
re-checks each file, renames, and rescans. The pre-action backup runs
first when enabled. Migration 9 stores the naming as JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-29 14:58:36 -07:00
1 parent 49fc32096a
commit 5fb195f0d6
12 files changed
+1297 -355

No files matched your search

+156
View File
@@ -0,0 +1,156 @@
package engine
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/cdnutter/playlink/internal/store"
)
func song(kv ...string) map[string]string {
m := map[string]string{}
for i := 0; i < len(kv); i += 2 {
m[kv[i]] = kv[i+1]
}
return m
}
func TestRenderPath(t *testing.T) {
d := DefaultNaming()
with := func(mod func(*NamingConfig)) NamingConfig { c := DefaultNaming(); mod(&c); return c }
nightmare := song("artist", "Avenged Sevenfold", "album", "Nightmare", "title", "Nightmare", "track", "1", "date", "2010-07-27")
cases := []struct {
name string
c NamingConfig
f map[string]string
ext string
want string
}{
{"default", d, nightmare, ".FLAC", "Avenged Sevenfold/Nightmare/01 Nightmare.flac"},
{"multi-disc disc 1 also gets its folder", d, song("artist", "The Beatles", "album", "White Album", "title", "Back in the USSR", "track", "1", "disc", "1", "disc_total", "2"), ".flac",
"The Beatles/White Album/Disc 01/01 Back in the USSR.flac"},
{"multi-disc disc 2", d, song("artist", "The Beatles", "album", "White Album", "title", "Birthday", "track", "1", "disc", "2/2"), ".flac",
"The Beatles/White Album/Disc 02/01 Birthday.flac"},
{"album artist wins", d, song("artist", "Queen", "album_artist", "Various Artists", "album", "Hits", "title", "Bohemian Rhapsody", "track", "12"), ".mp3",
"Various Artists/Hits/12 Bohemian Rhapsody.mp3"},
{"year with empty brackets removed", with(func(c *NamingConfig) { c.AlbumFolder = "{Album Title} ({Release Year})" }),
song("artist", "A", "album", "B", "title", "S", "track", "3"), ".mp3", "A/B/03 S.mp3"},
{"year present", with(func(c *NamingConfig) { c.AlbumFolder = "{Album Title} ({Release Year})" }), nightmare, ".flac", "Avenged Sevenfold/Nightmare (2010)/01 Nightmare.flac"},
{"no track number", d, song("artist", "A", "album", "B", "title", "Song"), ".mp3", "A/B/Song.mp3"},
{"separator left by missing number", with(func(c *NamingConfig) { c.TrackFormat = "{track:00} - {Track Title}" }), song("artist", "A", "album", "B", "title", "Song"), ".mp3", "A/B/Song.mp3"},
{"three digit tracks", d, song("artist", "A", "album", "B", "title", "S", "track", "105"), ".mp3", "A/B/105 S.mp3"},
{"NameThe and first character", with(func(c *NamingConfig) {
c.ArtistFolder = "{Artist NameFirstCharacter}"
c.AlbumFolder = "{Artist NameThe}"
}),
song("artist", "The Beatles", "album", "X", "title", "S", "track", "1"), ".mp3", "B/Beatles, The/01 S.mp3"},
{"dotted and uppercase tokens", with(func(c *NamingConfig) { c.ArtistFolder = "{Artist.Name}"; c.TrackFormat = "{track:0} {TRACK TITLE}" }), nightmare, ".flac",
"Avenged.Sevenfold/Nightmare/1 NIGHTMARE.flac"},
{"clean name", with(func(c *NamingConfig) { c.ArtistFolder = "{Artist CleanName}" }), song("artist", "AC/DC", "album", "B", "title", "S", "track", "1"), ".mp3", "ACDC/B/01 S.mp3"},
{"illegal characters replaced, smart colon", d, song("artist", "AC/DC", "album", "Who Made Who?", "title", "Hells Bells: Live \"1986\"", "track", "7"), ".mp3",
"AC-DC/Who Made Who/07 Hells Bells - Live '1986'.mp3"},
{"illegal characters removed, colon deleted", with(func(c *NamingConfig) { c.Illegal = "remove"; c.Colon = "delete" }),
song("artist", "AC/DC", "album", "B", "title", "Hells Bells: Live", "track", "7"), ".mp3", "ACDC/B/07 Hells Bells Live.mp3"},
{"colon space-dash-space", with(func(c *NamingConfig) { c.Colon = "space_dash_space" }), song("artist", "A", "album", "B", "title", "Part:Two", "track", "1"), ".mp3", "A/B/01 Part - Two.mp3"},
{"no hidden names", d, song("artist", "...And Justice", "album", "B", "title", "S", "track", "1"), ".mp3", "And Justice/B/01 S.mp3"},
}
for _, c := range cases {
got, missing := renderPath(c.c, c.ext, c.f)
if got != c.want || missing != "" {
t.Errorf("%s: got %q (missing %q), want %q", c.name, got, missing, c.want)
}
}
for field, f := range map[string]map[string]string{
"artist or album artist": song("album", "B", "title", "S"),
"album": song("artist", "A", "title", "S"),
"title": song("artist", "A", "album", "B"),
} {
if _, missing := renderPath(d, ".mp3", f); missing != field {
t.Errorf("want missing %q, got %q", field, missing)
}
}
}
func TestValidateNaming(t *testing.T) {
bad := []func(*NamingConfig){
func(c *NamingConfig) { c.ArtistFolder = "{Artist Name}/{Album Title}" },
func(c *NamingConfig) { c.TrackFormat = "{track:00}" },
func(c *NamingConfig) { c.TrackFormat = "{Bogus} {Track Title}" },
func(c *NamingConfig) { c.MultiDiscFormat = "../{Track Title}" },
func(c *NamingConfig) { c.AlbumFolder = "" },
func(c *NamingConfig) { c.Colon = "weird" },
func(c *NamingConfig) { c.AlbumFolder = "{Album Title" },
}
for i, mod := range bad {
c := DefaultNaming()
mod(&c)
if ValidateNaming(c) == nil {
t.Errorf("case %d should be rejected: %+v", i, c)
}
}
for _, p := range NamingPresets {
if err := ValidateNaming(p.Config); err != nil {
t.Errorf("preset %q invalid: %v", p.Name, err)
}
}
}
func TestNamingOfConvertsOldSettings(t *testing.T) {
c := NamingOf(store.Pair{OrganizeTemplate: "{artist}/{album} ({year})/{track} - {title}", OrganizeMultiDisc: "prefix", OrganizeDiscDigits: 1})
if c.ArtistFolder != "{Artist Name}" || c.AlbumFolder != "{Album Title} ({Release Year})" || c.TrackFormat != "{track:00} - {Track Title}" ||
c.MultiDiscFormat != "{medium:0}-{track:00} - {Track Title}" {
t.Fatalf("old settings not converted: %+v", c)
}
if got := NamingOf(store.Pair{}); got != DefaultNaming() {
t.Fatalf("pairs without settings should use the defaults, got %+v", got)
}
raw, _ := json.Marshal(NamingConfig{TrackFormat: "{track:0}. {Track Title}"})
got := NamingOf(store.Pair{OrganizeConfig: raw})
if got.TrackFormat != "{track:0}. {Track Title}" || got.ArtistFolder != "{Artist Name}" {
t.Fatalf("stored settings should win and gaps take defaults, got %+v", got)
}
ex := NamingExamples(DefaultNaming())
if len(ex) != 4 || ex[1].Path != "The Beatles/The Beatles (White Album)/Disc 01/01 Back in the U.S.S.R.flac" {
t.Fatalf("examples: %+v", ex)
}
}
func TestRenameNowPreviewAndApply(t *testing.T) {
ev := tagEnv(t, false)
mkTagged(t, filepath.Join(ev.slave, "in/a.mp3"), "title", "One", "artist", "Band", "album", "LP", "track", "1")
mkTagged(t, filepath.Join(ev.slave, "in/b.mp3"), "title", "Two", "artist", "Band", "album", "LP", "track", "2")
if _, err := ev.e.StartOrganizePreview(ev.pair.ID, "slave"); err != nil {
t.Fatal(err)
}
ev.wait()
plan, items := ev.e.OrganizePreview(ev.pair.ID)
if plan == nil || plan.Moves != 2 || len(items) != 2 {
t.Fatalf("preview: %+v %+v", plan, items)
}
var first int
for _, it := range items {
if it.From == "in/a.mp3" {
first = it.ID
}
}
if _, err := ev.e.StartOrganizeApply(ev.pair.ID, []int{first}); err != nil {
t.Fatal(err)
}
if j := ev.wait(); j.Results["renamed"] != 1 {
t.Fatalf("only the selected file should be renamed: %+v", j.Results)
}
if _, err := os.Stat(filepath.Join(ev.slave, "Band/LP/01 One.mp3")); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(ev.slave, "in/b.mp3")); err != nil {
t.Fatal("unselected files stay")
}
if p, _ := ev.e.OrganizePreview(ev.pair.ID); p != nil {
t.Fatal("the preview is stale after renaming and must be cleared")
}
if _, err := ev.e.StartOrganizeApply(ev.pair.ID, nil); err == nil {
t.Fatal("applying without a fresh preview must be refused")
}
}