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,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}
|
||||
}
|
||||
Reference in new issue
Block a user