Each pair is a Music (Artist/Album/01 Title) or Soundtracks (Album/01 Title) library. The new Fixer tab takes the master, then the slave, through four reviewed steps: folder structure, album tags (optional MusicBrainz lookup; the slave reuses the fixed master's tags), folder names and file names, then links the two libraries. Every fix is an undoable operation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
644 lines
21 KiB
Go
644 lines
21 KiB
Go
package engine
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"reflect"
|
|
"regexp"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/cdnutter/playlink/internal/store"
|
|
"github.com/cdnutter/playlink/internal/tags"
|
|
)
|
|
|
|
// Naming follows Lidarr's model: an artist folder, an album folder and a
|
|
// 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"`
|
|
TrackFormat string `json:"track_format"`
|
|
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 {
|
|
return NamingConfig{
|
|
ArtistFolder: "{Artist Name}",
|
|
AlbumFolder: "{Album Title}",
|
|
TrackFormat: "{track:00} {Track Title}",
|
|
MultiDiscFormat: "{Medium Format} {medium:00}/{track:00} {Track Title}",
|
|
Colon: "smart",
|
|
Illegal: "replace",
|
|
Folders: []FolderNaming{},
|
|
}
|
|
}
|
|
|
|
// Library types. Music is organized Artist/Album/01 Title; soundtracks (games,
|
|
// films) Album/01 Title, since their "artist" is often a composer or empty.
|
|
const (
|
|
LibraryMusic = "music"
|
|
LibrarySoundtracks = "soundtracks"
|
|
)
|
|
|
|
// DefaultNamingFor returns the recommended naming for a library type.
|
|
func DefaultNamingFor(libraryType string) NamingConfig {
|
|
c := DefaultNaming()
|
|
if libraryType == LibrarySoundtracks {
|
|
c.ArtistFolder = ""
|
|
}
|
|
return c
|
|
}
|
|
|
|
// 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"`
|
|
Config NamingConfig `json:"config"`
|
|
}{
|
|
{"Artist/Album/01 Title", DefaultNaming()},
|
|
{"Artist/Album (Year)/01 - Title", NamingConfig{ArtistFolder: "{Artist Name}", AlbumFolder: "{Album Title} ({Release Year})",
|
|
TrackFormat: "{track:00} - {Track Title}", MultiDiscFormat: "{Medium Format} {medium:00}/{track:00} - {Track Title}", Colon: "smart", Illegal: "replace"}},
|
|
{"Artist/Album/Artist - Album - 01 - Title", NamingConfig{ArtistFolder: "{Artist Name}", AlbumFolder: "{Album Title}",
|
|
TrackFormat: "{Artist Name} - {Album Title} - {track:00} - {Track Title}",
|
|
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
|
|
// versions (one path pattern plus a disc style) when none is stored yet.
|
|
func NamingOf(p store.Pair) NamingConfig {
|
|
c := DefaultNamingFor(p.LibraryType)
|
|
if len(p.OrganizeConfig) > 0 && string(p.OrganizeConfig) != "{}" {
|
|
if stored, err := parseNaming(p.OrganizeConfig); err == nil {
|
|
return stored
|
|
}
|
|
}
|
|
old := map[string]string{"{artist}": "{Artist Name}", "{album}": "{Album Title}", "{track}": "{track:00}", "{title}": "{Track Title}",
|
|
"{year}": "{Release Year}", "{track_artist}": "{Track ArtistName}", "{genre}": "{Genre}"}
|
|
medium := "{medium:00}"
|
|
if p.OrganizeDiscDigits == 1 {
|
|
medium = "{medium:0}"
|
|
}
|
|
old["{disc}"] = medium
|
|
if parts := strings.Split(p.OrganizeTemplate, "/"); len(parts) == 3 {
|
|
conv := func(s string) string {
|
|
for k, v := range old {
|
|
s = strings.ReplaceAll(s, k, v)
|
|
}
|
|
return s
|
|
}
|
|
c.ArtistFolder, c.AlbumFolder, c.TrackFormat = conv(parts[0]), conv(parts[1]), conv(parts[2])
|
|
}
|
|
switch p.OrganizeMultiDisc {
|
|
case "prefix":
|
|
c.MultiDiscFormat = strings.Replace(c.TrackFormat, "{track:00}", medium+"-{track:00}", 1)
|
|
case "none":
|
|
c.MultiDiscFormat = c.TrackFormat
|
|
default:
|
|
c.MultiDiscFormat = "{Medium Format} " + medium + "/" + c.TrackFormat
|
|
}
|
|
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() // missing keys: only very old or hand-made settings
|
|
if err := json.Unmarshal(raw, &c); err != nil {
|
|
return c, err
|
|
}
|
|
fill(&c, DefaultNaming())
|
|
return c, nil
|
|
}
|
|
|
|
// ChangeLibraryType switches a pair's naming to the new type's recommended
|
|
// naming when it still uses the old type's, so custom naming is kept.
|
|
func ChangeLibraryType(old store.Pair, p *store.Pair) {
|
|
if old.ID == 0 || old.LibraryType == p.LibraryType {
|
|
return
|
|
}
|
|
if reflect.DeepEqual(NamingOf(*p), DefaultNamingFor(old.LibraryType)) {
|
|
p.OrganizeConfig = nil
|
|
}
|
|
}
|
|
|
|
func fill(c *NamingConfig, d NamingConfig) {
|
|
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
|
|
}
|
|
if strings.TrimSpace(c.TrackFormat) == "" {
|
|
c.TrackFormat = d.TrackFormat
|
|
}
|
|
if strings.TrimSpace(c.MultiDiscFormat) == "" {
|
|
c.MultiDiscFormat = d.MultiDiscFormat
|
|
}
|
|
if c.Colon == "" {
|
|
c.Colon = d.Colon
|
|
}
|
|
if c.Illegal == "" {
|
|
c.Illegal = d.Illegal
|
|
}
|
|
}
|
|
|
|
// ---- tokens ----
|
|
|
|
var (
|
|
tokenPat = regexp.MustCompile(`\{([^{}]+)\}`)
|
|
numberTok = regexp.MustCompile(`(?i)^(track|medium):(0+)$`)
|
|
)
|
|
|
|
// TokenHelp documents the tokens (with example values) for the UI.
|
|
var TokenHelp = [][2]string{
|
|
{"{Artist Name}", "Avenged Sevenfold"}, {"{Artist NameThe}", "Beatles, The"}, {"{Artist CleanName}", "ACDC"},
|
|
{"{Artist NameFirstCharacter}", "A"}, {"{Album Title}", "Nightmare"}, {"{Album TitleThe}", "White Album, The"},
|
|
{"{Album CleanTitle}", "Nightmare"}, {"{Release Year}", "2010"}, {"{track:00}", "01"}, {"{track:0}", "1"},
|
|
{"{medium:00}", "01"}, {"{medium:0}", "1"}, {"{Medium Format}", "Disc"}, {"{Track Title}", "Nightmare"},
|
|
{"{Track CleanTitle}", "Nightmare"}, {"{Track ArtistName}", "Avenged Sevenfold"}, {"{Genre}", "Metal"},
|
|
}
|
|
|
|
var knownTokens = map[string]bool{
|
|
"artistname": true, "artistnamethe": true, "artistcleanname": true, "artistnamefirstcharacter": true,
|
|
"albumtitle": true, "albumtitlethe": true, "albumcleantitle": true, "releaseyear": true,
|
|
"tracktitle": true, "trackcleantitle": true, "trackartistname": true, "mediumformat": true, "genre": true,
|
|
}
|
|
|
|
// tokenKey normalizes "Artist Name", "Artist.Name" and "ARTIST_NAME".
|
|
func tokenKey(t string) string {
|
|
var b strings.Builder
|
|
for _, r := range strings.ToLower(t) {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// styleOf returns the word separator and case a token is written in.
|
|
func styleOf(t string) (sep string, cased string) {
|
|
sep = " "
|
|
switch {
|
|
case strings.Contains(t, "."):
|
|
sep = "."
|
|
case strings.Contains(t, "_"):
|
|
sep = "_"
|
|
}
|
|
letters := strings.Map(func(r rune) rune {
|
|
if unicode.IsLetter(r) {
|
|
return r
|
|
}
|
|
return -1
|
|
}, t)
|
|
switch {
|
|
case letters != "" && letters == strings.ToUpper(letters):
|
|
cased = "upper"
|
|
case letters != "" && letters == strings.ToLower(letters):
|
|
cased = "lower"
|
|
}
|
|
return
|
|
}
|
|
|
|
// ValidateNaming checks the formats.
|
|
func ValidateNaming(c NamingConfig) 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)
|
|
}
|
|
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("|")
|
|
for _, m := range tokenPat.FindAllStringSubmatch(f, -1) {
|
|
b.WriteString(tokenKey(m[1]) + "|")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// ---- value transforms ----
|
|
|
|
var articles = []string{"The ", "A ", "An "}
|
|
|
|
// nameThe moves a leading article to the end: "The Beatles" -> "Beatles, The".
|
|
func nameThe(s string) string {
|
|
for _, a := range articles {
|
|
if len(s) > len(a) && strings.EqualFold(s[:len(a)], a) {
|
|
return strings.TrimSpace(s[len(a):]) + ", " + strings.TrimSpace(s[:len(a)])
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// cleanName keeps letters, digits and spaces: "AC/DC" -> "ACDC".
|
|
func cleanName(s string) string {
|
|
s = strings.Map(func(r rune) rune {
|
|
switch {
|
|
case unicode.IsLetter(r), unicode.IsDigit(r), r == ' ':
|
|
return r
|
|
case r == '&':
|
|
return r
|
|
}
|
|
return -1
|
|
}, s)
|
|
return strings.TrimSpace(spaces.ReplaceAllString(s, " "))
|
|
}
|
|
|
|
func firstChar(s string) string {
|
|
for _, r := range nameThe(s) {
|
|
if unicode.IsLetter(r) {
|
|
return string(unicode.ToUpper(r))
|
|
}
|
|
if unicode.IsDigit(r) {
|
|
return "#"
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func colonReplace(s, mode string) string {
|
|
switch mode {
|
|
case "delete":
|
|
return strings.ReplaceAll(s, ":", "")
|
|
case "dash":
|
|
return strings.ReplaceAll(s, ":", "-")
|
|
case "space_dash":
|
|
return strings.ReplaceAll(s, ":", " -")
|
|
case "space_dash_space":
|
|
return strings.ReplaceAll(strings.ReplaceAll(s, " :", ":"), ":", " - ")
|
|
}
|
|
// smart: "Title: Sub" -> "Title - Sub", "10:30" -> "10-30"
|
|
s = strings.ReplaceAll(s, ": ", " - ")
|
|
return strings.ReplaceAll(s, ":", "-")
|
|
}
|
|
|
|
var (
|
|
illegalReplace = strings.NewReplacer("/", "-", "\\", "-", "|", "-", "*", "-", "?", "", "\"", "'", "<", "", ">", "")
|
|
illegalRemove = strings.NewReplacer("/", "", "\\", "", "|", "", "*", "", "?", "", "\"", "", "<", "", ">", "")
|
|
)
|
|
|
|
// safeValue makes a tag value safe inside a file or folder name.
|
|
func safeValue(s string, c NamingConfig) string {
|
|
s = colonReplace(s, c.Colon)
|
|
if c.Illegal == "remove" {
|
|
s = illegalRemove.Replace(s)
|
|
} else {
|
|
s = illegalReplace.Replace(s)
|
|
}
|
|
return strings.Map(func(r rune) rune {
|
|
if unicode.IsControl(r) {
|
|
return -1
|
|
}
|
|
return r
|
|
}, s)
|
|
}
|
|
|
|
var emptyBrackets = regexp.MustCompile(`\(\s*\)|\[\s*\]|\{\s*\}`)
|
|
|
|
// finishComponent tidies a rendered name: empty brackets and separators left
|
|
// by missing optional values are removed, and leading dots (hidden files)
|
|
// and trailing dots/spaces (invalid on Windows) are trimmed.
|
|
func finishComponent(s string) string {
|
|
s = emptyBrackets.ReplaceAllString(s, "")
|
|
s = spaces.ReplaceAllString(s, " ")
|
|
for _, dup := range []string{" - - ", " - - "} {
|
|
for strings.Contains(s, dup) {
|
|
s = strings.ReplaceAll(s, dup, " - ")
|
|
}
|
|
}
|
|
s = strings.Trim(s, " -._")
|
|
s = strings.TrimLeft(s, ".")
|
|
for len(s) > maxComponent {
|
|
_, size := utf8.DecodeLastRuneInString(s)
|
|
s = strings.TrimRight(s[:len(s)-size], ". ")
|
|
}
|
|
return s
|
|
}
|
|
|
|
// songValues are the raw values tokens draw from.
|
|
type songValues struct {
|
|
artist, trackArtist, album, title, year, genre string
|
|
track, disc, discTotal int
|
|
}
|
|
|
|
func valuesOf(f map[string]string) songValues {
|
|
v := songValues{
|
|
artist: strings.TrimSpace(f[tags.AlbumArtist]), trackArtist: strings.TrimSpace(f[tags.Artist]),
|
|
album: strings.TrimSpace(f[tags.Album]), title: strings.TrimSpace(f[tags.Title]), genre: strings.TrimSpace(f[tags.Genre]),
|
|
track: leadingNumber(f[tags.Track]), disc: leadingNumber(f[tags.Disc]), discTotal: leadingNumber(f[tags.DiscTotal]),
|
|
}
|
|
if v.artist == "" {
|
|
v.artist = v.trackArtist
|
|
}
|
|
if d := strings.TrimSpace(f[tags.Date]); len(d) >= 4 && leadingNumber(d[:4]) > 0 {
|
|
v.year = d[:4]
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (v songValues) multiDisc() bool { return v.discTotal > 1 || v.disc > 1 }
|
|
|
|
// 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)
|
|
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 "", "missing album tag"
|
|
}
|
|
if v.title == "" {
|
|
return "", "missing title tag"
|
|
}
|
|
var parts []string
|
|
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 "", "missing tags for " + part
|
|
}
|
|
parts = append(parts, out)
|
|
}
|
|
}
|
|
return filepath.Join(parts...) + strings.ToLower(ext), ""
|
|
}
|
|
|
|
func renderToken(t string, v songValues, c NamingConfig) string {
|
|
if m := numberTok.FindStringSubmatch(t); m != nil {
|
|
n := v.track
|
|
if strings.EqualFold(m[1], "medium") {
|
|
n = v.disc
|
|
if n == 0 {
|
|
n = 1
|
|
}
|
|
}
|
|
if n <= 0 {
|
|
return ""
|
|
}
|
|
width := len(m[2])
|
|
if strings.EqualFold(m[1], "track") && n >= 100 && width < 3 {
|
|
width = 3
|
|
}
|
|
return fmt.Sprintf("%0*d", width, n)
|
|
}
|
|
var val string
|
|
switch tokenKey(t) {
|
|
case "artistname":
|
|
val = v.artist
|
|
case "artistnamethe":
|
|
val = nameThe(v.artist)
|
|
case "artistcleanname":
|
|
val = cleanName(v.artist)
|
|
case "artistnamefirstcharacter":
|
|
val = firstChar(v.artist)
|
|
case "albumtitle":
|
|
val = v.album
|
|
case "albumtitlethe":
|
|
val = nameThe(v.album)
|
|
case "albumcleantitle":
|
|
val = cleanName(v.album)
|
|
case "releaseyear":
|
|
val = v.year
|
|
case "tracktitle":
|
|
val = v.title
|
|
case "trackcleantitle":
|
|
val = cleanName(v.title)
|
|
case "trackartistname":
|
|
val = v.trackArtist
|
|
case "mediumformat":
|
|
val = "Disc"
|
|
case "genre":
|
|
val = v.genre
|
|
}
|
|
val = safeValue(val, c)
|
|
sep, cased := styleOf(t)
|
|
if sep != " " {
|
|
val = strings.ReplaceAll(val, " ", sep)
|
|
}
|
|
switch cased {
|
|
case "upper":
|
|
val = strings.ToUpper(val)
|
|
case "lower":
|
|
val = strings.ToLower(val)
|
|
}
|
|
return val
|
|
}
|
|
|
|
// NamingExample is one sample rendering for the settings page.
|
|
type NamingExample struct {
|
|
Label string `json:"label"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
// NamingExamples renders sample songs with a naming config, using exactly
|
|
// the code that renames real files.
|
|
func NamingExamples(c NamingConfig) []NamingExample {
|
|
samples := []struct {
|
|
label, ext string
|
|
f map[string]string
|
|
}{
|
|
{"Album track", ".flac", map[string]string{tags.Artist: "Avenged Sevenfold", tags.Album: "Nightmare", tags.Title: "Nightmare",
|
|
tags.Track: "1", tags.Date: "2010", tags.Genre: "Metal"}},
|
|
{"Multi-disc album, disc 1", ".flac", map[string]string{tags.Artist: "The Beatles", tags.Album: "The Beatles (White Album)",
|
|
tags.Title: "Back in the U.S.S.R.", tags.Track: "1", tags.Disc: "1", tags.DiscTotal: "2", tags.Date: "1968"}},
|
|
{"Multi-disc album, disc 2", ".flac", map[string]string{tags.Artist: "The Beatles", tags.Album: "The Beatles (White Album)",
|
|
tags.Title: "Birthday", tags.Track: "1", tags.Disc: "2", tags.DiscTotal: "2", tags.Date: "1968"}},
|
|
{"Colon and slash in names", ".mp3", map[string]string{tags.Artist: "AC/DC", tags.Album: "Who Made Who", tags.Title: "Hells Bells: Live",
|
|
tags.Track: "7", tags.Date: "1986"}},
|
|
}
|
|
out := []NamingExample{}
|
|
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: 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
|
|
}
|