Files
pipu-music/services/api/internal/library/meta/normalize.go
T
mrleo1nidandClaude Opus 5.5 c583991d14
ci / api / api (push) Successful in 26s
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 22s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 37s
ci / ts / ts (packages + web) (push) Successful in 51s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 53s
feat(api): library sources, scanner and catalog (M2)
- schema for sources, path patterns, media files, artists, albums,
  tracks, genres (migration 00003)
- path patterns (RE2 with named groups, skip), merge with tags by
  source priority, fallbacks from the file name and folder, name
  normalization and multi-artist split
- scanner: walk, skip unchanged files by size and mtime, ffprobe in 8
  workers with a single catalog writer, renames by content hash keep
  the track, duplicates fold into one track with the best file as
  primary, missing files are kept 30 days
- storage-agent mounts NFS with validated server, export and an option
  whitelist, no shell; test mount counts audio files
- admin API: sources CRUD, connection test, patterns with preview,
  scan, library status, file errors, host folder picker; scheduled
  scans and health checks; mounts are restored on start
- catalog API: artist, artist albums, album with tracks, track
- api image is alpine with ffmpeg for ffprobe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:48:37 +03:00

113 lines
3.3 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, " & ") && (known == nil || !known(Key(part))) {
for _, p := range strings.Split(part, " & ") {
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
}