feat: storage writes via storage-agent, accepts_uploads and write_template (M5.5)
ci / api / api (push) Successful in 33s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 43s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m11s
ci / ts / ts (packages + web) (push) Successful in 1m10s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 51s

- storage-agent: POST /internal/files copies a staged file into a source
  (temp file, fsync, SHA-256 check, hard link so nothing is overwritten,
  " (2)" suffix on conflict, owner PIPU_FILE_UID/GID) and
  POST /internal/files/test. NFS sources that accept uploads are mounted
  rw in an agent-only /library-rw and bind-mounted read-only for others;
  local sources are written through /library-host-rw.
- meta: write templates ({track:02}, {disc:1-}, [optional]) with name
  sanitising and length limits, suggestion from the source's regexes
  and a reversibility check against 50 catalogue tracks.
- API and admin UI: accepts_uploads / write_template on sources,
  write-template suggest and check endpoints, write test result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 13:46:56 +03:00
1 parent 9b50038bb4
commit f66be2ba0a
27 files changed
+2422 -213

No files matched your search

+61 -6
View File
@@ -4,6 +4,9 @@ import (
"context"
"errors"
"fmt"
"path"
"strconv"
"strings"
"time"
"github.com/google/uuid"
@@ -41,6 +44,7 @@ type Source struct {
LastScanAt *time.Time
CreatedAt time.Time
AcceptsUploads bool
WriteTemplate *string
}
// PatternRow — шаблон пути источника.
@@ -81,7 +85,8 @@ func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
func newID() uuid.UUID { return uuid.Must(uuid.NewV7()) }
const sourceColumns = `id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority = 'tags_first',
extract(epoch from scan_interval)::bigint, status, status_message, last_scan_at, created_at, accepts_uploads`
extract(epoch from scan_interval)::bigint, status, status_message, last_scan_at, created_at, accepts_uploads,
write_template`
func scanSource(row pgx.Row) (Source, error) {
var (
@@ -90,7 +95,7 @@ func scanSource(row pgx.Row) (Source, error) {
interval *int64
)
err := row.Scan(&s.ID, &s.Name, &typ, &s.NFSServer, &s.NFSExport, &s.NFSOptions, &s.LocalPath, &s.TagsFirst,
&interval, &s.Status, &s.StatusMessage, &s.LastScanAt, &s.CreatedAt, &s.AcceptsUploads)
&interval, &s.Status, &s.StatusMessage, &s.LastScanAt, &s.CreatedAt, &s.AcceptsUploads, &s.WriteTemplate)
if errors.Is(err, pgx.ErrNoRows) {
return Source{}, ErrNotFound
}
@@ -147,14 +152,15 @@ func tagPriority(tagsFirst bool) string {
func (st *Store) SaveSource(ctx context.Context, s Source) (Source, error) {
return scanSource(st.pool.QueryRow(ctx, `
INSERT INTO library_sources (id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority,
scan_interval, status, status_message, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, make_interval(secs => $9::bigint), $10, $11, now())
scan_interval, status, status_message, accepts_uploads, write_template, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, make_interval(secs => $9::bigint), $10, $11, $12, $13, now())
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, nfs_server = EXCLUDED.nfs_server,
nfs_export = EXCLUDED.nfs_export, nfs_options = EXCLUDED.nfs_options, local_path = EXCLUDED.local_path,
tag_priority = EXCLUDED.tag_priority, scan_interval = EXCLUDED.scan_interval
tag_priority = EXCLUDED.tag_priority, scan_interval = EXCLUDED.scan_interval,
accepts_uploads = EXCLUDED.accepts_uploads, write_template = EXCLUDED.write_template
RETURNING `+sourceColumns,
s.ID, s.Name, string(s.Type), s.NFSServer, s.NFSExport, s.NFSOptions, s.LocalPath, tagPriority(s.TagsFirst),
intervalSeconds(s.ScanInterval), s.Status, s.StatusMessage))
intervalSeconds(s.ScanInterval), s.Status, s.StatusMessage, s.AcceptsUploads, s.WriteTemplate))
}
// SetSourceStatus обновляет состояние подключения.
@@ -683,3 +689,52 @@ func (st *Store) SaveFingerprint(ctx context.Context, fileID uuid.UUID, sum []by
}
return tx.Commit(ctx)
}
// TemplateSamples — случайные файлы источника с метаданными каталога (для проверки шаблона записи).
func (st *Store) TemplateSamples(ctx context.Context, sourceID uuid.UUID, n int) ([]meta.TemplateSample, error) {
rows, err := st.pool.Query(ctx, `
SELECT mf.rel_path, t.title, al.title, aa.name, coalesce(t.year, al.year), t.track_no, t.disc_no,
coalesce((SELECT string_agg(a.name, '; ' ORDER BY ta.position) FROM track_artists ta
JOIN artists a ON a.id = ta.artist_id WHERE ta.track_id = t.id AND ta.role = 'main'), aa.name),
(SELECT max(t2.disc_no) FROM tracks t2 WHERE t2.album_id = al.id),
(SELECT g.name FROM track_genres tg JOIN genres g ON g.id = tg.genre_id
WHERE tg.track_id = t.id ORDER BY tg.weight DESC LIMIT 1)
FROM media_files mf
JOIN tracks t ON t.id = mf.track_id
JOIN albums al ON al.id = t.album_id
JOIN artists aa ON aa.id = al.album_artist_id
WHERE mf.source_id = $1 AND mf.missing_since IS NULL
ORDER BY random() LIMIT $2`, sourceID, n)
if err != nil {
return nil, fmt.Errorf("library: template samples: %w", err)
}
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (meta.TemplateSample, error) {
var (
rel, title, album, albumArtist, artist string
year, track, disc, discs *int
genre *string
)
if err := r.Scan(&rel, &title, &album, &albumArtist, &year, &track, &disc, &artist, &discs, &genre); err != nil {
return meta.TemplateSample{}, err
}
num := func(p *int) string {
if p == nil || *p <= 0 {
return ""
}
return strconv.Itoa(*p)
}
f := meta.Fields{
Artist: artist, AlbumArtist: albumArtist, Album: album, Title: title, Year: num(year), Track: num(track),
Disc: num(disc),
}
if genre != nil {
f.Genre = *genre
}
count := 1
if discs != nil {
count = *discs
}
ext := strings.TrimPrefix(path.Ext(rel), ".")
return meta.TemplateSample{RelPath: rel, Input: meta.TemplateInput{Fields: f, DiscCount: count, Ext: ext}}, nil
})
}