Files
playlink/internal/engine/fixer_steps.go
T
cdnutterandClaude Opus 5.5 428935dc58 Add the step-by-step fixer and library types
Each pair is a Music (Artist/Album/01 Title) or Soundtracks (Album/01 Title)
library. The new Fixer tab takes the master, then the slave, through four
reviewed steps: folder structure, album tags (optional MusicBrainz lookup;
the slave reuses the fixed master's tags), folder names and file names,
then links the two libraries. Every fix is an undoable operation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:10:48 -07:00

501 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}