Files
pipu-music/services/api/internal/library/meta/merge.go
T
mrleo1nidandClaude Opus 5.5 c583991d14
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
feat(api): library sources, scanner and catalog (M2)
- 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>
2026-10-20 06:48:37 +03:00

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)
}