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
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user