feat(api): upload queue with quota, tus intake, processing and import (M5.5)
- Migration 00008: upload_batches, upload_files, upload_quota. - Quota reserved under a row lock when a batch is created; every status change goes through one transition that frees space in the same transaction. SHA-256 duplicates are rejected before any upload. - tus 1.0.0 core (HEAD/PATCH) for the author only; offset is the real staging file size, data beyond the declared size is refused (ADR-015). - Processing worker: ffprobe checks, SHA-256 verification, exact and Chromaprint duplicates against library and queue, metadata from tags or the dropped folder path, possible_duplicate on metadata match, and the target path from write_template reusing existing folder spelling. - Import worker: edited tags written with ffmpeg stream copy, file written by storage-agent, point scan of the new file, imported status; idempotent and resumed after restart. Stale uploads expire after 24 h, orphan staging files are removed at startup. - API: quota, precheck, batches, my uploads, withdraw, admin list, metadata edit, bulk review, retry and queue settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f66be2ba0a
commit
ca106e8eb1
21 files changed
+5552
-198
No files matched your search
@@ -0,0 +1,528 @@
|
||||
package uploads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrNotFound — файла (пачки) нет.
|
||||
var ErrNotFound = errors.New("uploads: not found")
|
||||
|
||||
// ErrWrongStatus — переход из текущего статуса не разрешён (гонка или повторное решение).
|
||||
var ErrWrongStatus = errors.New("uploads: wrong status")
|
||||
|
||||
// Store — таблицы очереди и чтение каталога для проверки дублей.
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewStore создаёт Store.
|
||||
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
||||
|
||||
func newID() uuid.UUID { return uuid.Must(uuid.NewV7()) }
|
||||
|
||||
// --- Настройки ---
|
||||
|
||||
const settingsKey = "uploads.settings"
|
||||
|
||||
// Settings — текущие настройки очереди (по умолчанию, если не сохранены).
|
||||
func (st *Store) Settings(ctx context.Context) (Settings, error) {
|
||||
var raw []byte
|
||||
err := st.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, settingsKey).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return DefaultSettings, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("uploads: settings: %w", err)
|
||||
}
|
||||
s := DefaultSettings
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return Settings{}, fmt.Errorf("uploads: settings: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// SaveSettings сохраняет настройки.
|
||||
func (st *Store) SaveSettings(ctx context.Context, s Settings) error {
|
||||
raw, _ := json.Marshal(s)
|
||||
_, err := st.pool.Exec(ctx, `
|
||||
INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, settingsKey, raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("uploads: save settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Квота ---
|
||||
|
||||
// occupyingStatuses — для SQL: статусы, занимающие место.
|
||||
var occupyingStatuses = []string{"uploading", "processing", "pending_review", "approved", "importing", "import_failed"}
|
||||
|
||||
// Usage — занято всего и пользователем.
|
||||
func (st *Store) Usage(ctx context.Context, userID uuid.UUID) (used, userUsed int64, err error) {
|
||||
err = st.pool.QueryRow(ctx, `
|
||||
SELECT (SELECT used_bytes FROM upload_quota),
|
||||
coalesce((SELECT sum(size_bytes) FROM upload_files WHERE author_id = $1 AND status::text = ANY($2)), 0)`,
|
||||
userID, occupyingStatuses).Scan(&used, &userUsed)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("uploads: usage: %w", err)
|
||||
}
|
||||
return used, userUsed, nil
|
||||
}
|
||||
|
||||
// NewFile — файл, заявленный при создании пачки.
|
||||
type NewFile struct {
|
||||
ClientID string
|
||||
Name string
|
||||
RelPath *string
|
||||
Size int64
|
||||
SHA256 []byte
|
||||
}
|
||||
|
||||
// Reserved — итог по файлу: либо id в очереди, либо код отказа.
|
||||
type Reserved struct {
|
||||
ClientID string
|
||||
FileID uuid.UUID
|
||||
Rejected string
|
||||
Track *uuid.UUID // для дубля — с каким треком совпал
|
||||
}
|
||||
|
||||
// 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) {
|
||||
tx, err := st.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, fmt.Errorf("uploads: create batch: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var used, userUsed int64
|
||||
if err := tx.QueryRow(ctx, `SELECT used_bytes FROM upload_quota WHERE id FOR UPDATE`).Scan(&used); err != nil {
|
||||
return uuid.Nil, nil, fmt.Errorf("uploads: lock quota: %w", err)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT coalesce(sum(size_bytes), 0) FROM upload_files WHERE author_id = $1 AND status::text = ANY($2)`,
|
||||
author, occupyingStatuses).Scan(&userUsed); err != nil {
|
||||
return uuid.Nil, nil, fmt.Errorf("uploads: user usage: %w", err)
|
||||
}
|
||||
|
||||
batchID := newID()
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO upload_batches (id, author_id, comment) VALUES ($1, $2, $3)`,
|
||||
batchID, author, comment); err != nil {
|
||||
return uuid.Nil, nil, fmt.Errorf("uploads: insert batch: %w", err)
|
||||
}
|
||||
out := make([]Reserved, 0, len(files))
|
||||
seen := map[string]bool{}
|
||||
var reserved int64
|
||||
for _, f := range files {
|
||||
r := Reserved{ClientID: f.ClientID}
|
||||
key := string(f.SHA256)
|
||||
switch {
|
||||
case f.Size > s.MaxFileBytes:
|
||||
r.Rejected = CodeTooLarge
|
||||
case seen[key]:
|
||||
r.Rejected = CodeDuplicate // второй такой же файл в одной пачке
|
||||
default:
|
||||
track, dup, err := duplicateBySHA(ctx, tx, f.SHA256, uuid.Nil)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, err
|
||||
}
|
||||
switch {
|
||||
case dup:
|
||||
r.Rejected, r.Track = CodeDuplicate, track
|
||||
case used+reserved+f.Size > s.QueueLimitBytes:
|
||||
r.Rejected = CodeQueueFull
|
||||
case s.PerUserLimitBytes != nil && userUsed+reserved+f.Size > *s.PerUserLimitBytes:
|
||||
r.Rejected = CodeUserQuotaFull
|
||||
}
|
||||
}
|
||||
seen[key] = true
|
||||
if r.Rejected == "" {
|
||||
r.FileID = newID()
|
||||
staging := batchID.String() + "/" + r.FileID.String()
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO upload_files (id, batch_id, author_id, status, original_name, client_rel_path, size_bytes, sha256,
|
||||
staging_path)
|
||||
VALUES ($1, $2, $3, 'uploading', $4, $5, $6, $7, $8)`,
|
||||
r.FileID, batchID, author, f.Name, f.RelPath, f.Size, f.SHA256, staging); err != nil {
|
||||
return uuid.Nil, nil, fmt.Errorf("uploads: insert file: %w", err)
|
||||
}
|
||||
reserved += f.Size
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE upload_quota SET used_bytes = used_bytes + $1 WHERE id`, reserved); err != nil {
|
||||
return uuid.Nil, nil, fmt.Errorf("uploads: reserve: %w", err)
|
||||
}
|
||||
return batchID, out, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// transition меняет статус под блокировкой строки и освобождает место, если новый статус его не занимает.
|
||||
func transition(ctx context.Context, tx pgx.Tx, id uuid.UUID, from []Status, to Status) (Status, error) {
|
||||
var (
|
||||
prev Status
|
||||
size int64
|
||||
)
|
||||
err := tx.QueryRow(ctx, `SELECT status, size_bytes FROM upload_files WHERE id = $1 FOR UPDATE`, id).Scan(&prev, &size)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("uploads: lock file: %w", err)
|
||||
}
|
||||
ok := false
|
||||
for _, f := range from {
|
||||
ok = ok || f == prev
|
||||
}
|
||||
if !ok {
|
||||
return prev, ErrWrongStatus
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE upload_files SET status = $2, updated_at = now() WHERE id = $1`, id, to); err != nil {
|
||||
return "", fmt.Errorf("uploads: set status: %w", err)
|
||||
}
|
||||
if prev.Occupies() && !to.Occupies() {
|
||||
if _, err := tx.Exec(ctx, `UPDATE upload_quota SET used_bytes = greatest(0, used_bytes - $1) WHERE id`, size); err != nil {
|
||||
return "", fmt.Errorf("uploads: release quota: %w", err)
|
||||
}
|
||||
}
|
||||
return prev, nil
|
||||
}
|
||||
|
||||
// Transition — смена статуса с дополнительным обновлением полей в той же транзакции (extra может быть nil).
|
||||
func (st *Store) Transition(ctx context.Context, id uuid.UUID, from []Status, to Status, extra func(tx pgx.Tx) error) error {
|
||||
tx, err := st.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("uploads: transition: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if _, err := transition(ctx, tx, id, from, to); err != nil {
|
||||
return err
|
||||
}
|
||||
if extra != nil {
|
||||
if err := extra(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// SetReceived — смещение tus после принятого куска.
|
||||
func (st *Store) SetReceived(ctx context.Context, id uuid.UUID, offset int64) error {
|
||||
_, err := st.pool.Exec(ctx, `
|
||||
UPDATE upload_files SET received_bytes = $2, updated_at = now() WHERE id = $1 AND status = 'uploading'`, id, offset)
|
||||
if err != nil {
|
||||
return fmt.Errorf("uploads: set received: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Чтение ---
|
||||
|
||||
const fileColumns = `f.id, f.batch_id, f.author_id, u.username, b.comment, f.status, f.original_name, f.client_rel_path,
|
||||
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.target_source_id, f.target_rel_path,
|
||||
f.status_reason, f.reviewed_by, f.reviewed_at, f.imported_file_id, f.created_at, f.updated_at`
|
||||
|
||||
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`
|
||||
|
||||
func scanFile(row pgx.Row) (File, error) {
|
||||
var (
|
||||
f File
|
||||
tags, meta []byte
|
||||
)
|
||||
err := row.Scan(&f.ID, &f.BatchID, &f.AuthorID, &f.AuthorName, &f.BatchComment, &f.Status, &f.OriginalName,
|
||||
&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.TargetSourceID, &f.TargetRelPath,
|
||||
&f.StatusReason, &f.ReviewedBy, &f.ReviewedAt, &f.ImportedFileID, &f.CreatedAt, &f.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return File{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return File{}, fmt.Errorf("uploads: scan file: %w", err)
|
||||
}
|
||||
if len(tags) > 0 {
|
||||
_ = json.Unmarshal(tags, &f.Tags)
|
||||
}
|
||||
if len(meta) > 0 {
|
||||
var m Meta
|
||||
if json.Unmarshal(meta, &m) == nil {
|
||||
f.Meta = &m
|
||||
}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// File — файл по id.
|
||||
func (st *Store) File(ctx context.Context, id uuid.UUID) (File, error) {
|
||||
return scanFile(st.pool.QueryRow(ctx, `SELECT `+fileColumns+fileFrom+` WHERE f.id = $1`, id))
|
||||
}
|
||||
|
||||
// ListFilter — фильтры списка очереди.
|
||||
type ListFilter struct {
|
||||
AuthorID *uuid.UUID
|
||||
Statuses []Status
|
||||
PossibleDuplicate *bool
|
||||
BatchID *uuid.UUID
|
||||
Cursor string // "<unix-nano>|<id>" последнего элемента
|
||||
Limit int
|
||||
}
|
||||
|
||||
// List — файлы очереди, новые первыми, страницами.
|
||||
func (st *Store) List(ctx context.Context, fl ListFilter) ([]File, string, error) {
|
||||
where := []string{"true"}
|
||||
args := []any{}
|
||||
add := func(cond string, v any) {
|
||||
args = append(args, v)
|
||||
where = append(where, strings.ReplaceAll(cond, "?", fmt.Sprintf("$%d", len(args))))
|
||||
}
|
||||
if fl.AuthorID != nil {
|
||||
add("f.author_id = ?", *fl.AuthorID)
|
||||
}
|
||||
if fl.BatchID != nil {
|
||||
add("f.batch_id = ?", *fl.BatchID)
|
||||
}
|
||||
if len(fl.Statuses) > 0 {
|
||||
ss := make([]string, len(fl.Statuses))
|
||||
for i, s := range fl.Statuses {
|
||||
ss[i] = string(s)
|
||||
}
|
||||
add("f.status::text = ANY(?)", ss)
|
||||
}
|
||||
if fl.PossibleDuplicate != nil {
|
||||
add("f.possible_duplicate = ?", *fl.PossibleDuplicate)
|
||||
}
|
||||
if fl.Cursor != "" {
|
||||
ts, id, ok := strings.Cut(fl.Cursor, "|")
|
||||
var nano int64
|
||||
cid, err := uuid.Parse(id)
|
||||
if _, serr := fmt.Sscan(ts, &nano); !ok || err != nil || serr != nil {
|
||||
return nil, "", errors.New("uploads: bad cursor")
|
||||
}
|
||||
args = append(args, time.Unix(0, nano).UTC(), cid)
|
||||
where = append(where, fmt.Sprintf("(f.created_at, f.id) < ($%d, $%d)", len(args)-1, len(args)))
|
||||
}
|
||||
limit := min(max(fl.Limit, 1), 200)
|
||||
args = append(args, limit+1)
|
||||
rows, err := st.pool.Query(ctx, `SELECT `+fileColumns+fileFrom+` WHERE `+strings.Join(where, " AND ")+
|
||||
fmt.Sprintf(` ORDER BY f.created_at DESC, f.id DESC LIMIT $%d`, len(args)), args...)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("uploads: list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []File
|
||||
for rows.Next() {
|
||||
f, err := scanFile(rows)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
next := ""
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
last := out[limit-1]
|
||||
next = fmt.Sprintf("%d|%s", last.CreatedAt.UnixNano(), last.ID)
|
||||
}
|
||||
return out, next, nil
|
||||
}
|
||||
|
||||
// NextWithStatus — самый старый файл в статусе (для фоновых обработчиков).
|
||||
func (st *Store) NextWithStatus(ctx context.Context, s Status) (File, error) {
|
||||
return scanFile(st.pool.QueryRow(ctx, `SELECT `+fileColumns+fileFrom+
|
||||
` WHERE f.status = $1 ORDER BY f.updated_at, f.id LIMIT 1`, s))
|
||||
}
|
||||
|
||||
// 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`,
|
||||
occupyingStatuses)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("uploads: staging in use: %w", err)
|
||||
}
|
||||
paths, err := pgx.CollectRows(rows, pgx.RowTo[string])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]bool, len(paths))
|
||||
for _, p := range paths {
|
||||
out[p] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("uploads: stale: %w", err)
|
||||
}
|
||||
return pgx.CollectRows(rows, pgx.RowTo[uuid.UUID])
|
||||
}
|
||||
|
||||
// Audit пишет действие в журнал.
|
||||
func (st *Store) Audit(ctx context.Context, actor *uuid.UUID, action string, target uuid.UUID, payload map[string]any) {
|
||||
raw, _ := json.Marshal(payload)
|
||||
_, _ = st.pool.Exec(ctx, `
|
||||
INSERT INTO audit_log (id, actor_id, action, target_type, target_id, payload) VALUES ($1, $2, $3, 'upload_file', $4, $5)`,
|
||||
newID(), actor, action, target, raw)
|
||||
}
|
||||
|
||||
// --- Каталог: дубли, сопоставление ---
|
||||
|
||||
// duplicateBySHA — точная копия: файл библиотеки или другой файл очереди, ещё занимающий место.
|
||||
func duplicateBySHA(ctx context.Context, q interface {
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
}, sum []byte, exclude uuid.UUID) (*uuid.UUID, bool, error) {
|
||||
if len(sum) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
var track *uuid.UUID
|
||||
err := q.QueryRow(ctx, `
|
||||
SELECT track_id FROM media_files WHERE sha256 = $1 AND missing_since IS NULL LIMIT 1`, sum).Scan(&track)
|
||||
if err == nil {
|
||||
return track, true, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, false, fmt.Errorf("uploads: sha duplicate: %w", err)
|
||||
}
|
||||
var id uuid.UUID
|
||||
err = q.QueryRow(ctx, `
|
||||
SELECT id FROM upload_files WHERE sha256 = $1 AND id <> $2 AND status::text = ANY($3) LIMIT 1`,
|
||||
sum, exclude, occupyingStatuses).Scan(&id)
|
||||
if err == nil {
|
||||
return nil, true, nil
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, fmt.Errorf("uploads: sha duplicate: %w", err)
|
||||
}
|
||||
|
||||
// DuplicateBySHA — см. duplicateBySHA.
|
||||
func (st *Store) DuplicateBySHA(ctx context.Context, sum []byte, exclude uuid.UUID) (*uuid.UUID, bool, error) {
|
||||
return duplicateBySHA(ctx, st.pool, sum, exclude)
|
||||
}
|
||||
|
||||
// PrintCandidate — отпечаток для сравнения: трек библиотеки или другой файл очереди.
|
||||
type PrintCandidate struct {
|
||||
TrackID *uuid.UUID
|
||||
Points []uint32
|
||||
}
|
||||
|
||||
// FingerprintCandidates — отпечатки библиотеки и очереди с длительностью в пределах tolMs.
|
||||
func (st *Store) FingerprintCandidates(ctx context.Context, durationMs, tolMs int, exclude uuid.UUID) ([]PrintCandidate, error) {
|
||||
rows, err := st.pool.Query(ctx, `
|
||||
SELECT mf.track_id, tf.fingerprint FROM track_fingerprints tf JOIN media_files mf ON mf.id = tf.file_id
|
||||
WHERE tf.duration_ms BETWEEN $1 AND $2 AND mf.missing_since IS NULL
|
||||
UNION ALL
|
||||
SELECT NULL, fingerprint FROM upload_files
|
||||
WHERE fingerprint IS NOT NULL AND id <> $3 AND status::text = ANY($4) AND duration_ms BETWEEN $1 AND $2`,
|
||||
durationMs-tolMs, durationMs+tolMs, exclude, occupyingStatuses)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("uploads: fingerprint candidates: %w", err)
|
||||
}
|
||||
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (PrintCandidate, error) {
|
||||
var (
|
||||
c PrintCandidate
|
||||
raw []int32
|
||||
)
|
||||
if err := r.Scan(&c.TrackID, &raw); err != nil {
|
||||
return c, err
|
||||
}
|
||||
c.Points = make([]uint32, len(raw))
|
||||
for i, v := range raw {
|
||||
c.Points[i] = uint32(v)
|
||||
}
|
||||
return c, nil
|
||||
})
|
||||
}
|
||||
|
||||
// MetadataMatch — трек библиотеки с тем же исполнителем и названием и длительностью ±tolMs.
|
||||
func (st *Store) MetadataMatch(ctx context.Context, artistKey, titleKey string, durationMs, tolMs int) (*uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := st.pool.QueryRow(ctx, `
|
||||
SELECT t.id FROM tracks t
|
||||
JOIN track_artists ta ON ta.track_id = t.id
|
||||
JOIN artists a ON a.id = ta.artist_id
|
||||
WHERE t.title_norm = $1 AND a.name_norm = $2 AND abs(t.duration_ms - $3) <= $4
|
||||
LIMIT 1`, titleKey, artistKey, durationMs, tolMs).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("uploads: metadata match: %w", err)
|
||||
}
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
// MatchCatalog — существующие исполнитель (по ключу имени) и его альбом (по ключу названия), если есть.
|
||||
func (st *Store) MatchCatalog(ctx context.Context, artistKey, albumKey string) (artist, album *uuid.UUID, maxDisc int, err error) {
|
||||
var a uuid.UUID
|
||||
err = st.pool.QueryRow(ctx, `SELECT id FROM artists WHERE name_norm = $1`, artistKey).Scan(&a)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil, 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, 0, fmt.Errorf("uploads: match artist: %w", err)
|
||||
}
|
||||
artist = &a
|
||||
var (
|
||||
al uuid.UUID
|
||||
disc *int
|
||||
)
|
||||
err = st.pool.QueryRow(ctx, `
|
||||
SELECT al.id, (SELECT max(disc_no) FROM tracks WHERE album_id = al.id)
|
||||
FROM albums al WHERE al.album_artist_id = $1 AND al.title_norm = $2`, a, albumKey).Scan(&al, &disc)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return artist, nil, 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, 0, fmt.Errorf("uploads: match album: %w", err)
|
||||
}
|
||||
if disc != nil {
|
||||
maxDisc = *disc
|
||||
}
|
||||
return artist, &al, maxDisc, nil
|
||||
}
|
||||
|
||||
// BatchMaxDisc — наибольший номер диска среди файлов пачки с тем же альбомом (для {disc:1-}).
|
||||
func (st *Store) BatchMaxDisc(ctx context.Context, batchID uuid.UUID, album string) (int, error) {
|
||||
var n *int
|
||||
err := st.pool.QueryRow(ctx, `
|
||||
SELECT max((metadata->>'disc')::int) FROM upload_files
|
||||
WHERE batch_id = $1 AND metadata->>'album' = $2 AND status::text = ANY($3)`,
|
||||
batchID, album, occupyingStatuses).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("uploads: batch discs: %w", err)
|
||||
}
|
||||
if n == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return *n, nil
|
||||
}
|
||||
|
||||
// MarkImported связывает файл библиотеки с загрузкой.
|
||||
func MarkImported(ctx context.Context, tx pgx.Tx, uploadID, mediaFileID uuid.UUID) error {
|
||||
if _, err := tx.Exec(ctx, `UPDATE upload_files SET imported_file_id = $2, status_reason = NULL WHERE id = $1`,
|
||||
uploadID, mediaFileID); err != nil {
|
||||
return fmt.Errorf("uploads: mark imported: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE media_files SET upload_file_id = $1 WHERE id = $2`, uploadID, mediaFileID); err != nil {
|
||||
return fmt.Errorf("uploads: link media file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user