feat(uploads): preview storage paths, detect templates, attach covers and withdraw batches
ci / module boundaries / boundaries (push) Successful in 7s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 55s
ci / ts / ts (packages + web) (push) Failing after 4m4s
ci / api / api (push) Successful in 55s
ci / db / db (push) Failing after 1m14s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m11s

This commit is contained in:
mrleo1nid committed 2026-10-29 10:28:29 +03:00
1 parent 5e2d1e902b
commit 64dd8ed05e
29 files changed
+1219 -471

No files matched your search

+24
View File
@@ -26,6 +26,30 @@ type BatchPatch struct {
const maxCoverBytes = 8 << 20
// createCover writes a new immutable cover before the quota transaction; Create removes it on failure.
func (s *Service) createCover(data []byte) (*string, error) {
if data == nil {
return nil, nil
}
if len(data) > maxCoverBytes {
return nil, apperr.Validation("cover_base64", "validation.cover_too_large", "cover is larger than 8 MB", nil)
}
ext, ok := coverExt(data)
if !ok {
return nil, apperr.Validation("cover_base64", "validation.cover_type", "cover must be jpeg, png or webp", nil)
}
rel := path.Join("covers", newID().String()+ext)
abs := s.stagingAbs(rel)
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return nil, err
}
if err := os.WriteFile(abs, data, 0o644); err != nil {
_ = os.Remove(abs)
return nil, err
}
return &rel, nil
}
// UpdateBatch применяет исполнителя, альбом, год и обложку к файлам пачки на рассмотрении.
func (s *Service) UpdateBatch(ctx context.Context, id uuid.UUID, in BatchPatch) ([]File, error) {
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
+12 -3
View File
@@ -35,7 +35,8 @@ func (h *Handler) fileDTO(ctx context.Context, f File) gen.UploadFile {
MetadataEdited: f.MetadataEdited, MatchedArtistId: f.MatchedArtistID, MatchedAlbumId: f.MatchedAlbumID,
DuplicateOfTrackId: f.DuplicateOfTrackID, PossibleDuplicate: f.PossibleDuplicate, QualityUpgrade: f.QualityUpgrade,
TargetSourceId: f.TargetSourceID, TargetRelPath: f.TargetRelPath, StatusReason: f.StatusReason,
ReviewedAt: f.ReviewedAt, CreatedAt: f.CreatedAt, UpdatedAt: f.UpdatedAt,
TargetSourceName: f.TargetSourceName,
ReviewedAt: f.ReviewedAt, CreatedAt: f.CreatedAt, UpdatedAt: f.UpdatedAt,
}
if out.Tags == nil {
out.Tags = map[string]string{}
@@ -154,7 +155,15 @@ func (h *Handler) CreateUploadBatch(ctx context.Context, req gen.CreateUploadBat
for _, f := range req.Body.Files {
in = append(in, CreateFile{ClientID: f.ClientId, Name: f.Name, RelPath: f.RelPath, Size: f.Size, SHA256: f.Sha256})
}
batch, res, err := h.svc.Create(ctx, req.Body.Comment, in)
var cover []byte
if req.Body.CoverBase64 != nil {
var err error
cover, err = DecodeCover(*req.Body.CoverBase64)
if err != nil {
return nil, err
}
}
batch, res, err := h.svc.Create(ctx, req.Body.Comment, in, cover)
if err != nil {
return nil, err
}
@@ -181,7 +190,7 @@ func (h *Handler) CreateUploadBatch(ctx context.Context, req gen.CreateUploadBat
// ListMyUploads реализует gen.StrictServerInterface.
func (h *Handler) ListMyUploads(ctx context.Context, req gen.ListMyUploadsRequestObject) (gen.ListMyUploadsResponseObject, error) {
files, next, err := h.svc.ListMine(ctx, statuses(req.Params.Status), deref(req.Params.Cursor, ""), deref(req.Params.Limit, 50))
files, next, err := h.svc.ListMine(ctx, statuses(req.Params.Status), deref(req.Params.Cursor, ""), deref(req.Params.Limit, 50), req.Params.Batch)
if err != nil {
return nil, err
}
+1
View File
@@ -140,6 +140,7 @@ type File struct {
PossibleDuplicate bool
QualityUpgrade bool
TargetSourceID *uuid.UUID
TargetSourceName *string
TargetRelPath *string
StatusReason *string
ReviewedBy *uuid.UUID
+27 -7
View File
@@ -178,7 +178,7 @@ type CreateFile struct {
}
// Create создаёт пачку и резервирует место; не поместившиеся и дубли отклоняются с кодом.
func (s *Service) Create(ctx context.Context, comment *string, files []CreateFile) (uuid.UUID, []Reserved, error) {
func (s *Service) Create(ctx context.Context, comment *string, files []CreateFile, cover []byte) (uuid.UUID, []Reserved, error) {
p, err := principal(ctx, auth.PermSuggestTracks)
if err != nil {
return uuid.Nil, nil, err
@@ -209,10 +209,25 @@ func (s *Service) Create(ctx context.Context, comment *string, files []CreateFil
}
accepted = append(accepted, NewFile{ClientID: f.ClientID, Name: name, RelPath: cleanClientPath(f.RelPath), Size: f.Size, SHA256: sum})
}
batch, res, err := s.store.CreateBatch(ctx, p.UserID, comment, accepted, st)
coverPath, err := s.createCover(cover)
if err != nil {
return uuid.Nil, nil, err
}
keepCover := false
defer func() {
if coverPath != nil && !keepCover {
_ = os.Remove(s.stagingAbs(*coverPath))
}
}()
batch, res, err := s.store.CreateBatch(ctx, p.UserID, comment, accepted, st, coverPath)
if err != nil {
return uuid.Nil, nil, err
}
for _, r := range res {
if r.Rejected == "" {
keepCover = true
}
}
return batch, append(res, rejected...), nil
}
@@ -240,12 +255,12 @@ func (s *Service) Complete(ctx context.Context, f File) error {
// --- Списки ---
// ListMine — предложения текущего пользователя.
func (s *Service) ListMine(ctx context.Context, statuses []Status, cursor string, limit int) ([]File, string, error) {
func (s *Service) ListMine(ctx context.Context, statuses []Status, cursor string, limit int, batch *uuid.UUID) ([]File, string, error) {
p, err := principal(ctx, auth.PermSuggestTracks)
if err != nil {
return nil, "", err
}
return s.list(ctx, ListFilter{AuthorID: &p.UserID, Statuses: statuses, Cursor: cursor, Limit: limit})
return s.list(ctx, ListFilter{AuthorID: &p.UserID, BatchID: batch, Statuses: statuses, Cursor: cursor, Limit: limit})
}
// ListAll — вся очередь (админ).
@@ -279,7 +294,7 @@ func (s *Service) Withdraw(ctx context.Context, id uuid.UUID) error {
if err := s.store.ChangeStatus(ctx, id, []Status{StatusUploading, StatusProcessing, StatusPendingReview}, StatusWithdrawn); err != nil {
return notFound(err)
}
s.removeStaging(f)
s.removeStaging(ctx, f)
return nil
}
@@ -380,7 +395,7 @@ func (s *Service) reviewOne(ctx context.Context, reviewer, id uuid.UUID, approve
if err := s.store.Decide(ctx, id, reviewer, false, reason); err != nil {
return notFound(err)
}
s.removeStaging(f)
s.removeStaging(ctx, f)
s.store.Audit(ctx, &reviewer, "upload.reject", id, map[string]any{"reason": reason})
return nil
}
@@ -542,7 +557,7 @@ func reuseExisting(root, rel string) string {
}
// removeStaging удаляет файл staging (и временные копии с тегами).
func (s *Service) removeStaging(f File) {
func (s *Service) removeStaging(ctx context.Context, f File) {
if f.StagingPath == "" {
return
}
@@ -552,6 +567,11 @@ func (s *Service) removeStaging(f File) {
_ = os.Remove(m)
}
_ = os.Remove(filepath.Dir(p)) // каталог пачки, если опустел
if f.CoverPath != nil {
if cover, err := s.store.UnusedBatchCover(ctx, f.BatchID); err == nil && cover != nil {
_ = os.Remove(s.stagingAbs(*cover))
}
}
}
// TusURL — адрес tus для файла.
+21 -7
View File
@@ -98,7 +98,7 @@ type Reserved struct {
// CreateBatch создаёт пачку и резервирует место под файлы: проверка «занято + запрошено ≤ лимит»
// под блокировкой строки счётчика (docs/uploads.md#3). Дубли по SHA-256 отклоняются, не занимая места.
func (st *Store) CreateBatch(ctx context.Context, author uuid.UUID, comment *string, files []NewFile, s Settings) (uuid.UUID, []Reserved, error) {
func (st *Store) CreateBatch(ctx context.Context, author uuid.UUID, comment *string, files []NewFile, s Settings, coverPath *string) (uuid.UUID, []Reserved, error) {
tx, err := st.pool.Begin(ctx)
if err != nil {
return uuid.Nil, nil, fmt.Errorf("uploads: create batch: %w", err)
@@ -116,8 +116,8 @@ func (st *Store) CreateBatch(ctx context.Context, author uuid.UUID, comment *str
}
batchID := newID()
if _, err := tx.Exec(ctx, `INSERT INTO upload_batches (id, author_id, comment) VALUES ($1, $2, $3)`,
batchID, author, comment); err != nil {
if _, err := tx.Exec(ctx, `INSERT INTO upload_batches (id, author_id, comment, cover_path) VALUES ($1, $2, $3, $4)`,
batchID, author, comment, coverPath); err != nil {
return uuid.Nil, nil, fmt.Errorf("uploads: insert batch: %w", err)
}
out := make([]Reserved, 0, len(files))
@@ -231,9 +231,10 @@ const fileColumns = `f.id, f.batch_id, f.author_id, u.username, b.comment, f.sta
f.size_bytes, f.received_bytes, f.sha256, coalesce(f.staging_path, ''), f.format, f.codec, f.bitrate, f.sample_rate,
f.bit_depth, f.duration_ms, f.tags, f.metadata, f.metadata_edited, f.matched_artist_id, f.matched_album_id,
f.duplicate_of_track_id, f.duplicate_level, f.possible_duplicate, f.quality_upgrade, f.target_source_id, f.target_rel_path,
f.status_reason, f.reviewed_by, f.reviewed_at, f.imported_file_id, b.cover_path, f.created_at, f.updated_at`
f.status_reason, f.reviewed_by, f.reviewed_at, f.imported_file_id, b.cover_path, f.created_at, f.updated_at, src.name`
const fileFrom = ` FROM upload_files f JOIN upload_batches b ON b.id = f.batch_id JOIN users u ON u.id = f.author_id`
const fileFrom = ` FROM upload_files f JOIN upload_batches b ON b.id = f.batch_id JOIN users u ON u.id = f.author_id
LEFT JOIN library_sources src ON src.id = f.target_source_id`
func scanFile(row pgx.Row) (File, error) {
var (
@@ -244,7 +245,7 @@ func scanFile(row pgx.Row) (File, error) {
&f.ClientRelPath, &f.Size, &f.Received, &f.SHA256, &f.StagingPath, &f.Format, &f.Codec, &f.Bitrate, &f.SampleRate,
&f.BitDepth, &f.DurationMs, &tags, &meta, &f.MetadataEdited, &f.MatchedArtistID, &f.MatchedAlbumID,
&f.DuplicateOfTrackID, &f.DuplicateLevel, &f.PossibleDuplicate, &f.QualityUpgrade, &f.TargetSourceID, &f.TargetRelPath,
&f.StatusReason, &f.ReviewedBy, &f.ReviewedAt, &f.ImportedFileID, &f.CoverPath, &f.CreatedAt, &f.UpdatedAt)
&f.StatusReason, &f.ReviewedBy, &f.ReviewedAt, &f.ImportedFileID, &f.CoverPath, &f.CreatedAt, &f.UpdatedAt, &f.TargetSourceName)
if errors.Is(err, pgx.ErrNoRows) {
return File{}, ErrNotFound
}
@@ -348,7 +349,9 @@ func (st *Store) NextWithStatus(ctx context.Context, s Status) (File, error) {
// StagingInUse — пути staging файлов, которым он ещё нужен (сверка при старте).
func (st *Store) StagingInUse(ctx context.Context) (map[string]bool, error) {
rows, err := st.pool.Query(ctx, `SELECT staging_path FROM upload_files WHERE status::text = ANY($1) AND staging_path IS NOT NULL`,
rows, err := st.pool.Query(ctx, `SELECT staging_path FROM upload_files WHERE status::text = ANY($1) AND staging_path IS NOT NULL
UNION SELECT b.cover_path FROM upload_batches b JOIN upload_files f ON f.batch_id = b.id
WHERE f.status::text = ANY($1) AND b.cover_path IS NOT NULL`,
occupyingStatuses)
if err != nil {
return nil, fmt.Errorf("uploads: staging in use: %w", err)
@@ -364,6 +367,17 @@ func (st *Store) StagingInUse(ctx context.Context) (map[string]bool, error) {
return out, nil
}
// UnusedBatchCover returns a cover only after every file in its batch has released staging.
func (st *Store) UnusedBatchCover(ctx context.Context, batch uuid.UUID) (*string, error) {
var cover *string
err := st.pool.QueryRow(ctx, `SELECT b.cover_path FROM upload_batches b WHERE b.id = $1
AND NOT EXISTS (SELECT 1 FROM upload_files f WHERE f.batch_id = b.id AND f.status::text = ANY($2))`, batch, occupyingStatuses).Scan(&cover)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return cover, err
}
// Stale — недокачанные файлы старше before.
func (st *Store) Stale(ctx context.Context, before time.Time) ([]uuid.UUID, error) {
rows, err := st.pool.Query(ctx, `SELECT id FROM upload_files WHERE status = 'uploading' AND updated_at < $1`, before)
@@ -182,6 +182,26 @@ func TestExtFor(t *testing.T) {
}
}
func TestInitialAlbumCover(t *testing.T) {
s := &Service{cfg: Config{StagingDir: t.TempDir()}}
if cover, err := s.createCover(nil); err != nil || cover != nil {
t.Fatalf("missing cover = %v, %v", cover, err)
}
if _, err := s.createCover([]byte("not an image")); err == nil {
t.Fatal("invalid cover must be rejected")
}
data := []byte{0xff, 0xd8, 0xff, 1}
rel, err := s.createCover(data)
require.NoError(t, err)
require.NotNil(t, rel)
got, err := os.ReadFile(s.stagingAbs(*rel))
require.NoError(t, err)
assert.Equal(t, data, got)
second, err := s.createCover(data)
require.NoError(t, err)
assert.NotEqual(t, *rel, *second, "album batches must not overwrite each other's cover")
}
func TestReuseExistingFolders(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(root, "Кино", "1988 - Группа крови"), 0o755))
+3 -3
View File
@@ -116,7 +116,7 @@ func (s *Service) process(ctx context.Context, f File) error {
return err
}
if o.status != StatusPendingReview {
s.removeStaging(f)
s.removeStaging(ctx, f)
}
s.log.Info("upload processed", "upload_id", f.ID, "status", o.status, "reason", o.reason)
return nil
@@ -326,7 +326,7 @@ func (s *Service) importFile(ctx context.Context, f File) error {
if err := s.store.CompleteImport(ctx, f.ID, pl.src.ID, rel, fileID); err != nil {
return err
}
s.removeStaging(f)
s.removeStaging(ctx, f)
s.store.Audit(ctx, f.ReviewedBy, "upload.import", f.ID, map[string]any{"source_id": pl.src.ID, "rel_path": rel})
s.log.Info("upload imported", "upload_id", f.ID, "source_id", pl.src.ID, "rel_path", rel)
return nil
@@ -397,7 +397,7 @@ func (s *Service) expire(ctx context.Context) {
continue
}
if err := s.store.ChangeStatus(ctx, id, []Status{StatusUploading}, StatusExpired); err == nil {
s.removeStaging(f)
s.removeStaging(ctx, f)
}
}
}