feat(api): upload queue with quota, tus intake, processing and import (M5.5)
ci / api / api (push) Successful in 46s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m3s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m22s
ci / ts / ts (packages + web) (push) Successful in 1m38s

- 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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 17:23:05 +03:00
1 parent f66be2ba0a
commit ca106e8eb1
21 files changed
+5552 -198

No files matched your search

+590
View File
@@ -359,6 +359,220 @@ paths:
$ref: "#/components/schemas/TemporaryPassword"
default:
$ref: "#/components/responses/Problem"
/api/v1/uploads/quota:
get:
operationId: getUploadQuota
tags: [uploads]
summary: Квота очереди на добавление (moderator, admin)
responses:
"200":
description: Лимиты и занятое место
content:
application/json:
schema:
$ref: "#/components/schemas/UploadQuota"
default:
$ref: "#/components/responses/Problem"
/api/v1/uploads/precheck:
post:
operationId: precheckUploads
tags: [uploads]
summary: Предпроверка дублей до загрузки (moderator, admin)
description: Ничего не резервирует. SHA-256 и теги клиент считает сам (docs/uploads.md#4).
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UploadPrecheckRequest"
responses:
"200":
description: Итог по каждому файлу
content:
application/json:
schema:
$ref: "#/components/schemas/UploadPrecheckResult"
default:
$ref: "#/components/responses/Problem"
/api/v1/uploads:
post:
operationId: createUploadBatch
tags: [uploads]
summary: Создать пачку и зарезервировать место (moderator, admin)
description: |
Для принятых файлов — `tus_url` (tus 1.0.0, только HEAD / PATCH, куски до 64 МБ). Не поместившиеся —
`uploads.queue_full` / `uploads.user_quota_full`, дубли — `uploads.duplicate`, без места в квоте.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UploadBatchRequest"
responses:
"201":
description: Пачка создана
content:
application/json:
schema:
$ref: "#/components/schemas/UploadBatchCreated"
default:
$ref: "#/components/responses/Problem"
/api/v1/uploads/mine:
get:
operationId: listMyUploads
tags: [uploads]
summary: Мои предложения (moderator, admin)
parameters:
- $ref: "#/components/parameters/UploadStatusFilter"
- $ref: "#/components/parameters/Limit"
- $ref: "#/components/parameters/Cursor"
responses:
"200":
description: Страница предложений
content:
application/json:
schema:
$ref: "#/components/schemas/UploadFilePage"
default:
$ref: "#/components/responses/Problem"
/api/v1/uploads/files/{id}:
parameters:
- $ref: "#/components/parameters/EntityId"
delete:
operationId: withdrawUpload
tags: [uploads]
summary: Отозвать своё предложение до решения
description: HEAD / PATCH по этому же адресу — приём файла по tus (вне контракта OpenAPI).
responses:
"204":
description: Отозвано, место освобождено
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/uploads:
get:
operationId: listUploads
tags: [uploads]
summary: Очередь на добавление (admin)
parameters:
- $ref: "#/components/parameters/UploadStatusFilter"
- name: author
in: query
schema:
type: string
format: uuid
- name: batch
in: query
schema:
type: string
format: uuid
- name: possible_duplicate
in: query
schema:
type: boolean
- $ref: "#/components/parameters/Limit"
- $ref: "#/components/parameters/Cursor"
responses:
"200":
description: Страница очереди
content:
application/json:
schema:
$ref: "#/components/schemas/UploadFilePage"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/uploads/files/{id}:
parameters:
- $ref: "#/components/parameters/EntityId"
patch:
operationId: updateUpload
tags: [uploads]
summary: Править метаданные и хранилище (admin)
description: Путь в хранилище пересчитывается; правленые теги записываются в файл при импорте.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UploadPatch"
responses:
"200":
description: Файл
content:
application/json:
schema:
$ref: "#/components/schemas/UploadFile"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/uploads/files/{id}/retry:
parameters:
- $ref: "#/components/parameters/EntityId"
post:
operationId: retryUploadImport
tags: [uploads]
summary: Повторить импорт после import_failed (admin)
responses:
"200":
description: Файл снова одобрен
content:
application/json:
schema:
$ref: "#/components/schemas/UploadFile"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/uploads/review:
post:
operationId: reviewUploads
tags: [uploads]
summary: Одобрить / отклонить файлы (admin)
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UploadReviewRequest"
responses:
"200":
description: Итог по каждому файлу
content:
application/json:
schema:
$ref: "#/components/schemas/UploadReviewResult"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/settings/uploads:
get:
operationId: getUploadSettings
tags: [uploads]
summary: Настройки очереди (admin)
responses:
"200":
description: Настройки
content:
application/json:
schema:
$ref: "#/components/schemas/UploadSettings"
default:
$ref: "#/components/responses/Problem"
patch:
operationId: updateUploadSettings
tags: [uploads]
summary: Изменить настройки очереди (admin)
description: Уменьшение лимита ниже занятого ничего не удаляет — новые загрузки ждут, пока очередь освободится.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UploadSettingsPatch"
responses:
"200":
description: Настройки
content:
application/json:
schema:
$ref: "#/components/schemas/UploadSettings"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/audit:
get:
operationId: listAudit
@@ -1174,6 +1388,16 @@ components:
description: Непрозрачный курсор из `next_cursor` предыдущей страницы
schema:
type: string
UploadStatusFilter:
name: status
in: query
description: Статусы через запятую
style: form
explode: false
schema:
type: array
items:
$ref: "#/components/schemas/UploadStatus"
UserId:
name: id
in: path
@@ -1194,6 +1418,372 @@ components:
schema:
$ref: "#/components/schemas/Problem"
schemas:
UserRef:
type: object
required: [id, username]
properties:
id:
type: string
format: uuid
username:
type: string
UploadStatus:
type: string
enum: [uploading, processing, pending_review, approved, importing, imported, import_failed, duplicate, invalid, rejected, withdrawn, expired]
UploadQuota:
type: object
required: [limit_bytes, used_bytes, per_user_limit_bytes, user_used_bytes, max_file_bytes]
properties:
limit_bytes:
type: integer
format: int64
used_bytes:
type: integer
format: int64
per_user_limit_bytes:
type: integer
format: int64
nullable: true
user_used_bytes:
type: integer
format: int64
max_file_bytes:
type: integer
format: int64
UploadPrecheckRequest:
type: object
required: [files]
properties:
files:
type: array
maxItems: 500
items:
type: object
required: [client_id, size, sha256]
properties:
client_id:
type: string
maxLength: 100
size:
type: integer
format: int64
sha256:
type: string
artist:
type: string
title:
type: string
duration_ms:
type: integer
UploadPrecheckResult:
type: object
required: [files]
properties:
files:
type: array
items:
type: object
required: [client_id, result, track_id, possible_duplicate]
properties:
client_id:
type: string
result:
type: string
enum: [accepted, duplicate, too_large]
track_id:
type: string
format: uuid
nullable: true
description: С каким треком совпал (дубль или похожие метаданные)
possible_duplicate:
type: boolean
description: Совпали только метаданные — файл примут, админ решит
UploadBatchRequest:
type: object
required: [files]
properties:
comment:
type: string
maxLength: 1000
files:
type: array
minItems: 1
maxItems: 500
items:
type: object
required: [client_id, name, size, sha256]
properties:
client_id:
type: string
maxLength: 100
name:
type: string
maxLength: 255
rel_path:
type: string
maxLength: 1000
description: Относительный путь в перетащенной папке
size:
type: integer
format: int64
sha256:
type: string
UploadBatchCreated:
type: object
required: [batch_id, files]
properties:
batch_id:
type: string
format: uuid
files:
type: array
items:
type: object
required: [client_id, upload_file_id, tus_url, rejected, track_id]
properties:
client_id:
type: string
upload_file_id:
type: string
format: uuid
nullable: true
tus_url:
type: string
nullable: true
rejected:
type: string
nullable: true
description: Код отказа — uploads.queue_full, uploads.user_quota_full, uploads.too_large, uploads.duplicate, uploads.unsupported_format
track_id:
type: string
format: uuid
nullable: true
UploadMeta:
type: object
required: [artist, albumartist, album, title, genre, year, track, disc]
properties:
artist:
type: string
maxLength: 500
albumartist:
type: string
maxLength: 500
album:
type: string
maxLength: 500
title:
type: string
maxLength: 500
genre:
type: string
maxLength: 100
year:
type: integer
nullable: true
track:
type: integer
nullable: true
disc:
type: integer
nullable: true
UploadFile:
type: object
required: [id, batch_id, batch_comment, author, status, original_name, client_rel_path, size_bytes, received_bytes, format, codec, bitrate, sample_rate, bit_depth, duration_ms, tags, metadata, metadata_edited, matched_artist_id, matched_album_id, duplicate_of_track_id, duplicate_level, possible_duplicate, target_source_id, target_rel_path, status_reason, reviewed_at, created_at, updated_at, tus_url]
properties:
id:
type: string
format: uuid
batch_id:
type: string
format: uuid
batch_comment:
type: string
nullable: true
author:
$ref: "#/components/schemas/UserRef"
status:
$ref: "#/components/schemas/UploadStatus"
original_name:
type: string
client_rel_path:
type: string
nullable: true
size_bytes:
type: integer
format: int64
received_bytes:
type: integer
format: int64
format:
type: string
nullable: true
codec:
type: string
nullable: true
bitrate:
type: integer
nullable: true
sample_rate:
type: integer
nullable: true
bit_depth:
type: integer
nullable: true
duration_ms:
type: integer
nullable: true
tags:
type: object
additionalProperties:
type: string
metadata:
nullable: true
allOf:
- $ref: "#/components/schemas/UploadMeta"
metadata_edited:
type: boolean
matched_artist_id:
type: string
format: uuid
nullable: true
description: Найден в каталоге — файл добавится к существующему исполнителю
matched_album_id:
type: string
format: uuid
nullable: true
description: Найден в каталоге — «будет добавлено к существующему альбому»
duplicate_of_track_id:
type: string
format: uuid
nullable: true
duplicate_level:
type: string
nullable: true
enum: [sha256, metadata, fingerprint, null]
possible_duplicate:
type: boolean
target_source_id:
type: string
format: uuid
nullable: true
target_rel_path:
type: string
nullable: true
description: Путь в хранилище по write_template (null — не построился, причина в status_reason)
status_reason:
type: string
nullable: true
reviewed_at:
type: string
format: date-time
nullable: true
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
tus_url:
type: string
nullable: true
description: Только автору и пока файл в статусе uploading
UploadFilePage:
type: object
required: [items, next_cursor]
properties:
items:
type: array
items:
$ref: "#/components/schemas/UploadFile"
next_cursor:
type: string
nullable: true
UploadPatch:
type: object
properties:
metadata:
$ref: "#/components/schemas/UploadMeta"
target_source_id:
type: string
format: uuid
UploadReviewRequest:
type: object
required: [file_ids, decision]
properties:
file_ids:
type: array
minItems: 1
maxItems: 500
items:
type: string
format: uuid
decision:
type: string
enum: [approve, reject]
reason:
type: string
maxLength: 1000
description: Причина отклонения — её видит автор
UploadReviewResult:
type: object
required: [items]
properties:
items:
type: array
items:
type: object
required: [id, ok, error]
properties:
id:
type: string
format: uuid
ok:
type: boolean
error:
nullable: true
allOf:
- $ref: "#/components/schemas/Problem"
UploadSettings:
type: object
required: [queue_limit_bytes, per_user_limit_bytes, max_file_bytes, default_source_id]
properties:
queue_limit_bytes:
type: integer
format: int64
per_user_limit_bytes:
type: integer
format: int64
nullable: true
max_file_bytes:
type: integer
format: int64
default_source_id:
type: string
format: uuid
nullable: true
UploadSettingsPatch:
type: object
description: Лимит на пользователя и хранилище по умолчанию передаются всегда (null — выключить / не задано).
required: [per_user_limit_bytes, default_source_id]
properties:
queue_limit_bytes:
type: integer
format: int64
minimum: 0
per_user_limit_bytes:
type: integer
format: int64
nullable: true
description: null — выключить лимит на пользователя
max_file_bytes:
type: integer
format: int64
minimum: 1
default_source_id:
type: string
format: uuid
nullable: true
description: null — без хранилища по умолчанию
HealthStatus:
type: object
required: [status]