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
- 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>
113 lines
3.3 KiB
Go
113 lines
3.3 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, " & ") && (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
|
||
}
|