Files
playlink/internal/engine/naming_test.go
T
cdnutterandClaude Opus 5.5 b11d0806c1 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>
2026-09-30 08:29:17 -07:00

254 lines
12 KiB
Go

package engine
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"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, "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)
}
}
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 _, 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}" },
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{}); !reflect.DeepEqual(got, DefaultNaming()) {
t.Fatalf("pairs without settings should use the defaults, got %+v", got)
}
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)
}
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")
}
}
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)
}
}
}