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 }