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
+119
-165
@@ -14,8 +14,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
"time"
|
||||
|
||||
"github.com/cdnutter/playlink/internal/fsops"
|
||||
"github.com/cdnutter/playlink/internal/store"
|
||||
@@ -27,61 +26,20 @@ import (
|
||||
// (same inode, so hardlinks survive), never rewrites them, and every move is
|
||||
// an operation that can be undone.
|
||||
|
||||
const DefaultOrganizeTemplate = "{artist}/{album}/{track} {title}"
|
||||
|
||||
var organizeTokens = []string{"artist", "track_artist", "album", "track", "title", "disc", "year", "genre"}
|
||||
|
||||
var tokenRe = regexp.MustCompile(`\{([a-z_]+)\}`)
|
||||
|
||||
// ValidateTemplate checks a naming template.
|
||||
func ValidateTemplate(t string) error {
|
||||
if strings.TrimSpace(t) == "" {
|
||||
return errors.New("the naming pattern is empty")
|
||||
}
|
||||
if !strings.Contains(t, "{title}") {
|
||||
return errors.New("the naming pattern must contain {title}")
|
||||
}
|
||||
if strings.HasPrefix(t, "/") || strings.Contains(t, "..") || strings.Contains(t, "\\") {
|
||||
return errors.New("the naming pattern must be a relative path without .. or backslashes")
|
||||
}
|
||||
known := map[string]bool{}
|
||||
for _, k := range organizeTokens {
|
||||
known[k] = true
|
||||
}
|
||||
for _, m := range tokenRe.FindAllStringSubmatch(t, -1) {
|
||||
if !known[m[1]] {
|
||||
return fmt.Errorf("unknown token {%s}; use %s", m[1], "{"+strings.Join(organizeTokens, "}, {")+"}")
|
||||
}
|
||||
}
|
||||
for _, part := range strings.Split(t, "/") {
|
||||
if strings.TrimSpace(part) == "" {
|
||||
return errors.New("the naming pattern has an empty folder name")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeOrganize validates a pair's organize settings.
|
||||
func normalizeOrganize(p *store.Pair) error {
|
||||
if strings.TrimSpace(p.OrganizeTemplate) == "" {
|
||||
p.OrganizeTemplate = DefaultOrganizeTemplate
|
||||
}
|
||||
p.OrganizeTemplate = strings.TrimSpace(p.OrganizeTemplate)
|
||||
if err := ValidateTemplate(p.OrganizeTemplate); err != nil {
|
||||
return err
|
||||
}
|
||||
switch p.OrganizeMultiDisc {
|
||||
case "":
|
||||
p.OrganizeMultiDisc = "prefix"
|
||||
case "prefix", "folder", "none":
|
||||
default:
|
||||
return errors.New("multi-disc style must be prefix, folder or none")
|
||||
}
|
||||
switch p.OrganizeDiscDigits {
|
||||
case 0:
|
||||
p.OrganizeDiscDigits = 2
|
||||
case 1, 2:
|
||||
default:
|
||||
return errors.New("disc numbers must use 1 or 2 digits")
|
||||
if len(p.OrganizeConfig) > 0 && string(p.OrganizeConfig) != "{}" && string(p.OrganizeConfig) != "null" {
|
||||
var c NamingConfig
|
||||
if err := json.Unmarshal(p.OrganizeConfig, &c); err != nil {
|
||||
return fmt.Errorf("naming settings: %w", err)
|
||||
}
|
||||
fill(&c, DefaultNaming())
|
||||
if err := ValidateNaming(c); err != nil {
|
||||
return err
|
||||
}
|
||||
p.OrganizeConfig, _ = json.Marshal(c)
|
||||
} else {
|
||||
p.OrganizeConfig = nil // derived from older settings or defaults (NamingOf)
|
||||
}
|
||||
switch p.AutoOrganize {
|
||||
case "":
|
||||
@@ -93,38 +51,11 @@ func normalizeOrganize(p *store.Pair) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- name cleanup ----
|
||||
|
||||
var unsafeChars = strings.NewReplacer(
|
||||
"/", "-", "\\", "-", "|", "-", ":", " -", "*", "", "?", "", "\"", "'", "<", "", ">", "",
|
||||
)
|
||||
|
||||
var spaces = regexp.MustCompile(`\s+`)
|
||||
|
||||
// maxComponent keeps names well below the 255-byte filesystem limit.
|
||||
const maxComponent = 150
|
||||
|
||||
// cleanComponent makes a tag value safe as a file or folder name, including
|
||||
// on Windows/SMB shares: path separators and reserved characters are
|
||||
// replaced, control characters dropped, and leading/trailing dots and spaces
|
||||
// removed (a leading dot would hide the file).
|
||||
func cleanComponent(s string) string {
|
||||
s = unsafeChars.Replace(s)
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
s = strings.TrimSpace(spaces.ReplaceAllString(s, " "))
|
||||
s = strings.Trim(s, ". ")
|
||||
for len(s) > maxComponent {
|
||||
_, size := utf8.DecodeLastRuneInString(s)
|
||||
s = strings.TrimRight(s[:len(s)-size], ". ")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func leadingNumber(s string) int {
|
||||
s = strings.TrimSpace(s)
|
||||
end := 0
|
||||
@@ -135,75 +66,6 @@ func leadingNumber(s string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// renderName builds the organized path for a file from its tags. It returns
|
||||
// the missing tag when a required one is empty.
|
||||
func renderName(tpl, multiDisc string, discDigits int, ext string, f map[string]string) (string, string) {
|
||||
if discDigits < 1 {
|
||||
discDigits = 2
|
||||
}
|
||||
discStr := func(d int) string { return fmt.Sprintf("%0*d", discDigits, d) }
|
||||
artist := f[tags.AlbumArtist]
|
||||
if strings.TrimSpace(artist) == "" {
|
||||
artist = f[tags.Artist]
|
||||
}
|
||||
track := leadingNumber(f[tags.Track])
|
||||
total := leadingNumber(f[tags.TrackTotal])
|
||||
disc, discTotal := leadingNumber(f[tags.Disc]), leadingNumber(f[tags.DiscTotal])
|
||||
multi := discTotal > 1 || disc > 1
|
||||
trackStr := ""
|
||||
if track > 0 {
|
||||
width := 2
|
||||
if total >= 100 || track >= 100 {
|
||||
width = 3
|
||||
}
|
||||
trackStr = fmt.Sprintf("%0*d", width, track)
|
||||
if multi && multiDisc == "prefix" && disc > 0 {
|
||||
trackStr = discStr(disc) + "-" + trackStr
|
||||
}
|
||||
}
|
||||
year := ""
|
||||
if d := strings.TrimSpace(f[tags.Date]); len(d) >= 4 && leadingNumber(d[:4]) > 0 {
|
||||
year = d[:4]
|
||||
}
|
||||
values := map[string]string{
|
||||
"artist": artist, "track_artist": f[tags.Artist], "album": f[tags.Album], "track": trackStr,
|
||||
"title": f[tags.Title], "disc": discStr(disc), "year": year, "genre": f[tags.Genre],
|
||||
}
|
||||
if disc == 0 {
|
||||
values["disc"] = ""
|
||||
}
|
||||
optional := map[string]bool{"track": true}
|
||||
parts := strings.Split(tpl, "/")
|
||||
for i, part := range parts {
|
||||
missing := ""
|
||||
out := tokenRe.ReplaceAllStringFunc(part, func(tok string) string {
|
||||
name := tok[1 : len(tok)-1]
|
||||
v := cleanComponent(values[name])
|
||||
if v == "" && !optional[name] && missing == "" {
|
||||
missing = name
|
||||
}
|
||||
return v
|
||||
})
|
||||
if missing != "" {
|
||||
if missing == "artist" {
|
||||
missing = "artist or album artist"
|
||||
}
|
||||
return "", missing
|
||||
}
|
||||
// A missing optional token must not leave "- " or double spaces.
|
||||
out = strings.Trim(spaces.ReplaceAllString(out, " "), " -.")
|
||||
if out == "" {
|
||||
return "", "tags for " + part
|
||||
}
|
||||
parts[i] = out
|
||||
}
|
||||
if multi && multiDisc == "folder" && disc > 0 {
|
||||
last := len(parts) - 1
|
||||
parts = append(parts[:last], "Disc "+discStr(disc), parts[last])
|
||||
}
|
||||
return filepath.Join(parts...) + strings.ToLower(ext), ""
|
||||
}
|
||||
|
||||
// ---- tag cache ----
|
||||
|
||||
type cachedTags struct {
|
||||
@@ -245,12 +107,13 @@ func (c *tagCache) get(ctx context.Context, t tags.Tools, abs string, in fsops.I
|
||||
|
||||
// organizeItem is one file the organizer looked at.
|
||||
type organizeItem struct {
|
||||
Side string
|
||||
From string
|
||||
To string
|
||||
Move bool
|
||||
Reason string // why it is skipped
|
||||
Sidecars []string // e.g. lyrics files moving along
|
||||
ID int `json:"id"`
|
||||
Side string `json:"side"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to,omitempty"`
|
||||
Move bool `json:"move"`
|
||||
Reason string `json:"reason,omitempty"` // why it is skipped
|
||||
Sidecars []string `json:"sidecars,omitempty"` // e.g. lyrics files moving along
|
||||
|
||||
ino uint64
|
||||
size, mtime int64
|
||||
@@ -258,11 +121,13 @@ type organizeItem struct {
|
||||
|
||||
// organizePlan is what organizing would do right now.
|
||||
type organizePlan struct {
|
||||
Scanned int
|
||||
Conforming int
|
||||
Moves int
|
||||
Skips map[string]int
|
||||
Items []organizeItem
|
||||
Sides []string `json:"sides"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Scanned int `json:"scanned"`
|
||||
Conforming int `json:"conforming"`
|
||||
Moves int `json:"moves"`
|
||||
Skips map[string]int `json:"skips"`
|
||||
Items []organizeItem `json:"-"`
|
||||
}
|
||||
|
||||
// sidecarExts move together with the song of the same name.
|
||||
@@ -283,7 +148,9 @@ func sidesOf(s string) []string {
|
||||
}
|
||||
|
||||
func (e *Engine) planOrganize(ctx context.Context, j *Job, l *libs, sides []string) (*organizePlan, error) {
|
||||
plan := &organizePlan{Skips: map[string]int{}}
|
||||
plan := &organizePlan{Sides: sides, CreatedAt: time.Now().Unix(), Skips: map[string]int{}}
|
||||
naming := NamingOf(l.pair)
|
||||
id := 0
|
||||
for _, side := range sides {
|
||||
root, _ := sideRoot(l, side)
|
||||
files := map[string]fsops.Info{}
|
||||
@@ -304,7 +171,7 @@ func (e *Engine) planOrganize(ctx context.Context, j *Job, l *libs, sides []stri
|
||||
item.Reason = "file type without editable tags"
|
||||
} else if fields, err := e.tagCache.get(ctx, e.tagTools, filepath.Join(root.Path, rel), in); err != nil {
|
||||
item.Reason = "tags could not be read"
|
||||
} else if to, missing := renderName(l.pair.OrganizeTemplate, l.pair.OrganizeMultiDisc, l.pair.OrganizeDiscDigits, filepath.Ext(rel), fields); missing != "" {
|
||||
} else if to, missing := renderPath(naming, filepath.Ext(rel), fields); missing != "" {
|
||||
item.Reason = "missing " + missing + " tag"
|
||||
} else if to == rel {
|
||||
plan.Conforming++
|
||||
@@ -323,6 +190,8 @@ func (e *Engine) planOrganize(ctx context.Context, j *Job, l *libs, sides []stri
|
||||
item.Sidecars = sidecarsOf(root, rel)
|
||||
}
|
||||
}
|
||||
id++
|
||||
item.ID = id
|
||||
if item.Move {
|
||||
plan.Moves++
|
||||
} else {
|
||||
@@ -633,3 +502,88 @@ func (e *Engine) autoOrganize(ctx context.Context, j *Job, l *libs) error {
|
||||
e.log.Info("auto-organizing", "pair", l.pair.Name, "files", len(moves))
|
||||
return e.applyOrganize(ctx, j, l, moves, "auto-organize")
|
||||
}
|
||||
|
||||
// ---- rename now (preview, then apply) ----
|
||||
|
||||
// StartOrganizePreview checks which files would be renamed.
|
||||
func (e *Engine) StartOrganizePreview(pairID int64, side string) (*Job, error) {
|
||||
if !e.TagEditing() {
|
||||
return nil, ErrNoTagTools
|
||||
}
|
||||
sides := sidesOf(side)
|
||||
if sides == nil {
|
||||
return nil, fmt.Errorf("%w: side must be slave, master or both", ErrInvalidBulk)
|
||||
}
|
||||
return e.startJob(pairID, "organize-preview", func(ctx context.Context, j *Job) error {
|
||||
l, err := e.loadPair(ctx, pairID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plan, err := e.planOrganize(ctx, j, l, sides)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.plansMu.Lock()
|
||||
if e.plans == nil {
|
||||
e.plans = map[int64]*organizePlan{}
|
||||
}
|
||||
e.plans[pairID] = plan
|
||||
e.plansMu.Unlock()
|
||||
j.update(func(j *Job) {
|
||||
j.Message = fmt.Sprintf("%d to rename, %d already organized, %d skipped", plan.Moves, plan.Conforming, len(plan.Items)-plan.Moves)
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// OrganizePreview returns the latest preview and its items.
|
||||
func (e *Engine) OrganizePreview(pairID int64) (*organizePlan, []organizeItem) {
|
||||
e.plansMu.Lock()
|
||||
defer e.plansMu.Unlock()
|
||||
p := e.plans[pairID]
|
||||
if p == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return p, append([]organizeItem{}, p.Items...)
|
||||
}
|
||||
|
||||
// StartOrganizeApply renames the selected files of the latest preview (all
|
||||
// when ids is empty), then rescans. Each file is checked again first.
|
||||
func (e *Engine) StartOrganizeApply(pairID int64, ids []int) (*Job, error) {
|
||||
e.plansMu.Lock()
|
||||
plan := e.plans[pairID]
|
||||
e.plansMu.Unlock()
|
||||
if plan == nil {
|
||||
return nil, fmt.Errorf("%w: check the files first", ErrInvalidBulk)
|
||||
}
|
||||
want := map[int]bool{}
|
||||
for _, id := range ids {
|
||||
want[id] = true
|
||||
}
|
||||
var items []organizeItem
|
||||
for _, it := range plan.Items {
|
||||
if it.Move && (len(want) == 0 || want[it.ID]) {
|
||||
items = append(items, it)
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("%w: nothing to rename", ErrInvalidBulk)
|
||||
}
|
||||
return e.startJob(pairID, "organize", func(ctx context.Context, j *Job) error {
|
||||
l, err := e.loadPair(ctx, pairID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.applyOrganize(ctx, j, l, items, "organize"); err != nil {
|
||||
return err
|
||||
}
|
||||
e.plansMu.Lock()
|
||||
delete(e.plans, pairID) // paths changed: the preview is stale
|
||||
e.plansMu.Unlock()
|
||||
if e.st.DryRun(ctx) {
|
||||
j.update(func(j *Job) { j.Message = "Dry run: renames recorded as planned in Activity" })
|
||||
return nil
|
||||
}
|
||||
return e.scan(ctx, j, pairID, "organize")
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user