feat: storage writes via storage-agent, accepts_uploads and write_template (M5.5)
ci / api / api (push) Successful in 33s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 43s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m11s
ci / ts / ts (packages + web) (push) Successful in 1m10s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 51s
ci / api / api (push) Successful in 33s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 43s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m11s
ci / ts / ts (packages + web) (push) Successful in 1m10s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 51s
- storage-agent: POST /internal/files copies a staged file into a source
(temp file, fsync, SHA-256 check, hard link so nothing is overwritten,
" (2)" suffix on conflict, owner PIPU_FILE_UID/GID) and
POST /internal/files/test. NFS sources that accept uploads are mounted
rw in an agent-only /library-rw and bind-mounted read-only for others;
local sources are written through /library-host-rw.
- meta: write templates ({track:02}, {disc:1-}, [optional]) with name
sanitising and length limits, suggestion from the source's regexes
and a reversibility check against 50 catalogue tracks.
- API and admin UI: accepts_uploads / write_template on sources,
write-template suggest and check endpoints, write test result.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9b50038bb4
commit
f66be2ba0a
27 files changed
+2422
-213
No files matched your search
@@ -0,0 +1,613 @@
|
||||
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())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package meta
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const docTemplate = "{albumartist}/{year} - {album}/{disc:1-}{track:02} - {title}.{ext}"
|
||||
|
||||
func in(f Fields, discs int, ext string) TemplateInput {
|
||||
return TemplateInput{Fields: f, DiscCount: discs, Ext: ext}
|
||||
}
|
||||
|
||||
func TestRenderTemplate(t *testing.T) {
|
||||
tpl, err := ParseTemplate(docTemplate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := Fields{Artist: "Кино", Album: "Группа крови", Year: "1988-01-05", Track: "3/11", Disc: "1", Title: "Война"}
|
||||
cases := []struct {
|
||||
name string
|
||||
input TemplateInput
|
||||
want string
|
||||
}{
|
||||
{"single disc", in(f, 1, "FLAC"), "Кино/1988 - Группа крови/03 - Война.flac"},
|
||||
{"multi disc", in(f, 2, "mp3"), "Кино/1988 - Группа крови/1-03 - Война.mp3"},
|
||||
{"forbidden chars and trailing dots", in(Fields{Artist: "AC/DC", Album: "What?...", Year: "1980", Track: "1", Title: ".hidden: x"}, 1, "mp3"),
|
||||
"AC_DC/1980 - What_/01 - .hidden_ x.mp3"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := tpl.Render(c.input)
|
||||
if err != nil || got != c.want {
|
||||
t.Errorf("%s: got %q, %v; want %q", c.name, got, err, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tpl.Render(in(Fields{Artist: "A", Album: "B", Track: "1", Title: "C"}, 1, "mp3")); err == nil {
|
||||
t.Error("missing year outside [ ] must fail")
|
||||
}
|
||||
opt, _ := ParseTemplate("{albumartist}/[{year} - ]{album}/{track:02} - {title}.{ext}")
|
||||
if got, _ := opt.Render(in(Fields{Artist: "A", Album: "B", Track: "1", Title: "C"}, 1, "mp3")); got != "A/B/01 - C.mp3" {
|
||||
t.Errorf("optional part: %q", got)
|
||||
}
|
||||
long, _ := opt.Render(in(Fields{Artist: "A", Album: "B", Track: "1", Title: strings.Repeat("я", 300)}, 1, "flac"))
|
||||
if seg := long[strings.LastIndex(long, "/")+1:]; len(seg) > MaxSegmentBytes || !strings.HasSuffix(seg, ".flac") {
|
||||
t.Errorf("long title segment: %d bytes %q", len(seg), seg[len(seg)-10:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTemplateErrors(t *testing.T) {
|
||||
for _, bad := range []string{"", "/{artist}/{title}.{ext}", "{artist}/{title}.mp3", "{artist}/{nope}/{title}.{ext}",
|
||||
"{artist}/../{title}.{ext}", "[{artist}/{title}.{ext}", "{artist}/{year:02}/{title}.{ext}", "{artist}//{title}.{ext}"} {
|
||||
if _, err := ParseTemplate(bad); err == nil {
|
||||
t.Errorf("%q must be rejected", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Шаблоны из docs/library-and-storage.md превращаются в шаблоны записи и проходят проверку обратимости.
|
||||
func TestSuggestAndCheck(t *testing.T) {
|
||||
exprs := []string{
|
||||
`^(?P<skip>_incoming)/`,
|
||||
`^(?P<artist>[^/]+)/(?P<year>\d{4}) - (?P<album>[^/]+)/(?:(?P<disc>\d)-)?(?P<track>\d{1,3}) - (?P<title>[^/]+)\.\w+$`,
|
||||
`^(?P<genre>[^/]+)/(?P<artist>[^/]+)/(?P<album>.+?) \((?P<year>\d{4})\)/(?P<track>\d+)\. (?P<title>[^/]+)\.\w+$`,
|
||||
}
|
||||
paths := []string{"Кино/1988 - Группа крови/01 - Группа крови.flac", "Кино/1988 - Группа крови/02 - Закрой за мной дверь.flac",
|
||||
"Rock/Queen/Jazz (1978)/3. Jealousy.mp3"}
|
||||
got, err := SuggestTemplate(exprs, paths)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "{artist}/{year} - {album}/{disc:1-}{track:02} - {title}.{ext}" {
|
||||
t.Fatalf("suggested %q", got)
|
||||
}
|
||||
|
||||
tpl, _ := ParseTemplate(got)
|
||||
patterns, _ := CompilePatterns(exprs)
|
||||
samples := []TemplateSample{
|
||||
{RelPath: paths[0], Input: in(Fields{Artist: "Кино", Year: "1988", Album: "Группа крови", Track: "1", Title: "Группа крови"}, 1, "flac")},
|
||||
{RelPath: paths[1], Input: in(Fields{Artist: "Кино", Year: "1988", Album: "Группа крови", Track: "2", Title: "Закрой за мной дверь"}, 1, "flac")},
|
||||
{RelPath: "Pink Floyd/1979 - The Wall/2-01 - Hey You.flac",
|
||||
Input: in(Fields{Artist: "Pink Floyd", Year: "1979", Album: "The Wall", Disc: "2", Track: "1", Title: "Hey You"}, 2, "flac")},
|
||||
}
|
||||
res := CheckTemplate(tpl, patterns, samples)
|
||||
if !res.OK || res.RoundTrip != 3 || res.SamePath != 3 {
|
||||
t.Fatalf("check: %+v", res)
|
||||
}
|
||||
|
||||
// Шаблон, который регэкспы источника не разберут обратно.
|
||||
bad, _ := ParseTemplate("{artist} - {album}/{title}.{ext}")
|
||||
res = CheckTemplate(bad, patterns, samples)
|
||||
if res.OK || res.RoundTrip != 0 || len(res.Mismatches) != 3 || res.Mismatches[0].Fields[0] != "pattern" {
|
||||
t.Fatalf("bad template check: %+v", res)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user