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
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:
1 parent
5e2d1e902b
commit
64dd8ed05e
29 files changed
+1219
-471
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ type File struct {
|
||||
PossibleDuplicate bool
|
||||
QualityUpgrade bool
|
||||
TargetSourceID *uuid.UUID
|
||||
TargetSourceName *string
|
||||
TargetRelPath *string
|
||||
StatusReason *string
|
||||
ReviewedBy *uuid.UUID
|
||||
|
||||
@@ -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 для файла.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user