Files
playlink/internal/engine/naming.go
T
cdnutterandClaude Opus 5.5 428935dc58 Add the step-by-step fixer and library types
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>
2026-09-30 09:10:48 -07:00

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
}