feat(api): library sources, scanner and catalog (M2)
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
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>
This commit is contained in:
1 parent
ab092def54
commit
c583991d14
24 files changed
+7467
-84
No files matched your search
@@ -0,0 +1,112 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user