Files
pipu-music/services/api/internal/library/meta/normalize.go
T
mrleo1nidandClaude Sonnet 5.5 17c85493b7
ci / api / api (push) Successful in 34s
ci / db / db (push) Successful in 42s
fix(library): always split comma-separated artists even if glued name exists
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 15:26:49 +03:00

129 lines
3.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}