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>
93 lines
3.0 KiB
Go
93 lines
3.0 KiB
Go
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}
|
|
}
|