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>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-30 09:10:48 -07:00
1 parent b11d0806c1
commit 428935dc58
19 files changed
+3503 -23

No files matched your search

+500
View File
@@ -0,0 +1,500 @@
package engine
import (
"context"
"fmt"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/tags"
)
// discNum recognizes disc folders. "Vol. 2" is left out on purpose: volumes
// are usually albums of their own.
var discNum = regexp.MustCompile(`(?i)^(?:disc|disk|cd|dvd|part)[\s._-]*(\d{1,2})(?:\b|$)`)
// junkFolder matches extra folders inside an album that only describe the
// files ("FLAC", "MP3 320", "24bit"), whose songs belong in the album folder.
var junkFolder = regexp.MustCompile(`(?i)^[\[(]?\s*(?:flac|mp3|aac|m4a|alac|ogg|opus|wav|lossless|lossy|hi-?res|web|` +
`(?:mp3\s*)?(?:320|256|192|v0|v2)(?:\s*kbps)?|\d{2}\s*-?\s*bit.*|audio|tracks?|files?|songs?)\s*[\])]?$`)
// folderNoise is release information in folder names: "Nightmare (2010)",
// "Nightmare [FLAC]", "2010 - Nightmare".
var (
folderNoise = regexp.MustCompile(`(?i)\s*[\[({]\s*(?:\d{4}(?:[-.]\d{2}){0,2}|flac|mp3|aac|alac|ogg|opus|web|cd|vinyl|lossless|` +
`deluxe|remaster(?:ed)?|\d{2}\s*-?\s*bit[^\])}]*|\d+(?:\.\d+)?\s*k(?:hz|bps)[^\])}]*|v0|v2|320|\d{2}-\d{2,3})\s*[\])}]\s*$`)
yearPrefix = regexp.MustCompile(`^\s*[\[(]?\d{4}[\])]?\s*[-–.]\s+`)
)
// cleanFolderName turns a folder name into an album or artist name.
func cleanFolderName(s string) string {
for {
t := folderNoise.ReplaceAllString(s, "")
t = yearPrefix.ReplaceAllString(t, "")
t = strings.TrimSpace(t)
if t == s || t == "" {
return s
}
s = t
}
}
// layoutFor returns the formats that apply at rel and the folder they are
// relative to ("" for the library, or a folder with its own naming).
func (c NamingConfig) layoutFor(rel string) (string, formats) {
if rule, ok := c.folderOf(rel); ok {
return rel[:len(rule.Folder)], rule.formats()
}
return "", c.formats()
}
// albumDepth is how many folders deep albums sit: Artist/Album or Album.
func (f formats) albumDepth() int {
if f.artist == "" {
return 1
}
return 2
}
// renderComponent renders a one-folder format from a song's tags.
func renderComponent(c NamingConfig, format string, f map[string]string) string {
v := valuesOf(f)
return finishComponent(tokenPat.ReplaceAllStringFunc(format, func(tok string) string { return renderToken(tok[1:len(tok)-1], v, c) }))
}
// renderAlbumDir is the album folder a song at rel belongs in, from its tags.
func renderAlbumDir(c NamingConfig, rel string, f map[string]string) (string, string) {
flat := c
flat.TrackFormat, flat.MultiDiscFormat = "{Track Title}", "{Track Title}"
flat.Folders = make([]FolderNaming, len(c.Folders))
for i, r := range c.Folders {
r.TrackFormat, r.MultiDiscFormat = "{Track Title}", "{Track Title}"
flat.Folders[i] = r
}
g := map[string]string{}
for k, v := range f {
g[k] = v
}
if strings.TrimSpace(g[tags.Title]) == "" {
g[tags.Title] = "x" // the title doesn't name folders
}
p, why := renderPath(flat, rel, g)
if why != "" {
return "", why
}
return filepath.Dir(p), ""
}
func depthOf(rel string) int {
if rel == "" || rel == "." {
return 0
}
return strings.Count(rel, "/") + 1
}
// majority is the most common non-empty value of a field.
func majority(fields map[string]map[string]string, songs []string, keys ...string) string {
for _, key := range keys {
count := map[string]int{}
best, bestN := "", 0
for _, s := range songs {
v := strings.TrimSpace(fields[s][key])
if v == "" {
continue
}
count[v]++
if count[v] > bestN || (count[v] == bestN && v < best) {
best, bestN = v, count[v]
}
}
if best != "" {
return best
}
}
return ""
}
func similar(a, b string) bool {
ka, kb := matchKey(a), matchKey(b)
return ka != "" && kb != "" && (ka == kb || levenshteinRatio(ka, kb) >= 0.8)
}
// readLibrary lists a library's songs and reads their tags.
func (e *Engine) readLibrary(ctx context.Context, j *Job, l *libs, side string) (fsops.Root, map[string]fsops.Info, map[string]map[string]string, error) {
root, err := sideRoot(l, side)
if err != nil {
return root, nil, nil, err
}
j.phase("Listing "+side+" files", 0)
files := map[string]fsops.Info{}
if _, err := walk(ctx, l, root, files); err != nil {
return root, nil, nil, err
}
j.phase("Reading "+side+" tags", int64(len(files)))
fields := make(map[string]map[string]string, len(files))
for _, rel := range sortedKeys(files) {
if ctx.Err() != nil {
return root, nil, nil, ctx.Err()
}
f := map[string]string{}
if _, ok := tags.FormatOf(rel); ok {
if got, err := e.tagCache.get(ctx, e.tagTools, filepath.Join(root.Path, rel), files[rel]); err == nil && got != nil {
f = got
}
}
fields[rel] = f
j.tick()
}
return root, files, fields, nil
}
func (e *Engine) moveItem(root fsops.Root, side, from, to string, in fsops.Info) *organizeItem {
return &organizeItem{Side: side, From: from, To: to, Move: true, ino: in.Ino, size: in.Size, mtime: in.MtimeNs,
Sidecars: sidecarsOf(root, from)}
}
// ---- step 1: structure ----
func (e *Engine) checkStructure(ctx context.Context, j *Job, l *libs, plan *FixPlan) error {
root, files, fields, err := e.readLibrary(ctx, j, l, plan.Side)
if err != nil {
return err
}
naming := NamingOf(l.pair)
albums := albumsOf(files)
plan.Songs, plan.Albums = len(files), len(albums)
claimed := map[string]string{} // lower(new path) -> old path
claim := func(from, to string) string {
k := strings.ToLower(to)
if c := claimed[k]; c != "" && c != from {
return "another item gets the same name (" + c + ")"
}
claimed[k] = from
return ""
}
// Songs that change tags here, so a move of the same song can re-read it.
type deep struct {
item int
extra string
}
var deeps []deep
for _, a := range albums {
if ctx.Err() != nil {
return ctx.Err()
}
prefix, fm := naming.layoutFor(filepath.Join(a.Dir, "x"))
rest := strings.TrimPrefix(strings.TrimPrefix(a.Dir, prefix), "/")
if rest == "." {
rest = ""
}
want, depth := fm.albumDepth(), depthOf(rest)
target := a.Dir // where the album will be, for filling tags from names
// looseInto moves songs into album folders named from their tags,
// under base (or where the naming puts them when base is "").
looseInto := func(songs []string, base, why string) {
for _, s := range songs {
var dir, blocked string
if base == "" {
dir, blocked = renderAlbumDir(naming, s, fields[s])
} else if strings.TrimSpace(fields[s][tags.Album]) == "" {
blocked = "missing album tag"
} else {
dir = filepath.Join(base, renderComponent(naming, fm.album, fields[s]))
}
it := FixItem{Kind: FixMoveFile, From: s, Why: why, Group: dir}
if blocked != "" {
it.Blocked = blocked + ": set it in the Tags tab, then check again"
it.Group = filepath.Dir(s)
} else {
it.To = filepath.Join(dir, filepath.Base(s))
if targetTaken(root, s, it.To) {
it.Blocked = "a different file already has that name"
} else if c := claim(s, it.To); c != "" {
it.Blocked = c
} else {
it.org = e.moveItem(root, plan.Side, s, it.To, files[s])
}
}
plan.add(it)
}
}
switch {
case depth == 0:
where := "the top of the library"
if prefix != "" {
where = "the top of " + prefix
}
looseInto(a.Songs, "", "Not in an album folder (at "+where+")")
continue
case depth < want:
// Music with one folder: an album without an artist folder, or an
// artist folder with songs directly inside it.
album := majority(fields, a.Songs, tags.Album)
artist := majority(fields, a.Songs, tags.AlbumArtist, tags.Artist)
name := filepath.Base(a.Dir)
isArtist := a.Nested || (artist != "" && similar(artist, cleanFolderName(name)) && !similar(album, cleanFolderName(name)))
if isArtist {
looseInto(a.Songs, a.Dir, "Songs directly in the artist folder, not in an album folder")
continue
}
it := FixItem{Kind: FixMoveFolder, From: a.Dir, Why: "Album folder is not inside an artist folder"}
if artist == "" {
it.Blocked = "its songs have no artist tag: set the album artist (Edit album in the Tags tab), then check again"
it.Group = a.Dir
} else {
f := map[string]string{tags.AlbumArtist: artist}
it.To = filepath.Join(prefix, renderComponent(naming, fm.artist, f), name)
it.Group = it.To
if why := checkDirMove(l, root, it.From, it.To); why != "" {
it.Blocked = why
} else if c := claim(it.From, it.To); c != "" {
it.Blocked = c
}
target = it.To
}
plan.add(it)
case depth > want:
comps := strings.Split(rest, "/")
last := comps[len(comps)-1]
if junkFolder.MatchString(last) && !a.Nested {
parent := filepath.Dir(a.Dir)
why := fmt.Sprintf("Extra folder %q inside the album", last)
for _, s := range a.Songs {
to := filepath.Join(parent, strings.TrimPrefix(s, a.Dir+"/"))
it := FixItem{Kind: FixMoveFile, From: s, To: to, Why: why, Group: parent}
if targetTaken(root, s, to) {
it.Blocked = "a different file already has that name"
} else if c := claim(s, to); c != "" {
it.Blocked = c
} else {
it.org = e.moveItem(root, plan.Side, s, to, files[s])
}
plan.add(it)
}
target = parent
break
}
extra := comps[:len(comps)-want]
to := filepath.Join(prefix, filepath.Join(comps[len(comps)-want:]...))
kind := "Album"
if want == 1 {
kind = "Soundtrack"
}
it := FixItem{Kind: FixMoveFolder, From: a.Dir, To: to, Group: to,
Why: fmt.Sprintf("%s is inside extra folders (%s)", kind, strings.Join(extra, "/"))}
switch {
case a.Nested:
it.Blocked = "other album folders are inside it"
default:
if why := checkDirMove(l, root, it.From, it.To); why != "" {
it.Blocked = why
} else if c := claim(it.From, it.To); c != "" {
it.Blocked = c
}
}
plan.add(it)
deeps = append(deeps, deep{item: len(plan.Items) - 1, extra: filepath.Join(prefix, extra[0])})
target = to
}
// Disc folders and tags filled from folder and file names.
if len(a.Discs) > 0 && a.Loose {
plan.add(FixItem{Kind: FixProblem, Group: a.Dir, From: a.Dir, Why: "Songs are both in the album folder and in disc folders",
Blocked: "move them into the right disc folder by hand"})
}
discTotal := 0
for _, n := range a.Discs {
discTotal = max(discTotal, n)
}
albumName := cleanFolderName(filepath.Base(target))
artistName := ""
if want == 2 && depthOf(strings.TrimPrefix(strings.TrimPrefix(target, prefix), "/")) == 2 {
artistName = cleanFolderName(filepath.Base(filepath.Dir(target)))
}
for _, s := range a.Songs {
f := fields[s]
if _, ok := tags.FormatOf(s); !ok {
continue
}
ch := map[string][2]string{}
var why []string
set := func(k, v string) {
if v != "" && strings.TrimSpace(f[k]) != v {
ch[k] = [2]string{f[k], v}
}
}
if n := a.Discs[filepath.Dir(s)]; n > 0 {
if leadingNumber(f[tags.Disc]) != n {
set(tags.Disc, strconv.Itoa(n))
}
if leadingNumber(f[tags.DiscTotal]) != discTotal {
set(tags.DiscTotal, strconv.Itoa(discTotal))
}
if len(ch) > 0 {
why = append(why, "disc number from its disc folder")
}
}
filled := len(ch)
if strings.TrimSpace(f[tags.Album]) == "" {
set(tags.Album, albumName)
}
if artistName != "" && strings.TrimSpace(f[tags.Artist]) == "" && strings.TrimSpace(f[tags.AlbumArtist]) == "" {
set(tags.Artist, artistName)
}
stem := strings.TrimSuffix(filepath.Base(s), filepath.Ext(s))
if strings.TrimSpace(f[tags.Title]) == "" {
if t := strings.TrimSpace(trackPrefix.ReplaceAllString(stem, "")); t != "" {
set(tags.Title, t)
} else {
set(tags.Title, stem)
}
}
if strings.TrimSpace(f[tags.Track]) == "" {
if m := trackNum.FindStringSubmatch(stem); m != nil {
set(tags.Track, strconv.Itoa(leadingNumber(m[1])))
}
}
if len(ch) > filled {
why = append(why, "missing tags filled from the folder and file names")
}
if len(ch) > 0 {
w := strings.Join(why, "; ")
plan.add(FixItem{Kind: FixTags, From: s, Group: target, Changes: ch, Why: strings.ToUpper(w[:1]) + w[1:]})
}
}
}
// Several albums inside the same extra folder look like a category
// ("Video Games/…", "Rock/…") rather than a mistake: don't select those.
byExtra := map[string]int{}
for _, d := range deeps {
byExtra[d.extra]++
}
var cats []string
for _, d := range deeps {
if byExtra[d.extra] >= 3 {
plan.Items[d.item].Unsure = true
}
}
for x, n := range byExtra {
if n >= 3 {
cats = append(cats, fmt.Sprintf("%s (%d albums)", x, n))
}
}
if len(cats) > 0 {
sort.Strings(cats)
plan.Notices = append(plan.Notices, "Some folders hold several albums one level deeper than expected: "+strings.Join(cats, ", ")+
". If they are categories you want to keep (like Video Games or Movies), add them under Organize → Folders with their own naming "+
"instead of moving the albums out. Those moves are not selected.")
}
return nil
}
// ---- step 3: folder names ----
func (e *Engine) checkFolders(ctx context.Context, j *Job, l *libs, plan *FixPlan) error {
root, files, fields, err := e.readLibrary(ctx, j, l, plan.Side)
if err != nil {
return err
}
naming := NamingOf(l.pair)
albums := albumsOf(files)
plan.Songs, plan.Albums = len(files), len(albums)
claimed := map[string]string{}
for _, a := range albums {
prefix, _ := naming.layoutFor(filepath.Join(a.Dir, "x"))
if a.Dir == "." || a.Dir == prefix {
continue // loose songs: the file step puts them in album folders
}
targets := map[string]string{}
var missing string
untagged := 0
for _, s := range a.Songs {
t, why := renderAlbumDir(naming, s, fields[s])
if why != "" {
missing = why
untagged++
continue
}
targets[strings.ToLower(t)] = t
}
switch {
case len(targets) == 0:
plan.add(FixItem{Kind: FixProblem, From: a.Dir, Group: a.Dir, Why: "Can't name this album folder",
Blocked: missing + " (fix it with Edit album in the Tags tab, then check again)"})
continue
case len(targets) > 1:
var names []string
for _, t := range targets {
names = append(names, t)
}
sort.Strings(names)
plan.add(FixItem{Kind: FixProblem, From: a.Dir, Group: a.Dir,
Why: fmt.Sprintf("Songs belong to %d different album folders (%s)", len(names), strings.Join(names, ", ")),
Blocked: "the file step sorts them into their own folders; or make the album tags agree with Edit album"})
continue
}
var to string
for _, t := range targets {
to = t
}
if to == a.Dir {
continue
}
why := "Renamed from its tags"
if filepath.Dir(to) != filepath.Dir(a.Dir) {
why = "Moved to the folder its tags name"
}
if untagged > 0 {
why += fmt.Sprintf(" (%d songs without tags move along)", untagged)
}
it := FixItem{Kind: FixMoveFolder, From: a.Dir, To: to, Group: to, Why: why}
switch {
case a.Nested:
it.Blocked = "other album folders are inside it"
default:
if b := checkDirMove(l, root, a.Dir, to); b != "" {
it.Blocked = b
if strings.Contains(b, "already exists") {
it.Blocked = "that folder already exists; its songs are moved into it in the file step"
}
} else if c := claimed[strings.ToLower(to)]; c != "" {
it.Blocked = "another folder gets the same name (" + c + ")"
} else {
claimed[strings.ToLower(to)] = a.Dir
}
}
plan.add(it)
}
return nil
}
// ---- step 4: file names ----
func (e *Engine) checkFiles(ctx context.Context, j *Job, l *libs, plan *FixPlan) error {
op, err := e.planOrganize(ctx, j, l, []string{plan.Side})
if err != nil {
return err
}
plan.Songs = op.Scanned
for _, it := range op.Items {
fi := FixItem{Kind: FixMoveFile, From: it.From, To: it.To, Why: "Renamed from its tags"}
if it.Move {
org := it
fi.org = &org
fi.Group = filepath.Dir(it.To)
if filepath.Dir(it.To) != filepath.Dir(it.From) {
fi.Why = "Moved and renamed from its tags"
}
} else {
fi.Kind, fi.Blocked, fi.Why, fi.Group = FixProblem, it.Reason, "Can't be renamed", filepath.Dir(it.From)
}
plan.add(fi)
}
return nil
}