Files
playlink/internal/engine/layout.go
T
cdnutterandClaude Opus 5.5 0ae4e85bd2 Stop warning about slave-only content in layout checks
A singles library naturally has artists and songs the albums library
lacks, so "only N of M top-level folders exist in the master" and "N of M
slave files have no counterpart" were false alarms.

Layout warnings now only flag real layout problems:
- Folders that are the same but named differently (case, punctuation,
  accents, "&"/"and", leading/trailing "The"), at the top level and one
  level down inside shared folders, with examples.
- Songs found in both libraries that mostly live in a different folder in
  the master (e.g. Artist/Song vs Artist/Album/Song). Slave-only songs are
  ignored, as are singles/EPs/compilations matched to their album version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 17:55:02 -07:00

149 lines
4.5 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()
}
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
}
func renamedWarning(pairs [][2]string) string {
if len(pairs) == 0 {
return ""
}
var ex []string
for _, p := range pairs[:min(3, len(pairs))] {
ex = append(ex, fmt.Sprintf("%q (slave) vs %q (master)", p[0], p[1]))
}
more := ""
if len(pairs) > 3 {
more = fmt.Sprintf(" and %d more", len(pairs)-3)
}
noun := "folder is"
if len(pairs) > 1 {
noun = "folders are"
}
return fmt.Sprintf("%d %s named differently in the two libraries: %s%s. Songs inside them only match by path "+
"once the names agree.", len(pairs), noun, strings.Join(ex, ", "), more)
}
// 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
example [][2]string // a few moved examples
}
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
}
// 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 len(ls.example) < 2 {
ls.example = append(ls.example, [2]string{relSlave, relMaster})
}
}
func (ls *layoutStats) warning() string {
if ls.shared < 20 || ls.moved*4 <= ls.shared {
return ""
}
var ex []string
for _, e := range ls.example {
ex = append(ex, fmt.Sprintf("%q is at %q", e[0], e[1]))
}
return fmt.Sprintf("Of %d songs found in both libraries, %d are in a different folder in the master library (for example %s). "+
"The libraries seem to use different folder layouts, so these songs only match through the fallbacks.",
ls.shared, ls.moved, strings.Join(ex, "; "))
}