package engine import ( "encoding/json" "errors" "fmt" "path/filepath" "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}. 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 } 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", } } // 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"}}, } // 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 := DefaultNaming() if len(p.OrganizeConfig) > 0 && string(p.OrganizeConfig) != "{}" { var stored NamingConfig if json.Unmarshal(p.OrganizeConfig, &stored) == nil { fill(&stored, c) 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 } func fill(c *NamingConfig, d NamingConfig) { if strings.TrimSpace(c.ArtistFolder) == "" { c.ArtistFolder = d.ArtistFolder } 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 { 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 { 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 { 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": 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 } 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. It returns the // missing tag when a required one is empty. func renderPath(c NamingConfig, ext string, f map[string]string) (string, string) { v := valuesOf(f) if v.artist == "" { return "", "artist or album artist" } if v.album == "" { return "", "album" } if v.title == "" { return "", "title" } track := c.TrackFormat if v.multiDisc() { track = c.MultiDiscFormat } var parts []string for _, f := range []string{c.ArtistFolder, c.AlbumFolder, track} { 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 } 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{} for _, s := range samples { p, missing := renderPath(c, s.ext, s.f) if missing != "" { p = "(skipped: missing " + missing + ")" } out = append(out, NamingExample{Label: s.label, Path: p}) } return out }