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:
cdnutterandClaude Opus 5.5 committed 2026-09-29 14:58:36 -07:00
1 parent 49fc32096a
commit 5fb195f0d6
12 files changed
+1269 -327

No files matched your search

+16 -9
View File
@@ -83,16 +83,23 @@ tags** buttons. Edits are safe with hardlinks:
- Each edit is listed in History (old → new values) and can be undone; an in-place edit interrupted by a crash is rolled back on
startup. Dry run applies.
**Organize (rename files from tags).** The *Organize* tab sets a naming layout, by default `{artist}/{album}/{track} {title}` →
`Avenged Sevenfold/Nightmare/01 Nightmare.flac` (`{artist}` is the album artist, falling back to the artist). Tokens: `{artist}`,
`{album}`, `{track}`, `{title}`, `{year}`, `{disc}`, `{track_artist}`, `{genre}`. Multi-disc albums get a disc prefix (`2-04 Title`) or
a `Disc 02` folder (two-digit disc numbers by default, optionally `Disc 2`), and names are made safe for Windows/SMB shares (`AC/DC` → `AC-DC`, `Title: Sub` → `Title - Sub`).
**Organize (rename files from tags).** The *Organize* tab sets how files are named, in the style of Lidarr: an **artist folder**, an
**album folder**, a **track format** and a separate **multi-disc track format**, with presets. The default is
`{Artist Name}/{Album Title}/{track:00} {Track Title}`, and multi-disc albums get `{Medium Format} {medium:00}/…` → `Disc 01`, `Disc 02`.
With **Organize automatically** set to the slave, the master or both (off by default), every scan renames files that don't follow the
layout. Files are only moved within their library, so contents and hardlinks never change; lyrics (`.lrc`) move with their song, cover
art follows a whole folder, and emptied folders are removed. Files with missing tags or clashing names are left alone (fix their tags
in the *Tags* tab). Each rename is in History and can be undone, and dry run applies. A slave file that is a hardlink of a master file
at a different path is still recognised as linked.
Tokens: `{Artist Name}`, `{Artist NameThe}` (The Beatles → Beatles, The), `{Artist CleanName}`, `{Artist NameFirstCharacter}`,
`{Album Title}`, `{Album TitleThe}`, `{Album CleanTitle}`, `{Release Year}`, `{track:0}`/`{track:00}`, `{medium:0}`/`{medium:00}`,
`{Medium Format}`, `{Track Title}`, `{Track CleanTitle}`, `{Track ArtistName}`, `{Genre}`. Like Lidarr, `{Artist.Name}` or `{Artist_Name}`
puts dots or underscores between words and `{ARTIST NAME}` / `{artist name}` change the case. The artist is the album artist, falling back
to the artist. Colons can be replaced smartly (`A: B` → `A - B`), with a dash, space dash, space dash space, or deleted; other characters
Windows/SMB rejects are replaced (`AC/DC` → `AC-DC`) or removed. Brackets and separators left empty by a missing value are cleaned up
(`Album ({Release Year})` without a year → `Album`). Examples are rendered by the same code that renames files.
**Rename now…** checks the slave, the master or both, then shows every change grouped by destination folder (old → new, selectable per
file or folder), plus skipped files with the reason and a *Fix tags* button. Files are only moved within their library, so contents and
hardlinks never change; lyrics (`.lrc`) move with their song, cover art follows a whole folder, and emptied folders are removed. Each
rename is in History and can be undone, and the library is rescanned afterwards. **Organize automatically** (off by default) applies the
same renames during every scan. A slave file that is a hardlink of a master file at a different path is still recognised as linked.
**Backups** (Settings → Backups), similar to Sonarr. A backup is a zip of playlink's database (settings, library pairs, scan results and
history, not music files) taken safely while the app runs. Choose a folder (default `/config/backups`), a schedule (off, every 1, 3, 7
+79 -6
View File
@@ -79,6 +79,8 @@ func (s *Server) Handler() http.Handler {
r.Get("/settings", s.handleGetSettings)
r.Put("/settings", s.handlePutSettings)
r.Get("/browse", s.handleBrowse)
r.Get("/naming", s.handleNamingInfo)
r.Post("/naming/examples", s.handleNamingExamples)
if s.cfg.Backups != nil {
s.backupRoutes(r)
}
@@ -101,6 +103,9 @@ func (s *Server) Handler() http.Handler {
r.Get("/files", s.handleFiles)
r.Post("/tags/read", s.handleReadTags)
r.Post("/tags", s.handleEditTags)
r.Post("/organize/preview", s.handleOrganizePreview)
r.Get("/organize/plan", s.handleOrganizePlan)
r.Post("/organize/apply", s.handleOrganizeApply)
r.Get("/renames", s.handleRenamePreview)
r.Post("/renames", s.handleRenames)
r.Post("/link-identical", s.handleLinkIdentical)
@@ -303,13 +308,14 @@ func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) {
type pairView struct {
store.Pair
Naming engine.NamingConfig `json:"naming"`
Summary store.Summary `json:"summary"`
Job *engine.Job `json:"job"`
}
func (s *Server) view(r *http.Request, p store.Pair) pairView {
sum, _ := s.st.Summary(r.Context(), p.ID)
return pairView{Pair: p, Summary: sum, Job: s.eng.Job(p.ID)}
return pairView{Pair: p, Naming: engine.NamingOf(p), Summary: sum, Job: s.eng.Job(p.ID)}
}
func (s *Server) handleListPairs(w http.ResponseWriter, r *http.Request) {
@@ -347,10 +353,9 @@ type pairInput struct {
SettleMinutes int `json:"settle_minutes"`
ApproxMatching bool `json:"approx_matching"`
ApproxThreshold int `json:"approx_threshold"`
OrganizeTemplate string `json:"organize_template"`
OrganizeMultiDisc string `json:"organize_multidisc"`
AutoOrganize string `json:"auto_organize"`
OrganizeDiscDigits int `json:"organize_disc_digits"`
// Naming is optional: when absent, the pair keeps its current naming.
Naming *engine.NamingConfig `json:"naming,omitempty"`
}
func (in pairInput) apply(p *store.Pair) {
@@ -358,8 +363,10 @@ func (in pairInput) apply(p *store.Pair) {
p.Extensions, p.AutoLinkIdentical = in.Extensions, in.AutoLinkIdentical
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes = in.ScanIntervalMinutes, in.RetentionDays, in.SettleMinutes
p.ApproxMatching, p.ApproxThreshold = in.ApproxMatching, in.ApproxThreshold
p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize = in.OrganizeTemplate, in.OrganizeMultiDisc, in.AutoOrganize
p.OrganizeDiscDigits = in.OrganizeDiscDigits
p.AutoOrganize = in.AutoOrganize
if in.Naming != nil {
p.OrganizeConfig, _ = json.Marshal(in.Naming)
}
}
func (s *Server) handleValidatePair(w http.ResponseWriter, r *http.Request) {
@@ -800,3 +807,69 @@ func (s *Server) preAction(w http.ResponseWriter, r *http.Request, pairID int64,
}
return true
}
// ---- organizer ----
func (s *Server) handleNamingInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"defaults": engine.DefaultNaming(), "presets": engine.NamingPresets, "tokens": engine.TokenHelp})
}
// handleNamingExamples renders sample songs with unsaved settings, using the
// same code as real renames.
func (s *Server) handleNamingExamples(w http.ResponseWriter, r *http.Request) {
var c engine.NamingConfig
if !readJSON(w, r, &c) {
return
}
if err := engine.ValidateNaming(c); err != nil {
writeJSON(w, 200, map[string]any{"error": err.Error(), "examples": []engine.NamingExample{}})
return
}
writeJSON(w, 200, map[string]any{"examples": engine.NamingExamples(c)})
}
func (s *Server) handleOrganizePreview(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body struct {
Side string `json:"side"` // slave, master or both
}
if !readJSON(w, r, &body) {
return
}
j, err := s.eng.StartOrganizePreview(id, body.Side)
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 202, j)
}
func (s *Server) handleOrganizePlan(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
plan, items := s.eng.OrganizePreview(id)
if plan == nil {
writeJSON(w, 200, map[string]any{"plan": nil, "items": []any{}})
return
}
writeJSON(w, 200, map[string]any{"plan": plan, "items": items})
}
func (s *Server) handleOrganizeApply(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body struct {
IDs []int `json:"ids"` // empty: every rename in the preview
}
if !readJSON(w, r, &body) {
return
}
if !s.preAction(w, r, id, "rename files") {
return
}
j, err := s.eng.StartOrganizeApply(id, body.IDs)
if err != nil {
opErr(w, err)
return
}
s.log.Info("rename now started", "pair", id, "selected", len(body.IDs))
writeJSON(w, 202, j)
}
+3
View File
@@ -46,6 +46,9 @@ type Engine struct {
beforeAction func(ctx context.Context, reason string) error
tagCache tagCache
plansMu sync.Mutex
plans map[int64]*organizePlan // latest "rename now" preview per pair
jobsMu sync.Mutex
jobs map[int64]*Job
nextID int64
+476
View File
@@ -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
}
+156
View File
@@ -0,0 +1,156 @@
package engine
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/cdnutter/playlink/internal/store"
)
func song(kv ...string) map[string]string {
m := map[string]string{}
for i := 0; i < len(kv); i += 2 {
m[kv[i]] = kv[i+1]
}
return m
}
func TestRenderPath(t *testing.T) {
d := DefaultNaming()
with := func(mod func(*NamingConfig)) NamingConfig { c := DefaultNaming(); mod(&c); return c }
nightmare := song("artist", "Avenged Sevenfold", "album", "Nightmare", "title", "Nightmare", "track", "1", "date", "2010-07-27")
cases := []struct {
name string
c NamingConfig
f map[string]string
ext string
want string
}{
{"default", d, nightmare, ".FLAC", "Avenged Sevenfold/Nightmare/01 Nightmare.flac"},
{"multi-disc disc 1 also gets its folder", d, song("artist", "The Beatles", "album", "White Album", "title", "Back in the USSR", "track", "1", "disc", "1", "disc_total", "2"), ".flac",
"The Beatles/White Album/Disc 01/01 Back in the USSR.flac"},
{"multi-disc disc 2", d, song("artist", "The Beatles", "album", "White Album", "title", "Birthday", "track", "1", "disc", "2/2"), ".flac",
"The Beatles/White Album/Disc 02/01 Birthday.flac"},
{"album artist wins", d, song("artist", "Queen", "album_artist", "Various Artists", "album", "Hits", "title", "Bohemian Rhapsody", "track", "12"), ".mp3",
"Various Artists/Hits/12 Bohemian Rhapsody.mp3"},
{"year with empty brackets removed", with(func(c *NamingConfig) { c.AlbumFolder = "{Album Title} ({Release Year})" }),
song("artist", "A", "album", "B", "title", "S", "track", "3"), ".mp3", "A/B/03 S.mp3"},
{"year present", with(func(c *NamingConfig) { c.AlbumFolder = "{Album Title} ({Release Year})" }), nightmare, ".flac", "Avenged Sevenfold/Nightmare (2010)/01 Nightmare.flac"},
{"no track number", d, song("artist", "A", "album", "B", "title", "Song"), ".mp3", "A/B/Song.mp3"},
{"separator left by missing number", with(func(c *NamingConfig) { c.TrackFormat = "{track:00} - {Track Title}" }), song("artist", "A", "album", "B", "title", "Song"), ".mp3", "A/B/Song.mp3"},
{"three digit tracks", d, song("artist", "A", "album", "B", "title", "S", "track", "105"), ".mp3", "A/B/105 S.mp3"},
{"NameThe and first character", with(func(c *NamingConfig) {
c.ArtistFolder = "{Artist NameFirstCharacter}"
c.AlbumFolder = "{Artist NameThe}"
}),
song("artist", "The Beatles", "album", "X", "title", "S", "track", "1"), ".mp3", "B/Beatles, The/01 S.mp3"},
{"dotted and uppercase tokens", with(func(c *NamingConfig) { c.ArtistFolder = "{Artist.Name}"; c.TrackFormat = "{track:0} {TRACK TITLE}" }), nightmare, ".flac",
"Avenged.Sevenfold/Nightmare/1 NIGHTMARE.flac"},
{"clean name", with(func(c *NamingConfig) { c.ArtistFolder = "{Artist CleanName}" }), song("artist", "AC/DC", "album", "B", "title", "S", "track", "1"), ".mp3", "ACDC/B/01 S.mp3"},
{"illegal characters replaced, smart colon", d, song("artist", "AC/DC", "album", "Who Made Who?", "title", "Hells Bells: Live \"1986\"", "track", "7"), ".mp3",
"AC-DC/Who Made Who/07 Hells Bells - Live '1986'.mp3"},
{"illegal characters removed, colon deleted", with(func(c *NamingConfig) { c.Illegal = "remove"; c.Colon = "delete" }),
song("artist", "AC/DC", "album", "B", "title", "Hells Bells: Live", "track", "7"), ".mp3", "ACDC/B/07 Hells Bells Live.mp3"},
{"colon space-dash-space", with(func(c *NamingConfig) { c.Colon = "space_dash_space" }), song("artist", "A", "album", "B", "title", "Part:Two", "track", "1"), ".mp3", "A/B/01 Part - Two.mp3"},
{"no hidden names", d, song("artist", "...And Justice", "album", "B", "title", "S", "track", "1"), ".mp3", "And Justice/B/01 S.mp3"},
}
for _, c := range cases {
got, missing := renderPath(c.c, c.ext, c.f)
if got != c.want || missing != "" {
t.Errorf("%s: got %q (missing %q), want %q", c.name, got, missing, c.want)
}
}
for field, f := range map[string]map[string]string{
"artist or album artist": song("album", "B", "title", "S"),
"album": song("artist", "A", "title", "S"),
"title": song("artist", "A", "album", "B"),
} {
if _, missing := renderPath(d, ".mp3", f); missing != field {
t.Errorf("want missing %q, got %q", field, missing)
}
}
}
func TestValidateNaming(t *testing.T) {
bad := []func(*NamingConfig){
func(c *NamingConfig) { c.ArtistFolder = "{Artist Name}/{Album Title}" },
func(c *NamingConfig) { c.TrackFormat = "{track:00}" },
func(c *NamingConfig) { c.TrackFormat = "{Bogus} {Track Title}" },
func(c *NamingConfig) { c.MultiDiscFormat = "../{Track Title}" },
func(c *NamingConfig) { c.AlbumFolder = "" },
func(c *NamingConfig) { c.Colon = "weird" },
func(c *NamingConfig) { c.AlbumFolder = "{Album Title" },
}
for i, mod := range bad {
c := DefaultNaming()
mod(&c)
if ValidateNaming(c) == nil {
t.Errorf("case %d should be rejected: %+v", i, c)
}
}
for _, p := range NamingPresets {
if err := ValidateNaming(p.Config); err != nil {
t.Errorf("preset %q invalid: %v", p.Name, err)
}
}
}
func TestNamingOfConvertsOldSettings(t *testing.T) {
c := NamingOf(store.Pair{OrganizeTemplate: "{artist}/{album} ({year})/{track} - {title}", OrganizeMultiDisc: "prefix", OrganizeDiscDigits: 1})
if c.ArtistFolder != "{Artist Name}" || c.AlbumFolder != "{Album Title} ({Release Year})" || c.TrackFormat != "{track:00} - {Track Title}" ||
c.MultiDiscFormat != "{medium:0}-{track:00} - {Track Title}" {
t.Fatalf("old settings not converted: %+v", c)
}
if got := NamingOf(store.Pair{}); got != DefaultNaming() {
t.Fatalf("pairs without settings should use the defaults, got %+v", got)
}
raw, _ := json.Marshal(NamingConfig{TrackFormat: "{track:0}. {Track Title}"})
got := NamingOf(store.Pair{OrganizeConfig: raw})
if got.TrackFormat != "{track:0}. {Track Title}" || got.ArtistFolder != "{Artist Name}" {
t.Fatalf("stored settings should win and gaps take defaults, got %+v", got)
}
ex := NamingExamples(DefaultNaming())
if len(ex) != 4 || ex[1].Path != "The Beatles/The Beatles (White Album)/Disc 01/01 Back in the U.S.S.R.flac" {
t.Fatalf("examples: %+v", ex)
}
}
func TestRenameNowPreviewAndApply(t *testing.T) {
ev := tagEnv(t, false)
mkTagged(t, filepath.Join(ev.slave, "in/a.mp3"), "title", "One", "artist", "Band", "album", "LP", "track", "1")
mkTagged(t, filepath.Join(ev.slave, "in/b.mp3"), "title", "Two", "artist", "Band", "album", "LP", "track", "2")
if _, err := ev.e.StartOrganizePreview(ev.pair.ID, "slave"); err != nil {
t.Fatal(err)
}
ev.wait()
plan, items := ev.e.OrganizePreview(ev.pair.ID)
if plan == nil || plan.Moves != 2 || len(items) != 2 {
t.Fatalf("preview: %+v %+v", plan, items)
}
var first int
for _, it := range items {
if it.From == "in/a.mp3" {
first = it.ID
}
}
if _, err := ev.e.StartOrganizeApply(ev.pair.ID, []int{first}); err != nil {
t.Fatal(err)
}
if j := ev.wait(); j.Results["renamed"] != 1 {
t.Fatalf("only the selected file should be renamed: %+v", j.Results)
}
if _, err := os.Stat(filepath.Join(ev.slave, "Band/LP/01 One.mp3")); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(ev.slave, "in/b.mp3")); err != nil {
t.Fatal("unselected files stay")
}
if p, _ := ev.e.OrganizePreview(ev.pair.ID); p != nil {
t.Fatal("the preview is stale after renaming and must be cleared")
}
if _, err := ev.e.StartOrganizeApply(ev.pair.ID, nil); err == nil {
t.Fatal("applying without a fresh preview must be refused")
}
}
+116 -162
View File
@@ -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
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)
}
p.OrganizeTemplate = strings.TrimSpace(p.OrganizeTemplate)
if err := ValidateTemplate(p.OrganizeTemplate); err != nil {
fill(&c, DefaultNaming())
if err := ValidateNaming(c); 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")
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")
})
}
-41
View File
@@ -10,47 +10,6 @@ import (
"github.com/cdnutter/playlink/internal/store"
)
func TestRenderName(t *testing.T) {
f := func(kv ...string) map[string]string {
m := map[string]string{}
for i := 0; i < len(kv); i += 2 {
m[kv[i]] = kv[i+1]
}
return m
}
cases := []struct {
name, multi, ext string
digits int
fields map[string]string
want, missing string
}{
{"basic", "prefix", ".mp3", 0, f("artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1", "title", "Nightmare"), "Avenged Sevenfold/Nightmare/01 Nightmare.mp3", ""},
{"album artist wins", "prefix", ".flac", 0, f("artist", "Avenged Sevenfold feat. X", "album_artist", "Avenged Sevenfold", "album", "A", "track", "3", "title", "T"), "Avenged Sevenfold/A/03 T.flac", ""},
{"no track number", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "title", "Song"), "A/B/Song.mp3", ""},
{"missing album", "prefix", ".mp3", 0, f("artist", "A", "title", "Song"), "", "album"},
{"missing title", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "track", "1"), "", "title"},
{"multi-disc prefix", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "track", "4", "disc", "2", "disc_total", "2", "title", "S"), "A/B/02-04 S.mp3", ""},
{"multi-disc folder", "folder", ".mp3", 0, f("artist", "A", "album", "B", "track", "4", "disc", "2", "title", "S"), "A/B/Disc 02/04 S.mp3", ""},
{"single disc of one", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "track", "4", "disc", "1", "disc_total", "1", "title", "S"), "A/B/04 S.mp3", ""},
{"unsafe characters", "prefix", ".MP3", 0, f("artist", "AC/DC", "album", "Who Made Who?", "track", "7", "title", "Title: Sub \"Live\"..."), "AC-DC/Who Made Who/07 Title - Sub 'Live'.mp3", ""},
{"no hidden names", "prefix", ".mp3", 0, f("artist", ".hidden", "album", "B", "title", "S"), "hidden/B/S.mp3", ""},
{"unpadded disc prefix", "prefix", ".mp3", 1, f("artist", "A", "album", "B", "track", "4", "disc", "2", "disc_total", "2", "title", "S"), "A/B/2-04 S.mp3", ""},
{"unpadded disc folder", "folder", ".mp3", 1, f("artist", "A", "album", "B", "track", "4", "disc", "2", "title", "S"), "A/B/Disc 2/04 S.mp3", ""},
{"big albums use 3 digits", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "track", "7", "track_total", "120", "title", "S"), "A/B/007 S.mp3", ""},
}
for _, c := range cases {
got, missing := renderName(DefaultOrganizeTemplate, c.multi, c.digits, c.ext, c.fields)
if got != c.want || missing != c.missing {
t.Errorf("%s: got %q (missing %q), want %q (missing %q)", c.name, got, missing, c.want, c.missing)
}
}
for _, bad := range []string{"", "{artist}/{album}", "/{title}", "../{title}", "{artist}/{bogus} {title}", "{artist}//{title}"} {
if ValidateTemplate(bad) == nil {
t.Errorf("template %q should be rejected", bad)
}
}
}
func mkTagged(t *testing.T, path string, kv ...string) {
t.Helper()
os.MkdirAll(filepath.Dir(path), 0o755)
+13 -9
View File
@@ -152,7 +152,8 @@ type Pair struct {
OrganizeTemplate string `json:"organize_template"`
OrganizeMultiDisc string `json:"organize_multidisc"` // prefix, folder or none
AutoOrganize string `json:"auto_organize"` // off, slave, master or both
OrganizeDiscDigits int `json:"organize_disc_digits"` // 2: Disc 01, 1: Disc 1
OrganizeDiscDigits int `json:"-"` // superseded by OrganizeConfig; read to convert old settings
OrganizeConfig json.RawMessage `json:"-"` // naming settings (engine.NamingConfig)
LastScanAt *int64 `json:"last_scan_at"`
LastScanError string `json:"last_scan_error"`
LastScanWarnings []Warning `json:"last_scan_warnings"`
@@ -161,18 +162,18 @@ type Pair struct {
const pairCols = `id, name, master_root, slave_root, quarantine_dir, extensions, auto_link_identical,
scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold, organize_template,
organize_multidisc, auto_organize, organize_disc_digits, last_scan_at, last_scan_error,
organize_multidisc, auto_organize, organize_disc_digits, organize_config, last_scan_at, last_scan_error,
last_scan_warnings, created_at`
type scanner interface{ Scan(...any) error }
func scanPair(r scanner) (Pair, error) {
var p Pair
var exts, warns string
var exts, warns, orgCfg string
var last sql.NullInt64
err := r.Scan(&p.ID, &p.Name, &p.MasterRoot, &p.SlaveRoot, &p.QuarantineDir, &exts, &p.AutoLinkIdentical,
&p.ScanIntervalMinutes, &p.RetentionDays, &p.SettleMinutes, &p.ApproxMatching, &p.ApproxThreshold, &p.OrganizeTemplate,
&p.OrganizeMultiDisc, &p.AutoOrganize, &p.OrganizeDiscDigits, &last, &p.LastScanError,
&p.OrganizeMultiDisc, &p.AutoOrganize, &p.OrganizeDiscDigits, &orgCfg, &last, &p.LastScanError,
&warns, &p.CreatedAt)
if err != nil {
return p, err
@@ -182,6 +183,9 @@ func scanPair(r scanner) (Pair, error) {
}
p.Extensions = strings.Split(exts, ",")
p.LastScanWarnings = decodeWarnings(warns)
if orgCfg != "" {
p.OrganizeConfig = json.RawMessage(orgCfg)
}
return p, nil
}
@@ -215,11 +219,11 @@ func (s *Store) SavePair(ctx context.Context, p *Pair) error {
if p.ID == 0 {
res, err := s.DB.ExecContext(ctx, `INSERT INTO pairs (name, master_root, slave_root, quarantine_dir, extensions,
auto_link_identical, scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold,
organize_template, organize_multidisc, auto_organize, organize_disc_digits, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
organize_template, organize_multidisc, auto_organize, organize_disc_digits, organize_config, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical,
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold,
p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, p.OrganizeDiscDigits, now())
p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, p.OrganizeDiscDigits, string(p.OrganizeConfig), now())
if err != nil {
return err
}
@@ -229,10 +233,10 @@ func (s *Store) SavePair(ctx context.Context, p *Pair) error {
_, err := s.DB.ExecContext(ctx, `UPDATE pairs SET name = ?, master_root = ?, slave_root = ?, quarantine_dir = ?,
extensions = ?, auto_link_identical = ?, scan_interval_minutes = ?, retention_days = ?, settle_minutes = ?,
approx_matching = ?, approx_threshold = ?, organize_template = ?, organize_multidisc = ?, auto_organize = ?,
organize_disc_digits = ? WHERE id = ?`,
organize_disc_digits = ?, organize_config = ? WHERE id = ?`,
p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical,
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold,
p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, p.OrganizeDiscDigits, p.ID)
p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, p.OrganizeDiscDigits, string(p.OrganizeConfig), p.ID)
return err
}
+3
View File
@@ -129,6 +129,9 @@ var migrations = []string{
// 8: zero-padded disc numbers (Disc 01) by default.
`ALTER TABLE pairs ADD COLUMN organize_disc_digits INTEGER NOT NULL DEFAULT 2;`,
// 9: Lidarr-style naming (artist/album folder and track formats) as JSON.
`ALTER TABLE pairs ADD COLUMN organize_config TEXT NOT NULL DEFAULT '';`,
}
// SchemaVersion is the newest database schema this build knows.
+14 -6
View File
@@ -76,10 +76,8 @@ export type Pair = {
settle_minutes: number;
approx_matching: boolean;
approx_threshold: number;
organize_template: string;
organize_multidisc: "prefix" | "folder" | "none";
auto_organize: "off" | "slave" | "master" | "both";
organize_disc_digits: number;
naming: Naming;
last_scan_at: number | null;
last_scan_error: string;
last_scan_warnings: ScanWarning[];
@@ -100,10 +98,7 @@ export type PairInput = Pick<
| "settle_minutes"
| "approx_matching"
| "approx_threshold"
| "organize_template"
| "organize_multidisc"
| "auto_organize"
| "organize_disc_digits"
>;
export type ProbeInfo = {
@@ -245,3 +240,16 @@ export type TagFile = {
export type BackupConfig = { folder: string; interval_days: number; retention_days: number; before_actions: boolean };
export type Backup = { name: string; type: "manual" | "scheduled" | "action"; size: number; created: number };
export type Naming = {
artist_folder: string;
album_folder: string;
track_format: string;
multi_disc_format: string;
colon: "smart" | "delete" | "dash" | "space_dash" | "space_dash_space";
illegal: "replace" | "remove";
};
export type OrganizeItem = { id: number; side: Side; from: string; to?: string; move: boolean; reason?: string; sidecars?: string[] };
export type OrganizePlan = { sides: Side[]; created_at: number; scanned: number; conforming: number; moves: number; skips: Record<string, number> };
+392 -87
View File
@@ -1,70 +1,61 @@
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, FolderTree } from "lucide-react";
import { api, type Pair } from "../api";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowRight, ChevronDown, ChevronRight, Folder, FolderTree, Tags as TagsIcon } from "lucide-react";
import { api, type Job, type Naming, type OrganizeItem, type OrganizePlan, type Pair, type Side } from "../api";
import { useInfo } from "../App";
import { Alert, Button, Card, Field, inputCls } from "../ui";
import { Alert, Badge, Button, Card, Field, Modal, Spinner, cx, inputCls } from "../ui";
import { useToast } from "../toast";
import { TagEditor } from "./Tags";
const TOKENS: [string, string][] = [
["artist", "album artist, or artist"],
["album", "album"],
["track", "track number, 01"],
["title", "title"],
["year", "year"],
["disc", "disc number"],
["track_artist", "the song's artist"],
["genre", "genre"],
type NamingInfo = { defaults: Naming; presets: { name: string; config: Naming }[]; tokens: [string, string][] };
type Scope = Side | "both";
const FORMAT_FIELDS: { key: keyof Naming; label: string; hint: string }[] = [
{ key: "artist_folder", label: "Artist folder", hint: "One folder name." },
{ key: "album_folder", label: "Album folder", hint: "One folder name, inside the artist folder." },
{ key: "track_format", label: "Track", hint: "File name inside the album folder. May add subfolders with /." },
{ key: "multi_disc_format", label: "Multi-disc track", hint: "Used when an album has more than one disc." },
];
const SAMPLE: Record<string, string> = {
artist: "Avenged Sevenfold",
album: "Nightmare",
track: "01",
title: "Nightmare",
year: "2010",
disc: "1",
track_artist: "Avenged Sevenfold",
genre: "Metal",
};
/** Renders the pattern for a sample song; disc 2 shows the multi-disc style. */
function example(tpl: string, multi?: Pair["organize_multidisc"], digits = 2) {
const d = digits === 1 ? "2" : "02";
const values: Record<string, string> = multi
? { ...SAMPLE, track: multi === "prefix" ? `${d}-04` : "04", title: "Victim", disc: d }
: { ...SAMPLE, disc: digits === 1 ? "1" : "01" };
let parts = tpl.split("/").map((part) => part.replace(/\{([a-z_]+)\}/g, (_, k: string) => values[k] ?? `{${k}}`));
if (multi === "folder") parts = [...parts.slice(0, -1), `Disc ${d}`, parts[parts.length - 1]];
return parts.join("/") + ".flac";
}
export function OrganizeTab({ pair }: { pair: Pair }) {
const info = useInfo();
if (info.data && !info.data.tag_editing)
return <Alert tone="amber">Organizing reads tags with ffmpeg and ffprobe, which were not found. The Docker image includes both.</Alert>;
return (
<div className="space-y-5">
<NamingSettings pair={pair} />
<RenameNowCard pair={pair} />
<NamingCard pair={pair} />
</div>
);
}
function NamingSettings({ pair }: { pair: Pair }) {
// ---- naming settings ----
function NamingCard({ pair }: { pair: Pair }) {
const qc = useQueryClient();
const toast = useToast();
const [tpl, setTpl] = useState(pair.organize_template);
const [multi, setMulti] = useState(pair.organize_multidisc);
const info = useQuery({ queryKey: ["naming-info"], queryFn: () => api.get<NamingInfo>("/naming"), staleTime: Infinity });
const [cfg, setCfg] = useState<Naming>(pair.naming);
const [auto, setAuto] = useState(pair.auto_organize);
const [digits, setDigits] = useState(pair.organize_disc_digits || 2);
const [showTokens, setShowTokens] = useState(false);
const focused = useRef<{ key: keyof Naming; el: HTMLInputElement } | null>(null);
const saved = JSON.stringify(pair.naming);
useEffect(() => {
setTpl(pair.organize_template);
setMulti(pair.organize_multidisc);
setCfg(pair.naming);
setAuto(pair.auto_organize);
setDigits(pair.organize_disc_digits || 2);
}, [pair.id, pair.organize_template, pair.organize_multidisc, pair.auto_organize, pair.organize_disc_digits]);
const dirty =
tpl !== pair.organize_template || multi !== pair.organize_multidisc || auto !== pair.auto_organize || digits !== (pair.organize_disc_digits || 2);
}, [pair.id, saved, pair.auto_organize]); // eslint-disable-line react-hooks/exhaustive-deps
const dirty = JSON.stringify(cfg) !== saved || auto !== pair.auto_organize;
// Examples are rendered by the server with the same code as real renames.
const [debounced, setDebounced] = useState(cfg);
useEffect(() => {
const t = setTimeout(() => setDebounced(cfg), 250);
return () => clearTimeout(t);
}, [cfg]);
const examples = useQuery({
queryKey: ["naming-examples", JSON.stringify(debounced)],
queryFn: () => api.post<{ examples: { label: string; path: string }[]; error?: string }>("/naming/examples", debounced),
});
const save = useMutation({
mutationFn: () =>
@@ -80,19 +71,30 @@ function NamingSettings({ pair }: { pair: Pair }) {
settle_minutes: pair.settle_minutes,
approx_matching: pair.approx_matching,
approx_threshold: pair.approx_threshold,
organize_template: tpl,
organize_multidisc: multi,
auto_organize: auto,
organize_disc_digits: digits,
naming: cfg,
}),
onSuccess: () => {
toast("ok", "Naming settings saved. Preview again to see the effect.");
toast("ok", "Naming saved");
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
qc.invalidateQueries({ queryKey: ["pairs"] });
},
onError: (e) => toast("err", e.message),
});
const insert = (token: string) => {
const f = focused.current;
if (!f) return;
const v = cfg[f.key] as string;
const start = f.el.selectionStart ?? v.length;
const end = f.el.selectionEnd ?? v.length;
setCfg({ ...cfg, [f.key]: v.slice(0, start) + token + v.slice(end) });
requestAnimationFrame(() => {
f.el.focus();
f.el.setSelectionRange(start + token.length, start + token.length);
});
};
return (
<Card className="space-y-4 p-5">
<div className="flex items-start justify-between gap-3">
@@ -100,57 +102,104 @@ function NamingSettings({ pair }: { pair: Pair }) {
<h2 className="flex items-center gap-2 font-semibold">
<FolderTree className="size-4 text-accent" /> File naming
</h2>
<p className="text-sm text-muted">
Files that don't follow this layout are renamed from their tags during scans, when <b>Organize automatically</b> is on. Nothing else about
a file changes.
</p>
<p className="text-sm text-muted">How files are named from their tags, in the style of Lidarr. Only the name and folder change.</p>
</div>
<Button variant="primary" size="sm" disabled={!dirty} loading={save.isPending} onClick={() => save.mutate()}>
<Button variant="primary" size="sm" disabled={!dirty || !!examples.data?.error} loading={save.isPending} onClick={() => save.mutate()}>
Save
</Button>
</div>
<Field label="Naming pattern" hint={<>Folders are separated by <code className="mono">/</code>. The file extension is added automatically.</>}>
<input className={inputCls + " mono"} value={tpl} onChange={(e) => setTpl(e.target.value)} spellCheck={false} />
</Field>
<div className="flex flex-wrap gap-1.5">
{TOKENS.map(([t, help]) => (
{info.data && (
<div className="flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-muted">Presets:</span>
{info.data.presets.map((p) => (
<button
key={t}
key={p.name}
type="button"
title={help}
className="rounded-md border border-line bg-panel-2 px-2 py-0.5 text-xs mono text-gray-300 hover:border-accent cursor-pointer"
onClick={() => setTpl((v) => v + `{${t}}`)}
className={cx(
"rounded-md border px-2 py-0.5 mono cursor-pointer",
JSON.stringify(p.config) === JSON.stringify(cfg) ? "border-accent bg-accent/15 text-white" : "border-line bg-panel-2 text-gray-300 hover:border-accent",
)}
onClick={() => setCfg(p.config)}
>
{`{${t}}`}
{p.name}
</button>
))}
</div>
<div className="rounded-lg border border-line bg-bg/60 p-3 text-xs">
<div className="text-muted">Example</div>
<div className="mono break-all text-gray-100">{example(tpl, undefined, digits)}</div>
{multi !== "none" && (
<div className="mono break-all text-gray-400">
{example(tpl, multi, digits)} <span className="font-sans text-muted">(disc 2 of a multi-disc album)</span>
)}
<div className="grid gap-4 md:grid-cols-2">
{FORMAT_FIELDS.map((f) => (
<Field key={f.key} label={f.label} hint={f.hint}>
<input
className={inputCls + " mono text-xs"}
value={cfg[f.key] as string}
spellCheck={false}
onFocus={(e) => (focused.current = { key: f.key, el: e.currentTarget })}
onChange={(e) => setCfg({ ...cfg, [f.key]: e.target.value })}
/>
</Field>
))}
</div>
<div>
<button className="flex items-center gap-1 text-xs text-muted hover:text-white cursor-pointer" onClick={() => setShowTokens(!showTokens)}>
{showTokens ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />} Tokens (click one to insert it into the field you
last clicked)
</button>
{showTokens && info.data && (
<div className="mt-2 grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
{info.data.tokens.map(([tok, ex]) => (
<button
key={tok}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => insert(tok)}
className="flex items-center justify-between gap-2 rounded-md border border-line bg-panel-2 px-2 py-1 text-left text-xs hover:border-accent cursor-pointer"
>
<span className="mono text-gray-200">{tok}</span>
<span className="truncate text-muted">{ex}</span>
</button>
))}
<p className="text-[11px] text-muted sm:col-span-2 lg:col-span-3">
Write a token with dots or underscores to use them between words (<code className="mono">{"{Artist.Name}"}</code> →
Avenged.Sevenfold), or in capitals or lower case to change the case (<code className="mono">{"{ARTIST NAME}"}</code>).
</p>
</div>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Multi-disc albums" hint="Keeps disc 1 and disc 2 tracks from getting the same name.">
<select className={inputCls} value={multi} onChange={(e) => setMulti(e.target.value as Pair["organize_multidisc"])}>
<option value="prefix">Disc number before the track ({digits === 1 ? "2" : "02"}-04 Title)</option>
<option value="folder">A folder per disc (Disc {digits === 1 ? "2" : "02"}/04 Title)</option>
<option value="none">Nothing (clashing names are skipped)</option>
<div className="rounded-lg border border-line bg-bg/60 p-3 text-xs">
<div className="mb-1 text-muted">Examples</div>
{examples.data?.error && <div className="text-red-300">{examples.data.error}</div>}
{!examples.data?.error &&
examples.data?.examples.map((ex) => (
<div key={ex.label} className="grid grid-cols-[11rem_1fr] gap-2 py-0.5">
<span className="text-muted">{ex.label}</span>
<span className="mono break-all text-gray-100">{ex.path}</span>
</div>
))}
</div>
<div className="grid gap-4 md:grid-cols-3">
<Field label="Colons" hint="Colons can't be used in names on Windows/SMB shares.">
<select className={inputCls} value={cfg.colon} onChange={(e) => setCfg({ ...cfg, colon: e.target.value as Naming["colon"] })}>
<option value="smart">Smart: "A: B" → "A - B"</option>
<option value="space_dash_space">Space dash space: "A - B"</option>
<option value="space_dash">Space dash: "A -B"</option>
<option value="dash">Dash: "A-B"</option>
<option value="delete">Delete: "A B"</option>
</select>
</Field>
<Field label="Disc numbers" hint="Also used for the {disc} token.">
<select className={inputCls} value={digits} onChange={(e) => setDigits(Number(e.target.value))}>
<option value={2}>Two digits: Disc 01, 01-04</option>
<option value={1}>As is: Disc 1, 1-04</option>
<Field label="Other illegal characters" hint={'/ \\ | * ? " < >'}>
<select className={inputCls} value={cfg.illegal} onChange={(e) => setCfg({ ...cfg, illegal: e.target.value as Naming["illegal"] })}>
<option value="replace">Replace (AC/DC → AC-DC)</option>
<option value="remove">Remove (AC/DC → ACDC)</option>
</select>
</Field>
<Field label="Organize automatically" hint="Renames run at the start of every scan.">
<Field label="Organize automatically" hint="Renames at the start of every scan.">
<select className={inputCls} value={auto} onChange={(e) => setAuto(e.target.value as Pair["auto_organize"])}>
<option value="off">Off: nothing is renamed</option>
<option value="off">Off: only with Rename now</option>
<option value="slave">Slave library</option>
<option value="master">Master library</option>
<option value="both">Both libraries</option>
@@ -159,11 +208,267 @@ function NamingSettings({ pair }: { pair: Pair }) {
</div>
{auto !== "off" && (
<Alert tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
Files are renamed during each scan without asking. Every rename is listed in History and can be undone, and files with missing tags or
clashing names are left alone (fix their tags in the Tags tab). Lyrics files and cover art move along. Dry run still applies.
{auto !== "both" && " Organizing only one library can make paths differ from the other; hardlinked songs stay paired either way."}
Files are renamed during each scan without asking. Every rename is in History and can be undone; files with missing tags or clashing
names are left alone. Dry run still applies.
</Alert>
)}
</Card>
);
}
// ---- rename now ----
function RenameNowCard({ pair }: { pair: Pair }) {
const [scope, setScope] = useState<Scope>("both");
const [open, setOpen] = useState(false);
const running = pair.job?.state === "running";
return (
<Card className="flex flex-wrap items-center justify-between gap-3 p-5">
<div>
<h2 className="font-semibold">Rename now</h2>
<p className="text-sm text-muted">Check the library against the naming below and review every change before anything is renamed.</p>
</div>
<div className="flex items-center gap-2">
<select className={inputCls + " h-9 w-auto"} value={scope} onChange={(e) => setScope(e.target.value as Scope)}>
<option value="both">Both libraries (recommended)</option>
<option value="slave">Slave library</option>
<option value="master">Master library</option>
</select>
<Button variant="primary" onClick={() => setOpen(true)} disabled={running}>
<FolderTree className="size-4" /> Rename now…
</Button>
</div>
{open && <RenameFilesDialog pair={pair} scope={scope} onClose={() => setOpen(false)} />}
</Card>
);
}
type Stage = { kind: "checking"; job?: Job } | { kind: "ready" } | { kind: "error"; message: string };
function RenameFilesDialog({ pair, scope, onClose }: { pair: Pair; scope: Scope; onClose: () => void }) {
const qc = useQueryClient();
const toast = useToast();
const dry = useInfo().data?.dry_run ?? true;
const [stage, setStage] = useState<Stage>({ kind: "checking" });
const [data, setData] = useState<{ plan: OrganizePlan; items: OrganizeItem[] } | null>(null);
const [view, setView] = useState<"changes" | "skipped">("changes");
const [sel, setSel] = useState<Set<number>>(new Set());
const [editTags, setEditTags] = useState<OrganizeItem | null>(null);
// Start a check and follow its job until it finishes.
const check = async () => {
setStage({ kind: "checking" });
try {
const job = await api.post<Job>(`/pairs/${pair.id}/organize/preview`, { side: scope });
for (;;) {
await new Promise((r) => setTimeout(r, 600));
const j = await api.get<Job | null>(`/pairs/${pair.id}/job`);
if (!j || j.id !== job.id) break;
if (j.state !== "running") {
if (j.state !== "done") throw new Error(j.error || j.state);
break;
}
setStage({ kind: "checking", job: j });
}
const res = await api.get<{ plan: OrganizePlan | null; items: OrganizeItem[] }>(`/pairs/${pair.id}/organize/plan`);
if (!res.plan) throw new Error("The check did not produce a result. Try again.");
setData({ plan: res.plan, items: res.items });
setSel(new Set(res.items.filter((i) => i.move).map((i) => i.id)));
setStage({ kind: "ready" });
} catch (e) {
setStage({ kind: "error", message: (e as Error).message });
}
};
useEffect(() => {
check();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const moves = useMemo(() => (data?.items ?? []).filter((i) => i.move), [data]);
const skipped = useMemo(() => (data?.items ?? []).filter((i) => !i.move), [data]);
// Group the changes by the folder they end up in.
const groups = useMemo(() => {
const m = new Map<string, OrganizeItem[]>();
for (const it of moves) {
const key = `${it.side}\u0000${it.to!.split("/").slice(0, -1).join("/")}`;
m.set(key, [...(m.get(key) ?? []), it]);
}
return [...m.entries()].sort(([a], [b]) => a.localeCompare(b));
}, [moves]);
const both = (data?.plan.sides.length ?? 0) > 1;
const toggle = (ids: number[], on: boolean) =>
setSel((s) => {
const n = new Set(s);
ids.forEach((id) => (on ? n.add(id) : n.delete(id)));
return n;
});
const apply = useMutation({
mutationFn: () => api.post(`/pairs/${pair.id}/organize/apply`, { ids: sel.size === moves.length ? [] : [...sel] }),
onSuccess: () => {
toast("ok", dry ? "Started (dry run): renames are recorded as planned only" : `Renaming ${sel.size} files. The library is rescanned afterwards.`);
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
onClose();
},
onError: (e) => toast("err", e.message),
});
return (
<Modal open onClose={onClose} title="Rename files to match the naming" wide>
{stage.kind === "checking" && (
<div className="space-y-2 py-4">
<div className="flex items-center gap-2 text-sm text-gray-300">
<Spinner className="size-4" /> {stage.job?.phase || "Checking files"}…
</div>
{stage.job && stage.job.total > 0 && (
<div className="h-1.5 overflow-hidden rounded-full bg-line">
<div className="h-full bg-accent transition-all" style={{ width: `${Math.round((stage.job.done / stage.job.total) * 100)}%` }} />
</div>
)}
<p className="text-xs text-muted">Reading each file's tags. Nothing is renamed until you confirm.</p>
</div>
)}
{stage.kind === "error" && (
<div className="space-y-3">
<Alert tone="red">{stage.message}</Alert>
<Button onClick={check}>Try again</Button>
</div>
)}
{stage.kind === "ready" && data && (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-4 gap-2">
<Stat label="checked" value={data.plan.scanned} />
<Stat label="already named right" value={data.plan.conforming} tone="text-emerald-300" />
<Stat label="to rename" value={data.plan.moves} tone="text-accent" />
<Stat label="skipped" value={skipped.length} tone={skipped.length ? "text-amber-300" : undefined} />
</div>
{moves.length === 0 && skipped.length === 0 && <Alert tone="green">Everything already follows the naming.</Alert>}
{(moves.length > 0 || skipped.length > 0) && (
<div className="flex gap-1 border-b border-line">
{(["changes", "skipped"] as const).map((v) => (
<button
key={v}
onClick={() => setView(v)}
className={cx("-mb-px border-b-2 px-3 py-1.5 text-sm cursor-pointer", view === v ? "border-accent text-white" : "border-transparent text-muted hover:text-white")}
>
{v === "changes" ? `Changes (${moves.length})` : `Skipped (${skipped.length})`}
</button>
))}
</div>
)}
{view === "changes" && moves.length > 0 && (
<div className="overflow-hidden rounded-lg border border-line">
<label className="flex items-center gap-3 border-b border-line bg-panel-2 px-3 py-2 text-xs text-muted cursor-pointer">
<input
type="checkbox"
className="accent-violet-500"
checked={sel.size === moves.length}
onChange={(e) => toggle(moves.map((m) => m.id), e.target.checked)}
/>
{sel.size} of {moves.length} selected, in {groups.length} folder{groups.length !== 1 && "s"}
</label>
<div className="max-h-[50vh] overflow-y-auto">
{groups.map(([key, items]) => {
const [side, folder] = key.split("\u0000");
const ids = items.map((i) => i.id);
return (
<div key={key} className="border-b border-line last:border-0">
<label className="flex items-center gap-2 bg-panel-2/50 px-3 py-1.5 text-xs cursor-pointer">
<input
type="checkbox"
className="accent-violet-500"
checked={ids.every((id) => sel.has(id))}
onChange={(e) => toggle(ids, e.target.checked)}
/>
<Folder className="size-3.5 text-accent" />
<span className="mono text-gray-100">{folder}</span>
{both && <Badge tone={side === "master" ? "violet" : "gray"}>{side}</Badge>}
</label>
{items.map((it) => (
<label key={it.id} className={cx("flex items-start gap-2 py-1 pl-9 pr-3 text-xs cursor-pointer", !sel.has(it.id) && "opacity-50")}>
<input type="checkbox" className="mt-0.5 accent-violet-500" checked={sel.has(it.id)} onChange={(e) => toggle([it.id], e.target.checked)} />
<div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-start gap-2 mono">
<span className="break-all text-gray-500">{it.from}</span>
<ArrowRight className="mt-0.5 size-3 text-muted" />
<span className="break-all text-gray-100">
{it.to!.split("/").pop()}
{it.sidecars?.length ? <span className="ml-1 font-sans text-[10px] text-muted">+ lyrics</span> : null}
</span>
</div>
</label>
))}
</div>
);
})}
</div>
</div>
)}
{view === "skipped" && (
<div className="space-y-2">
<ul className="text-xs text-muted">
{Object.entries(data.plan.skips).map(([why, n]) => (
<li key={why}>
<b className="tabular-nums text-gray-300">{n}</b> {why}
</li>
))}
</ul>
<div className="max-h-[45vh] overflow-y-auto rounded-lg border border-line">
{skipped.map((it) => (
<div key={it.id} className="flex items-center gap-2 border-b border-line px-3 py-1.5 text-xs last:border-0">
{both && <Badge tone={it.side === "master" ? "violet" : "gray"}>{it.side}</Badge>}
<div className="min-w-0 flex-1">
<div className="mono break-all text-gray-300">{it.from}</div>
<div className="text-amber-300">{it.reason}</div>
</div>
{it.reason?.startsWith("missing") && (
<Button size="sm" variant="ghost" onClick={() => setEditTags(it)}>
<TagsIcon className="size-3.5" /> Fix tags
</Button>
)}
</div>
))}
</div>
<p className="text-[11px] text-muted">After fixing tags, run Rename now again to include those files.</p>
</div>
)}
{moves.length > 0 && (
<ul className="list-disc space-y-0.5 pl-5 text-xs text-muted">
<li>Files move inside their own library; their contents and hardlinks don't change.</li>
<li>Lyrics files with the same name move along; cover art follows when a whole folder moves. Emptied folders are removed.</li>
<li>
<b className="text-gray-300">Playlists or apps that point at the old paths will need updating.</b>
</li>
{!both && <li>Only the {data.plan.sides[0]} library is renamed; paths may then differ from the other library (hardlinked songs stay paired).</li>}
<li>Each rename is in History and can be undone. The library is rescanned when it finishes.</li>
</ul>
)}
{dry && moves.length > 0 && <Alert tone="blue">Dry run is on, so the renames are recorded as planned in Activity. No files move.</Alert>}
</div>
)}
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
{stage.kind === "ready" && moves.length === 0 ? "Close" : "Cancel"}
</Button>
{stage.kind === "ready" && moves.length > 0 && (
<Button variant="primary" disabled={sel.size === 0} loading={apply.isPending} onClick={() => apply.mutate()}>
<FolderTree className="size-4" /> Rename {sel.size} file{sel.size !== 1 && "s"}
</Button>
)}
</div>
{editTags && <TagEditor pairId={pair.id} side={editTags.side} paths={[editTags.from]} onClose={() => setEditTags(null)} />}
</Modal>
);
}
function Stat({ label, value, tone }: { label: string; value: number; tone?: string }) {
return (
<div className="rounded-lg border border-line bg-panel-2 px-3 py-2">
<div className={cx("text-xl font-semibold tabular-nums", tone)}>{value}</div>
<div className="text-xs text-muted">{label}</div>
</div>
);
}
+1 -7
View File
@@ -25,10 +25,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
settle_minutes: 10,
approx_matching: false,
approx_threshold: 85,
organize_template: "",
organize_multidisc: "prefix",
auto_organize: "off",
organize_disc_digits: 2,
};
const [f, setF] = useState<PairInput>(pair ?? blank);
const [exts, setExts] = useState((pair?.extensions ?? []).join(", "));
@@ -56,11 +53,8 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
settle_minutes: f.settle_minutes,
approx_matching: f.approx_matching,
approx_threshold: f.approx_threshold,
// Edited on the Organize tab; sent unchanged so saving here keeps them.
organize_template: f.organize_template,
organize_multidisc: f.organize_multidisc,
// Edited on the Organize tab; sent unchanged so saving here keeps it.
auto_organize: f.auto_organize,
organize_disc_digits: f.organize_disc_digits,
extensions: exts.split(",").map((s) => s.trim()).filter(Boolean),
});
const set = <K extends keyof PairInput>(k: K, v: PairInput[K]) => {