feat(api): library sources, scanner and catalog (M2)
ci / api / api (push) Successful in 26s
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 22s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 37s
ci / ts / ts (packages + web) (push) Successful in 51s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 53s

- schema for sources, path patterns, media files, artists, albums,
  tracks, genres (migration 00003)
- path patterns (RE2 with named groups, skip), merge with tags by
  source priority, fallbacks from the file name and folder, name
  normalization and multi-artist split
- scanner: walk, skip unchanged files by size and mtime, ffprobe in 8
  workers with a single catalog writer, renames by content hash keep
  the track, duplicates fold into one track with the best file as
  primary, missing files are kept 30 days
- storage-agent mounts NFS with validated server, export and an option
  whitelist, no shell; test mount counts audio files
- admin API: sources CRUD, connection test, patterns with preview,
  scan, library status, file errors, host folder picker; scheduled
  scans and health checks; mounts are restored on start
- catalog API: artist, artist albums, album with tracks, track
- api image is alpine with ffmpeg for ffprobe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 06:48:37 +03:00
1 parent ab092def54
commit c583991d14
24 files changed
+7467 -84

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
package storageagent
import (
"context"
"errors"
"fmt"
"io/fs"
"net"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
// NFSSpec — параметры подключения NFS-хранилища.
type NFSSpec struct {
Server string `json:"server"`
Export string `json:"export"`
Options string `json:"options"`
}
// Опции mount, которые можно передать (docs/library-and-storage.md). Значение — допустимы ли аргументы.
var allowedOptions = map[string]*regexp.Regexp{
"ro": nil, "rw": nil, "soft": nil, "hard": nil, "noatime": nil, "nolock": nil,
"nfsvers": regexp.MustCompile(`^(3|4|4\.0|4\.1|4\.2)$`),
"vers": regexp.MustCompile(`^(3|4|4\.0|4\.1|4\.2)$`),
"port": regexp.MustCompile(`^\d{1,5}$`),
"timeo": regexp.MustCompile(`^\d{1,6}$`),
"retrans": regexp.MustCompile(`^\d{1,3}$`),
"rsize": regexp.MustCompile(`^\d{4,8}$`),
"wsize": regexp.MustCompile(`^\d{4,8}$`),
"sec": regexp.MustCompile(`^(sys|krb5|krb5i|krb5p|none)$`),
}
var hostnameRe = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$`)
// ValidationError — недопустимое поле NFS-подключения.
type ValidationError struct {
Field string `json:"field"`
Reason string `json:"reason"`
}
func (e *ValidationError) Error() string { return e.Field + ": " + e.Reason }
// Normalize проверяет параметры и возвращает опции в каноническом виде.
// writable = false принудительно добавляет ro (только чтение) и убирает rw.
// Ничего из ввода не попадает в shell: значения — отдельные аргументы exec.Command.
func (s NFSSpec) Normalize(writable bool) (NFSSpec, error) {
server := strings.TrimSpace(s.Server)
if ip := net.ParseIP(strings.Trim(server, "[]")); ip != nil {
server = ip.String()
if ip.To4() == nil {
server = "[" + server + "]"
}
} else if len(server) > 253 || !hostnameRe.MatchString(server) {
return NFSSpec{}, &ValidationError{Field: "nfs_server", Reason: "not a hostname or IP address"}
}
export := strings.TrimSpace(s.Export)
if !strings.HasPrefix(export, "/") || len(export) > 1024 || strings.ContainsAny(export, "\x00\n\r,") {
return NFSSpec{}, &ValidationError{Field: "nfs_export", Reason: "must be an absolute path"}
}
for _, part := range strings.Split(export, "/") {
if part == ".." {
return NFSSpec{}, &ValidationError{Field: "nfs_export", Reason: "must not contain .."}
}
}
export = path.Clean(export)
opts := []string{}
seen := map[string]bool{}
for _, raw := range strings.Split(s.Options, ",") {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
name, value, hasValue := strings.Cut(raw, "=")
re, ok := allowedOptions[name]
if !ok || (re == nil) == hasValue || (re != nil && !re.MatchString(value)) {
return NFSSpec{}, &ValidationError{Field: "nfs_options", Reason: fmt.Sprintf("option %q is not allowed", raw)}
}
if name == "ro" || name == "rw" || seen[name] {
continue
}
seen[name] = true
opts = append(opts, raw)
}
mode := "ro"
if writable {
mode = "rw"
}
opts = append([]string{mode}, opts...)
return NFSSpec{Server: server, Export: export, Options: strings.Join(opts, ",")}, nil
}
// Mounter выполняет mount / umount. Подменяется в тестах.
type Mounter interface {
Mount(ctx context.Context, spec NFSSpec, target string) error
Unmount(ctx context.Context, target string) error
}
// ExecMounter вызывает системные mount / umount.
type ExecMounter struct{}
// Mount реализует Mounter.
func (ExecMounter) Mount(ctx context.Context, spec NFSSpec, target string) error {
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
out, err := exec.CommandContext(ctx, "mount", "-t", "nfs", "-o", spec.Options,
spec.Server+":"+spec.Export, target).CombinedOutput()
if err != nil {
return fmt.Errorf("mount: %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()
if err != nil && !strings.Contains(string(out), "not mounted") {
return fmt.Errorf("umount: %s", strings.TrimSpace(string(out)))
}
return nil
}
var sourceIDRe = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
// mountPoint — каталог источника в корне библиотеки; id проверяется, чтобы путь не ушёл из корня.
func mountPoint(root, sourceID string) (string, error) {
if !sourceIDRe.MatchString(sourceID) {
return "", errors.New("invalid source id")
}
return filepath.Join(root, sourceID), nil
}
var audioExt = map[string]bool{
".flac": true, ".mp3": true, ".m4a": true, ".aac": true, ".ogg": true, ".opus": true,
".wav": true, ".aiff": true, ".aif": true, ".wv": true, ".ape": true,
}
// countAudio считает аудиофайлы в дереве, останавливаясь после limit файлов или по таймауту.
func countAudio(ctx context.Context, root string, limit int) (int, error) {
n := 0
stop := errors.New("stop")
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if ctx.Err() != nil || n >= limit {
return stop
}
if err != nil {
if p == root {
return err
}
return nil
}
if !d.IsDir() && audioExt[strings.ToLower(filepath.Ext(p))] {
n++
}
return nil
})
if err != nil && !errors.Is(err, stop) {
return n, err
}
return n, nil
}
// probeTimeout — сколько ждать листинга при проверке подключения.
const probeTimeout = 20 * time.Second
// itoa — для сообщений.
func itoa(n int) string { return strconv.Itoa(n) }
@@ -0,0 +1,105 @@
package storageagent
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNFSNormalize(t *testing.T) {
ok := []struct {
in NFSSpec
writable bool
want NFSSpec
}{
{NFSSpec{"nas.lan", "/export/music/", "rw,nfsvers=4.1,soft"}, false, NFSSpec{"nas.lan", "/export/music", "ro,nfsvers=4.1,soft"}},
{NFSSpec{" 192.168.1.10 ", "/m", ""}, true, NFSSpec{"192.168.1.10", "/m", "rw"}},
{NFSSpec{"fd00::1", "/m", "timeo=150,timeo=10"}, false, NFSSpec{"[fd00::1]", "/m", "ro,timeo=150"}},
}
for _, tc := range ok {
got, err := tc.in.Normalize(tc.writable)
require.NoError(t, err, tc.in)
assert.Equal(t, tc.want, got)
}
bad := []struct {
in NFSSpec
field string
}{
{NFSSpec{"nas;rm -rf /", "/m", ""}, "nfs_server"},
{NFSSpec{"-oexec", "/m", ""}, "nfs_server"},
{NFSSpec{"nas", "relative", ""}, "nfs_export"},
{NFSSpec{"nas", "/a/../etc", ""}, "nfs_export"},
{NFSSpec{"nas", "/m,exec", ""}, "nfs_export"},
{NFSSpec{"nas", "/m", "exec"}, "nfs_options"},
{NFSSpec{"nas", "/m", "nfsvers=9"}, "nfs_options"},
{NFSSpec{"nas", "/m", "soft=1"}, "nfs_options"},
{NFSSpec{"nas", "/m", "port"}, "nfs_options"},
}
for _, tc := range bad {
_, err := tc.in.Normalize(false)
var ve *ValidationError
require.ErrorAs(t, err, &ve, tc.in)
assert.Equal(t, tc.field, ve.Field, tc.in)
}
}
type fakeMounter struct {
mounted map[string]NFSSpec
files int
}
func (f *fakeMounter) Mount(_ context.Context, spec NFSSpec, target string) error {
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
for i := range f.files {
_ = os.WriteFile(filepath.Join(target, "t"+itoa(i)+".flac"), nil, 0o644)
}
f.mounted[target] = spec
return nil
}
func (f *fakeMounter) Unmount(_ context.Context, target string) error {
delete(f.mounted, target)
return nil
}
func TestMountEndpoint(t *testing.T) {
root := t.TempDir()
m := &fakeMounter{mounted: map[string]NFSSpec{}, files: 3}
h := NewRouterWithMounter(nil, "secret", root, m)
post := func(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()
h.ServeHTTP(rec, req)
return rec
}
id := "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b"
rec := post("/internal/mounts", map[string]any{"source_id": id, "server": "nas", "export": "/music", "options": "soft"})
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var res MountResult
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &res))
assert.Equal(t, MountResult{Status: "mounted", AudioFiles: 3, Message: "found 3 audio files"}, res)
assert.Equal(t, "ro,soft", m.mounted[filepath.Join(root, id)].Options)
assert.Equal(t, http.StatusUnprocessableEntity,
post("/internal/mounts", map[string]any{"source_id": "../etc", "server": "nas", "export": "/m"}).Code)
assert.Equal(t, http.StatusUnprocessableEntity,
post("/internal/mounts/test", map[string]any{"server": "nas", "export": "/m", "options": "exec"}).Code)
rec = post("/internal/mounts/test", map[string]any{"server": "nas", "export": "/m"})
require.Equal(t, http.StatusOK, rec.Code)
assert.Len(t, m.mounted, 1, "test mount is removed")
}
+124 -2
View File
@@ -1,10 +1,13 @@
// Package storageagent — внутренний сервис монтирования хранилищ (см. docs/library-and-storage.md).
// В M0 — каркас: проверка здоровья, аутентификация межсервисных запросов, список точек монтирования.
package storageagent
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"log/slog"
"net/http"
"os"
@@ -25,6 +28,11 @@ type Mount struct {
// NewRouter возвращает роутер агента. token — общий секрет INTERNAL_TOKEN;
// при пустом token все запросы к /internal отклоняются.
func NewRouter(log *slog.Logger, token, libraryRoot string) http.Handler {
return NewRouterWithMounter(log, token, libraryRoot, ExecMounter{})
}
// NewRouterWithMounter — NewRouter с заданным способом монтирования (для тестов).
func NewRouterWithMounter(log *slog.Logger, token, libraryRoot string, mounter Mounter) http.Handler {
if log == nil {
log = slog.New(slog.DiscardHandler)
}
@@ -44,10 +52,124 @@ func NewRouter(log *slog.Logger, token, libraryRoot string) http.Handler {
}
writeJSON(w, http.StatusOK, map[string]any{"mounts": mounts})
})
a := &agent{log: log, root: libraryRoot, mounter: mounter}
r.Post("/mounts", a.mount)
r.Post("/mounts/test", a.test)
r.Delete("/mounts/{sourceID}", a.unmount)
})
return r
}
type agent struct {
log *slog.Logger
root string
mounter Mounter
}
// MountRequest — тело POST /internal/mounts.
type MountRequest struct {
SourceID string `json:"source_id"`
NFSSpec
Writable bool `json:"writable"`
}
// MountResult — итог монтирования или проверки.
type MountResult struct {
Status string `json:"status"` // mounted | error
Message string `json:"message,omitempty"`
AudioFiles int `json:"audio_files"`
}
func (a *agent) decode(w http.ResponseWriter, r *http.Request, v any) bool {
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(v); err != nil {
httpx.WriteProblem(w, http.StatusBadRequest, "common.bad_request", "invalid json")
return false
}
return true
}
func writeValidation(w http.ResponseWriter, err error) {
var ve *ValidationError
if errors.As(err, &ve) {
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{
"status": http.StatusUnprocessableEntity, "code": "validation.nfs_invalid", "detail": ve.Reason,
"errors": []map[string]string{{"field": ve.Field, "code": "validation.nfs_invalid"}},
})
return
}
httpx.WriteProblem(w, http.StatusBadRequest, "common.bad_request", err.Error())
}
// mount (пере)монтирует источник в <root>/<source_id> и считает аудиофайлы для проверки.
func (a *agent) mount(w http.ResponseWriter, r *http.Request) {
var req MountRequest
if !a.decode(w, r, &req) {
return
}
target, err := mountPoint(a.root, req.SourceID)
if err != nil {
writeValidation(w, &ValidationError{Field: "source_id", Reason: err.Error()})
return
}
spec, err := req.NFSSpec.Normalize(req.Writable)
if err != nil {
writeValidation(w, err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), probeTimeout)
defer cancel()
_ = a.mounter.Unmount(ctx, target)
writeJSON(w, http.StatusOK, a.mountAndCount(ctx, spec, target))
}
// test монтирует во временную точку, считает файлы и отключает.
func (a *agent) test(w http.ResponseWriter, r *http.Request) {
var spec NFSSpec
if !a.decode(w, r, &spec) {
return
}
spec, err := spec.Normalize(false)
if err != nil {
writeValidation(w, err)
return
}
var rnd [6]byte
_, _ = rand.Read(rnd[:])
target := filepath.Join(a.root, ".test-"+hex.EncodeToString(rnd[:]))
ctx, cancel := context.WithTimeout(r.Context(), probeTimeout)
defer cancel()
res := a.mountAndCount(ctx, spec, target)
_ = a.mounter.Unmount(context.Background(), target)
_ = os.Remove(target)
writeJSON(w, http.StatusOK, res)
}
func (a *agent) mountAndCount(ctx context.Context, spec NFSSpec, target string) MountResult {
if err := a.mounter.Mount(ctx, spec, target); err != nil {
a.log.WarnContext(ctx, "nfs mount failed", "target", target, "err", err)
return MountResult{Status: "error", Message: err.Error()}
}
n, err := countAudio(ctx, target, 1000)
if err != nil {
return MountResult{Status: "error", Message: "mounted, but listing failed: " + err.Error()}
}
return MountResult{Status: "mounted", AudioFiles: n, Message: "found " + itoa(n) + " audio files"}
}
func (a *agent) unmount(w http.ResponseWriter, r *http.Request) {
target, err := mountPoint(a.root, chi.URLParam(r, "sourceID"))
if err != nil {
writeValidation(w, &ValidationError{Field: "source_id", Reason: err.Error()})
return
}
if err := a.mounter.Unmount(r.Context(), target); err != nil {
httpx.WriteProblem(w, http.StatusInternalServerError, "storage.unmount_failed", err.Error())
return
}
_ = os.Remove(target)
w.WriteHeader(http.StatusNoContent)
}
func requireToken(token string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -72,7 +194,7 @@ func listMounts(root string) ([]Mount, error) {
}
mounts := make([]Mount, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
mounts = append(mounts, Mount{SourceID: e.Name(), Path: filepath.Join(root, e.Name())})
}
}