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:
1 parent
d8ac25a55d
commit
b11d0806c1
7 files changed
+462
-100
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user