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

+5 -1
View File
@@ -32,7 +32,11 @@ func run() error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
srv := httpx.NewServer(cfg.HTTPAddr, storageagent.NewRouter(log, cfg.InternalToken, cfg.LibraryRoot))
paths := storageagent.Paths{
Library: cfg.LibraryRoot, LibraryRW: cfg.LibraryRWRoot, HostRW: cfg.HostRWRoot, Uploads: cfg.UploadsRoot,
FileUID: cfg.FileUID, FileGID: cfg.FileGID,
}
srv := httpx.NewServer(cfg.HTTPAddr, storageagent.NewRouterWithPaths(log, cfg.InternalToken, paths, storageagent.ExecMounter{}))
log.Info("starting", "version", buildinfo.Version)
return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
}
+466 -186
View File
@@ -684,9 +684,11 @@ type SessionList struct {
// Source defines model for Source.
type Source struct {
CreatedAt time.Time `json:"created_at"`
Id openapi_types.UUID `json:"id"`
LastScanAt *time.Time `json:"last_scan_at"`
// AcceptsUploads Принимает импорт из очереди на добавление (NFS монтируется агентом ещё и на запись)
AcceptsUploads bool `json:"accepts_uploads"`
CreatedAt time.Time `json:"created_at"`
Id openapi_types.UUID `json:"id"`
LastScanAt *time.Time `json:"last_scan_at"`
// LocalPath Подпапка относительно LIBRARY_HOST, пусто — весь каталог
LocalPath *string `json:"local_path"`
@@ -702,10 +704,14 @@ type Source struct {
StatusMessage *string `json:"status_message"`
TagPriority TagPriority `json:"tag_priority"`
Type SourceType `json:"type"`
// WriteTemplate Шаблон пути новых файлов (docs/uploads.md#7), null — не задан
WriteTemplate *string `json:"write_template"`
}
// SourceInput defines model for SourceInput.
type SourceInput struct {
AcceptsUploads *bool `json:"accepts_uploads,omitempty"`
LocalPath *string `json:"local_path,omitempty"`
Name string `json:"name"`
NfsExport *string `json:"nfs_export,omitempty"`
@@ -714,6 +720,7 @@ type SourceInput struct {
ScanIntervalMinutes *int `json:"scan_interval_minutes"`
TagPriority *TagPriority `json:"tag_priority,omitempty"`
Type SourceType `json:"type"`
WriteTemplate *string `json:"write_template,omitempty"`
}
// SourceList defines model for SourceList.
@@ -723,6 +730,9 @@ type SourceList struct {
// SourcePatch defines model for SourcePatch.
type SourcePatch struct {
// AcceptsUploads Смена переподключает NFS-хранилище (rw-точка агента)
AcceptsUploads *bool `json:"accepts_uploads,omitempty"`
// Enabled false — отключить хранилище (треки становятся недоступны), true — подключить снова
Enabled *bool `json:"enabled,omitempty"`
LocalPath *string `json:"local_path,omitempty"`
@@ -734,6 +744,10 @@ type SourcePatch struct {
// ScanIntervalMinutes 0 — отключить автоматический скан
ScanIntervalMinutes *int `json:"scan_interval_minutes,omitempty"`
TagPriority *TagPriority `json:"tag_priority,omitempty"`
// WriteTemplate Шаблон пути новых файлов; пусто — убрать. Неверный синтаксис — `422 validation.template_invalid`
// с `params.reason`. Проверка обратимости не обязательна для сохранения — её показывает `write-template/check`.
WriteTemplate *string `json:"write_template,omitempty"`
}
// SourceStatus defines model for SourceStatus.
@@ -752,9 +766,13 @@ type SourceStatusItem struct {
// SourceTestResult defines model for SourceTestResult.
type SourceTestResult struct {
// AudioFiles Сколько аудиофайлов найдено (проверка останавливается на 1000)
AudioFiles int `json:"audio_files"`
Message string `json:"message"`
Ok bool `json:"ok"`
AudioFiles int `json:"audio_files"`
Message string `json:"message"`
Ok bool `json:"ok"`
WriteMessage *string `json:"write_message"`
// WriteOk Удалось создать и удалить `.pipu-write-test` (только для accepts_uploads; иначе null)
WriteOk *bool `json:"write_ok"`
}
// SourceType defines model for SourceType.
@@ -929,6 +947,41 @@ type WaveStart struct {
// WaveStartSeedType defines model for WaveStart.Seed.Type.
type WaveStartSeedType string
// WriteTemplateCheck defines model for WriteTemplateCheck.
type WriteTemplateCheck struct {
Mismatches []WriteTemplateMismatch `json:"mismatches"`
// Ok Все пути разобраны обратно без изменений и ≥ 90% совпали с реальными
Ok bool `json:"ok"`
RoundTrip int `json:"round_trip"`
SamePath int `json:"same_path"`
Total int `json:"total"`
}
// WriteTemplateCheckRequest defines model for WriteTemplateCheckRequest.
type WriteTemplateCheckRequest struct {
Template string `json:"template"`
}
// WriteTemplateMismatch defines model for WriteTemplateMismatch.
type WriteTemplateMismatch struct {
Error *string `json:"error"`
// Fields Поля, которые регэкспы хранилища распознали не так; `pattern` — не совпало ни одно выражение
Fields []string `json:"fields"`
// RelPath Реальный путь файла
RelPath string `json:"rel_path"`
// Rendered Путь по шаблону, null — не построился
Rendered *string `json:"rendered"`
}
// WriteTemplateSuggestion defines model for WriteTemplateSuggestion.
type WriteTemplateSuggestion struct {
Template string `json:"template"`
}
// Cursor defines model for Cursor.
type Cursor = string
@@ -1056,6 +1109,9 @@ type PreviewSourcePatternsJSONRequestBody = PatternPreviewRequest
// ScanSourceJSONRequestBody defines body for ScanSource for application/json ContentType.
type ScanSourceJSONRequestBody = ScanRequest
// CheckWriteTemplateJSONRequestBody defines body for CheckWriteTemplate for application/json ContentType.
type CheckWriteTemplateJSONRequestBody = WriteTemplateCheckRequest
// CreateUserJSONRequestBody defines body for CreateUser for application/json ContentType.
type CreateUserJSONRequestBody = CreateUserRequest
@@ -1145,6 +1201,12 @@ type ServerInterface interface {
// Проверить подключение (admin)
// (POST /api/v1/admin/sources/{id}/test)
TestSource(w http.ResponseWriter, r *http.Request, id SourceId)
// Проверить шаблон на обратимость (admin)
// (POST /api/v1/admin/sources/{id}/write-template/check)
CheckWriteTemplate(w http.ResponseWriter, r *http.Request, id SourceId)
// Подобрать шаблон пути по существующей структуре (admin)
// (POST /api/v1/admin/sources/{id}/write-template/suggest)
SuggestWriteTemplate(w http.ResponseWriter, r *http.Request, id SourceId)
// Пользователи (admin, moderator)
// (GET /api/v1/admin/users)
ListUsers(w http.ResponseWriter, r *http.Request, params ListUsersParams)
@@ -1370,6 +1432,18 @@ func (_ Unimplemented) TestSource(w http.ResponseWriter, r *http.Request, id Sou
w.WriteHeader(http.StatusNotImplemented)
}
// Проверить шаблон на обратимость (admin)
// (POST /api/v1/admin/sources/{id}/write-template/check)
func (_ Unimplemented) CheckWriteTemplate(w http.ResponseWriter, r *http.Request, id SourceId) {
w.WriteHeader(http.StatusNotImplemented)
}
// Подобрать шаблон пути по существующей структуре (admin)
// (POST /api/v1/admin/sources/{id}/write-template/suggest)
func (_ Unimplemented) SuggestWriteTemplate(w http.ResponseWriter, r *http.Request, id SourceId) {
w.WriteHeader(http.StatusNotImplemented)
}
// Пользователи (admin, moderator)
// (GET /api/v1/admin/users)
func (_ Unimplemented) ListUsers(w http.ResponseWriter, r *http.Request, params ListUsersParams) {
@@ -2080,6 +2154,68 @@ func (siw *ServerInterfaceWrapper) TestSource(w http.ResponseWriter, r *http.Req
handler.ServeHTTP(w, r)
}
// CheckWriteTemplate operation middleware
func (siw *ServerInterfaceWrapper) CheckWriteTemplate(w http.ResponseWriter, r *http.Request) {
var err error
// ------------- Path parameter "id" -------------
var id SourceId
err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
return
}
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.CheckWriteTemplate(w, r, id)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// SuggestWriteTemplate operation middleware
func (siw *ServerInterfaceWrapper) SuggestWriteTemplate(w http.ResponseWriter, r *http.Request) {
var err error
// ------------- Path parameter "id" -------------
var id SourceId
err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
return
}
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.SuggestWriteTemplate(w, r, id)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// ListUsers operation middleware
func (siw *ServerInterfaceWrapper) ListUsers(w http.ResponseWriter, r *http.Request) {
@@ -3513,6 +3649,12 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
r.Group(func(r chi.Router) {
r.Post(options.BaseURL+"/api/v1/admin/sources/{id}/test", wrapper.TestSource)
})
r.Group(func(r chi.Router) {
r.Post(options.BaseURL+"/api/v1/admin/sources/{id}/write-template/check", wrapper.CheckWriteTemplate)
})
r.Group(func(r chi.Router) {
r.Post(options.BaseURL+"/api/v1/admin/sources/{id}/write-template/suggest", wrapper.SuggestWriteTemplate)
})
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/admin/users", wrapper.ListUsers)
})
@@ -4082,6 +4224,65 @@ func (response TestSourcedefaultApplicationProblemPlusJSONResponse) VisitTestSou
return json.NewEncoder(w).Encode(response.Body)
}
type CheckWriteTemplateRequestObject struct {
Id SourceId `json:"id"`
Body *CheckWriteTemplateJSONRequestBody
}
type CheckWriteTemplateResponseObject interface {
VisitCheckWriteTemplateResponse(w http.ResponseWriter) error
}
type CheckWriteTemplate200JSONResponse WriteTemplateCheck
func (response CheckWriteTemplate200JSONResponse) VisitCheckWriteTemplateResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type CheckWriteTemplatedefaultApplicationProblemPlusJSONResponse struct {
Body Problem
StatusCode int
}
func (response CheckWriteTemplatedefaultApplicationProblemPlusJSONResponse) VisitCheckWriteTemplateResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(response.StatusCode)
return json.NewEncoder(w).Encode(response.Body)
}
type SuggestWriteTemplateRequestObject struct {
Id SourceId `json:"id"`
}
type SuggestWriteTemplateResponseObject interface {
VisitSuggestWriteTemplateResponse(w http.ResponseWriter) error
}
type SuggestWriteTemplate200JSONResponse WriteTemplateSuggestion
func (response SuggestWriteTemplate200JSONResponse) VisitSuggestWriteTemplateResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type SuggestWriteTemplatedefaultApplicationProblemPlusJSONResponse struct {
Body Problem
StatusCode int
}
func (response SuggestWriteTemplatedefaultApplicationProblemPlusJSONResponse) VisitSuggestWriteTemplateResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(response.StatusCode)
return json.NewEncoder(w).Encode(response.Body)
}
type ListUsersRequestObject struct {
Params ListUsersParams
}
@@ -5380,6 +5581,12 @@ type StrictServerInterface interface {
// Проверить подключение (admin)
// (POST /api/v1/admin/sources/{id}/test)
TestSource(ctx context.Context, request TestSourceRequestObject) (TestSourceResponseObject, error)
// Проверить шаблон на обратимость (admin)
// (POST /api/v1/admin/sources/{id}/write-template/check)
CheckWriteTemplate(ctx context.Context, request CheckWriteTemplateRequestObject) (CheckWriteTemplateResponseObject, error)
// Подобрать шаблон пути по существующей структуре (admin)
// (POST /api/v1/admin/sources/{id}/write-template/suggest)
SuggestWriteTemplate(ctx context.Context, request SuggestWriteTemplateRequestObject) (SuggestWriteTemplateResponseObject, error)
// Пользователи (admin, moderator)
// (GET /api/v1/admin/users)
ListUsers(ctx context.Context, request ListUsersRequestObject) (ListUsersResponseObject, error)
@@ -5957,6 +6164,65 @@ func (sh *strictHandler) TestSource(w http.ResponseWriter, r *http.Request, id S
}
}
// CheckWriteTemplate operation middleware
func (sh *strictHandler) CheckWriteTemplate(w http.ResponseWriter, r *http.Request, id SourceId) {
var request CheckWriteTemplateRequestObject
request.Id = id
var body CheckWriteTemplateJSONRequestBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err))
return
}
request.Body = &body
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.CheckWriteTemplate(ctx, request.(CheckWriteTemplateRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "CheckWriteTemplate")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(CheckWriteTemplateResponseObject); ok {
if err := validResponse.VisitCheckWriteTemplateResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// SuggestWriteTemplate operation middleware
func (sh *strictHandler) SuggestWriteTemplate(w http.ResponseWriter, r *http.Request, id SourceId) {
var request SuggestWriteTemplateRequestObject
request.Id = id
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.SuggestWriteTemplate(ctx, request.(SuggestWriteTemplateRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "SuggestWriteTemplate")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(SuggestWriteTemplateResponseObject); ok {
if err := validResponse.VisitSuggestWriteTemplateResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// ListUsers operation middleware
func (sh *strictHandler) ListUsers(w http.ResponseWriter, r *http.Request, params ListUsersParams) {
var request ListUsersRequestObject
@@ -7138,186 +7404,200 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"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",
}
// GetSwagger returns the content of the embedded swagger specification file
+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
})
}
@@ -66,9 +66,16 @@ type API struct {
// StorageAgent — конфигурация внутреннего сервиса storage-agent.
type StorageAgent struct {
HTTPAddr string `env:"HTTP_ADDR" envDefault:":8090"`
InternalToken string `env:"INTERNAL_TOKEN,required,notEmpty"`
LibraryRoot string `env:"LIBRARY_ROOT" envDefault:"/library"`
HTTPAddr string `env:"HTTP_ADDR" envDefault:":8090"`
InternalToken string `env:"INTERNAL_TOKEN,required,notEmpty"`
LibraryRoot string `env:"LIBRARY_ROOT" envDefault:"/library"`
// Запись импортированных загрузок: rw-точки NFS (только внутри агента), LIBRARY_HOST на запись, staging.
LibraryRWRoot string `env:"LIBRARY_RW_ROOT" envDefault:"/library-rw"`
HostRWRoot string `env:"LIBRARY_HOST_RW_ROOT" envDefault:"/library-host-rw"`
UploadsRoot string `env:"UPLOADS_ROOT" envDefault:"/uploads"`
// Владелец новых файлов на файловом сервере; -1 — оставить как есть.
FileUID int `env:"PIPU_FILE_UID" envDefault:"-1"`
FileGID int `env:"PIPU_FILE_GID" envDefault:"-1"`
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"15s"`
}
+289
View File
@@ -0,0 +1,289 @@
package storageagent
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"strings"
)
// Запись в хранилище (docs/library-and-storage.md#запись-в-хранилище): только импорт одобренных загрузок,
// только создание новых файлов. Перезаписи и удаления файлов библиотеки в коде агента нет.
// Paths — каталоги агента.
type Paths struct {
Library string // ro-точки NFS: <Library>/<source_id> (видны api, nginx, analyzer)
LibraryRW string // rw-точки NFS-источников с accepts_uploads; видны только агенту. Пусто — запись в NFS выключена
HostRW string // LIBRARY_HOST на запись (local-источники); пусто — запись в local выключена
Uploads string // staging очереди на добавление (только чтение)
FileUID int // владелец новых файлов и каталогов; < 0 — не менять
FileGID int
}
// WriteRequest — тело POST /internal/files.
type WriteRequest struct {
SourceID string `json:"source_id"`
LocalPath *string `json:"local_path,omitempty"` // для local-источника: папка внутри LIBRARY_HOST
RelPath string `json:"rel_path"` // желаемый путь в хранилище
StagingPath string `json:"staging_path"` // путь файла относительно каталога staging
SHA256 string `json:"sha256"` // ожидаемый хэш (hex), копия сверяется с ним
}
// WriteResult — где файл оказался (при конфликте имени — с суффиксом « (2)»).
type WriteResult struct {
RelPath string `json:"rel_path"`
Size int64 `json:"size"`
Renamed bool `json:"renamed"`
}
// WriteTestRequest — тело POST /internal/files/test.
type WriteTestRequest struct {
SourceID string `json:"source_id"`
LocalPath *string `json:"local_path,omitempty"`
}
// WriteTestResult — можно ли писать в хранилище.
type WriteTestResult struct {
OK bool `json:"ok"`
Message string `json:"message,omitempty"`
}
const maxConflicts = 100
// errBadPath — путь вне корня, абсолютный или с «..».
var errBadPath = errors.New("path must be relative, without .. and hidden segments")
// cleanRel проверяет относительный путь с `/` и возвращает его в каноническом виде.
func cleanRel(rel string) (string, error) {
if rel == "" || strings.HasPrefix(rel, "/") || strings.ContainsAny(rel, "\\\x00") {
return "", errBadPath
}
for _, seg := range strings.Split(rel, "/") {
if seg == "" || seg == "." || seg == ".." || strings.HasPrefix(seg, ".") {
return "", errBadPath
}
}
return path.Clean(rel), nil
}
// writeRoot — каталог источника на запись.
func (a *agent) writeRoot(sourceID string, localPath *string) (string, error) {
if localPath != nil {
if a.paths.HostRW == "" {
return "", errors.New("writing to local sources is disabled")
}
root := a.paths.HostRW
if *localPath != "" {
rel, err := cleanRel(strings.Trim(*localPath, "/"))
if err != nil {
return "", err
}
root = filepath.Join(root, filepath.FromSlash(rel))
}
if info, err := os.Stat(root); err != nil || !info.IsDir() {
return "", errors.New("source folder not found")
}
return root, nil
}
if a.paths.LibraryRW == "" {
return "", errors.New("writing to NFS sources is disabled")
}
root, err := mountPoint(a.paths.LibraryRW, sourceID)
if err != nil {
return "", err
}
if _, err := os.Stat(root); err != nil {
return "", errors.New("source is not mounted for writing (accepts_uploads is off?)")
}
return root, nil
}
func (a *agent) writeFile(w http.ResponseWriter, r *http.Request) {
var req WriteRequest
if !a.decode(w, r, &req) {
return
}
root, err := a.writeRoot(req.SourceID, req.LocalPath)
if err != nil {
writeValidation(w, &ValidationError{Field: "source_id", Reason: err.Error()})
return
}
rel, err := cleanRel(req.RelPath)
if err != nil {
writeValidation(w, &ValidationError{Field: "rel_path", Reason: err.Error()})
return
}
staging, err := cleanRel(req.StagingPath)
if err != nil || a.paths.Uploads == "" {
writeValidation(w, &ValidationError{Field: "staging_path", Reason: "invalid staging path"})
return
}
want, err := hex.DecodeString(req.SHA256)
if err != nil || len(want) != sha256.Size {
writeValidation(w, &ValidationError{Field: "sha256", Reason: "must be 64 hex characters"})
return
}
res, err := a.write(root, rel, filepath.Join(a.paths.Uploads, filepath.FromSlash(staging)), want)
if err != nil {
a.log.ErrorContext(r.Context(), "write file", "source_id", req.SourceID, "rel_path", rel, "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]any{
"status": http.StatusInternalServerError, "code": "storage.write_failed", "detail": err.Error(),
})
return
}
a.log.InfoContext(r.Context(), "file written", "source_id", req.SourceID, "rel_path", res.RelPath, "size", res.Size)
writeJSON(w, http.StatusOK, res)
}
// write копирует staging во временный файл рядом с целью, fsync, сверяет SHA-256 и создаёт итоговое имя
// жёсткой ссылкой (не перезаписывает существующий файл, в отличие от rename), затем убирает временный.
func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, error) {
target := filepath.Join(root, filepath.FromSlash(rel))
dir := filepath.Dir(target)
if err := a.mkdirs(root, dir); err != nil {
return WriteResult{}, err
}
tmp := filepath.Join(dir, "."+filepath.Base(target)+".pipu-tmp")
size, err := a.copyTemp(staging, tmp)
if err != nil {
_ = os.Remove(tmp)
return WriteResult{}, err
}
defer func() { _ = os.Remove(tmp) }()
got, err := fileSHA256(tmp)
if err != nil {
return WriteResult{}, err
}
if !bytes.Equal(got, want) {
return WriteResult{}, fmt.Errorf("checksum mismatch: copy %x, expected %x", got, want)
}
ext := path.Ext(rel)
base := strings.TrimSuffix(rel, ext)
for n := 1; n <= maxConflicts; n++ {
name := rel
if n > 1 {
name = fmt.Sprintf("%s (%d)%s", base, n, ext)
}
err := os.Link(tmp, filepath.Join(root, filepath.FromSlash(name)))
if err == nil {
syncDir(dir)
return WriteResult{RelPath: name, Size: size, Renamed: n > 1}, nil
}
if !os.IsExist(err) {
return WriteResult{}, fmt.Errorf("link: %w", err)
}
}
return WriteResult{}, errors.New("too many files with the same name")
}
// mkdirs создаёт недостающие каталоги от root до dir (0755, владелец — FileUID/FileGID).
func (a *agent) mkdirs(root, dir string) error {
rel, err := filepath.Rel(root, dir)
if err != nil || strings.HasPrefix(rel, "..") {
return errBadPath
}
cur := root
for _, seg := range strings.Split(rel, string(filepath.Separator)) {
if seg == "." || seg == "" {
continue
}
cur = filepath.Join(cur, seg)
if err := os.Mkdir(cur, 0o755); err != nil {
if os.IsExist(err) {
continue
}
return fmt.Errorf("mkdir: %w", err)
}
a.chown(cur)
}
return nil
}
func (a *agent) copyTemp(staging, tmp string) (int64, error) {
src, err := os.Open(staging)
if err != nil {
return 0, fmt.Errorf("staging: %w", err)
}
defer func() { _ = src.Close() }()
_ = os.Remove(tmp) // остаток прерванной попытки
dst, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
return 0, fmt.Errorf("create temp: %w", err)
}
n, err := io.Copy(dst, src)
if err == nil {
err = dst.Sync()
}
if cerr := dst.Close(); err == nil {
err = cerr
}
if err != nil {
return 0, fmt.Errorf("copy: %w", err)
}
a.chown(tmp)
return n, nil
}
func (a *agent) chown(p string) {
if a.paths.FileUID >= 0 {
if err := os.Lchown(p, a.paths.FileUID, a.paths.FileGID); err != nil {
a.log.Warn("chown failed", "path", p, "err", err)
}
}
}
// testWrite создаёт и удаляет .pipu-write-test в корне источника на запись.
func (a *agent) testWrite(w http.ResponseWriter, r *http.Request) {
var req WriteTestRequest
if !a.decode(w, r, &req) {
return
}
root, err := a.writeRoot(req.SourceID, req.LocalPath)
if err != nil {
writeJSON(w, http.StatusOK, WriteTestResult{Message: err.Error()})
return
}
p := filepath.Join(root, ".pipu-write-test")
f, err := os.OpenFile(p, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err == nil {
_, err = f.WriteString("pipu")
if cerr := f.Close(); err == nil {
err = cerr
}
}
_ = os.Remove(p)
if err != nil {
writeJSON(w, http.StatusOK, WriteTestResult{Message: "cannot write: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, WriteTestResult{OK: true, Message: "write access ok"})
}
func fileSHA256(p string) ([]byte, error) {
f, err := os.Open(p)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return nil, err
}
return h.Sum(nil), nil
}
// syncDir — fsync каталога, чтобы новое имя пережило сбой питания (на Windows не поддерживается — молча).
func syncDir(dir string) {
if d, err := os.Open(dir); err == nil {
_ = d.Sync()
_ = d.Close()
}
}
@@ -0,0 +1,131 @@
package storageagent
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const srcID = "0191d3a4-7c1e-7a51-9a3b-2f6f5c3e9b10"
type writeEnv struct {
h http.Handler
paths Paths
mounter *fakeMounter
}
func newWriteEnv(t *testing.T) writeEnv {
base := t.TempDir()
p := Paths{
Library: filepath.Join(base, "library"), LibraryRW: filepath.Join(base, "library-rw"),
HostRW: filepath.Join(base, "host"), Uploads: filepath.Join(base, "uploads"), FileUID: -1, FileGID: -1,
}
for _, d := range []string{p.Library, p.LibraryRW, p.HostRW, filepath.Join(p.LibraryRW, srcID), filepath.Join(p.Uploads, "u1"), filepath.Join(p.HostRW, "music")} {
require.NoError(t, os.MkdirAll(d, 0o755))
}
m := &fakeMounter{mounted: map[string]NFSSpec{}}
return writeEnv{h: NewRouterWithPaths(nil, "secret", p, m), paths: p, mounter: m}
}
func (e writeEnv) post(path string, body any) *httptest.ResponseRecorder {
raw, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
req.Header.Set("Authorization", "Bearer secret")
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
return rec
}
func (e writeEnv) staging(t *testing.T, name, content string) string {
require.NoError(t, os.WriteFile(filepath.Join(e.paths.Uploads, "u1", name), []byte(content), 0o644))
sum := sha256.Sum256([]byte(content))
return hex.EncodeToString(sum[:])
}
func TestWriteFile(t *testing.T) {
e := newWriteEnv(t)
sum := e.staging(t, "f1", "audio-bytes")
req := WriteRequest{SourceID: srcID, RelPath: "Кино/1988 - Группа крови/01 - Группа крови.flac", StagingPath: "u1/f1", SHA256: sum}
rec := e.post("/internal/files", req)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var res WriteResult
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &res))
assert.Equal(t, WriteResult{RelPath: req.RelPath, Size: 11}, res)
dir := filepath.Join(e.paths.LibraryRW, srcID, "Кино", "1988 - Группа крови")
got, err := os.ReadFile(filepath.Join(dir, "01 - Группа крови.flac"))
require.NoError(t, err)
assert.Equal(t, "audio-bytes", string(got))
// Существующий файл не перезаписывается — новое имя с суффиксом; временных файлов не остаётся.
rec = e.post("/internal/files", req)
require.Equal(t, http.StatusOK, rec.Code)
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &res))
assert.Equal(t, "Кино/1988 - Группа крови/01 - Группа крови (2).flac", res.RelPath)
assert.True(t, res.Renamed)
entries, _ := os.ReadDir(dir)
assert.Len(t, entries, 2)
// Local-источник пишется в LIBRARY_HOST.
local := "music"
rec = e.post("/internal/files", WriteRequest{SourceID: srcID, LocalPath: &local, RelPath: "A/t.mp3", StagingPath: "u1/f1", SHA256: sum})
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
assert.FileExists(t, filepath.Join(e.paths.HostRW, "music", "A", "t.mp3"))
}
func TestWriteFileRejects(t *testing.T) {
e := newWriteEnv(t)
sum := e.staging(t, "f1", "audio-bytes")
cases := map[string]WriteRequest{
"traversal": {SourceID: srcID, RelPath: "../escape.flac", StagingPath: "u1/f1", SHA256: sum},
"absolute": {SourceID: srcID, RelPath: "/etc/x.flac", StagingPath: "u1/f1", SHA256: sum},
"hidden": {SourceID: srcID, RelPath: "A/.x.flac", StagingPath: "u1/f1", SHA256: sum},
"staging escape": {SourceID: srcID, RelPath: "A/x.flac", StagingPath: "../library/x", SHA256: sum},
"not writable": {SourceID: "0191d3a4-7c1e-7a51-9a3b-2f6f5c3e9b11", RelPath: "A/x.flac", StagingPath: "u1/f1", SHA256: sum},
"bad sha256": {SourceID: srcID, RelPath: "A/x.flac", StagingPath: "u1/f1", SHA256: "zz"},
"bad local path": {SourceID: srcID, LocalPath: ptr("../x"), RelPath: "A/x.flac", StagingPath: "u1/f1", SHA256: sum},
}
for name, req := range cases {
rec := e.post("/internal/files", req)
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, name)
}
// Копия не совпала с ожидаемым хэшем — ошибка, ни итогового, ни временного файла.
other := sha256.Sum256([]byte("other"))
rec := e.post("/internal/files", WriteRequest{SourceID: srcID, RelPath: "A/x.flac", StagingPath: "u1/f1", SHA256: hex.EncodeToString(other[:])})
assert.Equal(t, http.StatusInternalServerError, rec.Code)
entries, _ := os.ReadDir(filepath.Join(e.paths.LibraryRW, srcID, "A"))
assert.Empty(t, entries)
}
func TestWritableMount(t *testing.T) {
e := newWriteEnv(t)
rec := e.post("/internal/mounts", MountRequest{SourceID: srcID, NFSSpec: NFSSpec{Server: "nas", Export: "/music"}, Writable: true})
require.Equal(t, http.StatusOK, rec.Code)
rw := filepath.Join(e.paths.LibraryRW, srcID)
ro := filepath.Join(e.paths.Library, srcID)
assert.Contains(t, e.mounter.mounted[rw].Options, "rw")
assert.Equal(t, "bind:"+rw, e.mounter.mounted[ro].Export)
rec = e.post("/internal/files/test", WriteTestRequest{SourceID: srcID})
var res WriteTestResult
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &res))
assert.True(t, res.OK, res.Message)
assert.NoFileExists(t, filepath.Join(rw, ".pipu-write-test"))
// Без accepts_uploads rw-точка снимается.
e.post("/internal/mounts", MountRequest{SourceID: srcID, NFSSpec: NFSSpec{Server: "nas", Export: "/music"}})
assert.NotContains(t, e.mounter.mounted, rw)
assert.Contains(t, e.mounter.mounted[ro].Options, "ro")
}
func ptr(s string) *string { return &s }
+17
View File
@@ -101,6 +101,8 @@ func (s NFSSpec) Normalize(writable bool) (NFSSpec, error) {
type Mounter interface {
Mount(ctx context.Context, spec NFSSpec, target string) error
Unmount(ctx context.Context, target string) error
// BindReadOnly показывает каталог src в target только на чтение.
BindReadOnly(ctx context.Context, src, target string) error
}
// ExecMounter вызывает системные mount / umount.
@@ -119,6 +121,21 @@ func (ExecMounter) Mount(ctx context.Context, spec NFSSpec, target string) error
return nil
}
// BindReadOnly реализует Mounter: bind, затем remount ro (флаг ro у bind ставится только перемонтированием).
func (ExecMounter) BindReadOnly(ctx context.Context, src, target string) error {
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
if out, err := exec.CommandContext(ctx, "mount", "--bind", src, target).CombinedOutput(); err != nil {
return fmt.Errorf("mount --bind: %s", strings.TrimSpace(string(out)))
}
if out, err := exec.CommandContext(ctx, "mount", "-o", "remount,bind,ro", target).CombinedOutput(); err != nil {
_, _ = exec.CommandContext(ctx, "umount", target).CombinedOutput()
return fmt.Errorf("remount ro: %s", strings.TrimSpace(string(out)))
}
return nil
}
// Unmount реализует Mounter. Ленивое отключение: зависший NFS-сервер не блокирует агента.
func (ExecMounter) Unmount(ctx context.Context, target string) error {
out, err := exec.CommandContext(ctx, "umount", "-l", target).CombinedOutput()
@@ -68,6 +68,11 @@ func (f *fakeMounter) Mount(_ context.Context, spec NFSSpec, target string) erro
return nil
}
func (f *fakeMounter) BindReadOnly(_ context.Context, src, target string) error {
f.mounted[target] = NFSSpec{Export: "bind:" + src}
return os.MkdirAll(target, 0o755)
}
func (f *fakeMounter) Unmount(_ context.Context, target string) error {
delete(f.mounted, target)
return nil
+48 -2
View File
@@ -33,6 +33,12 @@ func NewRouter(log *slog.Logger, token, libraryRoot string) http.Handler {
// NewRouterWithMounter — NewRouter с заданным способом монтирования (для тестов).
func NewRouterWithMounter(log *slog.Logger, token, libraryRoot string, mounter Mounter) http.Handler {
return NewRouterWithPaths(log, token, Paths{Library: libraryRoot, FileUID: -1, FileGID: -1}, mounter)
}
// NewRouterWithPaths — роутер с каталогами записи (импорт загрузок).
func NewRouterWithPaths(log *slog.Logger, token string, paths Paths, mounter Mounter) http.Handler {
libraryRoot := paths.Library
if log == nil {
log = slog.New(slog.DiscardHandler)
}
@@ -52,10 +58,12 @@ func NewRouterWithMounter(log *slog.Logger, token, libraryRoot string, mounter M
}
writeJSON(w, http.StatusOK, map[string]any{"mounts": mounts})
})
a := &agent{log: log, root: libraryRoot, mounter: mounter}
a := &agent{log: log, root: libraryRoot, paths: paths, mounter: mounter}
r.Post("/mounts", a.mount)
r.Post("/mounts/test", a.test)
r.Delete("/mounts/{sourceID}", a.unmount)
r.Post("/files", a.writeFile)
r.Post("/files/test", a.testWrite)
})
return r
}
@@ -63,6 +71,7 @@ func NewRouterWithMounter(log *slog.Logger, token, libraryRoot string, mounter M
type agent struct {
log *slog.Logger
root string
paths Paths
mounter Mounter
}
@@ -119,7 +128,40 @@ func (a *agent) mount(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), probeTimeout)
defer cancel()
_ = a.mounter.Unmount(ctx, target)
writeJSON(w, http.StatusOK, a.mountAndCount(ctx, spec, target))
rwTarget := a.rwPoint(req.SourceID)
if rwTarget != "" {
_ = a.mounter.Unmount(ctx, rwTarget)
_ = os.Remove(rwTarget)
}
if !req.Writable {
writeJSON(w, http.StatusOK, a.mountAndCount(ctx, spec, target))
return
}
// Источник принимает загрузки: rw-точка видна только агенту, всем остальным — bind-копия только на чтение.
if rwTarget == "" {
writeJSON(w, http.StatusOK, MountResult{Status: "error", Message: "writable mounts are disabled (LIBRARY_RW_ROOT)"})
return
}
res := a.mountAndCount(ctx, spec, rwTarget)
if res.Status == "mounted" {
if err := a.mounter.BindReadOnly(ctx, rwTarget, target); err != nil {
_ = a.mounter.Unmount(ctx, rwTarget)
res = MountResult{Status: "error", Message: err.Error()}
}
}
writeJSON(w, http.StatusOK, res)
}
// rwPoint — rw-точка источника ("" — запись в NFS выключена или id неверный).
func (a *agent) rwPoint(sourceID string) string {
if a.paths.LibraryRW == "" {
return ""
}
p, err := mountPoint(a.paths.LibraryRW, sourceID)
if err != nil {
return ""
}
return p
}
// test монтирует во временную точку, считает файлы и отключает.
@@ -167,6 +209,10 @@ func (a *agent) unmount(w http.ResponseWriter, r *http.Request) {
return
}
_ = os.Remove(target)
if rw := a.rwPoint(chi.URLParam(r, "sourceID")); rw != "" {
_ = a.mounter.Unmount(r.Context(), rw)
_ = os.Remove(rw)
}
w.WriteHeader(http.StatusNoContent)
}