Import missing artist portraits from uploaded album folders
ci / module boundaries / boundaries (push) Successful in 6s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 2m1s
ci / ts / ts (packages + web) (push) Failing after 5m13s
ci / api / api (push) Successful in 41s
ci / db / db (push) Failing after 1m33s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 2m25s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 2m19s
ci / module boundaries / boundaries (push) Successful in 6s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 2m1s
ci / ts / ts (packages + web) (push) Failing after 5m13s
ci / api / api (push) Successful in 41s
ci / db / db (push) Failing after 1m33s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 2m25s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 2m19s
This commit is contained in:
1 parent
fd17c06397
commit
82bf015283
23 files changed
+829
-317
No files matched your search
@@ -0,0 +1,64 @@
|
||||
//go:build integration
|
||||
|
||||
package uploads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/library"
|
||||
"pipu/api/internal/platform/db"
|
||||
)
|
||||
|
||||
func TestUploadedArtistPortraitStagingLifetime(t *testing.T) {
|
||||
address := os.Getenv("TEST_DATABASE_URL")
|
||||
if address == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := db.Connect(ctx, address)
|
||||
require.NoError(t, err)
|
||||
defer pool.Close()
|
||||
require.NoError(t, db.Migrate(ctx, pool))
|
||||
store := NewStore(pool)
|
||||
author := uuid.New()
|
||||
_, err = pool.Exec(ctx, `INSERT INTO users(id,username,password_hash,role) VALUES($1,$2,'x','admin')`, author, "portrait-"+author.String())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM upload_batches WHERE author_id=$1`, author)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, author)
|
||||
_ = store.ReconcileQuota(ctx)
|
||||
})
|
||||
svc := NewService(store, nil, nil, nil, nil, nil, library.FPCalc{}, Config{StagingDir: t.TempDir()}, slog.New(slog.DiscardHandler))
|
||||
owner := auth.WithPrincipal(ctx, auth.Principal{UserID: author, Role: auth.RoleAdmin})
|
||||
files := []CreateFile{}
|
||||
for _, name := range []string{"one.flac", "two.flac"} {
|
||||
sum := sha256.Sum256([]byte(uuid.NewString()))
|
||||
files = append(files, CreateFile{ClientID: name, Name: name, Size: 10, SHA256: hex.EncodeToString(sum[:])})
|
||||
}
|
||||
batch, reserved, err := svc.Create(owner, nil, files, nil, []byte{0xff, 0xd8, 0xff, 1})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reserved, 2)
|
||||
first, err := store.File(ctx, reserved[0].FileID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, first.ArtistImagePath)
|
||||
image := svc.stagingAbs(*first.ArtistImagePath)
|
||||
require.FileExists(t, image)
|
||||
inUse, err := store.StagingInUse(ctx)
|
||||
require.NoError(t, err)
|
||||
require.True(t, inUse[*first.ArtistImagePath], "restart reconciliation must retain pending portraits")
|
||||
require.NoError(t, svc.Withdraw(owner, first.ID))
|
||||
require.FileExists(t, image, "the second file still owns this portrait")
|
||||
require.NoError(t, svc.Withdraw(owner, reserved[1].FileID))
|
||||
require.NoFileExists(t, image)
|
||||
unused, err := store.UnusedArtistImage(ctx, batch)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, first.ArtistImagePath, unused)
|
||||
}
|
||||
@@ -165,7 +165,15 @@ func (h *Handler) CreateUploadBatch(ctx context.Context, req gen.CreateUploadBat
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
batch, res, err := h.svc.Create(ctx, req.Body.Comment, in, cover)
|
||||
var artistImage []byte
|
||||
if req.Body.ArtistImageBase64 != nil {
|
||||
var err error
|
||||
artistImage, err = DecodeCover(*req.Body.ArtistImageBase64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
batch, res, err := h.svc.Create(ctx, req.Body.Comment, in, cover, artistImage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ type File struct {
|
||||
ReviewedAt *time.Time
|
||||
ImportedFileID *uuid.UUID
|
||||
CoverPath *string
|
||||
ArtistImagePath *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -45,8 +45,15 @@ type Service struct {
|
||||
catalogue ReleaseCatalogue
|
||||
identifier AudioIdentifier
|
||||
encodedFingerprint func(context.Context, string) (string, error)
|
||||
artistImages ArtistImages
|
||||
}
|
||||
|
||||
type ArtistImages interface {
|
||||
ApplyArtistImage(context.Context, uuid.UUID, []byte) error
|
||||
}
|
||||
|
||||
func (s *Service) SetArtistImages(images ArtistImages) { s.artistImages = images }
|
||||
|
||||
type ReleaseCatalogue interface {
|
||||
Search(context.Context, musicbrainz.Query) ([]musicbrainz.Release, error)
|
||||
Cover(context.Context, musicbrainz.Release) (musicbrainz.Cover, error)
|
||||
@@ -199,7 +206,7 @@ type CreateFile struct {
|
||||
}
|
||||
|
||||
// Create создаёт пачку и резервирует место; не поместившиеся и дубли отклоняются с кодом.
|
||||
func (s *Service) Create(ctx context.Context, comment *string, files []CreateFile, cover []byte) (uuid.UUID, []Reserved, error) {
|
||||
func (s *Service) Create(ctx context.Context, comment *string, files []CreateFile, cover []byte, artistImages ...[]byte) (uuid.UUID, []Reserved, error) {
|
||||
p, err := principal(ctx, auth.PermSuggestTracks)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, err
|
||||
@@ -235,12 +242,22 @@ func (s *Service) Create(ctx context.Context, comment *string, files []CreateFil
|
||||
return uuid.Nil, nil, err
|
||||
}
|
||||
keepCover := false
|
||||
var artistImagePath *string
|
||||
defer func() {
|
||||
if coverPath != nil && !keepCover {
|
||||
_ = os.Remove(s.stagingAbs(*coverPath))
|
||||
}
|
||||
if artistImagePath != nil && !keepCover {
|
||||
_ = os.Remove(s.stagingAbs(*artistImagePath))
|
||||
}
|
||||
}()
|
||||
batch, res, err := s.store.CreateBatch(ctx, p.UserID, comment, accepted, st, coverPath)
|
||||
if len(artistImages) > 0 {
|
||||
artistImagePath, err = s.createCover(artistImages[0])
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, err
|
||||
}
|
||||
}
|
||||
batch, res, err := s.store.CreateBatch(ctx, p.UserID, comment, accepted, st, coverPath, artistImagePath)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, err
|
||||
}
|
||||
@@ -609,6 +626,9 @@ func (s *Service) removeStaging(ctx context.Context, f File) {
|
||||
if cover, err := s.store.UnusedBatchCover(ctx, f.BatchID); err == nil && cover != nil {
|
||||
_ = os.Remove(s.stagingAbs(*cover))
|
||||
}
|
||||
if image, err := s.store.UnusedArtistImage(ctx, f.BatchID); err == nil && image != nil {
|
||||
_ = os.Remove(s.stagingAbs(*image))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, coverPath *string) (uuid.UUID, []Reserved, error) {
|
||||
func (st *Store) CreateBatch(ctx context.Context, author uuid.UUID, comment *string, files []NewFile, s Settings, coverPath *string, artistImages ...*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,12 @@ 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, cover_path) VALUES ($1, $2, $3, $4)`,
|
||||
batchID, author, comment, coverPath); err != nil {
|
||||
var artistImage *string
|
||||
if len(artistImages) > 0 {
|
||||
artistImage = artistImages[0]
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO upload_batches (id, author_id, comment, cover_path, artist_image_path) VALUES ($1, $2, $3, $4, $5)`,
|
||||
batchID, author, comment, coverPath, artistImage); err != nil {
|
||||
return uuid.Nil, nil, fmt.Errorf("uploads: insert batch: %w", err)
|
||||
}
|
||||
out := make([]Reserved, 0, len(files))
|
||||
@@ -231,7 +235,7 @@ 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, CASE WHEN f.release_group_id IS NULL THEN b.cover_path ELSE rg.cover_path END, f.created_at, f.updated_at, src.name`
|
||||
f.status_reason, f.reviewed_by, f.reviewed_at, f.imported_file_id, CASE WHEN f.release_group_id IS NULL THEN b.cover_path ELSE rg.cover_path END, f.created_at, f.updated_at, src.name, b.artist_image_path`
|
||||
|
||||
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 upload_release_groups rg ON rg.id=f.release_group_id
|
||||
@@ -246,7 +250,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.TargetSourceName)
|
||||
&f.StatusReason, &f.ReviewedBy, &f.ReviewedAt, &f.ImportedFileID, &f.CoverPath, &f.CreatedAt, &f.UpdatedAt, &f.TargetSourceName, &f.ArtistImagePath)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return File{}, ErrNotFound
|
||||
}
|
||||
@@ -353,6 +357,8 @@ 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
|
||||
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
|
||||
UNION SELECT b.artist_image_path FROM upload_batches b JOIN upload_files f ON f.batch_id = b.id
|
||||
WHERE f.status::text = ANY($1) AND b.artist_image_path IS NOT NULL
|
||||
UNION SELECT g.cover_path FROM upload_release_groups g JOIN upload_files f ON f.release_group_id=g.id
|
||||
WHERE f.status::text = ANY($1) AND g.cover_path IS NOT NULL`,
|
||||
occupyingStatuses)
|
||||
@@ -381,6 +387,17 @@ func (st *Store) UnusedBatchCover(ctx context.Context, batch uuid.UUID) (*string
|
||||
return cover, err
|
||||
}
|
||||
|
||||
// UnusedArtistImage releases a staged portrait only after every batch file is terminal.
|
||||
func (st *Store) UnusedArtistImage(ctx context.Context, batch uuid.UUID) (*string, error) {
|
||||
var image *string
|
||||
err := st.pool.QueryRow(ctx, `SELECT b.artist_image_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(&image)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return image, 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)
|
||||
|
||||
@@ -334,6 +334,15 @@ func (s *Service) importFile(ctx context.Context, f File) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("scan: %w", err)
|
||||
}
|
||||
if f.ArtistImagePath != nil && s.artistImages != nil {
|
||||
image, err := os.ReadFile(s.stagingAbs(*f.ArtistImagePath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read artist image: %w", err)
|
||||
}
|
||||
if err := s.artistImages.ApplyArtistImage(ctx, fileID, image); err != nil {
|
||||
return fmt.Errorf("artist image: %w", err)
|
||||
}
|
||||
}
|
||||
if err := s.store.CompleteImport(ctx, f.ID, pl.src.ID, rel, fileID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user