Files
pipu-music/services/api/internal/library/meta/template.go
T
mrleo1nid db4548b115
ci / module boundaries / boundaries (push) Successful in 7s
ci / db / db (push) Successful in 2m33s
ci / api / api (push) Successful in 1m10s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m45s
ci / ts / ts (packages + web) (push) Successful in 6m29s
Omit missing years from upload destination paths
2026-10-30 11:40:42 +03:00

673 lines
19 KiB
Go

package meta
import (
"errors"
"fmt"
"path"
"regexp"
"regexp/syntax"
"strconv"
"strings"
"unicode/utf8"
"golang.org/x/text/unicode/norm"
)
// Шаблон пути для записи (write_template, docs/uploads.md#7-раскладка-в-хранилище-как-принято-на-сервере):
//
// {albumartist}/{year} - {album}/{disc:1-}{track:02} - {title}.{ext}
//
// {name} — поле; {track:02} — номер с нулями до ширины 2; {disc:1-} — номер диска с суффиксом «-», только если
// дисков в альбоме больше одного; [ ... ] — необязательный фрагмент, опускается целиком, если пусто любое поле внутри.
// Пределы длины пути (байты UTF-8).
const (
MaxSegmentBytes = 200
MaxPathBytes = 1000
MaxTemplateLen = 500
)
var templateFields = map[string]bool{
"artist": true, "albumartist": true, "album": true, "title": true, "genre": true,
"year": true, "track": true, "disc": true, "ext": true,
}
// TemplateError — шаблон не разобрался (показывается под полем ввода).
type TemplateError struct{ Reason string }
func (e *TemplateError) Error() string { return "template: " + e.Reason }
// MissingFieldError — у трека нет значения обязательного (не в [ ]) поля шаблона.
type MissingFieldError struct{ Field string }
func (e *MissingFieldError) Error() string { return "template: missing field " + e.Field }
// TemplateInput — значения для шаблона.
type TemplateInput struct {
Fields Fields
DiscCount int // дисков в альбоме (для {disc:1-})
Ext string // расширение по реальному формату, без точки
}
type tnode struct {
lit string
field string
pad int // {track:02}
suffix string // {disc:1-}: выводится «<n><suffix>», только если дисков > 1
multi bool // есть суффикс-форма
opt []tnode
isOpt bool
}
// Template — разобранный шаблон пути.
type Template struct {
src string
nodes []tnode
}
// String — исходный текст шаблона.
func (t Template) String() string { return t.src }
// ParseTemplate разбирает и проверяет шаблон: поля известны, путь относительный, заканчивается на .{ext},
// есть {title}.
func ParseTemplate(src string) (Template, error) {
src = strings.TrimSpace(src)
if src == "" {
return Template{}, &TemplateError{"empty"}
}
if len(src) > MaxTemplateLen {
return Template{}, &TemplateError{fmt.Sprintf("longer than %d characters", MaxTemplateLen)}
}
if strings.HasPrefix(src, "/") || strings.Contains(src, "\\") {
return Template{}, &TemplateError{"must be a relative path with / separators"}
}
nodes, rest, err := parseNodes(src, false)
if err != nil {
return Template{}, err
}
if rest != "" {
return Template{}, &TemplateError{"unexpected ]"}
}
for _, seg := range strings.Split(src, "/") {
if seg == "" || seg == "." || seg == ".." {
return Template{}, &TemplateError{"empty, . or .. path segment"}
}
}
if !strings.HasSuffix(src, ".{ext}") {
return Template{}, &TemplateError{"must end with .{ext}"}
}
if !strings.Contains(src, "{title}") {
return Template{}, &TemplateError{"must contain {title}"}
}
return Template{src: src, nodes: nodes}, nil
}
func parseNodes(s string, inOpt bool) ([]tnode, string, error) {
var out []tnode
var lit strings.Builder
flush := func() {
if lit.Len() > 0 {
out = append(out, tnode{lit: lit.String()})
lit.Reset()
}
}
for len(s) > 0 {
switch s[0] {
case '{':
end := strings.IndexByte(s, '}')
if end < 0 {
return nil, "", &TemplateError{"unclosed {"}
}
n, err := parsePlaceholder(s[1:end])
if err != nil {
return nil, "", err
}
flush()
out = append(out, n)
s = s[end+1:]
case '}':
return nil, "", &TemplateError{"unexpected }"}
case '[':
if inOpt {
return nil, "", &TemplateError{"nested [ ] are not supported"}
}
inner, rest, err := parseNodes(s[1:], true)
if err != nil {
return nil, "", err
}
if !strings.HasPrefix(rest, "]") {
return nil, "", &TemplateError{"unclosed ["}
}
flush()
out = append(out, tnode{isOpt: true, opt: inner})
s = rest[1:]
case ']':
if !inOpt {
return nil, "", &TemplateError{"unexpected ]"}
}
flush()
return out, s, nil
default:
lit.WriteByte(s[0])
s = s[1:]
}
}
if inOpt {
return nil, "", &TemplateError{"unclosed ["}
}
flush()
return out, "", nil
}
func parsePlaceholder(body string) (tnode, error) {
name, format, hasFormat := strings.Cut(body, ":")
if !templateFields[name] {
return tnode{}, &TemplateError{fmt.Sprintf("unknown field {%s}", name)}
}
n := tnode{field: name}
if !hasFormat {
return n, nil
}
if name != "track" && name != "disc" {
return tnode{}, &TemplateError{fmt.Sprintf("{%s} has no format", name)}
}
switch {
case len(format) == 2 && format[0] == '0' && format[1] >= '1' && format[1] <= '9':
n.pad = int(format[1] - '0')
case name == "disc" && strings.HasPrefix(format, "1") && len(format) <= 4 && !strings.ContainsAny(format[1:], "/{}[]"):
n.multi, n.suffix = true, format[1:]
default:
return tnode{}, &TemplateError{fmt.Sprintf("bad format %q for {%s}", format, name)}
}
return n, nil
}
// fieldValue — значение поля для пути (до очистки), "" — нет значения.
func fieldValue(in TemplateInput, n tnode) string {
f := in.Fields
switch n.field {
case "artist":
return f.Artist
case "albumartist":
if f.AlbumArtist != "" {
return f.AlbumArtist
}
return f.Artist
case "album":
return f.Album
case "title":
return f.Title
case "genre":
return f.Genre
case "year":
if y := parseYear(f.Year); y > 0 {
return strconv.Itoa(y)
}
return ""
case "ext":
return strings.ToLower(strings.TrimPrefix(in.Ext, "."))
case "track", "disc":
raw := f.Track
if n.field == "disc" {
raw = f.Disc
}
num := parseNumber(raw)
if num <= 0 {
return ""
}
if n.multi {
if in.DiscCount <= 1 {
return ""
}
return strconv.Itoa(num) + n.suffix
}
return fmt.Sprintf("%0*d", n.pad, num)
}
return ""
}
// Render строит относительный путь по шаблону. Значения очищаются (запрещённые символы → `_`, NFC),
// сегменты обрезаются до MaxSegmentBytes, путь — до MaxPathBytes с сохранением расширения.
func (t Template) Render(in TemplateInput) (string, error) {
var b strings.Builder
for _, n := range t.nodes {
switch {
case n.isOpt:
part, ok := renderOptional(in, n.opt)
if ok {
b.WriteString(part)
}
case n.field != "":
v := cleanValue(fieldValue(in, n))
if v == "" {
// {disc:1-} у однодискового альбома пуст по смыслу, это не ошибка.
if n.multi {
continue
}
return "", &MissingFieldError{Field: n.field}
}
b.WriteString(v)
default:
b.WriteString(n.lit)
}
}
return finishPath(b.String())
}
// RenderWithoutMissingYear omits an unknown year and its punctuation for uploads.
// Other required metadata still uses strict rendering. Optional fragments retain
// their usual all-or-nothing semantics.
func (t Template) RenderWithoutMissingYear(in TemplateInput) (string, error) {
if fieldValue(in, tnode{field: "year"}) != "" {
return t.Render(in)
}
var src strings.Builder
for _, n := range t.nodes {
if n.isOpt {
if _, ok := renderOptional(in, n.opt); ok {
src.WriteString("[")
for _, o := range n.opt {
src.WriteString(templateNodeSource(o))
}
src.WriteString("]")
}
} else {
src.WriteString(templateNodeSource(n))
}
}
s := src.String()
for _, re := range missingYearParts {
s = re.ReplaceAllString(s, "")
}
// A year-only directory disappears as a whole.
var segments []string
for _, seg := range strings.Split(s, "/") {
if strings.TrimSpace(seg) != "" {
segments = append(segments, seg)
}
}
tpl, err := ParseTemplate(strings.Join(segments, "/"))
if err != nil {
return "", err
}
return tpl.Render(in)
}
var missingYearParts = []*regexp.Regexp{
regexp.MustCompile(`\(\s*\{year\}\s*\)`),
regexp.MustCompile(`\{year\}[ \t]*[-–—_][ \t]*`),
regexp.MustCompile(`[ \t]*[-–—_][ \t]*\{year\}`),
regexp.MustCompile(`\{year\}`),
}
func templateNodeSource(n tnode) string {
if n.field == "" {
return n.lit
}
if n.multi {
return "{" + n.field + ":1" + n.suffix + "}"
}
if n.pad > 0 {
return fmt.Sprintf("{%s:0%d}", n.field, n.pad)
}
return "{" + n.field + "}"
}
func renderOptional(in TemplateInput, nodes []tnode) (string, bool) {
var b strings.Builder
for _, n := range nodes {
if n.field == "" {
b.WriteString(n.lit)
continue
}
v := cleanValue(fieldValue(in, n))
if v == "" {
return "", false
}
b.WriteString(v)
}
return b.String(), true
}
// cleanValue — значение поля внутри сегмента: без разделителей пути и запрещённых в именах символов.
func cleanValue(s string) string {
s = norm.NFC.String(strings.TrimSpace(s))
return strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f || strings.ContainsRune(`/\:*?"<>|`, r) {
return '_'
}
return r
}, s)
}
// finishPath нормализует сегменты: без точек и пробелов в конце, без скрытых имён, длина в пределах.
func finishPath(p string) (string, error) {
segs := strings.Split(p, "/")
for i, seg := range segs {
last := i == len(segs)-1
if last {
ext := path.Ext(seg)
base := trimSegment(strings.TrimSuffix(seg, ext))
base = truncateBytes(base, MaxSegmentBytes-len(ext))
seg = base + ext
} else {
seg = truncateBytes(trimSegment(seg), MaxSegmentBytes)
seg = strings.TrimRight(seg, ". ")
}
if strings.HasPrefix(seg, ".") {
seg = "_" + seg[1:]
}
if seg == "" || seg == "_" && last {
return "", &MissingFieldError{Field: "title"}
}
segs[i] = seg
}
out := strings.Join(segs, "/")
if over := len(out) - MaxPathBytes; over > 0 {
lastSeg := segs[len(segs)-1]
ext := path.Ext(lastSeg)
base := strings.TrimSuffix(lastSeg, ext)
if len(base)-over < 1 {
return "", &TemplateError{"path is too long"}
}
segs[len(segs)-1] = truncateBytes(base, len(base)-over) + ext
out = strings.Join(segs, "/")
}
return out, nil
}
func trimSegment(s string) string { return strings.TrimRight(strings.TrimSpace(s), ". ") }
// truncateBytes обрезает строку до n байт по границе символа.
func truncateBytes(s string, n int) string {
if len(s) <= n {
return s
}
if n <= 0 {
return ""
}
for n > 0 && !utf8.RuneStart(s[n]) {
n--
}
return strings.TrimRight(s[:n], " ")
}
// --- Автоподбор и проверка обратимости ---
// ErrNoTemplate — ни одно выражение источника не удалось превратить в шаблон.
var ErrNoTemplate = errors.New("template: no pattern can be converted")
// SuggestTemplate выбирает выражение, совпадающее с наибольшим числом путей выборки, и строит шаблон,
// порождающий такие же пути: именованные группы → поля, литералы между ними → текст, необязательная группа → [ ].
// Ширина номера трека берётся из выборки (01 → {track:02}).
func SuggestTemplate(exprs []string, samplePaths []string) (string, error) {
type cand struct {
expr string
re *regexp.Regexp
count int
}
var cands []cand
for _, e := range exprs {
re, err := regexp.Compile(e)
if err != nil || re.SubexpIndex("skip") >= 0 {
continue
}
c := cand{expr: e, re: re}
for _, p := range samplePaths {
if re.MatchString(p) {
c.count++
}
}
if c.count > 0 {
cands = append(cands, c)
}
}
// Стабильно по убыванию совпадений: при равенстве — порядок шаблонов источника.
for i := 1; i < len(cands); i++ {
for j := i; j > 0 && cands[j].count > cands[j-1].count; j-- {
cands[j], cands[j-1] = cands[j-1], cands[j]
}
}
for _, c := range cands {
pad := trackWidth(c.re, samplePaths)
if tpl, err := regexToTemplate(c.expr, pad); err == nil {
if _, err := ParseTemplate(tpl); err == nil {
return tpl, nil
}
}
}
return "", ErrNoTemplate
}
// trackWidth — ширина номера трека в путях выборки (0 — без дополнения нулями).
func trackWidth(re *regexp.Regexp, paths []string) int {
gi := re.SubexpIndex("track")
if gi < 0 {
return 0
}
widths := map[int]int{}
for _, p := range paths {
m := re.FindStringSubmatch(p)
if m == nil || m[gi] == "" {
continue
}
if strings.HasPrefix(m[gi], "0") {
widths[len(m[gi])]++
} else {
widths[0]++
}
}
best, n := 0, -1
for w, c := range widths {
if c > n || c == n && w > best {
best, n = w, c
}
}
return best
}
func regexToTemplate(expr string, trackPad int) (string, error) {
re, err := syntax.Parse(expr, syntax.Perl)
if err != nil {
return "", err
}
nodes := []*syntax.Regexp{re}
if re.Op == syntax.OpConcat {
nodes = re.Sub
}
var b strings.Builder
for i, n := range nodes {
// Хвост «\.\w+$» / «\.(flac|mp3)$» — расширение.
if isExtTail(nodes, i) {
b.WriteString("{ext}")
continue
}
s, err := convertNode(n, trackPad, false)
if err != nil {
return "", err
}
b.WriteString(s)
}
return b.String(), nil
}
func isExtTail(nodes []*syntax.Regexp, i int) bool {
rest := nodes[i:]
for len(rest) > 0 && rest[len(rest)-1].Op == syntax.OpEndText {
rest = rest[:len(rest)-1]
}
if len(rest) != 1 || i == 0 {
return false
}
prev := nodes[i-1]
if prev.Op != syntax.OpLiteral || !strings.HasSuffix(string(prev.Rune), ".") {
return false
}
n := rest[0]
if n.Op == syntax.OpCapture && n.Name == "" {
n = n.Sub[0]
}
switch n.Op {
case syntax.OpPlus, syntax.OpStar, syntax.OpRepeat, syntax.OpAlternate, syntax.OpCharClass, syntax.OpConcat:
return true
case syntax.OpLiteral:
return true
}
return false
}
func convertNode(n *syntax.Regexp, trackPad int, inOpt bool) (string, error) {
switch n.Op {
case syntax.OpBeginText, syntax.OpEndText, syntax.OpBeginLine, syntax.OpEndLine, syntax.OpEmptyMatch:
return "", nil
case syntax.OpLiteral:
s := string(n.Rune)
if strings.ContainsAny(s, "{}[]") {
return "", errors.New("template: literal with braces")
}
return s, nil
case syntax.OpConcat:
var b strings.Builder
for _, sub := range n.Sub {
s, err := convertNode(sub, trackPad, inOpt)
if err != nil {
return "", err
}
b.WriteString(s)
}
return b.String(), nil
case syntax.OpCapture:
if n.Name == "" {
return convertNode(n.Sub[0], trackPad, inOpt)
}
if !templateFields[n.Name] {
return "", fmt.Errorf("template: group %s", n.Name)
}
if n.Name == "track" && trackPad > 1 {
return fmt.Sprintf("{track:0%d}", trackPad), nil
}
return "{" + n.Name + "}", nil
case syntax.OpQuest:
if inOpt {
return "", errors.New("template: nested optional")
}
sub := n.Sub[0]
if sub.Op == syntax.OpCapture && sub.Name == "" {
sub = sub.Sub[0]
}
// (?:(?P<disc>\d)-)? → {disc:1-}
if sub.Op == syntax.OpConcat && len(sub.Sub) == 2 && sub.Sub[0].Op == syntax.OpCapture &&
sub.Sub[0].Name == "disc" && sub.Sub[1].Op == syntax.OpLiteral {
return "{disc:1" + string(sub.Sub[1].Rune) + "}", nil
}
s, err := convertNode(sub, trackPad, true)
if err != nil {
return "", err
}
if !strings.Contains(s, "{") {
return "", errors.New("template: optional part without fields")
}
return "[" + s + "]", nil
}
return "", fmt.Errorf("template: unsupported %v", n.Op)
}
// TemplateSample — существующий файл источника и его метаданные из каталога.
type TemplateSample struct {
RelPath string
Input TemplateInput
}
// TemplateMismatch — пример расхождения для показа админу.
type TemplateMismatch struct {
RelPath string
Rendered string
Fields []string // поля, которые регэкспы распознали не так (или "pattern", если ни одно не совпало)
Error string // шаблон не построился (нет поля)
}
// TemplateCheck — итог проверки обратимости.
type TemplateCheck struct {
Total int
RoundTrip int // путь разобран регэкспами обратно без изменений
SamePath int // путь совпал с реальным
OK bool
Mismatches []TemplateMismatch // до 10 примеров
}
// MinSamePathRatio — доля путей, которые должны совпасть с реальными.
const MinSamePathRatio = 0.9
// CheckTemplate применяет шаблон к файлам выборки и прогоняет результат через регэкспы источника:
// поля должны распознаться обратно без изменений, а в ≥ 90% случаев путь — совпасть с реальным.
func CheckTemplate(t Template, patterns []Pattern, samples []TemplateSample) TemplateCheck {
res := TemplateCheck{Total: len(samples)}
for _, s := range samples {
rendered, err := t.Render(s.Input)
if err != nil {
res.add(TemplateMismatch{RelPath: s.RelPath, Error: err.Error()})
continue
}
same := norm.NFC.String(rendered) == norm.NFC.String(s.RelPath)
if same {
res.SamePath++
}
bad := roundTrip(t, patterns, s.Input, rendered)
if len(bad) == 0 {
res.RoundTrip++
}
if len(bad) > 0 || !same {
res.add(TemplateMismatch{RelPath: s.RelPath, Rendered: rendered, Fields: bad})
}
}
res.OK = res.Total > 0 && res.RoundTrip == res.Total &&
float64(res.SamePath) >= MinSamePathRatio*float64(res.Total)
return res
}
func (c *TemplateCheck) add(m TemplateMismatch) {
if len(c.Mismatches) < 10 {
c.Mismatches = append(c.Mismatches, m)
}
}
// roundTrip — поля шаблона, которые регэкспы не вернули такими, какими они записаны в путь.
func roundTrip(t Template, patterns []Pattern, in TemplateInput, rendered string) []string {
m := MatchPath(patterns, rendered)
if m.Index < 0 || m.Skip {
return []string{"pattern"}
}
var bad []string
check := func(n tnode, inOpt bool) {
if n.field == "" || n.field == "ext" {
return
}
want := cleanValue(fieldValue(in, n))
if n.multi {
want = strings.TrimSuffix(want, n.suffix)
}
got := m.Fields[n.field]
if n.field == "track" || n.field == "disc" {
if want == "" && got == "" || parseNumber(want) == parseNumber(got) && want != "" {
return
}
} else if strings.TrimSpace(want) == got {
return
}
if want == "" && inOpt {
return
}
bad = append(bad, n.field)
}
for _, n := range t.nodes {
if n.isOpt {
for _, o := range n.opt {
check(o, true)
}
continue
}
check(n, false)
}
return bad
}