ci / module boundaries / boundaries (push) Successful in 8s
ci / api / api (push) Successful in 1m17s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m21s
ci / ts / ts (packages + web) (push) Successful in 3m43s
ci / db / db (push) Canceled after 11m9s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
113 lines
3.4 KiB
Go
113 lines
3.4 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) {
|
||
if (strings.Contains(part, " & ") || strings.Contains(part, ", ")) && (known == nil || !known(Key(part))) {
|
||
for _, p := range splitAny(part, []string{" & ", ", "}) {
|
||
add(p, "main")
|
||
}
|
||
continue
|
||
}
|
||
add(part, "main")
|
||
}
|
||
for _, part := range splitAny(feat, append(artistSeparators, " & ", ", ")) {
|
||
add(part, "feat")
|
||
}
|
||
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
|
||
}
|