package artist import ( "bufio" "errors" "fmt" "os" "path/filepath" "sort" "strconv" "strings" "unicode" "golang.org/x/text/unicode/runenames" "intake/internal/normalize" ) var CollabSeparators = []string{ " feat. ", " feat ", " ft. ", " ft ", " featuring ", " & ", " and ", " + ", " x ", " × ", " vs. ", " vs ", " versus ", " with ", " / ", " presents ", ", ", } func Split(credit string) ([]string, bool) { parts := []string{credit} changed := false for _, sep := range CollabSeparators { var next []string for _, p := range parts { if strings.Contains(strings.ToLower(p), strings.ToLower(sep)) { changed = true } next = append(next, splitFold(p, sep)...) } parts = next } var out []string for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } if len(out) == 0 { return []string{credit}, false } return out, changed && len(out) > 1 } func splitFold(s, sep string) []string { lower, lsep := strings.ToLower(s), strings.ToLower(sep) var parts []string for { i := strings.Index(lower, lsep) if i < 0 { parts = append(parts, s) break } parts = append(parts, s[:i]) s, lower = s[i+len(sep):], lower[i+len(sep):] } return parts } const VariousArtists = "various-artists" type Candidate struct { Dir string Credit string Score float64 ExactAlias bool ExactDir bool RomanizedMatch bool Fuzzy bool } type Match struct { Credit string Dirs []Candidate Exact bool NeedsUser bool Candidates []string } type Index struct { dirs map[string]bool raw map[string]bool Alias map[string]string } func NewIndex() *Index { return &Index{dirs: map[string]bool{}, raw: map[string]bool{}, Alias: map[string]string{}} } func (ix *Index) AddDir(name string) { name = strings.TrimSpace(name) if name == "" { return } ix.raw[name] = true if k := normalize.DirName(name); k != "" { ix.dirs[k] = true } } func (ix *Index) Dirs() []string { out := make([]string, 0, len(ix.raw)) for d := range ix.raw { out = append(out, d) } sort.Strings(out) return out } func (ix *Index) Resolve(credit string) Match { m := Match{Credit: credit} m.Candidates = ix.Dirs() trimmed := strings.TrimSpace(credit) if trimmed == "" { return m } if dir, ok := ix.Alias[trimmed]; ok && dir != "" { m.Dirs = append(m.Dirs, Candidate{Dir: dir, Credit: trimmed, Score: 1, ExactAlias: true}) m.Exact = true return m } artists, _ := Split(trimmed) primary := artists[0] seen := map[string]bool{} add := func(c Candidate) { if c.Dir == "" || seen[c.Dir] { return } seen[c.Dir] = true m.Dirs = append(m.Dirs, c) } for _, name := range append([]string{trimmed}, artists...) { key := normalize.DirName(name) if key != "" && ix.dirs[key] { add(Candidate{Dir: key, Credit: name, Score: 1, ExactDir: true}) m.Exact = true } } for _, name := range append([]string{trimmed}, artists...) { rom := Romanize(name) if rom == "" { continue } key := normalize.DirName(rom) if key != "" && ix.dirs[key] { add(Candidate{Dir: key, Credit: name, Score: 0.95, RomanizedMatch: true}) m.Exact = true continue } for existing := range ix.dirs { if Romanize(existing) != "" && normalize.DirName(Romanize(existing)) == key { add(Candidate{Dir: existing, Credit: name, Score: 0.9, RomanizedMatch: true}) m.Exact = true } } } type scored struct { dir string score float64 } var fuzzy []scored for _, name := range append([]string{trimmed}, artists...) { key := normalize.DirName(name) if key == "" { continue } for existing := range ix.dirs { s := Similarity(key, existing) if s >= 0.75 { fuzzy = append(fuzzy, scored{existing, s}) } } } sort.Slice(fuzzy, func(i, j int) bool { if fuzzy[i].score != fuzzy[j].score { return fuzzy[i].score > fuzzy[j].score } return fuzzy[i].dir < fuzzy[j].dir }) for _, f := range fuzzy { add(Candidate{Dir: f.dir, Credit: trimmed, Score: f.score, Fuzzy: true}) } dir := normalize.DirName(primary) if dir == "" { if rom := Romanize(primary); rom != "" { dir = normalize.DirName(rom) } } if dir == "" { dir = "unknown-artist" m.NeedsUser = true } add(Candidate{Dir: dir, Credit: primary, Score: 0}) return m } func Similarity(a, b string) float64 { if a == "" || b == "" { return 0 } if a == b { return 1 } tok := tokenOverlap(a, b) ed := editSimilarity(a, b) if tok > ed { return tok } return ed } func tokenOverlap(a, b string) float64 { ta := strings.FieldsFunc(a, func(r rune) bool { return r == '-' }) tb := strings.FieldsFunc(b, func(r rune) bool { return r == '-' }) if len(ta) == 0 || len(tb) == 0 { return 0 } set := map[string]bool{} for _, t := range ta { set[t] = true } common := 0 for _, t := range tb { if set[t] { common++ } } if common == 0 { return 0 } jaccard := float64(common) / float64(len(ta)+len(tb)-common) containment := float64(common) / float64(min(len(ta), len(tb))) if containment > jaccard { return containment } return jaccard } func editSimilarity(a, b string) float64 { d := levenshtein(a, b) maxLen := len(a) if len(b) > maxLen { maxLen = len(b) } if maxLen == 0 { return 0 } return 1 - float64(d)/float64(maxLen) } func levenshtein(a, b string) int { ra, rb := []rune(a), []rune(b) prev := make([]int, len(rb)+1) cur := make([]int, len(rb)+1) for j := range prev { prev[j] = j } for i := 1; i <= len(ra); i++ { cur[0] = i for j := 1; j <= len(rb); j++ { cost := 1 if ra[i-1] == rb[j-1] { cost = 0 } cur[j] = min3(prev[j]+1, cur[j-1]+1, prev[j-1]+cost) } prev, cur = cur, prev } return prev[len(rb)] } func min3(a, b, c int) int { if b < a { a = b } if c < a { a = c } return a } func min(a, b int) int { if b < a { return b } return a } func Romanize(s string) string { if !hasNonLatin(s) { return "" } var words []string var pending strings.Builder flush := func() { if pending.Len() > 0 { words = append(words, pending.String()) pending.Reset() } } for _, r := range s { if unicode.IsSpace(r) { flush() continue } if unit, ok := translit(r); ok { pending.WriteString(unit) continue } flush() } flush() if len(words) == 0 { return "" } return strings.Join(words, " ") } func translit(r rune) (string, bool) { lower := unicode.ToLower(r) if v, ok := cyrillic[lower]; ok { return v, true } if v, ok := greek[lower]; ok { return v, true } name := runenames.Name(r) if name == "" || strings.HasPrefix(name, "<") { return "", false } if strings.HasPrefix(name, "HIRAGANA LETTER ") || strings.HasPrefix(name, "KATAKANA LETTER ") { w := strings.Fields(name) last := w[len(w)-1] if last == "SMALL" { return "", false } return strings.ToLower(last), true } return "", false } var cyrillic = map[rune]string{ 'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d", 'е': "e", 'ё': "yo", 'ж': "zh", 'з': "z", 'и': "i", 'й': "y", 'к': "k", 'л': "l", 'м': "m", 'н': "n", 'о': "o", 'п': "p", 'р': "r", 'с': "s", 'т': "t", 'у': "u", 'ф': "f", 'х': "kh", 'ц': "ts", 'ч': "ch", 'ш': "sh", 'щ': "shch", 'ъ': "", 'ы': "y", 'ь': "", 'э': "e", 'ю': "yu", 'я': "ya", 'і': "i", 'ї': "yi", 'є': "ye", 'ґ': "g", } var greek = map[rune]string{ 'α': "a", 'β': "v", 'γ': "g", 'δ': "d", 'ε': "e", 'ζ': "z", 'η': "i", 'θ': "th", 'ι': "i", 'κ': "k", 'λ': "l", 'μ': "m", 'ν': "n", 'ξ': "x", 'ο': "o", 'π': "p", 'ρ': "r", 'σ': "s", 'ς': "s", 'τ': "t", 'υ': "y", 'φ': "f", 'χ': "ch", 'ψ': "ps", 'ω': "o", 'ά': "a", 'έ': "e", 'ή': "i", 'ί': "i", 'ό': "o", 'ύ': "y", 'ώ': "o", 'ϊ': "i", 'ϋ': "y", 'ΐ': "i", 'ΰ': "y", } func hasNonLatin(s string) bool { for _, r := range s { if r > unicode.MaxASCII && unicode.IsLetter(r) { return true } } return false } func AliasPath(root string) string { return filepath.Join(root, "artists.toml") } func (ix *Index) LoadAliases(path string) error { f, err := os.Open(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil } return err } defer f.Close() sc := bufio.NewScanner(f) for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "[") { continue } key, value, ok := strings.Cut(line, "=") if !ok { continue } key = strings.TrimSpace(key) value = strings.Trim(strings.TrimSpace(value), `"'`) if key == "" || value == "" { continue } ix.Alias[unquoteTOML(key)] = value } return sc.Err() } func (ix *Index) SetAlias(rawCredit, dir string) { if ix.Alias == nil { ix.Alias = map[string]string{} } ix.Alias[strings.TrimSpace(rawCredit)] = dir } func (ix *Index) SaveAliases(path string) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } keys := make([]string, 0, len(ix.Alias)) for k := range ix.Alias { keys = append(keys, k) } sort.Strings(keys) var b strings.Builder b.WriteString("# intake artist folder decisions\n") b.WriteString("#\n") b.WriteString("# Maps a raw artist credit, exactly as it appears in the tags, to the\n") b.WriteString("# folder it belongs in under archive/ and library/. intake writes an\n") b.WriteString("# entry here whenever you confirm an artist, so the same credit matches\n") b.WriteString("# instantly next time instead of creating a second folder.\n") b.WriteString("#\n") b.WriteString("# Edit freely: the value is used as-is, so it must be a safe directory name.\n\n") for _, k := range keys { fmt.Fprintf(&b, "%s = %q\n", TOMLKey(k), ix.Alias[k]) } tmp := path + ".tmp" if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil { return err } return os.Rename(tmp, path) } func TOMLKey(s string) string { if s == "" { return `""` } safe := true for _, r := range s { if !(r == '-' || r == '_' || r == ' ' || r == '.' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) { safe = false break } } if safe { return s } return fmt.Sprintf("%q", s) } // unquoteTOML undoes TOMLKey. Trimming the outer quotes is not enough: TOMLKey // writes %q, so a credit containing a quote or a backslash -- "Weird Al" // Yankovic, or anything with a \u escape in it -- comes back escaped, no longer // equals the credit it was written for, and the entry silently never matches. func unquoteTOML(s string) string { if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { if unquoted, err := strconv.Unquote(s); err == nil { return unquoted } return s[1 : len(s)-1] } return s }