Files
pipu-music/services/api/internal/library/meta/normalize.go
T
mrleo1nidandClaude Sonnet 5.5 795bb7de46
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
fix(library): split comma-separated main artists
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:39:32 +03:00

113 lines
3.4 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) {
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
}