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 }