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
}