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
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:
1 parent
ab092def54
commit
c583991d14
24 files changed
+7467
-84
No files matched your search
@@ -0,0 +1,105 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/storageagent"
|
||||
)
|
||||
|
||||
// Agent — клиент storage-agent (монтирование NFS).
|
||||
type Agent interface {
|
||||
Mount(ctx context.Context, sourceID uuid.UUID, spec storageagent.NFSSpec) (storageagent.MountResult, error)
|
||||
Unmount(ctx context.Context, sourceID uuid.UUID) error
|
||||
Test(ctx context.Context, spec storageagent.NFSSpec) (storageagent.MountResult, error)
|
||||
}
|
||||
|
||||
// HTTPAgent ходит в storage-agent по внутренней сети с общим секретом.
|
||||
type HTTPAgent struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// NewHTTPAgent создаёт клиента агента.
|
||||
func NewHTTPAgent(baseURL, token string) *HTTPAgent {
|
||||
return &HTTPAgent{BaseURL: baseURL, Token: token, Client: &http.Client{Timeout: 40 * time.Second}}
|
||||
}
|
||||
|
||||
// AgentError — агент отклонил запрос (например, недопустимые параметры NFS).
|
||||
type AgentError struct {
|
||||
Status int
|
||||
Field string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *AgentError) Error() string {
|
||||
return fmt.Sprintf("storage-agent: %d %s %s", e.Status, e.Field, e.Reason)
|
||||
}
|
||||
|
||||
func (a *HTTPAgent) do(ctx context.Context, method, path string, body any, out any) error {
|
||||
var rd *bytes.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rd = bytes.NewReader(raw)
|
||||
} else {
|
||||
rd = bytes.NewReader(nil)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, a.BaseURL+path, rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+a.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res, err := a.Client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage-agent: %w", err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
if res.StatusCode >= 400 {
|
||||
var p struct {
|
||||
Detail string `json:"detail"`
|
||||
Errors []struct {
|
||||
Field string `json:"field"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
_ = json.NewDecoder(res.Body).Decode(&p)
|
||||
ae := &AgentError{Status: res.StatusCode, Reason: p.Detail}
|
||||
if len(p.Errors) > 0 {
|
||||
ae.Field = p.Errors[0].Field
|
||||
}
|
||||
return ae
|
||||
}
|
||||
if out != nil {
|
||||
return json.NewDecoder(res.Body).Decode(out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mount реализует Agent.
|
||||
func (a *HTTPAgent) Mount(ctx context.Context, id uuid.UUID, spec storageagent.NFSSpec) (storageagent.MountResult, error) {
|
||||
var res storageagent.MountResult
|
||||
err := a.do(ctx, http.MethodPost, "/internal/mounts", storageagent.MountRequest{SourceID: id.String(), NFSSpec: spec}, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
// Unmount реализует Agent.
|
||||
func (a *HTTPAgent) Unmount(ctx context.Context, id uuid.UUID) error {
|
||||
return a.do(ctx, http.MethodDelete, "/internal/mounts/"+id.String(), nil, nil)
|
||||
}
|
||||
|
||||
// Test реализует Agent.
|
||||
func (a *HTTPAgent) Test(ctx context.Context, spec storageagent.NFSSpec) (storageagent.MountResult, error) {
|
||||
var res storageagent.MountResult
|
||||
err := a.do(ctx, http.MethodPost, "/internal/mounts/test", spec, &res)
|
||||
return res, err
|
||||
}
|
||||
Reference in new issue
Block a user