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
+194
-55
@@ -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,42 +236,32 @@ 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 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 {
|
||||
if err := checkFormats("", c.formats()); 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 {
|
||||
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
|
||||
}
|
||||
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":
|
||||
@@ -219,6 +274,51 @@ func ValidateNaming(c NamingConfig) error {
|
||||
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 {
|
||||
var b strings.Builder
|
||||
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 }
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user