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>
173 lines
5.6 KiB
Go
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
|
|
}
|