Redo file naming in the style of Lidarr and add "Rename now"
Naming: separate artist folder, album folder, track and multi-disc track
formats with presets, Lidarr-style tokens ({Artist Name}, {Artist
NameThe}, {Artist CleanName}, {Artist NameFirstCharacter}, {Album
Title}, {Album TitleThe}, {Album CleanTitle}, {Release Year},
{track:00}, {medium:00}, {Medium Format}, {Track Title}, {Track
CleanTitle}, {Track ArtistName}, {Genre}) with Lidarr's word-separator
and case variants, colon replacement modes and replace/remove for other
illegal characters. Empty brackets and separators left by missing values
are cleaned up. Defaults to Artist/Album/01 Title with Disc 01 folders
for multi-disc albums; earlier pattern/disc settings are converted.
Examples are now rendered by the server with the same code as real
renames, fixing the settings page showing paths that differed from what
would happen (e.g. disc 1 of a multi-disc album outside its Disc folder).
"Rename now..." checks one or both libraries, then lists every change
grouped by destination folder with per-file and per-folder selection,
skipped files with reasons and a Fix tags button, and warnings; applying
re-checks each file, renames, and rescans. The pre-action backup runs
first when enabled. Migration 9 stores the naming as JSON.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
49fc32096a
commit
5fb195f0d6
12 files changed
+1297
-355
No files matched your search
@@ -0,0 +1,476 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user