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
465 lines
14 KiB
Go
465 lines
14 KiB
Go
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
|
|
}
|