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 }