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

- 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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 13:46:56 +03:00
1 parent 9b50038bb4
commit f66be2ba0a
27 files changed
+2422 -213

No files matched your search

+26 -4
View File
@@ -13,11 +13,14 @@ import (
"pipu/api/internal/storageagent"
)
// Agent — клиент storage-agent (монтирование NFS).
// Agent — клиент storage-agent (монтирование NFS, запись импортированных файлов).
type Agent interface {
Mount(ctx context.Context, sourceID uuid.UUID, spec storageagent.NFSSpec) (storageagent.MountResult, error)
// Mount монтирует NFS; writable — источник принимает загрузки (ещё rw-точка, видимая только агенту).
Mount(ctx context.Context, sourceID uuid.UUID, spec storageagent.NFSSpec, writable bool) (storageagent.MountResult, error)
Unmount(ctx context.Context, sourceID uuid.UUID) error
Test(ctx context.Context, spec storageagent.NFSSpec) (storageagent.MountResult, error)
TestWrite(ctx context.Context, req storageagent.WriteTestRequest) (storageagent.WriteTestResult, error)
WriteFile(ctx context.Context, req storageagent.WriteRequest) (storageagent.WriteResult, error)
}
// HTTPAgent ходит в storage-agent по внутренней сети с общим секретом.
@@ -86,9 +89,28 @@ func (a *HTTPAgent) do(ctx context.Context, method, path string, body any, out a
}
// Mount реализует Agent.
func (a *HTTPAgent) Mount(ctx context.Context, id uuid.UUID, spec storageagent.NFSSpec) (storageagent.MountResult, error) {
func (a *HTTPAgent) Mount(ctx context.Context, id uuid.UUID, spec storageagent.NFSSpec, writable bool) (storageagent.MountResult, error) {
var res storageagent.MountResult
err := a.do(ctx, http.MethodPost, "/internal/mounts", storageagent.MountRequest{SourceID: id.String(), NFSSpec: spec}, &res)
err := a.do(ctx, http.MethodPost, "/internal/mounts",
storageagent.MountRequest{SourceID: id.String(), NFSSpec: spec, Writable: writable}, &res)
return res, err
}
// TestWrite реализует Agent.
func (a *HTTPAgent) TestWrite(ctx context.Context, req storageagent.WriteTestRequest) (storageagent.WriteTestResult, error) {
var res storageagent.WriteTestResult
err := a.do(ctx, http.MethodPost, "/internal/files/test", req, &res)
return res, err
}
// WriteFile реализует Agent. Копирование большого файла по NFS может идти минуты — отдельный таймаут.
func (a *HTTPAgent) WriteFile(ctx context.Context, req storageagent.WriteRequest) (storageagent.WriteResult, error) {
var res storageagent.WriteResult
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
defer cancel()
c := *a
c.Client = &http.Client{}
err := c.do(ctx, http.MethodPost, "/internal/files", req, &res)
return res, err
}
+43 -2
View File
@@ -63,7 +63,7 @@ func (h *Handler) sourceDTO(s Source) gen.Source {
Id: s.ID, Name: s.Name, Type: gen.SourceType(s.Type), NfsServer: s.NFSServer, NfsExport: s.NFSExport,
NfsOptions: s.NFSOptions, LocalPath: s.LocalPath, TagPriority: gen.TagsFirst,
Status: gen.SourceStatus(s.Status), StatusMessage: s.StatusMessage, LastScanAt: s.LastScanAt,
CreatedAt: s.CreatedAt,
CreatedAt: s.CreatedAt, AcceptsUploads: s.AcceptsUploads, WriteTemplate: s.WriteTemplate,
}
if !s.TagsFirst {
out.TagPriority = gen.PathFirst
@@ -118,6 +118,7 @@ func (h *Handler) CreateSource(ctx context.Context, req gen.CreateSourceRequestO
src, err := h.svc.CreateSource(ctx, SourceInput{
Name: &b.Name, Type: SourceType(b.Type), NFSServer: b.NfsServer, NFSExport: b.NfsExport, NFSOptions: b.NfsOptions,
LocalPath: b.LocalPath, TagsFirst: tagsFirst(b.TagPriority), ScanIntervalMinutes: b.ScanIntervalMinutes,
AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate,
})
if err != nil {
return nil, err
@@ -140,6 +141,7 @@ func (h *Handler) UpdateSource(ctx context.Context, req gen.UpdateSourceRequestO
src, err := h.svc.UpdateSource(ctx, req.Id, SourceInput{
Name: b.Name, NFSServer: b.NfsServer, NFSExport: b.NfsExport, NFSOptions: b.NfsOptions, LocalPath: b.LocalPath,
TagsFirst: tagsFirst(b.TagPriority), ScanIntervalMinutes: b.ScanIntervalMinutes, Enabled: b.Enabled,
AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate,
})
if err != nil {
return nil, err
@@ -161,7 +163,46 @@ func (h *Handler) TestSource(ctx context.Context, req gen.TestSourceRequestObjec
if err != nil {
return nil, err
}
return gen.TestSource200JSONResponse{Ok: r.OK, Message: r.Message, AudioFiles: r.AudioFiles}, nil
out := gen.TestSource200JSONResponse{Ok: r.OK, Message: r.Message, AudioFiles: r.AudioFiles}
if r.Write != nil {
out.WriteOk, out.WriteMessage = &r.Write.OK, &r.Write.Message
}
return out, nil
}
// SuggestWriteTemplate реализует gen.StrictServerInterface.
func (h *Handler) SuggestWriteTemplate(ctx context.Context, req gen.SuggestWriteTemplateRequestObject) (gen.SuggestWriteTemplateResponseObject, error) {
tpl, err := h.svc.SuggestWriteTemplate(ctx, req.Id)
if err != nil {
return nil, err
}
return gen.SuggestWriteTemplate200JSONResponse{Template: tpl}, nil
}
// CheckWriteTemplate реализует gen.StrictServerInterface.
func (h *Handler) CheckWriteTemplate(ctx context.Context, req gen.CheckWriteTemplateRequestObject) (gen.CheckWriteTemplateResponseObject, error) {
res, err := h.svc.CheckWriteTemplate(ctx, req.Id, req.Body.Template)
if err != nil {
return nil, err
}
out := gen.CheckWriteTemplate200JSONResponse{
Ok: res.OK, Total: res.Total, RoundTrip: res.RoundTrip, SamePath: res.SamePath,
Mismatches: make([]gen.WriteTemplateMismatch, 0, len(res.Mismatches)),
}
for _, m := range res.Mismatches {
dto := gen.WriteTemplateMismatch{RelPath: m.RelPath, Fields: m.Fields}
if dto.Fields == nil {
dto.Fields = []string{}
}
if m.Rendered != "" {
dto.Rendered = &m.Rendered
}
if m.Error != "" {
dto.Error = &m.Error
}
out.Mismatches = append(out.Mismatches, dto)
}
return out, nil
}
func patternsDTO(rows []PatternRow) gen.PatternList {
@@ -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)
}
}
+105 -2
View File
@@ -35,6 +35,8 @@ const (
CodeNameInvalid = "validation.name_invalid"
CodeIntervalInvalid = "validation.scan_interval_invalid"
CodeNotFound = "catalog.not_found"
CodeTemplateInvalid = "validation.template_invalid"
CodeTemplateNone = "library.template_unavailable"
)
// Config — пути к хранилищам в файловой системе api.
@@ -139,6 +141,8 @@ type SourceInput struct {
TagsFirst *bool
ScanIntervalMinutes *int // 0 — выключить
Enabled *bool
AcceptsUploads *bool
WriteTemplate *string // "" — убрать
}
func (s *Service) apply(src *Source, in SourceInput) error {
@@ -164,6 +168,21 @@ func (s *Service) apply(src *Source, in SourceInput) error {
src.ScanInterval = &d
}
}
if in.AcceptsUploads != nil {
src.AcceptsUploads = *in.AcceptsUploads
}
if in.WriteTemplate != nil {
if strings.TrimSpace(*in.WriteTemplate) == "" {
src.WriteTemplate = nil
} else {
tpl, err := meta.ParseTemplate(*in.WriteTemplate)
if err != nil {
return apperr.Validation("write_template", CodeTemplateInvalid, err.Error(), map[string]any{"reason": err.Error()})
}
text := tpl.String()
src.WriteTemplate = &text
}
}
switch src.Type {
case SourceLocal:
if in.LocalPath != nil {
@@ -251,7 +270,8 @@ func (s *Service) UpdateSource(ctx context.Context, id uuid.UUID, in SourceInput
return Source{}, err
}
location := func(x Source) string {
return fmt.Sprint(x.LocalPath != nil && *x.LocalPath != "", deref(x.LocalPath), deref(x.NFSServer), deref(x.NFSExport), x.NFSOptions)
return fmt.Sprint(x.LocalPath != nil && *x.LocalPath != "", deref(x.LocalPath), deref(x.NFSServer), deref(x.NFSExport), x.NFSOptions,
x.Type == SourceNFS && x.AcceptsUploads)
}
switch {
case in.Enabled != nil && !*in.Enabled:
@@ -308,7 +328,7 @@ func (s *Service) check(ctx context.Context, src Source, mount bool) (string, *s
}
return "mounted", nil
}
res, err := s.agent.Mount(ctx, src.ID, nfsSpec(src))
res, err := s.agent.Mount(ctx, src.ID, nfsSpec(src), src.AcceptsUploads)
if err != nil {
return "error", msg(err.Error())
}
@@ -354,6 +374,17 @@ type TestResult struct {
OK bool
Message string
AudioFiles int
Write *storageagent.WriteTestResult // только для accepts_uploads
}
// writeTarget — как агенту найти источник на запись.
func writeTarget(src Source) storageagent.WriteTestRequest {
req := storageagent.WriteTestRequest{SourceID: src.ID.String()}
if src.Type == SourceLocal {
lp := deref(src.LocalPath)
req.LocalPath = &lp
}
return req
}
// TestSource проверяет доступ к хранилищу и считает аудиофайлы (до 1000).
@@ -365,6 +396,19 @@ func (s *Service) TestSource(ctx context.Context, id uuid.UUID) (TestResult, err
if err != nil {
return TestResult{}, notFound(err)
}
res, err := s.testRead(ctx, src)
if err != nil || !src.AcceptsUploads {
return res, err
}
w, err := s.agent.TestWrite(ctx, writeTarget(src))
if err != nil {
w = storageagent.WriteTestResult{Message: err.Error()}
}
res.Write = &w
return res, nil
}
func (s *Service) testRead(ctx context.Context, src Source) (TestResult, error) {
if src.Type == SourceNFS {
res, err := s.agent.Test(ctx, nfsSpec(src))
if err != nil {
@@ -715,3 +759,62 @@ func (s *Service) healthCheck(ctx context.Context, src Source) {
}
}
}
// templateSampleSize — сколько треков берёт проверка шаблона записи (docs/uploads.md#7).
const templateSampleSize = 50
// SuggestWriteTemplate подбирает шаблон записи по существующим путям и выражениям источника.
func (s *Service) SuggestWriteTemplate(ctx context.Context, id uuid.UUID) (string, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return "", err
}
if _, err := s.store.GetSource(ctx, id); err != nil {
return "", notFound(err)
}
rows, err := s.store.Patterns(ctx, id)
if err != nil {
return "", err
}
var exprs []string
for _, r := range rows {
if r.IsEnabled {
exprs = append(exprs, r.Regex)
}
}
paths, err := s.store.SampleFiles(ctx, id, 200)
if err != nil {
return "", err
}
tpl, err := meta.SuggestTemplate(exprs, paths)
if err != nil {
return "", apperr.New(http.StatusUnprocessableEntity, CodeTemplateNone, "no path pattern can be turned into a template")
}
return tpl, nil
}
// CheckWriteTemplate применяет шаблон к трекам источника и разбирает пути его выражениями обратно.
func (s *Service) CheckWriteTemplate(ctx context.Context, id uuid.UUID, template string) (meta.TemplateCheck, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return meta.TemplateCheck{}, err
}
if _, err := s.store.GetSource(ctx, id); err != nil {
return meta.TemplateCheck{}, notFound(err)
}
tpl, err := meta.ParseTemplate(template)
if err != nil {
return meta.TemplateCheck{}, apperr.Validation("template", CodeTemplateInvalid, err.Error(), map[string]any{"reason": err.Error()})
}
rows, err := s.store.Patterns(ctx, id)
if err != nil {
return meta.TemplateCheck{}, err
}
compiled, err := compile(rows)
if err != nil {
return meta.TemplateCheck{}, err
}
samples, err := s.store.TemplateSamples(ctx, id, templateSampleSize)
if err != nil {
return meta.TemplateCheck{}, err
}
return meta.CheckTemplate(tpl, compiled, samples), nil
}
+61 -6
View File
@@ -4,6 +4,9 @@ import (
"context"
"errors"
"fmt"
"path"
"strconv"
"strings"
"time"
"github.com/google/uuid"
@@ -41,6 +44,7 @@ type Source struct {
LastScanAt *time.Time
CreatedAt time.Time
AcceptsUploads bool
WriteTemplate *string
}
// PatternRow — шаблон пути источника.
@@ -81,7 +85,8 @@ func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
func newID() uuid.UUID { return uuid.Must(uuid.NewV7()) }
const sourceColumns = `id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority = 'tags_first',
extract(epoch from scan_interval)::bigint, status, status_message, last_scan_at, created_at, accepts_uploads`
extract(epoch from scan_interval)::bigint, status, status_message, last_scan_at, created_at, accepts_uploads,
write_template`
func scanSource(row pgx.Row) (Source, error) {
var (
@@ -90,7 +95,7 @@ func scanSource(row pgx.Row) (Source, error) {
interval *int64
)
err := row.Scan(&s.ID, &s.Name, &typ, &s.NFSServer, &s.NFSExport, &s.NFSOptions, &s.LocalPath, &s.TagsFirst,
&interval, &s.Status, &s.StatusMessage, &s.LastScanAt, &s.CreatedAt, &s.AcceptsUploads)
&interval, &s.Status, &s.StatusMessage, &s.LastScanAt, &s.CreatedAt, &s.AcceptsUploads, &s.WriteTemplate)
if errors.Is(err, pgx.ErrNoRows) {
return Source{}, ErrNotFound
}
@@ -147,14 +152,15 @@ func tagPriority(tagsFirst bool) string {
func (st *Store) SaveSource(ctx context.Context, s Source) (Source, error) {
return scanSource(st.pool.QueryRow(ctx, `
INSERT INTO library_sources (id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority,
scan_interval, status, status_message, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, make_interval(secs => $9::bigint), $10, $11, now())
scan_interval, status, status_message, accepts_uploads, write_template, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, make_interval(secs => $9::bigint), $10, $11, $12, $13, now())
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, nfs_server = EXCLUDED.nfs_server,
nfs_export = EXCLUDED.nfs_export, nfs_options = EXCLUDED.nfs_options, local_path = EXCLUDED.local_path,
tag_priority = EXCLUDED.tag_priority, scan_interval = EXCLUDED.scan_interval
tag_priority = EXCLUDED.tag_priority, scan_interval = EXCLUDED.scan_interval,
accepts_uploads = EXCLUDED.accepts_uploads, write_template = EXCLUDED.write_template
RETURNING `+sourceColumns,
s.ID, s.Name, string(s.Type), s.NFSServer, s.NFSExport, s.NFSOptions, s.LocalPath, tagPriority(s.TagsFirst),
intervalSeconds(s.ScanInterval), s.Status, s.StatusMessage))
intervalSeconds(s.ScanInterval), s.Status, s.StatusMessage, s.AcceptsUploads, s.WriteTemplate))
}
// SetSourceStatus обновляет состояние подключения.
@@ -683,3 +689,52 @@ func (st *Store) SaveFingerprint(ctx context.Context, fileID uuid.UUID, sum []by
}
return tx.Commit(ctx)
}
// TemplateSamples — случайные файлы источника с метаданными каталога (для проверки шаблона записи).
func (st *Store) TemplateSamples(ctx context.Context, sourceID uuid.UUID, n int) ([]meta.TemplateSample, error) {
rows, err := st.pool.Query(ctx, `
SELECT mf.rel_path, t.title, al.title, aa.name, coalesce(t.year, al.year), t.track_no, t.disc_no,
coalesce((SELECT string_agg(a.name, '; ' ORDER BY ta.position) FROM track_artists ta
JOIN artists a ON a.id = ta.artist_id WHERE ta.track_id = t.id AND ta.role = 'main'), aa.name),
(SELECT max(t2.disc_no) FROM tracks t2 WHERE t2.album_id = al.id),
(SELECT g.name FROM track_genres tg JOIN genres g ON g.id = tg.genre_id
WHERE tg.track_id = t.id ORDER BY tg.weight DESC LIMIT 1)
FROM media_files mf
JOIN tracks t ON t.id = mf.track_id
JOIN albums al ON al.id = t.album_id
JOIN artists aa ON aa.id = al.album_artist_id
WHERE mf.source_id = $1 AND mf.missing_since IS NULL
ORDER BY random() LIMIT $2`, sourceID, n)
if err != nil {
return nil, fmt.Errorf("library: template samples: %w", err)
}
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (meta.TemplateSample, error) {
var (
rel, title, album, albumArtist, artist string
year, track, disc, discs *int
genre *string
)
if err := r.Scan(&rel, &title, &album, &albumArtist, &year, &track, &disc, &artist, &discs, &genre); err != nil {
return meta.TemplateSample{}, err
}
num := func(p *int) string {
if p == nil || *p <= 0 {
return ""
}
return strconv.Itoa(*p)
}
f := meta.Fields{
Artist: artist, AlbumArtist: albumArtist, Album: album, Title: title, Year: num(year), Track: num(track),
Disc: num(disc),
}
if genre != nil {
f.Genre = *genre
}
count := 1
if discs != nil {
count = *discs
}
ext := strings.TrimPrefix(path.Ext(rel), ".")
return meta.TemplateSample{RelPath: rel, Input: meta.TemplateInput{Fields: f, DiscCount: count, Ext: ext}}, nil
})
}