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>
501 lines
16 KiB
Go
501 lines
16 KiB
Go
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
|
||
}
|