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 }