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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user