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>
209 lines
6.8 KiB
Go
209 lines
6.8 KiB
Go
// Package storageagent — внутренний сервис монтирования хранилищ (см. docs/library-and-storage.md).
|
|
package storageagent
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"pipu/api/internal/platform/httpx"
|
|
)
|
|
|
|
// Mount — состояние точки монтирования источника.
|
|
type Mount struct {
|
|
SourceID string `json:"source_id"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
r := chi.NewRouter()
|
|
r.NotFound(httpx.NotFound)
|
|
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
})
|
|
r.Route("/internal", func(r chi.Router) {
|
|
r.Use(requireToken(token))
|
|
r.Get("/mounts", func(w http.ResponseWriter, r *http.Request) {
|
|
mounts, err := listMounts(libraryRoot)
|
|
if err != nil {
|
|
log.ErrorContext(r.Context(), "list mounts", "err", err)
|
|
httpx.WriteProblem(w, http.StatusInternalServerError, "storage.list_failed", "cannot list mounts")
|
|
return
|
|
}
|
|
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) {
|
|
got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
if !ok || token == "" || subtle.ConstantTimeCompare([]byte(got), []byte(token)) != 1 {
|
|
httpx.WriteProblem(w, http.StatusUnauthorized, "auth.invalid_internal_token", "invalid internal token")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// listMounts перечисляет подкаталоги корня библиотеки (каждый — точка монтирования источника).
|
|
func listMounts(root string) ([]Mount, error) {
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return []Mount{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
mounts := make([]Mount, 0, len(entries))
|
|
for _, e := range entries {
|
|
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
|
|
mounts = append(mounts, Mount{SourceID: e.Name(), Path: filepath.Join(root, e.Name())})
|
|
}
|
|
}
|
|
return mounts, nil
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|