Files
cdnutterandClaude Opus 5.5 42161e4287 Add renaming slave folders to the master's spelling
The "N folders are named differently" warning now offers to rename the
slave folders to match the master, marked as the recommended first step
after a scan (also linked from the dashboard card).

- A preview lists each rename with the differing characters highlighted;
  renames are selectable, and conflicts (the slave already has a folder
  with the master's name, symlinks, the quarantine) are skipped with a
  reason.
- Only renames the server computes itself are applied; a request can
  only narrow them down, never name arbitrary paths. Only slave folders
  are renamed.
- Case-only renames go through a temporary name so they also work on
  case-insensitive filesystems; a rename interrupted by a crash is
  completed on startup (the folder is never removed).
- Renaming everything repeats until folders revealed inside renamed ones
  (an album under a renamed artist) are fixed too, then rescans.
- Each rename is an operation in History that can be undone (which also
  rescans). Dry run records renames as planned.

Also fixes the Review list not refreshing after a job that started and
finished between two status polls, and logs the new path of operations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:20:52 -07:00

173 lines
5.6 KiB
Go

package engine
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/cdnutter/playlink/internal/store"
)
// Layout warnings flag real layout problems only. Content that exists in just
// one library is normal (a singles library naturally has artists and songs
// the albums library lacks), so it is never counted against the layout.
var accentFold = strings.NewReplacer(
"á", "a", "à", "a", "â", "a", "ä", "a", "ã", "a", "å", "a", "é", "e", "è", "e", "ê", "e", "ë", "e",
"í", "i", "ì", "i", "î", "i", "ï", "i", "ó", "o", "ò", "o", "ô", "o", "ö", "o", "õ", "o", "ø", "o",
"ú", "u", "ù", "u", "û", "u", "ü", "u", "ñ", "n", "ç", "c", "ý", "y", "ÿ", "y", "œ", "oe", "æ", "ae", "ß", "ss",
)
// folderKey identifies a folder name regardless of case, punctuation, accents,
// "&" vs "and" and a leading/trailing "The": "AC/DC", "AC-DC" and "ACDC", or
// "The Beatles" and "Beatles, The", get the same key.
func folderKey(name string) string {
s := accentFold.Replace(strings.ToLower(strings.TrimSpace(name)))
s = strings.ReplaceAll(s, "&", " and ")
s = strings.TrimPrefix(s, "the ")
s = strings.TrimSuffix(s, ", the")
var b strings.Builder
for _, w := range words(s) {
b.WriteString(w)
}
return b.String()
}
// pathKey applies folderKey to every component of a relative folder path.
func pathKey(dir string) string {
parts := strings.Split(dir, string(filepath.Separator))
for i, p := range parts {
parts[i] = folderKey(p)
}
return strings.Join(parts, "/")
}
func subdirs(dir string) []string {
ents, _ := os.ReadDir(dir)
var out []string
for _, en := range ents {
if en.IsDir() && !strings.HasPrefix(en.Name(), ".") {
out = append(out, en.Name())
}
}
return out
}
// renamedFolders finds slave folders that are the same folder as a master
// folder under a different name. It checks top-level folders and, for
// top-level folders both libraries share, the folders inside them.
func renamedFolders(l *libs) [][2]string {
var out [][2]string
compare := func(prefix string, master, slave []string) {
exact := map[string]bool{}
byKey := map[string]string{}
for _, m := range master {
exact[m] = true
if k := folderKey(m); k != "" {
byKey[k] = m
}
}
for _, s := range slave {
if exact[s] {
continue
}
if m, ok := byKey[folderKey(s)]; ok {
out = append(out, [2]string{filepath.Join(prefix, s), filepath.Join(prefix, m)})
}
}
}
mTop, sTop := subdirs(l.master.Path), subdirs(l.slave.Path)
compare("", mTop, sTop)
shared := map[string]bool{}
for _, m := range mTop {
shared[m] = true
}
for _, s := range sTop {
if shared[s] {
compare(s, subdirs(filepath.Join(l.master.Path, s)), subdirs(filepath.Join(l.slave.Path, s)))
}
}
sort.Slice(out, func(i, j int) bool { return out[i][0] < out[j][0] })
return out
}
// maxWarningItems caps how many rows a warning carries.
const maxWarningItems = 200
func renamedWarning(pairs [][2]string) *store.Warning {
if len(pairs) == 0 {
return nil
}
w := &store.Warning{Code: store.WarnRenamedFolders, Total: len(pairs),
Detail: "Songs inside these folders can only match by path once the names agree. Renaming the slave folders to the master's spelling is the recommended first step after a scan."}
if len(pairs) == 1 {
w.Title = "1 folder is named differently in the two libraries"
} else {
w.Title = fmt.Sprintf("%d folders are named differently in the two libraries", len(pairs))
}
for _, p := range pairs[:min(maxWarningItems, len(pairs))] {
w.Items = append(w.Items, store.WarningItem{Slave: p[0], Master: p[1]})
}
return w
}
// layoutStats watches, during a scan, where songs found in both libraries
// live. Songs only in the slave library are ignored.
type layoutStats struct {
shared int // slave files with a master counterpart
moved int // ...that live in a different folder in the master
pairs map[[2]string]int // (slave album folder, master album folder) -> songs
}
func (ls *layoutStats) add(relSlave, relMaster, kind, reason string) {
if kind == store.KindNone || relMaster == "" {
return
}
ls.shared++
if kind != store.KindElsewhere && kind != store.KindApprox {
return
}
if albumKey(relSlave) == albumKey(relMaster) {
return // only a disc folder differs
}
if pathKey(AlbumFolder(relSlave)) == pathKey(AlbumFolder(relMaster)) {
return // same folder under another name: the renamed-folders warning covers it
}
// A single, EP or compilation matched to its album version is the
// intended behaviour, not a layout problem.
if st := parseTitle(relSlave); st.single || st.compilation || strings.Contains(reason, "album version preferred") {
return
}
ls.moved++
if ls.pairs == nil {
ls.pairs = map[[2]string]int{}
}
ls.pairs[[2]string{AlbumFolder(relSlave), AlbumFolder(relMaster)}]++
}
func (ls *layoutStats) warning() *store.Warning {
if ls.shared < 20 || ls.moved*4 <= ls.shared {
return nil
}
items := make([]store.WarningItem, 0, len(ls.pairs))
for p, n := range ls.pairs {
items = append(items, store.WarningItem{Slave: p[0], Master: p[1], Count: n})
}
sort.Slice(items, func(i, j int) bool {
if items[i].Count != items[j].Count {
return items[i].Count > items[j].Count
}
return items[i].Slave < items[j].Slave
})
w := &store.Warning{
Title: fmt.Sprintf("%d of %d songs found in both libraries are in a different folder in the master library", ls.moved, ls.shared),
Detail: "The libraries seem to use different folder layouts here, so these songs only match through the fallbacks. " +
"Folders are listed with the most affected songs first.",
Total: len(items),
Items: items[:min(maxWarningItems, len(items))],
}
return w
}