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

- 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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 06:48:37 +03:00
1 parent ab092def54
commit c583991d14
24 files changed
+7467 -84

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
package meta
import (
"path"
"regexp"
"strconv"
"strings"
)
// UnknownArtist — исполнитель, если его нет ни в тегах, ни в пути.
const UnknownArtist = "Unknown artist"
// Fields — значения метаданных из одного источника (теги файла или шаблон пути). Пусто — нет значения.
type Fields struct {
Artist string
AlbumArtist string
Album string
Title string
Genre string
Year string
Track string
Disc string
}
// FromPath собирает Fields из групп шаблона.
func FromPath(m map[string]string) Fields {
return Fields{
Artist: m["artist"], AlbumArtist: m["albumartist"], Album: m["album"], Title: m["title"],
Genre: m["genre"], Year: m["year"], Track: m["track"], Disc: m["disc"],
}
}
// Meta — итоговые метаданные файла.
type Meta struct {
Artist string // поле исполнителя целиком (разбивается SplitArtists)
AlbumArtist string
Album string
Title string
Genres []string
Year int
Track int
Disc int
}
// Merge сводит теги и поля из пути. tagsFirst: тег важнее пути (по умолчанию), иначе наоборот.
// Без обоих значений: title — имя файла без номера и расширения, artist — UnknownArtist,
// album — имя папки, albumartist — artist.
func Merge(tags, fromPath Fields, tagsFirst bool, relPath string) Meta {
first, second := tags, fromPath
if !tagsFirst {
first, second = fromPath, tags
}
pick := func(a, b string) string {
if v := Clean(a); v != "" {
return v
}
return Clean(b)
}
m := Meta{
Artist: pick(first.Artist, second.Artist),
AlbumArtist: pick(first.AlbumArtist, second.AlbumArtist),
Album: pick(first.Album, second.Album),
Title: pick(first.Title, second.Title),
Year: parseYear(pick(first.Year, second.Year)),
Track: parseNumber(pick(first.Track, second.Track)),
Disc: parseNumber(pick(first.Disc, second.Disc)),
}
if g := pick(first.Genre, second.Genre); g != "" {
m.Genres = splitGenres(g)
}
if m.Title == "" {
m.Title = titleFromFile(relPath)
}
if m.Artist == "" {
m.Artist = m.AlbumArtist
}
if m.Artist == "" {
m.Artist = UnknownArtist
}
if m.AlbumArtist == "" {
m.AlbumArtist = m.Artist
}
if m.Album == "" {
if dir := path.Base(path.Dir(relPath)); dir != "." && dir != "/" {
m.Album = Clean(dir)
}
}
return m
}
var yearRe = regexp.MustCompile(`\b(1[89]\d\d|20\d\d)\b`)
// parseYear достаёт год из «2019», «2019-05-01», «(1999)».
func parseYear(s string) int {
if y := yearRe.FindString(s); y != "" {
n, _ := strconv.Atoi(y)
return n
}
return 0
}
// parseNumber разбирает «3», «03», «3/12».
func parseNumber(s string) int {
s, _, _ = strings.Cut(s, "/")
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n < 0 || n > 9999 {
return 0
}
return n
}
func splitGenres(s string) []string {
var out []string
seen := map[string]bool{}
for _, g := range splitAny(s, []string{";", "/", ","}) {
g = Clean(g)
if k := Key(g); k != "" && !seen[k] {
seen[k] = true
out = append(out, g)
}
}
return out
}
var leadingNumber = regexp.MustCompile(`^\s*(?:\d{1,2}[-.])?\d{1,3}\s*[-._)]*\s*`)
// titleFromFile: «03 - Song name.flac» → «Song name».
func titleFromFile(relPath string) string {
name := path.Base(relPath)
name = strings.TrimSuffix(name, path.Ext(name))
if t := Clean(leadingNumber.ReplaceAllString(name, "")); t != "" {
return t
}
return Clean(name)
}
@@ -0,0 +1,92 @@
package meta
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestKey(t *testing.T) {
assert.Equal(t, "the beatles", Key(" The_Beatles! "))
assert.Equal(t, Key("Ёлка"), Key("елка"))
assert.Equal(t, "ac dc", Key("AC/DC"))
assert.Equal(t, "", Key(" -- "))
}
func TestClean(t *testing.T) {
assert.Equal(t, "Hello World", Clean(" Hello__World "))
// «й» из двух кодовых точек (и + кратка) становится одной.
assert.Equal(t, "й", Clean("й"))
}
func TestSplitArtists(t *testing.T) {
known := func(k string) bool { return k == "simon garfunkel" }
names := func(cs []ArtistCredit) []string {
var out []string
for _, c := range cs {
out = append(out, c.Role+":"+c.Name)
}
return out
}
assert.Equal(t, []string{"main:Simon & Garfunkel"}, names(SplitArtists("Simon & Garfunkel", known)))
assert.Equal(t, []string{"main:Tom", "main:Jerry"}, names(SplitArtists("Tom & Jerry", known)))
assert.Equal(t, []string{"main:A", "main:B"}, names(SplitArtists("A; B", nil)))
assert.Equal(t, []string{"main:A", "feat:B", "feat:C"}, names(SplitArtists("A feat. B & C", nil)))
assert.Equal(t, []string{"main:A"}, names(SplitArtists("A / a", nil)), "duplicates by key are dropped")
assert.Empty(t, SplitArtists(" ", nil))
}
func TestPatterns(t *testing.T) {
patterns, err := CompilePatterns([]string{
`^(?P<skip>_incoming)/`,
`^(?P<artist>[^/]+)/(?P<year>\d{4}) - (?P<album>[^/]+)/(?:(?P<disc>\d)-)?(?P<track>\d{1,3}) - (?P<title>[^/]+)\.\w+$`,
`^(?P<genre>[^/]+)/(?P<artist>[^/]+)/(?P<album>.+?) \((?P<year>\d{4})\)/(?P<track>\d+)\. (?P<title>[^/]+)\.\w+$`,
})
require.NoError(t, err)
m := MatchPath(patterns, "Кино/1988 - Группа крови/1-01 - Группа крови.flac")
assert.Equal(t, 1, m.Index)
assert.Equal(t, map[string]string{
"artist": "Кино", "year": "1988", "album": "Группа крови", "disc": "1", "track": "01", "title": "Группа крови",
}, m.Fields)
m = MatchPath(patterns, "Rock/Queen/Jazz (1978)/03. Jealousy.mp3")
assert.Equal(t, 2, m.Index)
assert.Equal(t, "Rock", m.Fields["genre"])
assert.True(t, MatchPath(patterns, "_incoming/x.mp3").Skip)
assert.Equal(t, -1, MatchPath(patterns, "loose.mp3").Index)
}
func TestCompilePatternErrors(t *testing.T) {
for _, bad := range []string{`(?P<artist>[`, `^(?P<band>.+)$`, `^.+$`} {
_, err := CompilePatterns([]string{`^(?P<title>.+)$`, bad})
var pe *PatternError
require.ErrorAs(t, err, &pe, bad)
assert.Equal(t, 1, pe.Index)
}
}
func TestMerge(t *testing.T) {
tags := Fields{Title: "Tag title", Artist: "", Album: "Tag album", Year: "2019-05-01", Track: "3/12"}
path := Fields{Title: "Path title", Artist: "Path artist", Album: "Path album", Year: "2018", Track: "04"}
m := Merge(tags, path, true, "x/y.flac")
assert.Equal(t, "Tag title", m.Title)
assert.Equal(t, "Path artist", m.Artist, "empty tag falls back to the path")
assert.Equal(t, "Path artist", m.AlbumArtist, "album artist defaults to the artist")
assert.Equal(t, 2019, m.Year)
assert.Equal(t, 3, m.Track)
m = Merge(tags, path, false, "x/y.flac")
assert.Equal(t, "Path title", m.Title)
assert.Equal(t, 2018, m.Year)
assert.Equal(t, 4, m.Track)
m = Merge(Fields{Genre: "Rock; Pop/rock"}, Fields{}, true, "Some Folder/07 - My_Song.mp3")
assert.Equal(t, "My Song", m.Title)
assert.Equal(t, UnknownArtist, m.Artist)
assert.Equal(t, "Some Folder", m.Album)
assert.Equal(t, []string{"Rock", "Pop", "rock"}[:2], m.Genres[:2])
}
@@ -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
}
@@ -0,0 +1,92 @@
package meta
import (
"fmt"
"regexp"
"strings"
)
// MaxPatternLen — предел длины выражения (RE2 линеен, но огромные выражения бессмысленны).
const MaxPatternLen = 1000
// Группы шаблона и поля, которые они заполняют.
var knownGroups = map[string]bool{
"artist": true, "albumartist": true, "album": true, "year": true, "disc": true,
"track": true, "title": true, "genre": true, "skip": true,
}
// PatternError — ошибка компиляции выражения (для показа под полем ввода).
type PatternError struct {
Index int
Reason string
}
func (e *PatternError) Error() string { return fmt.Sprintf("pattern %d: %s", e.Index, e.Reason) }
// Pattern — скомпилированный шаблон пути.
type Pattern struct {
Source string
re *regexp.Regexp
}
// CompilePatterns проверяет и компилирует выражения по порядку.
func CompilePatterns(exprs []string) ([]Pattern, error) {
out := make([]Pattern, 0, len(exprs))
for i, e := range exprs {
if len(e) > MaxPatternLen {
return nil, &PatternError{Index: i, Reason: fmt.Sprintf("longer than %d characters", MaxPatternLen)}
}
re, err := regexp.Compile(e)
if err != nil {
return nil, &PatternError{Index: i, Reason: err.Error()}
}
named := 0
for _, name := range re.SubexpNames() {
if name == "" {
continue
}
if !knownGroups[name] {
return nil, &PatternError{Index: i, Reason: fmt.Sprintf("unknown group %q", name)}
}
named++
}
if named == 0 {
return nil, &PatternError{Index: i, Reason: "no named groups"}
}
out = append(out, Pattern{Source: e, re: re})
}
return out, nil
}
// PathMatch — результат применения шаблонов к пути.
type PathMatch struct {
Index int // номер совпавшего выражения, -1 — ни одно
Fields map[string]string // извлечённые группы (непустые)
Skip bool // совпала группа skip — файл игнорируется
}
// MatchPath применяет шаблоны к пути относительно корня источника (разделитель `/`).
// Побеждает первое совпавшее выражение.
func MatchPath(patterns []Pattern, relPath string) PathMatch {
for i, p := range patterns {
m := p.re.FindStringSubmatch(relPath)
if m == nil {
continue
}
// Выражение с группой skip описывает игнорируемые файлы: совпало — пропускаем.
if p.re.SubexpIndex("skip") >= 0 {
return PathMatch{Index: i, Skip: true}
}
fields := map[string]string{}
for gi, name := range p.re.SubexpNames() {
if name == "" {
continue
}
if v := strings.TrimSpace(m[gi]); v != "" {
fields[name] = v
}
}
return PathMatch{Index: i, Fields: fields}
}
return PathMatch{Index: -1}
}