refactor: isolate playback and upload use cases
ci / module boundaries / boundaries (push) Successful in 6s
ci / api / api (push) Successful in 47s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Successful in 1m4s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Successful in 2m54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m14s
ci / module boundaries / boundaries (push) Successful in 6s
ci / api / api (push) Successful in 47s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Successful in 1m4s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Successful in 2m54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m14s
This commit is contained in:
1 parent
c84f90e2cc
commit
e158558da3
56 files changed
+3337
-2196
No files matched your search
@@ -0,0 +1,69 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
type ScanStore interface {
|
||||
GetSource(context.Context, uuid.UUID) (Source, error)
|
||||
RetryFailedAnalysis(context.Context) (int, error)
|
||||
}
|
||||
type ScanStarter interface {
|
||||
Start(Source, string, bool) error
|
||||
}
|
||||
|
||||
// ScanService owns explicit and scheduled scan starts.
|
||||
type ScanService struct {
|
||||
store ScanStore
|
||||
scanner ScanStarter
|
||||
board *ProgressBoard
|
||||
root func(Source) (string, error)
|
||||
}
|
||||
|
||||
// StartScan запускает скан источника в фоне.
|
||||
// RetryAnalysis возвращает в очередь треки, которые анализатор не смог обработать (например, пока диск
|
||||
// не был смонтирован).
|
||||
func (s *ScanService) RetryAnalysis(ctx context.Context) (int, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.store.RetryFailedAnalysis(ctx)
|
||||
}
|
||||
|
||||
func (s *ScanService) StartScan(ctx context.Context, id uuid.UUID, full bool) (Progress, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return Progress{}, err
|
||||
}
|
||||
src, err := s.store.GetSource(ctx, id)
|
||||
if err != nil {
|
||||
return Progress{}, notFound(err)
|
||||
}
|
||||
if err := s.startScan(src, full); err != nil {
|
||||
return Progress{}, err
|
||||
}
|
||||
p, _ := s.board.Get(id)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *ScanService) startScan(src Source, full bool) error {
|
||||
if src.Status != "mounted" {
|
||||
return apperr.New(http.StatusConflict, CodeSourceOffline, "source is not connected")
|
||||
}
|
||||
root, err := s.root(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.scanner.Start(src, root, full); errors.Is(err, ErrScanRunning) {
|
||||
return apperr.Conflict(CodeScanRunning, "scan is already running")
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
type scanStoreStub struct{ source Source }
|
||||
|
||||
func (s scanStoreStub) GetSource(context.Context, uuid.UUID) (Source, error) { return s.source, nil }
|
||||
func (s scanStoreStub) RetryFailedAnalysis(context.Context) (int, error) { return 7, nil }
|
||||
|
||||
type scanStartFunc func(Source, string, bool) error
|
||||
|
||||
func (f scanStartFunc) Start(s Source, root string, full bool) error { return f(s, root, full) }
|
||||
|
||||
func TestScanServiceAuthorizationAndStart(t *testing.T) {
|
||||
id := uuid.New()
|
||||
called := false
|
||||
s := &ScanService{
|
||||
store: scanStoreStub{Source{ID: id, Status: "mounted"}}, board: NewProgressBoard(),
|
||||
root: func(Source) (string, error) { return "music", nil },
|
||||
scanner: scanStartFunc(func(src Source, root string, full bool) error {
|
||||
called = true
|
||||
require.Equal(t, id, src.ID)
|
||||
require.Equal(t, "music", root)
|
||||
require.True(t, full)
|
||||
return ErrScanRunning
|
||||
}),
|
||||
}
|
||||
_, err := s.StartScan(context.Background(), id, true)
|
||||
require.Error(t, err)
|
||||
require.False(t, called)
|
||||
ctx := auth.WithPrincipal(context.Background(), auth.Principal{Role: auth.RoleAdmin})
|
||||
_, err = s.StartScan(ctx, id, true)
|
||||
var problem *apperr.Error
|
||||
require.ErrorAs(t, err, &problem)
|
||||
require.Equal(t, CodeScanRunning, problem.Code)
|
||||
require.True(t, called)
|
||||
}
|
||||
|
||||
func TestScanServiceDoesNotStartOfflineOrUnresolvedSource(t *testing.T) {
|
||||
s := &ScanService{} // no scanner needed: errors must be returned before IO
|
||||
require.Error(t, s.startScan(Source{Status: "error"}, false))
|
||||
failure := errors.New("invalid root")
|
||||
s.root = func(Source) (string, error) { return "", failure }
|
||||
require.ErrorIs(t, s.startScan(Source{Status: "mounted"}, false), failure)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
type ScheduleStore interface {
|
||||
ListSources(context.Context) ([]Source, error)
|
||||
SetSourceStatus(context.Context, uuid.UUID, string, *string) error
|
||||
}
|
||||
|
||||
type sourceConnection interface {
|
||||
connect(context.Context, Source) (Source, error)
|
||||
check(context.Context, Source, bool) (string, *string)
|
||||
}
|
||||
|
||||
type scheduledScan interface {
|
||||
startScan(Source, bool) error
|
||||
}
|
||||
|
||||
// Scheduler owns the periodic lifetime, not source or template business rules.
|
||||
type Scheduler struct {
|
||||
store ScheduleStore
|
||||
sources sourceConnection
|
||||
scans scheduledScan
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// Restore при старте api переподключает NFS-хранилища и проверяет local (после перезапуска агента).
|
||||
func (s *Scheduler) Restore(ctx context.Context) {
|
||||
sources, err := s.store.ListSources(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("restore sources", "err", err)
|
||||
return
|
||||
}
|
||||
for _, src := range sources {
|
||||
if src.Status == "disabled" {
|
||||
continue
|
||||
}
|
||||
if _, err := s.sources.connect(ctx, src); err != nil {
|
||||
s.log.Error("restore source", "source_id", src.ID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunSchedule раз в минуту проверяет доступность хранилищ и запускает сканы по расписанию.
|
||||
func (s *Scheduler) RunSchedule(ctx context.Context) {
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
sources, err := s.store.ListSources(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("schedule: list sources", "err", err)
|
||||
continue
|
||||
}
|
||||
for _, src := range sources {
|
||||
if src.Status == "disabled" {
|
||||
continue
|
||||
}
|
||||
s.healthCheck(ctx, src)
|
||||
if src.ScanInterval == nil || src.Status != "mounted" {
|
||||
continue
|
||||
}
|
||||
if src.LastScanAt == nil || time.Since(*src.LastScanAt) >= *src.ScanInterval {
|
||||
if err := s.scans.startScan(src, false); err != nil && !errors.Is(err, apperr.Conflict(CodeScanRunning, "")) {
|
||||
s.log.Warn("scheduled scan", "source_id", src.ID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// healthCheck: недоступное хранилище — status=error (треки серые), вернулось — снова mounted.
|
||||
func (s *Scheduler) healthCheck(ctx context.Context, src Source) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
type result struct {
|
||||
status string
|
||||
message *string
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
status, message := s.sources.check(ctx, src, false)
|
||||
done <- result{status, message}
|
||||
}()
|
||||
var status string
|
||||
var message *string
|
||||
select {
|
||||
case r := <-done:
|
||||
status, message = r.status, r.message
|
||||
case <-ctx.Done():
|
||||
m := "storage does not respond"
|
||||
status, message = "error", &m
|
||||
}
|
||||
if status == "error" && src.Type == SourceNFS && src.Status == "error" {
|
||||
// Попробовать перемонтировать, как при старте.
|
||||
if _, err := s.sources.connect(ctx, src); err == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if status != src.Status || deref(message) != deref(src.StatusMessage) {
|
||||
if err := s.store.SetSourceStatus(ctx, src.ID, status, message); err != nil {
|
||||
s.log.Warn("health check", "source_id", src.ID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,25 +3,10 @@ package library
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/library/meta"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
"pipu/api/internal/storageagent"
|
||||
)
|
||||
|
||||
// Коды ошибок библиотеки.
|
||||
@@ -45,24 +30,23 @@ type Config struct {
|
||||
HostRoot string // LIBRARY_HOST внутри контейнера, для local-источников
|
||||
}
|
||||
|
||||
// Service управляет хранилищами и сканами.
|
||||
// Service preserves the API while each use case owns only its dependencies.
|
||||
type Service struct {
|
||||
store *Store
|
||||
scanner *Scanner
|
||||
board *ProgressBoard
|
||||
agent Agent
|
||||
prober Prober
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
mu sync.Mutex // сериализует переподключение одного и того же источника
|
||||
|
||||
// OnCatalogChanged — каталог изменился не сканом (удалено хранилище): переиндексация поиска.
|
||||
OnCatalogChanged func()
|
||||
*SourceService
|
||||
*TemplateService
|
||||
*ScanService
|
||||
*Scheduler
|
||||
}
|
||||
|
||||
// NewService создаёт сервис.
|
||||
func NewService(store *Store, scanner *Scanner, board *ProgressBoard, agent Agent, prober Prober, cfg Config, log *slog.Logger) *Service {
|
||||
return &Service{store: store, scanner: scanner, board: board, agent: agent, prober: prober, cfg: cfg, log: log}
|
||||
sources := &SourceService{store: store, board: board, agent: agent, cfg: cfg, log: log}
|
||||
scans := &ScanService{store: store, scanner: scanner, board: board, root: sources.Root}
|
||||
return &Service{
|
||||
SourceService: sources,
|
||||
TemplateService: &TemplateService{store: store, prober: prober, root: sources.Root},
|
||||
ScanService: scans,
|
||||
Scheduler: &Scheduler{store: store, sources: sources, scans: scans, log: log},
|
||||
}
|
||||
}
|
||||
|
||||
func forbidden() error { return apperr.Forbidden(auth.CodeForbidden, "not allowed") }
|
||||
@@ -84,746 +68,3 @@ func notFound(err error) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Root — каталог источника в файловой системе api.
|
||||
func (s *Service) Root(src Source) (string, error) {
|
||||
switch src.Type {
|
||||
case SourceLocal:
|
||||
rel := ""
|
||||
if src.LocalPath != nil {
|
||||
rel = *src.LocalPath
|
||||
}
|
||||
return s.hostPath(rel)
|
||||
case SourceNFS:
|
||||
return filepath.Join(s.cfg.LibraryRoot, src.ID.String()), nil
|
||||
}
|
||||
return "", fmt.Errorf("library: unknown source type %q", src.Type)
|
||||
}
|
||||
|
||||
// hostPath — путь внутри LIBRARY_HOST; выйти за его пределы нельзя.
|
||||
func (s *Service) hostPath(rel string) (string, error) {
|
||||
rel = strings.Trim(strings.ReplaceAll(rel, "\\", "/"), "/")
|
||||
clean := path.Clean("/" + rel)
|
||||
if clean != "/"+rel && rel != "" || strings.Contains(rel, "..") {
|
||||
return "", apperr.Validation("local_path", CodeLocalPathInvalid, "path must be relative without ..", nil)
|
||||
}
|
||||
return filepath.Join(s.cfg.HostRoot, filepath.FromSlash(strings.TrimPrefix(clean, "/"))), nil
|
||||
}
|
||||
|
||||
// ListSources — хранилища с прогрессом сканов.
|
||||
func (s *Service) ListSources(ctx context.Context) ([]Source, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.ListSources(ctx)
|
||||
}
|
||||
|
||||
// GetSource — хранилище по id.
|
||||
func (s *Service) GetSource(ctx context.Context, id uuid.UUID) (Source, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
src, err := s.store.GetSource(ctx, id)
|
||||
return src, notFound(err)
|
||||
}
|
||||
|
||||
// Progress — прогресс скана источника, если он был.
|
||||
func (s *Service) Progress(id uuid.UUID) (Progress, bool) { return s.board.Get(id) }
|
||||
|
||||
// SourceInput — поля создания / изменения хранилища. nil — не задано.
|
||||
type SourceInput struct {
|
||||
Name *string
|
||||
Type SourceType
|
||||
NFSServer *string
|
||||
NFSExport *string
|
||||
NFSOptions *string
|
||||
LocalPath *string
|
||||
TagsFirst *bool
|
||||
ScanIntervalMinutes *int // 0 — выключить
|
||||
Enabled *bool
|
||||
AcceptsUploads *bool
|
||||
WriteTemplate *string // "" — убрать
|
||||
}
|
||||
|
||||
func (s *Service) apply(src *Source, in SourceInput) error {
|
||||
if in.Name != nil {
|
||||
name := strings.TrimSpace(*in.Name)
|
||||
if name == "" || utf8.RuneCountInString(name) > 100 {
|
||||
return apperr.Validation("name", CodeNameInvalid, "name must be 1-100 characters", map[string]any{"max": 100})
|
||||
}
|
||||
src.Name = name
|
||||
}
|
||||
if in.TagsFirst != nil {
|
||||
src.TagsFirst = *in.TagsFirst
|
||||
}
|
||||
if in.ScanIntervalMinutes != nil {
|
||||
switch m := *in.ScanIntervalMinutes; {
|
||||
case m == 0:
|
||||
src.ScanInterval = nil
|
||||
case m < 5 || m > 60*24*30:
|
||||
return apperr.Validation("scan_interval_minutes", CodeIntervalInvalid, "interval must be 5 minutes .. 30 days",
|
||||
map[string]any{"min": 5})
|
||||
default:
|
||||
d := time.Duration(m) * time.Minute
|
||||
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 {
|
||||
rel := strings.Trim(strings.ReplaceAll(*in.LocalPath, "\\", "/"), "/")
|
||||
if _, err := s.hostPath(rel); err != nil {
|
||||
return err
|
||||
}
|
||||
src.LocalPath = &rel
|
||||
}
|
||||
if src.LocalPath == nil {
|
||||
empty := ""
|
||||
src.LocalPath = &empty
|
||||
}
|
||||
case SourceNFS:
|
||||
if in.NFSServer != nil {
|
||||
src.NFSServer = in.NFSServer
|
||||
}
|
||||
if in.NFSExport != nil {
|
||||
src.NFSExport = in.NFSExport
|
||||
}
|
||||
if in.NFSOptions != nil {
|
||||
src.NFSOptions = *in.NFSOptions
|
||||
}
|
||||
spec, err := nfsSpec(*src).Normalize(false)
|
||||
if err != nil {
|
||||
var ve *storageagent.ValidationError
|
||||
if errors.As(err, &ve) {
|
||||
return apperr.Validation(ve.Field, CodeNFSInvalid, ve.Reason, nil)
|
||||
}
|
||||
return err
|
||||
}
|
||||
src.NFSServer, src.NFSExport, src.NFSOptions = &spec.Server, &spec.Export, spec.Options
|
||||
default:
|
||||
return apperr.Validation("type", "validation.source_type_invalid", "unknown source type", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nfsSpec(src Source) storageagent.NFSSpec {
|
||||
var spec storageagent.NFSSpec
|
||||
if src.NFSServer != nil {
|
||||
spec.Server = *src.NFSServer
|
||||
}
|
||||
if src.NFSExport != nil {
|
||||
spec.Export = *src.NFSExport
|
||||
}
|
||||
spec.Options = src.NFSOptions
|
||||
return spec
|
||||
}
|
||||
|
||||
// CreateSource сохраняет хранилище и сразу пытается подключить его.
|
||||
func (s *Service) CreateSource(ctx context.Context, in SourceInput) (Source, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
src := Source{ID: newID(), Type: in.Type, TagsFirst: true, Status: "pending", NFSOptions: "ro,nfsvers=4.1,soft,timeo=150,retrans=3"}
|
||||
if in.Name == nil {
|
||||
in.Name = new(string)
|
||||
}
|
||||
if err := s.apply(&src, in); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
src, err := s.store.SaveSource(ctx, src)
|
||||
if err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
return s.connect(ctx, src)
|
||||
}
|
||||
|
||||
// UpdateSource меняет хранилище; смена адреса или папки переподключает его.
|
||||
func (s *Service) UpdateSource(ctx context.Context, id uuid.UUID, in SourceInput) (Source, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
src, err := s.store.GetSource(ctx, id)
|
||||
if err != nil {
|
||||
return Source{}, notFound(err)
|
||||
}
|
||||
before := src
|
||||
in.Type = src.Type
|
||||
if err := s.apply(&src, in); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
if src, err = s.store.SaveSource(ctx, src); err != nil {
|
||||
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,
|
||||
x.Type == SourceNFS && x.AcceptsUploads)
|
||||
}
|
||||
switch {
|
||||
case in.Enabled != nil && !*in.Enabled:
|
||||
if src.Type == SourceNFS {
|
||||
_ = s.agent.Unmount(ctx, src.ID)
|
||||
}
|
||||
if err := s.store.SetSourceStatus(ctx, src.ID, "disabled", nil); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
return s.store.GetSource(ctx, src.ID)
|
||||
case in.Enabled != nil && *in.Enabled, location(before) != location(src), src.Status == "error":
|
||||
return s.connect(ctx, src)
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
|
||||
func deref(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// connect подключает хранилище и записывает статус: local — проверка каталога, nfs — монтирование агентом.
|
||||
func (s *Service) connect(ctx context.Context, src Source) (Source, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
status, message := s.check(ctx, src, true)
|
||||
if err := s.store.SetSourceStatus(ctx, src.ID, status, message); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
return s.store.GetSource(ctx, src.ID)
|
||||
}
|
||||
|
||||
// check проверяет доступность; mount — смонтировать NFS (иначе только проверить точку).
|
||||
func (s *Service) check(ctx context.Context, src Source, mount bool) (string, *string) {
|
||||
msg := func(m string) *string { return &m }
|
||||
switch src.Type {
|
||||
case SourceLocal:
|
||||
root, err := s.Root(src)
|
||||
if err != nil {
|
||||
return "error", msg(err.Error())
|
||||
}
|
||||
info, err := os.Stat(root)
|
||||
if err != nil || !info.IsDir() {
|
||||
return "error", msg("folder not found: " + deref(src.LocalPath))
|
||||
}
|
||||
return "mounted", nil
|
||||
case SourceNFS:
|
||||
if !mount {
|
||||
root, _ := s.Root(src)
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
return "error", msg(err.Error())
|
||||
}
|
||||
return "mounted", nil
|
||||
}
|
||||
res, err := s.agent.Mount(ctx, src.ID, nfsSpec(src), src.AcceptsUploads)
|
||||
if err != nil {
|
||||
return "error", msg(err.Error())
|
||||
}
|
||||
if res.Status != "mounted" {
|
||||
return "error", msg(res.Message)
|
||||
}
|
||||
return "mounted", nil
|
||||
}
|
||||
return "error", msg("unknown type")
|
||||
}
|
||||
|
||||
// DeleteSource отключает хранилище и удаляет его файлы из каталога.
|
||||
func (s *Service) DeleteSource(ctx context.Context, id uuid.UUID) error {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return err
|
||||
}
|
||||
src, err := s.store.GetSource(ctx, id)
|
||||
if err != nil {
|
||||
return notFound(err)
|
||||
}
|
||||
if p, ok := s.board.Get(id); ok && p.Running {
|
||||
return apperr.Conflict(CodeScanRunning, "scan is running")
|
||||
}
|
||||
if src.Type == SourceNFS {
|
||||
if err := s.agent.Unmount(ctx, id); err != nil {
|
||||
s.log.WarnContext(ctx, "unmount failed", "source_id", id, "err", err)
|
||||
}
|
||||
}
|
||||
if err := s.store.DeleteSource(ctx, id); err != nil {
|
||||
return notFound(err)
|
||||
}
|
||||
if err := s.store.CleanupOrphans(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.OnCatalogChanged != nil {
|
||||
s.OnCatalogChanged()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestResult — итог проверки подключения.
|
||||
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).
|
||||
func (s *Service) TestSource(ctx context.Context, id uuid.UUID) (TestResult, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return TestResult{}, err
|
||||
}
|
||||
src, err := s.store.GetSource(ctx, id)
|
||||
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 {
|
||||
return TestResult{Message: err.Error()}, nil
|
||||
}
|
||||
return TestResult{OK: res.Status == "mounted", Message: res.Message, AudioFiles: res.AudioFiles}, nil
|
||||
}
|
||||
root, err := s.Root(src)
|
||||
if err != nil {
|
||||
return TestResult{Message: err.Error()}, nil
|
||||
}
|
||||
n := 0
|
||||
err = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() && IsAudio(d.Name()) {
|
||||
n++
|
||||
if n >= 1000 {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return TestResult{Message: err.Error(), AudioFiles: n}, nil
|
||||
}
|
||||
return TestResult{OK: true, Message: fmt.Sprintf("found %d audio files", n), AudioFiles: n}, nil
|
||||
}
|
||||
|
||||
// Patterns — шаблоны путей источника.
|
||||
func (s *Service) Patterns(ctx context.Context, id uuid.UUID) ([]PatternRow, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.store.GetSource(ctx, id); err != nil {
|
||||
return nil, notFound(err)
|
||||
}
|
||||
return s.store.Patterns(ctx, id)
|
||||
}
|
||||
|
||||
func compile(patterns []PatternRow) ([]meta.Pattern, error) {
|
||||
var exprs []string
|
||||
idx := []int{}
|
||||
for i, p := range patterns {
|
||||
if p.IsEnabled {
|
||||
exprs = append(exprs, p.Regex)
|
||||
idx = append(idx, i)
|
||||
}
|
||||
}
|
||||
compiled, err := meta.CompilePatterns(exprs)
|
||||
var pe *meta.PatternError
|
||||
if errors.As(err, &pe) {
|
||||
at := idx[pe.Index]
|
||||
return nil, &apperr.Error{
|
||||
Status: http.StatusUnprocessableEntity, Code: CodePatternInvalid, Detail: pe.Reason,
|
||||
Params: map[string]any{"index": at, "reason": pe.Reason},
|
||||
Fields: []apperr.FieldError{{Field: fmt.Sprintf("patterns[%d].regex", at), Code: CodePatternInvalid}},
|
||||
}
|
||||
}
|
||||
return compiled, err
|
||||
}
|
||||
|
||||
// PutPatterns проверяет и сохраняет шаблоны.
|
||||
func (s *Service) PutPatterns(ctx context.Context, id uuid.UUID, patterns []PatternRow) ([]PatternRow, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.store.GetSource(ctx, id); err != nil {
|
||||
return nil, notFound(err)
|
||||
}
|
||||
if _, err := compile(patterns); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.ReplacePatterns(ctx, id, patterns); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.Patterns(ctx, id)
|
||||
}
|
||||
|
||||
// PreviewItem — разбор одного файла.
|
||||
type PreviewItem struct {
|
||||
RelPath string
|
||||
PatternIndex *int
|
||||
Skipped bool
|
||||
PathFields map[string]string
|
||||
Tags map[string]string
|
||||
Result meta.Meta
|
||||
Error string
|
||||
}
|
||||
|
||||
// Preview применяет шаблоны к выборке файлов без сохранения: из каталога, а до первого скана — из обхода.
|
||||
func (s *Service) Preview(ctx context.Context, id uuid.UUID, patterns []PatternRow, sample int) ([]PreviewItem, int, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
src, err := s.store.GetSource(ctx, id)
|
||||
if err != nil {
|
||||
return nil, 0, notFound(err)
|
||||
}
|
||||
compiled, err := compile(patterns)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
sample = min(max(sample, 1), 200)
|
||||
root, err := s.Root(src)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
paths, err := s.store.SampleFiles(ctx, id, sample)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
found, err := walk(root, func(string) bool { return false })
|
||||
if err != nil {
|
||||
return nil, 0, apperr.New(http.StatusConflict, CodeSourceOffline, err.Error())
|
||||
}
|
||||
rand.Shuffle(len(found), func(i, j int) { found[i], found[j] = found[j], found[i] })
|
||||
for _, w := range found[:min(sample, len(found))] {
|
||||
paths = append(paths, w.rel)
|
||||
}
|
||||
}
|
||||
slices.Sort(paths)
|
||||
|
||||
items := make([]PreviewItem, len(paths))
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, 8)
|
||||
for i, rel := range paths {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
items[i] = s.previewOne(ctx, root, rel, compiled, src.TagsFirst)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
unmatched := 0
|
||||
for _, it := range items {
|
||||
if it.PatternIndex == nil {
|
||||
unmatched++
|
||||
}
|
||||
}
|
||||
return items, unmatched, nil
|
||||
}
|
||||
|
||||
func (s *Service) previewOne(ctx context.Context, root, rel string, patterns []meta.Pattern, tagsFirst bool) PreviewItem {
|
||||
it := PreviewItem{RelPath: rel, PathFields: map[string]string{}, Tags: map[string]string{}}
|
||||
m := meta.MatchPath(patterns, rel)
|
||||
if m.Index >= 0 {
|
||||
idx := m.Index
|
||||
it.PatternIndex = &idx
|
||||
}
|
||||
it.Skipped = m.Skip
|
||||
for k, v := range m.Fields {
|
||||
it.PathFields[k] = v
|
||||
}
|
||||
p, err := s.prober.Probe(ctx, filepath.Join(root, filepath.FromSlash(rel)))
|
||||
if err != nil {
|
||||
it.Error = err.Error()
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
"artist": p.Tags.Artist, "albumartist": p.Tags.AlbumArtist, "album": p.Tags.Album, "title": p.Tags.Title,
|
||||
"genre": p.Tags.Genre, "year": p.Tags.Year, "track": p.Tags.Track, "disc": p.Tags.Disc,
|
||||
} {
|
||||
if v != "" {
|
||||
it.Tags[k] = v
|
||||
}
|
||||
}
|
||||
it.Result = meta.Merge(p.Tags, meta.FromPath(m.Fields), tagsFirst, rel)
|
||||
if it.Result.Album == "" {
|
||||
it.Result.Album = UnknownAlbum
|
||||
}
|
||||
return it
|
||||
}
|
||||
|
||||
// StartScan запускает скан источника в фоне.
|
||||
// RetryAnalysis возвращает в очередь треки, которые анализатор не смог обработать (например, пока диск
|
||||
// не был смонтирован).
|
||||
func (s *Service) RetryAnalysis(ctx context.Context) (int, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.store.RetryFailedAnalysis(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) StartScan(ctx context.Context, id uuid.UUID, full bool) (Progress, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return Progress{}, err
|
||||
}
|
||||
src, err := s.store.GetSource(ctx, id)
|
||||
if err != nil {
|
||||
return Progress{}, notFound(err)
|
||||
}
|
||||
if err := s.startScan(src, full); err != nil {
|
||||
return Progress{}, err
|
||||
}
|
||||
p, _ := s.board.Get(id)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Service) startScan(src Source, full bool) error {
|
||||
if src.Status != "mounted" {
|
||||
return apperr.New(http.StatusConflict, CodeSourceOffline, "source is not connected")
|
||||
}
|
||||
root, err := s.Root(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.scanner.Start(src, root, full); errors.Is(err, ErrScanRunning) {
|
||||
return apperr.Conflict(CodeScanRunning, "scan is already running")
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status — состояние библиотеки (admin, moderator).
|
||||
type Status struct {
|
||||
Counts Counts
|
||||
Sources []Source
|
||||
Analysis AnalysisCounts
|
||||
Wave WaveMetrics
|
||||
}
|
||||
|
||||
// Status собирает счётчики и состояние источников.
|
||||
func (s *Service) Status(ctx context.Context) (Status, error) {
|
||||
if err := requirePerm(ctx, auth.PermViewLibraryStatus); err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
c, err := s.store.Counts(ctx)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
sources, err := s.store.ListSources(ctx)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
analysis, err := s.store.AnalysisCounts(ctx)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
wave, err := s.store.WaveMetrics(ctx)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
return Status{Counts: c, Sources: sources, Analysis: analysis, Wave: wave}, nil
|
||||
}
|
||||
|
||||
// FileErrors — файлы с ошибками чтения.
|
||||
func (s *Service) FileErrors(ctx context.Context, after string, limit int) ([]FileError, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.FileErrors(ctx, after, limit)
|
||||
}
|
||||
|
||||
// HostDirs — подпапки каталога LIBRARY_HOST для выбора local-хранилища.
|
||||
func (s *Service) HostDirs(ctx context.Context, rel string) (string, []string, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
dir, err := s.hostPath(rel)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return "", nil, apperr.Validation("path", CodeLocalPathInvalid, "folder not found", nil)
|
||||
}
|
||||
dirs := []string{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
|
||||
dirs = append(dirs, e.Name())
|
||||
}
|
||||
}
|
||||
slices.Sort(dirs)
|
||||
return strings.Trim(strings.ReplaceAll(rel, "\\", "/"), "/"), dirs, nil
|
||||
}
|
||||
|
||||
// Restore при старте api переподключает NFS-хранилища и проверяет local (после перезапуска агента).
|
||||
func (s *Service) Restore(ctx context.Context) {
|
||||
sources, err := s.store.ListSources(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("restore sources", "err", err)
|
||||
return
|
||||
}
|
||||
for _, src := range sources {
|
||||
if src.Status == "disabled" {
|
||||
continue
|
||||
}
|
||||
if _, err := s.connect(ctx, src); err != nil {
|
||||
s.log.Error("restore source", "source_id", src.ID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunSchedule раз в минуту проверяет доступность хранилищ и запускает сканы по расписанию.
|
||||
func (s *Service) RunSchedule(ctx context.Context) {
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
sources, err := s.store.ListSources(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("schedule: list sources", "err", err)
|
||||
continue
|
||||
}
|
||||
for _, src := range sources {
|
||||
if src.Status == "disabled" {
|
||||
continue
|
||||
}
|
||||
s.healthCheck(ctx, src)
|
||||
if src.ScanInterval == nil || src.Status != "mounted" {
|
||||
continue
|
||||
}
|
||||
if src.LastScanAt == nil || time.Since(*src.LastScanAt) >= *src.ScanInterval {
|
||||
if err := s.startScan(src, false); err != nil && !errors.Is(err, apperr.Conflict(CodeScanRunning, "")) {
|
||||
s.log.Warn("scheduled scan", "source_id", src.ID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// healthCheck: недоступное хранилище — status=error (треки серые), вернулось — снова mounted.
|
||||
func (s *Service) healthCheck(ctx context.Context, src Source) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
done := make(chan struct{})
|
||||
var status string
|
||||
var message *string
|
||||
go func() {
|
||||
status, message = s.check(ctx, src, false)
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
m := "storage does not respond"
|
||||
status, message = "error", &m
|
||||
}
|
||||
if status == "error" && src.Type == SourceNFS && src.Status == "error" {
|
||||
// Попробовать перемонтировать, как при старте.
|
||||
if _, err := s.connect(ctx, src); err == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if status != src.Status || deref(message) != deref(src.StatusMessage) {
|
||||
if err := s.store.SetSourceStatus(ctx, src.ID, status, message); err != nil {
|
||||
s.log.Warn("health check", "source_id", src.ID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/library/meta"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
"pipu/api/internal/storageagent"
|
||||
)
|
||||
|
||||
type SourceStore interface {
|
||||
ListSources(context.Context) ([]Source, error)
|
||||
GetSource(context.Context, uuid.UUID) (Source, error)
|
||||
SaveSource(context.Context, Source) (Source, error)
|
||||
SetSourceStatus(context.Context, uuid.UUID, string, *string) error
|
||||
DeleteSource(context.Context, uuid.UUID) error
|
||||
CleanupOrphans(context.Context) error
|
||||
Counts(context.Context) (Counts, error)
|
||||
AnalysisCounts(context.Context) (AnalysisCounts, error)
|
||||
WaveMetrics(context.Context) (WaveMetrics, error)
|
||||
FileErrors(context.Context, string, int) ([]FileError, error)
|
||||
}
|
||||
|
||||
// SourceService owns source configuration, connectivity and diagnostics.
|
||||
type SourceService struct {
|
||||
store SourceStore
|
||||
board *ProgressBoard
|
||||
agent Agent
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
mu sync.Mutex
|
||||
OnCatalogChanged func()
|
||||
}
|
||||
|
||||
// Root — каталог источника в файловой системе api.
|
||||
func (s *SourceService) Root(src Source) (string, error) {
|
||||
switch src.Type {
|
||||
case SourceLocal:
|
||||
rel := ""
|
||||
if src.LocalPath != nil {
|
||||
rel = *src.LocalPath
|
||||
}
|
||||
return s.hostPath(rel)
|
||||
case SourceNFS:
|
||||
return filepath.Join(s.cfg.LibraryRoot, src.ID.String()), nil
|
||||
}
|
||||
return "", fmt.Errorf("library: unknown source type %q", src.Type)
|
||||
}
|
||||
|
||||
// hostPath — путь внутри LIBRARY_HOST; выйти за его пределы нельзя.
|
||||
func (s *SourceService) hostPath(rel string) (string, error) {
|
||||
rel = strings.Trim(strings.ReplaceAll(rel, "\\", "/"), "/")
|
||||
clean := path.Clean("/" + rel)
|
||||
if clean != "/"+rel && rel != "" || strings.Contains(rel, "..") {
|
||||
return "", apperr.Validation("local_path", CodeLocalPathInvalid, "path must be relative without ..", nil)
|
||||
}
|
||||
return filepath.Join(s.cfg.HostRoot, filepath.FromSlash(strings.TrimPrefix(clean, "/"))), nil
|
||||
}
|
||||
|
||||
// ListSources — хранилища с прогрессом сканов.
|
||||
func (s *SourceService) ListSources(ctx context.Context) ([]Source, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.ListSources(ctx)
|
||||
}
|
||||
|
||||
// GetSource — хранилище по id.
|
||||
func (s *SourceService) GetSource(ctx context.Context, id uuid.UUID) (Source, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
src, err := s.store.GetSource(ctx, id)
|
||||
return src, notFound(err)
|
||||
}
|
||||
|
||||
// Progress — прогресс скана источника, если он был.
|
||||
func (s *SourceService) Progress(id uuid.UUID) (Progress, bool) { return s.board.Get(id) }
|
||||
|
||||
// SourceInput — поля создания / изменения хранилища. nil — не задано.
|
||||
type SourceInput struct {
|
||||
Name *string
|
||||
Type SourceType
|
||||
NFSServer *string
|
||||
NFSExport *string
|
||||
NFSOptions *string
|
||||
LocalPath *string
|
||||
TagsFirst *bool
|
||||
ScanIntervalMinutes *int // 0 — выключить
|
||||
Enabled *bool
|
||||
AcceptsUploads *bool
|
||||
WriteTemplate *string // "" — убрать
|
||||
}
|
||||
|
||||
func (s *SourceService) apply(src *Source, in SourceInput) error {
|
||||
if in.Name != nil {
|
||||
name := strings.TrimSpace(*in.Name)
|
||||
if name == "" || utf8.RuneCountInString(name) > 100 {
|
||||
return apperr.Validation("name", CodeNameInvalid, "name must be 1-100 characters", map[string]any{"max": 100})
|
||||
}
|
||||
src.Name = name
|
||||
}
|
||||
if in.TagsFirst != nil {
|
||||
src.TagsFirst = *in.TagsFirst
|
||||
}
|
||||
if in.ScanIntervalMinutes != nil {
|
||||
switch m := *in.ScanIntervalMinutes; {
|
||||
case m == 0:
|
||||
src.ScanInterval = nil
|
||||
case m < 5 || m > 60*24*30:
|
||||
return apperr.Validation("scan_interval_minutes", CodeIntervalInvalid, "interval must be 5 minutes .. 30 days",
|
||||
map[string]any{"min": 5})
|
||||
default:
|
||||
d := time.Duration(m) * time.Minute
|
||||
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 {
|
||||
rel := strings.Trim(strings.ReplaceAll(*in.LocalPath, "\\", "/"), "/")
|
||||
if _, err := s.hostPath(rel); err != nil {
|
||||
return err
|
||||
}
|
||||
src.LocalPath = &rel
|
||||
}
|
||||
if src.LocalPath == nil {
|
||||
empty := ""
|
||||
src.LocalPath = &empty
|
||||
}
|
||||
case SourceNFS:
|
||||
if in.NFSServer != nil {
|
||||
src.NFSServer = in.NFSServer
|
||||
}
|
||||
if in.NFSExport != nil {
|
||||
src.NFSExport = in.NFSExport
|
||||
}
|
||||
if in.NFSOptions != nil {
|
||||
src.NFSOptions = *in.NFSOptions
|
||||
}
|
||||
spec, err := nfsSpec(*src).Normalize(false)
|
||||
if err != nil {
|
||||
var ve *storageagent.ValidationError
|
||||
if errors.As(err, &ve) {
|
||||
return apperr.Validation(ve.Field, CodeNFSInvalid, ve.Reason, nil)
|
||||
}
|
||||
return err
|
||||
}
|
||||
src.NFSServer, src.NFSExport, src.NFSOptions = &spec.Server, &spec.Export, spec.Options
|
||||
default:
|
||||
return apperr.Validation("type", "validation.source_type_invalid", "unknown source type", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nfsSpec(src Source) storageagent.NFSSpec {
|
||||
var spec storageagent.NFSSpec
|
||||
if src.NFSServer != nil {
|
||||
spec.Server = *src.NFSServer
|
||||
}
|
||||
if src.NFSExport != nil {
|
||||
spec.Export = *src.NFSExport
|
||||
}
|
||||
spec.Options = src.NFSOptions
|
||||
return spec
|
||||
}
|
||||
|
||||
// CreateSource сохраняет хранилище и сразу пытается подключить его.
|
||||
func (s *SourceService) CreateSource(ctx context.Context, in SourceInput) (Source, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
src := Source{ID: newID(), Type: in.Type, TagsFirst: true, Status: "pending", NFSOptions: "ro,nfsvers=4.1,soft,timeo=150,retrans=3"}
|
||||
if in.Name == nil {
|
||||
in.Name = new(string)
|
||||
}
|
||||
if err := s.apply(&src, in); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
src, err := s.store.SaveSource(ctx, src)
|
||||
if err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
return s.connect(ctx, src)
|
||||
}
|
||||
|
||||
// UpdateSource меняет хранилище; смена адреса или папки переподключает его.
|
||||
func (s *SourceService) UpdateSource(ctx context.Context, id uuid.UUID, in SourceInput) (Source, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
src, err := s.store.GetSource(ctx, id)
|
||||
if err != nil {
|
||||
return Source{}, notFound(err)
|
||||
}
|
||||
before := src
|
||||
in.Type = src.Type
|
||||
if err := s.apply(&src, in); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
if src, err = s.store.SaveSource(ctx, src); err != nil {
|
||||
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,
|
||||
x.Type == SourceNFS && x.AcceptsUploads)
|
||||
}
|
||||
switch {
|
||||
case in.Enabled != nil && !*in.Enabled:
|
||||
if src.Type == SourceNFS {
|
||||
_ = s.agent.Unmount(ctx, src.ID)
|
||||
}
|
||||
if err := s.store.SetSourceStatus(ctx, src.ID, "disabled", nil); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
return s.store.GetSource(ctx, src.ID)
|
||||
case in.Enabled != nil && *in.Enabled, location(before) != location(src), src.Status == "error":
|
||||
return s.connect(ctx, src)
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
|
||||
func deref(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// connect подключает хранилище и записывает статус: local — проверка каталога, nfs — монтирование агентом.
|
||||
func (s *SourceService) connect(ctx context.Context, src Source) (Source, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
status, message := s.check(ctx, src, true)
|
||||
if err := s.store.SetSourceStatus(ctx, src.ID, status, message); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
return s.store.GetSource(ctx, src.ID)
|
||||
}
|
||||
|
||||
// check проверяет доступность; mount — смонтировать NFS (иначе только проверить точку).
|
||||
func (s *SourceService) check(ctx context.Context, src Source, mount bool) (string, *string) {
|
||||
msg := func(m string) *string { return &m }
|
||||
switch src.Type {
|
||||
case SourceLocal:
|
||||
root, err := s.Root(src)
|
||||
if err != nil {
|
||||
return "error", msg(err.Error())
|
||||
}
|
||||
info, err := os.Stat(root)
|
||||
if err != nil || !info.IsDir() {
|
||||
return "error", msg("folder not found: " + deref(src.LocalPath))
|
||||
}
|
||||
return "mounted", nil
|
||||
case SourceNFS:
|
||||
if !mount {
|
||||
root, _ := s.Root(src)
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
return "error", msg(err.Error())
|
||||
}
|
||||
return "mounted", nil
|
||||
}
|
||||
res, err := s.agent.Mount(ctx, src.ID, nfsSpec(src), src.AcceptsUploads)
|
||||
if err != nil {
|
||||
return "error", msg(err.Error())
|
||||
}
|
||||
if res.Status != "mounted" {
|
||||
return "error", msg(res.Message)
|
||||
}
|
||||
return "mounted", nil
|
||||
}
|
||||
return "error", msg("unknown type")
|
||||
}
|
||||
|
||||
// DeleteSource отключает хранилище и удаляет его файлы из каталога.
|
||||
func (s *SourceService) DeleteSource(ctx context.Context, id uuid.UUID) error {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return err
|
||||
}
|
||||
src, err := s.store.GetSource(ctx, id)
|
||||
if err != nil {
|
||||
return notFound(err)
|
||||
}
|
||||
if p, ok := s.board.Get(id); ok && p.Running {
|
||||
return apperr.Conflict(CodeScanRunning, "scan is running")
|
||||
}
|
||||
if src.Type == SourceNFS {
|
||||
if err := s.agent.Unmount(ctx, id); err != nil {
|
||||
s.log.WarnContext(ctx, "unmount failed", "source_id", id, "err", err)
|
||||
}
|
||||
}
|
||||
if err := s.store.DeleteSource(ctx, id); err != nil {
|
||||
return notFound(err)
|
||||
}
|
||||
if err := s.store.CleanupOrphans(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.OnCatalogChanged != nil {
|
||||
s.OnCatalogChanged()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestResult — итог проверки подключения.
|
||||
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).
|
||||
func (s *SourceService) TestSource(ctx context.Context, id uuid.UUID) (TestResult, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return TestResult{}, err
|
||||
}
|
||||
src, err := s.store.GetSource(ctx, id)
|
||||
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 *SourceService) testRead(ctx context.Context, src Source) (TestResult, error) {
|
||||
if src.Type == SourceNFS {
|
||||
res, err := s.agent.Test(ctx, nfsSpec(src))
|
||||
if err != nil {
|
||||
return TestResult{Message: err.Error()}, nil
|
||||
}
|
||||
return TestResult{OK: res.Status == "mounted", Message: res.Message, AudioFiles: res.AudioFiles}, nil
|
||||
}
|
||||
root, err := s.Root(src)
|
||||
if err != nil {
|
||||
return TestResult{Message: err.Error()}, nil
|
||||
}
|
||||
n := 0
|
||||
err = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() && IsAudio(d.Name()) {
|
||||
n++
|
||||
if n >= 1000 {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return TestResult{Message: err.Error(), AudioFiles: n}, nil
|
||||
}
|
||||
return TestResult{OK: true, Message: fmt.Sprintf("found %d audio files", n), AudioFiles: n}, nil
|
||||
}
|
||||
|
||||
// Status — состояние библиотеки (admin, moderator).
|
||||
type Status struct {
|
||||
Counts Counts
|
||||
Sources []Source
|
||||
Analysis AnalysisCounts
|
||||
Wave WaveMetrics
|
||||
}
|
||||
|
||||
// Status собирает счётчики и состояние источников.
|
||||
func (s *SourceService) Status(ctx context.Context) (Status, error) {
|
||||
if err := requirePerm(ctx, auth.PermViewLibraryStatus); err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
c, err := s.store.Counts(ctx)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
sources, err := s.store.ListSources(ctx)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
analysis, err := s.store.AnalysisCounts(ctx)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
wave, err := s.store.WaveMetrics(ctx)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
return Status{Counts: c, Sources: sources, Analysis: analysis, Wave: wave}, nil
|
||||
}
|
||||
|
||||
// FileErrors — файлы с ошибками чтения.
|
||||
func (s *SourceService) FileErrors(ctx context.Context, after string, limit int) ([]FileError, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.FileErrors(ctx, after, limit)
|
||||
}
|
||||
|
||||
// HostDirs — подпапки каталога LIBRARY_HOST для выбора local-хранилища.
|
||||
func (s *SourceService) HostDirs(ctx context.Context, rel string) (string, []string, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
dir, err := s.hostPath(rel)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return "", nil, apperr.Validation("path", CodeLocalPathInvalid, "folder not found", nil)
|
||||
}
|
||||
dirs := []string{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
|
||||
dirs = append(dirs, e.Name())
|
||||
}
|
||||
}
|
||||
slices.Sort(dirs)
|
||||
return strings.Trim(strings.ReplaceAll(rel, "\\", "/"), "/"), dirs, nil
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/library/meta"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
type PatternStore interface {
|
||||
GetSource(context.Context, uuid.UUID) (Source, error)
|
||||
Patterns(context.Context, uuid.UUID) ([]PatternRow, error)
|
||||
ReplacePatterns(context.Context, uuid.UUID, []PatternRow) error
|
||||
SampleFiles(context.Context, uuid.UUID, int) ([]string, error)
|
||||
TemplateSamples(context.Context, uuid.UUID, int) ([]meta.TemplateSample, error)
|
||||
}
|
||||
|
||||
// TemplateService owns metadata pattern preview and write template validation.
|
||||
type TemplateService struct {
|
||||
store PatternStore
|
||||
prober Prober
|
||||
root func(Source) (string, error)
|
||||
}
|
||||
|
||||
// Patterns — шаблоны путей источника.
|
||||
func (s *TemplateService) Patterns(ctx context.Context, id uuid.UUID) ([]PatternRow, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.store.GetSource(ctx, id); err != nil {
|
||||
return nil, notFound(err)
|
||||
}
|
||||
return s.store.Patterns(ctx, id)
|
||||
}
|
||||
|
||||
func compile(patterns []PatternRow) ([]meta.Pattern, error) {
|
||||
var exprs []string
|
||||
idx := []int{}
|
||||
for i, p := range patterns {
|
||||
if p.IsEnabled {
|
||||
exprs = append(exprs, p.Regex)
|
||||
idx = append(idx, i)
|
||||
}
|
||||
}
|
||||
compiled, err := meta.CompilePatterns(exprs)
|
||||
var pe *meta.PatternError
|
||||
if errors.As(err, &pe) {
|
||||
at := idx[pe.Index]
|
||||
return nil, &apperr.Error{
|
||||
Status: http.StatusUnprocessableEntity, Code: CodePatternInvalid, Detail: pe.Reason,
|
||||
Params: map[string]any{"index": at, "reason": pe.Reason},
|
||||
Fields: []apperr.FieldError{{Field: fmt.Sprintf("patterns[%d].regex", at), Code: CodePatternInvalid}},
|
||||
}
|
||||
}
|
||||
return compiled, err
|
||||
}
|
||||
|
||||
// PutPatterns проверяет и сохраняет шаблоны.
|
||||
func (s *TemplateService) PutPatterns(ctx context.Context, id uuid.UUID, patterns []PatternRow) ([]PatternRow, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.store.GetSource(ctx, id); err != nil {
|
||||
return nil, notFound(err)
|
||||
}
|
||||
if _, err := compile(patterns); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.ReplacePatterns(ctx, id, patterns); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.Patterns(ctx, id)
|
||||
}
|
||||
|
||||
// PreviewItem — разбор одного файла.
|
||||
type PreviewItem struct {
|
||||
RelPath string
|
||||
PatternIndex *int
|
||||
Skipped bool
|
||||
PathFields map[string]string
|
||||
Tags map[string]string
|
||||
Result meta.Meta
|
||||
Error string
|
||||
}
|
||||
|
||||
// Preview применяет шаблоны к выборке файлов без сохранения: из каталога, а до первого скана — из обхода.
|
||||
func (s *TemplateService) Preview(ctx context.Context, id uuid.UUID, patterns []PatternRow, sample int) ([]PreviewItem, int, error) {
|
||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
src, err := s.store.GetSource(ctx, id)
|
||||
if err != nil {
|
||||
return nil, 0, notFound(err)
|
||||
}
|
||||
compiled, err := compile(patterns)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
sample = min(max(sample, 1), 200)
|
||||
root, err := s.root(src)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
paths, err := s.store.SampleFiles(ctx, id, sample)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
found, err := walk(root, func(string) bool { return false })
|
||||
if err != nil {
|
||||
return nil, 0, apperr.New(http.StatusConflict, CodeSourceOffline, err.Error())
|
||||
}
|
||||
rand.Shuffle(len(found), func(i, j int) { found[i], found[j] = found[j], found[i] })
|
||||
for _, w := range found[:min(sample, len(found))] {
|
||||
paths = append(paths, w.rel)
|
||||
}
|
||||
}
|
||||
slices.Sort(paths)
|
||||
|
||||
items := make([]PreviewItem, len(paths))
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, 8)
|
||||
for i, rel := range paths {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
items[i] = s.previewOne(ctx, root, rel, compiled, src.TagsFirst)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
unmatched := 0
|
||||
for _, it := range items {
|
||||
if it.PatternIndex == nil {
|
||||
unmatched++
|
||||
}
|
||||
}
|
||||
return items, unmatched, nil
|
||||
}
|
||||
|
||||
func (s *TemplateService) previewOne(ctx context.Context, root, rel string, patterns []meta.Pattern, tagsFirst bool) PreviewItem {
|
||||
it := PreviewItem{RelPath: rel, PathFields: map[string]string{}, Tags: map[string]string{}}
|
||||
m := meta.MatchPath(patterns, rel)
|
||||
if m.Index >= 0 {
|
||||
idx := m.Index
|
||||
it.PatternIndex = &idx
|
||||
}
|
||||
it.Skipped = m.Skip
|
||||
for k, v := range m.Fields {
|
||||
it.PathFields[k] = v
|
||||
}
|
||||
p, err := s.prober.Probe(ctx, filepath.Join(root, filepath.FromSlash(rel)))
|
||||
if err != nil {
|
||||
it.Error = err.Error()
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
"artist": p.Tags.Artist, "albumartist": p.Tags.AlbumArtist, "album": p.Tags.Album, "title": p.Tags.Title,
|
||||
"genre": p.Tags.Genre, "year": p.Tags.Year, "track": p.Tags.Track, "disc": p.Tags.Disc,
|
||||
} {
|
||||
if v != "" {
|
||||
it.Tags[k] = v
|
||||
}
|
||||
}
|
||||
it.Result = meta.Merge(p.Tags, meta.FromPath(m.Fields), tagsFirst, rel)
|
||||
if it.Result.Album == "" {
|
||||
it.Result.Album = UnknownAlbum
|
||||
}
|
||||
return it
|
||||
}
|
||||
|
||||
// templateSampleSize — сколько треков берёт проверка шаблона записи (docs/uploads.md#7).
|
||||
const templateSampleSize = 50
|
||||
|
||||
// SuggestWriteTemplate подбирает шаблон записи по существующим путям и выражениям источника.
|
||||
func (s *TemplateService) 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 *TemplateService) 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
|
||||
}
|
||||
Reference in new issue
Block a user