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-}: выводится «», только если дисков > 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\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 }