129 lines
3.9 KiB
Go
129 lines
3.9 KiB
Go
// Package meta — метаданные треков: нормализация имён, шаблоны путей, слияние с тегами
|
||
// (docs/library-and-storage.md).
|
||
package meta
|
||
|
||
import (
|
||
"strings"
|
||
"unicode"
|
||
|
||
"golang.org/x/text/unicode/norm"
|
||
)
|
||
|
||
// Clean приводит значение к виду для показа: NFC, `_` → пробел, без лишних пробелов.
|
||
func Clean(s string) string {
|
||
s = norm.NFC.String(s)
|
||
s = strings.ReplaceAll(s, "_", " ")
|
||
return strings.Join(strings.Fields(s), " ")
|
||
}
|
||
|
||
// Key — ключ сопоставления (name_norm): нижний регистр, ё → е, без пунктуации, пробелы схлопнуты.
|
||
// «The Beatles», «the beatles!» и «THE BEATLES» дают один ключ.
|
||
func Key(s string) string {
|
||
s = norm.NFKC.String(s)
|
||
var b strings.Builder
|
||
b.Grow(len(s))
|
||
space := false
|
||
for _, r := range strings.ToLower(s) {
|
||
switch {
|
||
case r == 'ё':
|
||
r = 'е'
|
||
case unicode.IsPunct(r) || unicode.IsSymbol(r) || unicode.IsSpace(r) || r == '_':
|
||
space = b.Len() > 0
|
||
continue
|
||
}
|
||
if space {
|
||
b.WriteByte(' ')
|
||
space = false
|
||
}
|
||
b.WriteRune(r)
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// SortName — имя для сортировки исполнителей: без ведущего артикля «The ».
|
||
func SortName(name string) string {
|
||
if rest, ok := strings.CutPrefix(name, "The "); ok && rest != "" {
|
||
return rest
|
||
}
|
||
return name
|
||
}
|
||
|
||
// Разделители нескольких исполнителей в одном поле, кроме « & ».
|
||
var artistSeparators = []string{";", " / ", " feat. ", " feat ", " ft. ", " Feat. ", " Ft. ", " featuring "}
|
||
|
||
// ArtistCredit — исполнитель трека и его роль.
|
||
type ArtistCredit struct {
|
||
Name string
|
||
Role string // main | feat
|
||
}
|
||
|
||
// SplitArtists разбирает поле исполнителя на отдельных. « & » делит имя, только если составного
|
||
// исполнителя нет в библиотеке (known), — иначе «Simon & Garfunkel» распался бы на двоих.
|
||
// Всё после feat./ft. — приглашённые исполнители.
|
||
func SplitArtists(s string, known func(key string) bool) []ArtistCredit {
|
||
s = Clean(s)
|
||
if s == "" {
|
||
return nil
|
||
}
|
||
var out []ArtistCredit
|
||
seen := map[string]bool{}
|
||
add := func(name, role string) {
|
||
name = Clean(name)
|
||
k := Key(name)
|
||
if k == "" || seen[k] {
|
||
return
|
||
}
|
||
seen[k] = true
|
||
out = append(out, ArtistCredit{Name: name, Role: role})
|
||
}
|
||
|
||
main, feat := s, ""
|
||
for _, sep := range []string{" feat. ", " feat ", " ft. ", " Feat. ", " Ft. ", " featuring "} {
|
||
if i := strings.Index(main, sep); i >= 0 {
|
||
main, feat = main[:i], main[i+len(sep):]
|
||
break
|
||
}
|
||
}
|
||
for _, part := range splitAny(main, artistSeparators) {
|
||
for _, seg := range splitComma(part) {
|
||
if strings.Contains(seg, " & ") && (known == nil || !known(Key(seg))) {
|
||
for _, p := range strings.Split(seg, " & ") {
|
||
add(p, "main")
|
||
}
|
||
continue
|
||
}
|
||
add(seg, "main")
|
||
}
|
||
}
|
||
for _, part := range splitAny(feat, append(artistSeparators, " & ", ", ")) {
|
||
add(part, "feat")
|
||
}
|
||
return out
|
||
}
|
||
|
||
// splitComma делит по «, » без оглядки на библиотеку: иначе уже склеенное имя («A, B») навсегда
|
||
// остаётся «известным». Продолжение «the …» — часть имени («Tyler, the Creator»).
|
||
func splitComma(s string) []string {
|
||
var out []string
|
||
for _, p := range strings.Split(s, ", ") {
|
||
if n := len(out); n > 0 && strings.HasPrefix(strings.ToLower(p), "the ") {
|
||
out[n-1] += ", " + p
|
||
continue
|
||
}
|
||
out = append(out, p)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func splitAny(s string, seps []string) []string {
|
||
parts := []string{s}
|
||
for _, sep := range seps {
|
||
var next []string
|
||
for _, p := range parts {
|
||
next = append(next, strings.Split(p, sep)...)
|
||
}
|
||
parts = next
|
||
}
|
||
return parts
|
||
}
|