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