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>
138 lines
3.7 KiB
Go
138 lines
3.7 KiB
Go
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)
|
|
}
|