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]
|
||||
|
||||
+3
-2
@@ -135,8 +135,8 @@
|
||||
| GET | `/uploads/quota` | moderator, admin | `{limit_bytes, used_bytes, per_user_limit_bytes, user_used_bytes, max_file_bytes}` |
|
||||
| POST | `/uploads/precheck` | moderator, admin | `{files:[{client_id, size, sha256, tags:{artist,title,album,duration_ms}}]}` → для каждого `accepted` / `duplicate` (+ `track`) / `too_large`; ничего не резервирует |
|
||||
| POST | `/uploads` | moderator, admin | Создать пачку `{comment, files:[{client_id, name, rel_path, size, sha256}]}` → резерв квоты; `{batch_id, files:[{client_id, upload_file_id, tus_url} | {client_id, rejected: code}]}`; при нехватке места — `uploads.queue_full` для не поместившихся |
|
||||
| tus | `/uploads/files/{upload_file_id}` | автор | Resumable-загрузка (`HEAD`/`PATCH`) |
|
||||
| GET | `/uploads/mine?status=&cursor=` | moderator, admin | Мои предложения |
|
||||
| tus | `/uploads/files/{upload_file_id}` | автор | Resumable-загрузка (`HEAD`/`PATCH`, ядро tus 1.0.0 — ADR-015) |
|
||||
| GET | `/uploads/mine?status=&cursor=` | moderator, admin | Мои предложения (`status` — через запятую) |
|
||||
| DELETE | `/uploads/files/{id}` | автор | Отозвать (`withdrawn`), только до решения |
|
||||
| GET | `/admin/uploads?status=&author=&possible_duplicate=&cursor=` | admin | Очередь, сгруппированная по пачкам |
|
||||
| GET | `/admin/uploads/files/{id}/stream?sig=` | admin | Прослушивание из staging |
|
||||
@@ -144,6 +144,7 @@
|
||||
| PATCH | `/admin/uploads/batches/{id}` | admin | Правка общих полей для всех файлов пачки (исполнитель, альбом, год, обложка) |
|
||||
| POST | `/admin/uploads/review` | admin | `{file_ids:[], decision: "approve"|"reject", reason?}` — массовое решение |
|
||||
| POST | `/admin/uploads/files/{id}/retry` | admin | Повторить импорт после `import_failed` |
|
||||
| GET · PATCH | `/admin/settings/uploads` | admin | Лимиты очереди и хранилище по умолчанию |
|
||||
| GET | `/admin/audit?cursor=` | admin |
|
||||
|
||||
## Внутренние API
|
||||
|
||||
@@ -187,3 +187,19 @@ Valence — грубая оценка (лад + яркость); `mood_*`, `arou
|
||||
**Последствия.** Анализ работает на любом CPU без моделей. Контексты используют энергию, темп, танцевальность
|
||||
и valence; настроения «весёлое» / «грустное» пока приблизительны. Переход на Essentia — новая версия
|
||||
`analyzer_ver`: воркер сам ставит пересчёт всех треков.
|
||||
|
||||
---
|
||||
|
||||
## ADR-015. Приём загрузок — ядро tus своими силами, без tusd
|
||||
|
||||
**Контекст.** ADR-012 предполагал `tusd` как библиотеку. Но файл очереди создаёт `POST /uploads` вместе с резервом
|
||||
квоты и проверкой дублей, а права (только автор, только `uploading`) проверяет `api`. От tus остаётся только ядро
|
||||
протокола: `HEAD` (сколько принято) и `PATCH` (следующий кусок с `Upload-Offset`).
|
||||
|
||||
**Решение.** Ядро tus 1.0.0 — в `internal/uploads/tus.go` (~200 строк): смещение — фактический размер файла в staging,
|
||||
один `PATCH` на файл одновременно, данные сверх заявленного размера не принимаются, куски до 64 МБ. Расширения
|
||||
creation / termination / checksum не нужны. Клиент — `tus-js-client` с готовым `uploadUrl` из ответа `POST /uploads`.
|
||||
|
||||
**Последствия.** Нет новой зависимости и отдельного хранилища состояния tus; квота и права — в одной транзакции
|
||||
с созданием файла. Целостность проверяется после приёма: SHA-256 файла сверяется с заявленным клиентом.
|
||||
|
||||
@@ -175,6 +175,29 @@
|
||||
- Автор видит статусы в «Мои предложения» (раздел админки модератора), бейдж с числом решений с последнего визита.
|
||||
- Админ видит бейдж «N на рассмотрении» в сайдбаре.
|
||||
|
||||
## Реализация (`services/api/internal/uploads`)
|
||||
|
||||
- Таблицы — миграция `00008_uploads.sql`; смещение tus — `upload_files.received_bytes`, отпечаток Chromaprint файла
|
||||
очереди — `upload_files.fingerprint` (дубли внутри очереди ловятся так же, как с библиотекой).
|
||||
- Квота: `Store.CreateBatch` резервирует под `SELECT … FOR UPDATE` строки `upload_quota`; любая смена статуса идёт через
|
||||
`transition` — место освобождается в той же транзакции, если новый статус его не занимает (таблица раздела 2).
|
||||
- Приём — ядро tus (ADR-015): `HEAD` / `PATCH /api/v1/uploads/files/{id}`, только автор и только в `uploading`.
|
||||
Принят целиком → `processing`.
|
||||
- Обработка (`worker.go`, по одному файлу): размер → `ffprobe` (5 с – 3 ч) → SHA-256 (сверка с заявленным) →
|
||||
дубль по SHA-256 → Chromaprint против библиотеки и очереди (длительность ±5 с, `library.Similarity` ≥ 0.75) →
|
||||
метаданные (теги → путь из папки по регэкспам целевого хранилища → имя файла) → совпадение метаданных
|
||||
(`possible_duplicate`) → путь по `write_template` с переиспользованием существующих папок → `pending_review`.
|
||||
Если путь не строится (нет хранилища / шаблона / поля), файл всё равно ждёт рассмотрения, причина — в `status_reason`.
|
||||
- Хранилище: выбранное админом → `default_source_id` из настроек → единственное с `accepts_uploads`.
|
||||
- Импорт: правленые метаданные → копия с тегами (`ffmpeg -map 0 -c copy -metadata …`, FLAC/MP3/M4A/Ogg/Opus) →
|
||||
`storage-agent` `POST /internal/files` → `Scanner.ScanFile` (точечный скан) → `imported`, связь `media_files.upload_file_id`.
|
||||
Повторяемо: если по пути уже лежит файл с тем же хэшем, запись пропускается. Прерванный импорт при старте
|
||||
возвращается в `approved`.
|
||||
- Очистка: раз в 10 минут `uploading` старше 24 ч → `expired`; при старте — удаление файлов staging без записи в БД.
|
||||
- Настройки: `GET` / `PATCH /admin/settings/uploads` (ключ `uploads.settings` в `app_settings`).
|
||||
- Пока не сделано: `allow_quality_upgrade`, правка «на всю пачку» (`PATCH /admin/uploads/batches/{id}`),
|
||||
прослушивание из staging, отдельная обложка пачки, ночная сверка счётчика квоты, экраны веба.
|
||||
|
||||
## 11. Очистка
|
||||
- `uploading` старше 24 ч → `expired`, файлы удаляются.
|
||||
- Записи завершённых загрузок (`imported`, `rejected`, …) хранятся 180 дней для истории, без файлов.
|
||||
|
||||
@@ -305,6 +305,199 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/uploads/quota": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Квота очереди на добавление (moderator, admin) */
|
||||
get: operations["getUploadQuota"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/uploads/precheck": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Предпроверка дублей до загрузки (moderator, admin)
|
||||
* @description Ничего не резервирует. SHA-256 и теги клиент считает сам (docs/uploads.md#4).
|
||||
*/
|
||||
post: operations["precheckUploads"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/uploads": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Создать пачку и зарезервировать место (moderator, admin)
|
||||
* @description Для принятых файлов — `tus_url` (tus 1.0.0, только HEAD / PATCH, куски до 64 МБ). Не поместившиеся —
|
||||
* `uploads.queue_full` / `uploads.user_quota_full`, дубли — `uploads.duplicate`, без места в квоте.
|
||||
*/
|
||||
post: operations["createUploadBatch"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/uploads/mine": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Мои предложения (moderator, admin) */
|
||||
get: operations["listMyUploads"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/uploads/files/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: components["parameters"]["EntityId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/**
|
||||
* Отозвать своё предложение до решения
|
||||
* @description HEAD / PATCH по этому же адресу — приём файла по tus (вне контракта OpenAPI).
|
||||
*/
|
||||
delete: operations["withdrawUpload"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/uploads": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Очередь на добавление (admin) */
|
||||
get: operations["listUploads"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/uploads/files/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: components["parameters"]["EntityId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/**
|
||||
* Править метаданные и хранилище (admin)
|
||||
* @description Путь в хранилище пересчитывается; правленые теги записываются в файл при импорте.
|
||||
*/
|
||||
patch: operations["updateUpload"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/uploads/files/{id}/retry": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: components["parameters"]["EntityId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Повторить импорт после import_failed (admin) */
|
||||
post: operations["retryUploadImport"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/uploads/review": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Одобрить / отклонить файлы (admin) */
|
||||
post: operations["reviewUploads"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/settings/uploads": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Настройки очереди (admin) */
|
||||
get: operations["getUploadSettings"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/**
|
||||
* Изменить настройки очереди (admin)
|
||||
* @description Уменьшение лимита ниже занятого ничего не удаляет — новые загрузки ждут, пока очередь освободится.
|
||||
*/
|
||||
patch: operations["updateUploadSettings"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/audit": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -969,6 +1162,191 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
UserRef: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
/** @enum {string} */
|
||||
UploadStatus: "uploading" | "processing" | "pending_review" | "approved" | "importing" | "imported" | "import_failed" | "duplicate" | "invalid" | "rejected" | "withdrawn" | "expired";
|
||||
UploadQuota: {
|
||||
/** Format: int64 */
|
||||
limit_bytes: number;
|
||||
/** Format: int64 */
|
||||
used_bytes: number;
|
||||
/** Format: int64 */
|
||||
per_user_limit_bytes: number | null;
|
||||
/** Format: int64 */
|
||||
user_used_bytes: number;
|
||||
/** Format: int64 */
|
||||
max_file_bytes: number;
|
||||
};
|
||||
UploadPrecheckRequest: {
|
||||
files: {
|
||||
client_id: string;
|
||||
/** Format: int64 */
|
||||
size: number;
|
||||
sha256: string;
|
||||
artist?: string;
|
||||
title?: string;
|
||||
duration_ms?: number;
|
||||
}[];
|
||||
};
|
||||
UploadPrecheckResult: {
|
||||
files: {
|
||||
client_id: string;
|
||||
/** @enum {string} */
|
||||
result: "accepted" | "duplicate" | "too_large";
|
||||
/**
|
||||
* Format: uuid
|
||||
* @description С каким треком совпал (дубль или похожие метаданные)
|
||||
*/
|
||||
track_id: string | null;
|
||||
/** @description Совпали только метаданные — файл примут, админ решит */
|
||||
possible_duplicate: boolean;
|
||||
}[];
|
||||
};
|
||||
UploadBatchRequest: {
|
||||
comment?: string;
|
||||
files: {
|
||||
client_id: string;
|
||||
name: string;
|
||||
/** @description Относительный путь в перетащенной папке */
|
||||
rel_path?: string;
|
||||
/** Format: int64 */
|
||||
size: number;
|
||||
sha256: string;
|
||||
}[];
|
||||
};
|
||||
UploadBatchCreated: {
|
||||
/** Format: uuid */
|
||||
batch_id: string;
|
||||
files: {
|
||||
client_id: string;
|
||||
/** Format: uuid */
|
||||
upload_file_id: string | null;
|
||||
tus_url: string | null;
|
||||
/** @description Код отказа — uploads.queue_full, uploads.user_quota_full, uploads.too_large, uploads.duplicate, uploads.unsupported_format */
|
||||
rejected: string | null;
|
||||
/** Format: uuid */
|
||||
track_id: string | null;
|
||||
}[];
|
||||
};
|
||||
UploadMeta: {
|
||||
artist: string;
|
||||
albumartist: string;
|
||||
album: string;
|
||||
title: string;
|
||||
genre: string;
|
||||
year: number | null;
|
||||
track: number | null;
|
||||
disc: number | null;
|
||||
};
|
||||
UploadFile: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** Format: uuid */
|
||||
batch_id: string;
|
||||
batch_comment: string | null;
|
||||
author: components["schemas"]["UserRef"];
|
||||
status: components["schemas"]["UploadStatus"];
|
||||
original_name: string;
|
||||
client_rel_path: string | null;
|
||||
/** Format: int64 */
|
||||
size_bytes: number;
|
||||
/** Format: int64 */
|
||||
received_bytes: number;
|
||||
format: string | null;
|
||||
codec: string | null;
|
||||
bitrate: number | null;
|
||||
sample_rate: number | null;
|
||||
bit_depth: number | null;
|
||||
duration_ms: number | null;
|
||||
tags: {
|
||||
[key: string]: string;
|
||||
};
|
||||
metadata: components["schemas"]["UploadMeta"] | null;
|
||||
metadata_edited: boolean;
|
||||
/**
|
||||
* Format: uuid
|
||||
* @description Найден в каталоге — файл добавится к существующему исполнителю
|
||||
*/
|
||||
matched_artist_id: string | null;
|
||||
/**
|
||||
* Format: uuid
|
||||
* @description Найден в каталоге — «будет добавлено к существующему альбому»
|
||||
*/
|
||||
matched_album_id: string | null;
|
||||
/** Format: uuid */
|
||||
duplicate_of_track_id: string | null;
|
||||
/** @enum {string|null} */
|
||||
duplicate_level: "sha256" | "metadata" | "fingerprint" | null;
|
||||
possible_duplicate: boolean;
|
||||
/** Format: uuid */
|
||||
target_source_id: string | null;
|
||||
/** @description Путь в хранилище по write_template (null — не построился, причина в status_reason) */
|
||||
target_rel_path: string | null;
|
||||
status_reason: string | null;
|
||||
/** Format: date-time */
|
||||
reviewed_at: string | null;
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
/** Format: date-time */
|
||||
updated_at: string;
|
||||
/** @description Только автору и пока файл в статусе uploading */
|
||||
tus_url: string | null;
|
||||
};
|
||||
UploadFilePage: {
|
||||
items: components["schemas"]["UploadFile"][];
|
||||
next_cursor: string | null;
|
||||
};
|
||||
UploadPatch: {
|
||||
metadata?: components["schemas"]["UploadMeta"];
|
||||
/** Format: uuid */
|
||||
target_source_id?: string;
|
||||
};
|
||||
UploadReviewRequest: {
|
||||
file_ids: string[];
|
||||
/** @enum {string} */
|
||||
decision: "approve" | "reject";
|
||||
/** @description Причина отклонения — её видит автор */
|
||||
reason?: string;
|
||||
};
|
||||
UploadReviewResult: {
|
||||
items: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
ok: boolean;
|
||||
error: components["schemas"]["Problem"] | null;
|
||||
}[];
|
||||
};
|
||||
UploadSettings: {
|
||||
/** Format: int64 */
|
||||
queue_limit_bytes: number;
|
||||
/** Format: int64 */
|
||||
per_user_limit_bytes: number | null;
|
||||
/** Format: int64 */
|
||||
max_file_bytes: number;
|
||||
/** Format: uuid */
|
||||
default_source_id: string | null;
|
||||
};
|
||||
/** @description Лимит на пользователя и хранилище по умолчанию передаются всегда (null — выключить / не задано). */
|
||||
UploadSettingsPatch: {
|
||||
/** Format: int64 */
|
||||
queue_limit_bytes?: number;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description null — выключить лимит на пользователя
|
||||
*/
|
||||
per_user_limit_bytes: number | null;
|
||||
/** Format: int64 */
|
||||
max_file_bytes?: number;
|
||||
/**
|
||||
* Format: uuid
|
||||
* @description null — без хранилища по умолчанию
|
||||
*/
|
||||
default_source_id: string | null;
|
||||
};
|
||||
HealthStatus: {
|
||||
/** @enum {string} */
|
||||
status: "ok";
|
||||
@@ -1635,6 +2013,8 @@ export interface components {
|
||||
Limit: number;
|
||||
/** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */
|
||||
Cursor: string;
|
||||
/** @description Статусы через запятую */
|
||||
UploadStatusFilter: components["schemas"]["UploadStatus"][];
|
||||
UserId: string;
|
||||
};
|
||||
requestBodies: never;
|
||||
@@ -2064,6 +2444,276 @@ export interface operations {
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
getUploadQuota: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Лимиты и занятое место */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UploadQuota"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
precheckUploads: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UploadPrecheckRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Итог по каждому файлу */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UploadPrecheckResult"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
createUploadBatch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UploadBatchRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Пачка создана */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UploadBatchCreated"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
listMyUploads: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Статусы через запятую */
|
||||
status?: components["parameters"]["UploadStatusFilter"];
|
||||
limit?: components["parameters"]["Limit"];
|
||||
/** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */
|
||||
cursor?: components["parameters"]["Cursor"];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Страница предложений */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UploadFilePage"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
withdrawUpload: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: components["parameters"]["EntityId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Отозвано, место освобождено */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
listUploads: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Статусы через запятую */
|
||||
status?: components["parameters"]["UploadStatusFilter"];
|
||||
author?: string;
|
||||
batch?: string;
|
||||
possible_duplicate?: boolean;
|
||||
limit?: components["parameters"]["Limit"];
|
||||
/** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */
|
||||
cursor?: components["parameters"]["Cursor"];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Страница очереди */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UploadFilePage"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
updateUpload: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: components["parameters"]["EntityId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UploadPatch"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Файл */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UploadFile"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
retryUploadImport: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: components["parameters"]["EntityId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Файл снова одобрен */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UploadFile"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
reviewUploads: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UploadReviewRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Итог по каждому файлу */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UploadReviewResult"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
getUploadSettings: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Настройки */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UploadSettings"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
updateUploadSettings: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UploadSettingsPatch"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Настройки */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UploadSettings"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
listAudit: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -76,5 +76,22 @@
|
||||
},
|
||||
"app": {
|
||||
"update_unavailable": "Update is unavailable"
|
||||
},
|
||||
"uploads": {
|
||||
"queue_full": "The upload queue is full",
|
||||
"user_quota_full": "Your upload queue limit is exceeded",
|
||||
"too_large": "The file is larger than allowed",
|
||||
"duplicate": "This track is already in the library or in the queue",
|
||||
"invalid_audio": "The file does not look like audio (one audio stream, 5 s to 3 h)",
|
||||
"unsupported_format": "Unsupported format: FLAC, MP3, M4A, OGG, Opus, WAV, AIFF and WavPack are accepted",
|
||||
"not_found": "Upload not found",
|
||||
"wrong_status": "The file is already in another status; refresh the page",
|
||||
"no_target": "No storage accepts uploads (or several do and none is the default)",
|
||||
"no_template": "The storage has no path template",
|
||||
"missing_field": "Not enough metadata to build the storage path",
|
||||
"offset_mismatch": "The upload got out of sync; try again",
|
||||
"size_mismatch": "The file size does not match the declared one",
|
||||
"hash_mismatch": "The file was corrupted in transit (SHA-256 mismatch)",
|
||||
"import_failed": "Could not write the file to the storage"
|
||||
}
|
||||
}
|
||||
@@ -76,5 +76,22 @@
|
||||
},
|
||||
"app": {
|
||||
"update_unavailable": "Обновление недоступно"
|
||||
},
|
||||
"uploads": {
|
||||
"queue_full": "В очереди на добавление нет места",
|
||||
"user_quota_full": "Превышен ваш лимит в очереди на добавление",
|
||||
"too_large": "Файл больше допустимого размера",
|
||||
"duplicate": "Такой трек уже есть в библиотеке или в очереди",
|
||||
"invalid_audio": "Файл не похож на аудио (нужен один аудиопоток длительностью от 5 с до 3 ч)",
|
||||
"unsupported_format": "Формат не поддерживается: подойдут FLAC, MP3, M4A, OGG, Opus, WAV, AIFF, WavPack",
|
||||
"not_found": "Загрузка не найдена",
|
||||
"wrong_status": "Файл уже в другом статусе — обновите страницу",
|
||||
"no_target": "Нет хранилища, принимающего загрузки (или их несколько и не выбрано по умолчанию)",
|
||||
"no_template": "У хранилища не задан шаблон пути",
|
||||
"missing_field": "Не хватает метаданных для пути в хранилище",
|
||||
"offset_mismatch": "Загрузка рассинхронизировалась — повторите",
|
||||
"size_mismatch": "Размер файла не совпал с заявленным",
|
||||
"hash_mismatch": "Файл повредился при загрузке (не совпал SHA-256)",
|
||||
"import_failed": "Не удалось записать файл в хранилище"
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"pipu/api/internal/search"
|
||||
"pipu/api/internal/server"
|
||||
"pipu/api/internal/system"
|
||||
"pipu/api/internal/uploads"
|
||||
"pipu/api/internal/wave"
|
||||
)
|
||||
|
||||
@@ -94,7 +95,8 @@ func run() error {
|
||||
prober := library.FFprobe{Bin: cfg.FFprobePath, Timeout: time.Minute}
|
||||
board := library.NewProgressBoard()
|
||||
scanner := library.NewScanner(libStore, prober, board, log)
|
||||
libSvc := library.NewService(libStore, scanner, board, library.NewHTTPAgent(cfg.StorageAgentURL, cfg.InternalToken),
|
||||
agent := library.NewHTTPAgent(cfg.StorageAgentURL, cfg.InternalToken)
|
||||
libSvc := library.NewService(libStore, scanner, board, agent,
|
||||
prober, library.Config{LibraryRoot: cfg.LibraryRoot, HostRoot: cfg.LibraryHostRoot}, log)
|
||||
meili := search.NewMeili(cfg.MeiliURL, cfg.MeiliMasterKey)
|
||||
indexer := search.NewIndexer(pool, meili, log)
|
||||
@@ -145,6 +147,11 @@ func run() error {
|
||||
go libSvc.Restore(ctx)
|
||||
go libSvc.RunSchedule(ctx)
|
||||
|
||||
upSvc := uploads.NewService(uploads.NewStore(pool), libStore, libSvc, scanner, agent, prober,
|
||||
library.FPCalc{Bin: cfg.FPCalcPath, Timeout: time.Minute},
|
||||
uploads.Config{StagingDir: cfg.UploadsDir, FFmpeg: cfg.FFmpegPath}, log)
|
||||
upSvc.Run(ctx)
|
||||
|
||||
srv := httpx.NewServer(cfg.HTTPAddr, server.NewRouter(server.Deps{
|
||||
Log: log,
|
||||
System: sys,
|
||||
@@ -157,6 +164,8 @@ func run() error {
|
||||
Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log),
|
||||
App: appupdate.NewHandler(appupdate.Config{GiteaURL: cfg.GiteaURL, Repo: cfg.GiteaRepo, Token: cfg.GiteaToken,
|
||||
Dir: cfg.UpdatesDir, MinSupported: cfg.MinAndroidVersionCode}, log),
|
||||
Uploads: uploads.NewHandler(upSvc),
|
||||
TUS: uploads.NewTUS(uploads.NewStore(pool), cfg.UploadsDir, upSvc.Complete, log),
|
||||
}))
|
||||
log.Info("starting", "version", buildinfo.Version)
|
||||
return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -271,6 +271,49 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
|
||||
return sc.store.SetLastScan(ctx, src.ID, sc.now())
|
||||
}
|
||||
|
||||
// ScanFile — точечный скан одного файла (scan_path): после импорта из очереди трек сразу появляется в каталоге.
|
||||
// Возвращает id записи media_files. Хуки AfterScan (обложки, поиск, отпечатки) вызывает сам.
|
||||
func (sc *Scanner) ScanFile(ctx context.Context, src Source, root, rel string) (uuid.UUID, error) {
|
||||
rows, err := sc.store.Patterns(ctx, src.ID)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
var exprs []string
|
||||
for _, r := range rows {
|
||||
if r.IsEnabled {
|
||||
exprs = append(exprs, r.Regex)
|
||||
}
|
||||
}
|
||||
patterns, err := meta.CompilePatterns(exprs)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("library: patterns: %w", err)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel)))
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("library: scan file: %w", err)
|
||||
}
|
||||
j := job{walked: walked{rel: rel, size: info.Size(), mtime: info.ModTime().UTC().Truncate(time.Microsecond)}}
|
||||
if f, ok, err := sc.store.FileByPath(ctx, src.ID, rel); err != nil {
|
||||
return uuid.Nil, err
|
||||
} else if ok {
|
||||
j.fileID, j.oldTrack = f.ID, f.TrackID
|
||||
}
|
||||
if err := sc.process(ctx, src, root, patterns, []job{j}); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
f, ok, err := sc.store.FileByPath(ctx, src.ID, rel)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if !ok {
|
||||
return uuid.Nil, fmt.Errorf("library: scanned file %s not saved", rel)
|
||||
}
|
||||
if sc.AfterScan != nil {
|
||||
go sc.AfterScan(context.WithoutCancel(ctx))
|
||||
}
|
||||
return f.ID, nil
|
||||
}
|
||||
|
||||
// process читает файлы параллельно, а каталог обновляет один писатель —
|
||||
// так дубли и новые исполнители не создаются дважды в гонке.
|
||||
func (sc *Scanner) process(ctx context.Context, src Source, root string, patterns []meta.Pattern, jobs []job) error {
|
||||
|
||||
@@ -244,6 +244,22 @@ func (st *Store) Files(ctx context.Context, sourceID uuid.UUID) (map[string]File
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// FileByPath — файл источника по пути (точечный скан).
|
||||
func (st *Store) FileByPath(ctx context.Context, sourceID uuid.UUID, rel string) (FileRow, bool, error) {
|
||||
var f FileRow
|
||||
err := st.pool.QueryRow(ctx, `
|
||||
SELECT id, rel_path, size_bytes, mtime, content_hash, track_id, missing_since IS NOT NULL
|
||||
FROM media_files WHERE source_id = $1 AND rel_path = $2`, sourceID, rel).
|
||||
Scan(&f.ID, &f.RelPath, &f.Size, &f.Mtime, &f.ContentHash, &f.TrackID, &f.Missing)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return FileRow{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return FileRow{}, false, fmt.Errorf("library: file by path: %w", err)
|
||||
}
|
||||
return f, true, nil
|
||||
}
|
||||
|
||||
// SampleFiles — случайная выборка путей источника (для предпросмотра шаблонов).
|
||||
func (st *Store) SampleFiles(ctx context.Context, sourceID uuid.UUID, n int) ([]string, error) {
|
||||
rows, err := st.pool.Query(ctx, `
|
||||
|
||||
@@ -62,6 +62,8 @@ type API struct {
|
||||
GiteaRepo string `env:"GITEA_REPO"`
|
||||
GiteaToken string `env:"GITEA_TOKEN"`
|
||||
UpdatesDir string `env:"UPDATES_DIR" envDefault:"/updates"`
|
||||
// UploadsDir — staging очереди на добавление (локальный диск, общий со storage-agent на чтение).
|
||||
UploadsDir string `env:"UPLOADS_DIR" envDefault:"/uploads"`
|
||||
}
|
||||
|
||||
// StorageAgent — конфигурация внутреннего сервиса storage-agent.
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"pipu/api/internal/playback"
|
||||
"pipu/api/internal/search"
|
||||
"pipu/api/internal/system"
|
||||
"pipu/api/internal/uploads"
|
||||
"pipu/api/internal/wave"
|
||||
)
|
||||
|
||||
@@ -32,6 +33,9 @@ type Deps struct {
|
||||
// Wave — «Моя волна».
|
||||
Wave *wave.Handler
|
||||
App *appupdate.Handler
|
||||
// Uploads — очередь на добавление; TUS — приём файлов (вне OpenAPI).
|
||||
Uploads *uploads.Handler
|
||||
TUS *uploads.TUS
|
||||
// AuthService проверяет access-токены в middleware. nil — только для тестов служебных эндпоинтов.
|
||||
AuthService *auth.Service
|
||||
}
|
||||
@@ -47,6 +51,7 @@ type (
|
||||
playbackHandler = playback.Handler
|
||||
waveHandler = wave.Handler
|
||||
appHandler = appupdate.Handler
|
||||
uploadsHandler = uploads.Handler
|
||||
)
|
||||
|
||||
type handlers struct {
|
||||
@@ -57,6 +62,7 @@ type handlers struct {
|
||||
*playbackHandler
|
||||
*waveHandler
|
||||
*appHandler
|
||||
*uploadsHandler
|
||||
}
|
||||
|
||||
var _ gen.StrictServerInterface = handlers{}
|
||||
@@ -75,8 +81,11 @@ func NewRouter(d Deps) http.Handler {
|
||||
}
|
||||
r.NotFound(httpx.NotFound)
|
||||
r.MethodNotAllowed(httpx.MethodNotAllowed)
|
||||
if d.TUS != nil {
|
||||
d.TUS.Mount(r)
|
||||
}
|
||||
|
||||
strict := gen.NewStrictHandlerWithOptions(handlers{systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback, waveHandler: d.Wave, appHandler: d.App},
|
||||
strict := gen.NewStrictHandlerWithOptions(handlers{systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback, waveHandler: d.Wave, appHandler: d.App, uploadsHandler: d.Uploads},
|
||||
[]gen.StrictMiddlewareFunc{httpx.StrictRequest}, gen.StrictHTTPServerOptions{
|
||||
RequestErrorHandlerFunc: httpx.RequestError,
|
||||
ResponseErrorHandlerFunc: httpx.ResponseError(log),
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
package uploads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
// Handler — эндпоинты очереди на добавление (часть gen.StrictServerInterface).
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
// NewHandler создаёт обработчик.
|
||||
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
|
||||
|
||||
func fileDTO(ctx context.Context, f File) gen.UploadFile {
|
||||
out := gen.UploadFile{
|
||||
Id: f.ID, BatchId: f.BatchID, BatchComment: f.BatchComment,
|
||||
Author: gen.UserRef{Id: f.AuthorID, Username: f.AuthorName},
|
||||
Status: gen.UploadStatus(f.Status), OriginalName: f.OriginalName, ClientRelPath: f.ClientRelPath,
|
||||
SizeBytes: f.Size, ReceivedBytes: f.Received, Format: f.Format, Codec: f.Codec, Bitrate: f.Bitrate,
|
||||
SampleRate: f.SampleRate, BitDepth: f.BitDepth, DurationMs: f.DurationMs, Tags: f.Tags,
|
||||
MetadataEdited: f.MetadataEdited, MatchedArtistId: f.MatchedArtistID, MatchedAlbumId: f.MatchedAlbumID,
|
||||
DuplicateOfTrackId: f.DuplicateOfTrackID, PossibleDuplicate: f.PossibleDuplicate,
|
||||
TargetSourceId: f.TargetSourceID, TargetRelPath: f.TargetRelPath, StatusReason: f.StatusReason,
|
||||
ReviewedAt: f.ReviewedAt, CreatedAt: f.CreatedAt, UpdatedAt: f.UpdatedAt,
|
||||
}
|
||||
if out.Tags == nil {
|
||||
out.Tags = map[string]string{}
|
||||
}
|
||||
if f.DuplicateLevel != nil {
|
||||
l := gen.UploadFileDuplicateLevel(*f.DuplicateLevel)
|
||||
out.DuplicateLevel = &l
|
||||
}
|
||||
if f.Meta != nil {
|
||||
m := metaDTO(*f.Meta)
|
||||
out.Metadata = &m
|
||||
}
|
||||
if p, ok := auth.PrincipalFrom(ctx); ok && p.UserID == f.AuthorID && f.Status == StatusUploading {
|
||||
u := TusURL(f.ID)
|
||||
out.TusUrl = &u
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func metaDTO(m Meta) gen.UploadMeta {
|
||||
return gen.UploadMeta{
|
||||
Artist: m.Artist, Albumartist: m.AlbumArtist, Album: m.Album, Title: m.Title, Genre: m.Genre,
|
||||
Year: m.Year, Track: m.Track, Disc: m.Disc,
|
||||
}
|
||||
}
|
||||
|
||||
func metaFromDTO(m gen.UploadMeta) Meta {
|
||||
return Meta{
|
||||
Artist: m.Artist, AlbumArtist: m.Albumartist, Album: m.Album, Title: m.Title, Genre: m.Genre,
|
||||
Year: m.Year, Track: m.Track, Disc: m.Disc,
|
||||
}
|
||||
}
|
||||
|
||||
func pageDTO(ctx context.Context, files []File, next string) gen.UploadFilePage {
|
||||
out := gen.UploadFilePage{Items: make([]gen.UploadFile, 0, len(files))}
|
||||
for _, f := range files {
|
||||
out.Items = append(out.Items, fileDTO(ctx, f))
|
||||
}
|
||||
if next != "" {
|
||||
out.NextCursor = &next
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func statuses(in *gen.UploadStatusFilter) []Status {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]Status, 0, len(*in))
|
||||
for _, s := range *in {
|
||||
out = append(out, Status(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func deref[T any](p *T, def T) T {
|
||||
if p == nil {
|
||||
return def
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func settingsDTO(s Settings) gen.UploadSettings {
|
||||
return gen.UploadSettings{
|
||||
QueueLimitBytes: s.QueueLimitBytes, PerUserLimitBytes: s.PerUserLimitBytes, MaxFileBytes: s.MaxFileBytes,
|
||||
DefaultSourceId: s.DefaultSourceID,
|
||||
}
|
||||
}
|
||||
|
||||
// GetUploadQuota реализует gen.StrictServerInterface.
|
||||
func (h *Handler) GetUploadQuota(ctx context.Context, _ gen.GetUploadQuotaRequestObject) (gen.GetUploadQuotaResponseObject, error) {
|
||||
q, err := h.svc.Quota(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.GetUploadQuota200JSONResponse{
|
||||
LimitBytes: q.QueueLimitBytes, UsedBytes: q.UsedBytes, PerUserLimitBytes: q.PerUserLimitBytes,
|
||||
UserUsedBytes: q.UserUsedBytes, MaxFileBytes: q.MaxFileBytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PrecheckUploads реализует gen.StrictServerInterface.
|
||||
func (h *Handler) PrecheckUploads(ctx context.Context, req gen.PrecheckUploadsRequestObject) (gen.PrecheckUploadsResponseObject, error) {
|
||||
in := make([]PrecheckFile, 0, len(req.Body.Files))
|
||||
for _, f := range req.Body.Files {
|
||||
in = append(in, PrecheckFile{
|
||||
ClientID: f.ClientId, Size: f.Size, SHA256: f.Sha256, Artist: deref(f.Artist, ""), Title: deref(f.Title, ""),
|
||||
DurationMs: deref(f.DurationMs, 0),
|
||||
})
|
||||
}
|
||||
res, err := h.svc.Precheck(ctx, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := gen.PrecheckUploads200JSONResponse{}
|
||||
for _, r := range res {
|
||||
out.Files = append(out.Files, struct {
|
||||
ClientId string `json:"client_id"`
|
||||
PossibleDuplicate bool `json:"possible_duplicate"`
|
||||
Result gen.UploadPrecheckResultFilesResult `json:"result"`
|
||||
TrackId *uuid.UUID `json:"track_id"`
|
||||
}{ClientId: r.ClientID, PossibleDuplicate: r.Possible, Result: gen.UploadPrecheckResultFilesResult(r.Result), TrackId: r.Track})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateUploadBatch реализует gen.StrictServerInterface.
|
||||
func (h *Handler) CreateUploadBatch(ctx context.Context, req gen.CreateUploadBatchRequestObject) (gen.CreateUploadBatchResponseObject, error) {
|
||||
in := make([]CreateFile, 0, len(req.Body.Files))
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := gen.CreateUploadBatch201JSONResponse{BatchId: batch}
|
||||
for _, r := range res {
|
||||
item := struct {
|
||||
ClientId string `json:"client_id"`
|
||||
Rejected *string `json:"rejected"`
|
||||
TrackId *uuid.UUID `json:"track_id"`
|
||||
TusUrl *string `json:"tus_url"`
|
||||
UploadFileId *uuid.UUID `json:"upload_file_id"`
|
||||
}{ClientId: r.ClientID, TrackId: r.Track}
|
||||
if r.Rejected != "" {
|
||||
code := r.Rejected
|
||||
item.Rejected = &code
|
||||
} else {
|
||||
id, u := r.FileID, TusURL(r.FileID)
|
||||
item.UploadFileId, item.TusUrl = &id, &u
|
||||
}
|
||||
out.Files = append(out.Files, item)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.ListMyUploads200JSONResponse(pageDTO(ctx, files, next)), nil
|
||||
}
|
||||
|
||||
// WithdrawUpload реализует gen.StrictServerInterface.
|
||||
func (h *Handler) WithdrawUpload(ctx context.Context, req gen.WithdrawUploadRequestObject) (gen.WithdrawUploadResponseObject, error) {
|
||||
if err := h.svc.Withdraw(ctx, req.Id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.WithdrawUpload204Response{}, nil
|
||||
}
|
||||
|
||||
// ListUploads реализует gen.StrictServerInterface.
|
||||
func (h *Handler) ListUploads(ctx context.Context, req gen.ListUploadsRequestObject) (gen.ListUploadsResponseObject, error) {
|
||||
p := req.Params
|
||||
files, next, err := h.svc.ListAll(ctx, ListFilter{
|
||||
AuthorID: p.Author, BatchID: p.Batch, Statuses: statuses(p.Status), PossibleDuplicate: p.PossibleDuplicate,
|
||||
Cursor: deref(p.Cursor, ""), Limit: deref(p.Limit, 50),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.ListUploads200JSONResponse(pageDTO(ctx, files, next)), nil
|
||||
}
|
||||
|
||||
// UpdateUpload реализует gen.StrictServerInterface.
|
||||
func (h *Handler) UpdateUpload(ctx context.Context, req gen.UpdateUploadRequestObject) (gen.UpdateUploadResponseObject, error) {
|
||||
in := UpdateInput{TargetSourceID: req.Body.TargetSourceId}
|
||||
if req.Body.Metadata != nil {
|
||||
m := metaFromDTO(*req.Body.Metadata)
|
||||
in.Meta = &m
|
||||
}
|
||||
f, err := h.svc.Update(ctx, req.Id, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.UpdateUpload200JSONResponse(fileDTO(ctx, f)), nil
|
||||
}
|
||||
|
||||
// RetryUploadImport реализует gen.StrictServerInterface.
|
||||
func (h *Handler) RetryUploadImport(ctx context.Context, req gen.RetryUploadImportRequestObject) (gen.RetryUploadImportResponseObject, error) {
|
||||
f, err := h.svc.Retry(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.RetryUploadImport200JSONResponse(fileDTO(ctx, f)), nil
|
||||
}
|
||||
|
||||
// ReviewUploads реализует gen.StrictServerInterface.
|
||||
func (h *Handler) ReviewUploads(ctx context.Context, req gen.ReviewUploadsRequestObject) (gen.ReviewUploadsResponseObject, error) {
|
||||
res, err := h.svc.Review(ctx, req.Body.FileIds, req.Body.Decision == "approve", deref(req.Body.Reason, ""))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := gen.ReviewUploads200JSONResponse{}
|
||||
for _, r := range res {
|
||||
item := struct {
|
||||
Error *gen.Problem `json:"error"`
|
||||
Id uuid.UUID `json:"id"`
|
||||
Ok bool `json:"ok"`
|
||||
}{Id: r.ID, Ok: r.Error == nil}
|
||||
if r.Error != nil {
|
||||
item.Error = problem(r.Error)
|
||||
}
|
||||
out.Items = append(out.Items, item)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// problem — ошибка решения по одному файлу в виде Problem (массовое действие не падает целиком).
|
||||
func problem(err error) *gen.Problem {
|
||||
var ae *apperr.Error
|
||||
if !errors.As(err, &ae) {
|
||||
return &gen.Problem{Status: http.StatusInternalServerError, Code: "common.internal"}
|
||||
}
|
||||
p := &gen.Problem{Status: ae.Status, Code: ae.Code}
|
||||
if len(ae.Params) > 0 {
|
||||
params := map[string]any(ae.Params)
|
||||
p.Params = ¶ms
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// GetUploadSettings реализует gen.StrictServerInterface.
|
||||
func (h *Handler) GetUploadSettings(ctx context.Context, _ gen.GetUploadSettingsRequestObject) (gen.GetUploadSettingsResponseObject, error) {
|
||||
s, err := h.svc.GetSettings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.GetUploadSettings200JSONResponse(settingsDTO(s)), nil
|
||||
}
|
||||
|
||||
// UpdateUploadSettings реализует gen.StrictServerInterface.
|
||||
func (h *Handler) UpdateUploadSettings(ctx context.Context, req gen.UpdateUploadSettingsRequestObject) (gen.UpdateUploadSettingsResponseObject, error) {
|
||||
b := req.Body
|
||||
s, err := h.svc.UpdateSettings(ctx, SettingsInput{
|
||||
QueueLimitBytes: b.QueueLimitBytes, MaxFileBytes: b.MaxFileBytes,
|
||||
PerUserLimitBytes: b.PerUserLimitBytes, ClearPerUser: b.PerUserLimitBytes == nil,
|
||||
DefaultSourceID: b.DefaultSourceId, ClearDefault: b.DefaultSourceId == nil,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.UpdateUploadSettings200JSONResponse(settingsDTO(s)), nil
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Package uploads — очередь на добавление: резерв квоты, tus-приём файлов, обработка (ffprobe, хэши, Chromaprint,
|
||||
// проверка дублей), рассмотрение админом и импорт в хранилище через storage-agent (docs/uploads.md).
|
||||
package uploads
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/library/meta"
|
||||
)
|
||||
|
||||
// Status — состояние файла в очереди (docs/uploads.md#2-жизненный-цикл-загрузки).
|
||||
type Status string
|
||||
|
||||
// Статусы загрузки.
|
||||
const (
|
||||
StatusUploading Status = "uploading"
|
||||
StatusProcessing Status = "processing"
|
||||
StatusPendingReview Status = "pending_review"
|
||||
StatusApproved Status = "approved"
|
||||
StatusImporting Status = "importing"
|
||||
StatusImported Status = "imported"
|
||||
StatusImportFailed Status = "import_failed"
|
||||
StatusDuplicate Status = "duplicate"
|
||||
StatusInvalid Status = "invalid"
|
||||
StatusRejected Status = "rejected"
|
||||
StatusWithdrawn Status = "withdrawn"
|
||||
StatusExpired Status = "expired"
|
||||
)
|
||||
|
||||
// Occupies — занимает ли файл в этом статусе место в очереди (docs/uploads.md#2, таблица).
|
||||
func (s Status) Occupies() bool {
|
||||
switch s {
|
||||
case StatusUploading, StatusProcessing, StatusPendingReview, StatusApproved, StatusImporting, StatusImportFailed:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// KeepsStaging — файл в этом статусе ещё нужен в staging.
|
||||
func (s Status) KeepsStaging() bool { return s.Occupies() }
|
||||
|
||||
// Коды ошибок и причин.
|
||||
const (
|
||||
CodeQueueFull = "uploads.queue_full"
|
||||
CodeUserQuotaFull = "uploads.user_quota_full"
|
||||
CodeTooLarge = "uploads.too_large"
|
||||
CodeDuplicate = "uploads.duplicate"
|
||||
CodeInvalidAudio = "uploads.invalid_audio"
|
||||
CodeBadExtension = "uploads.unsupported_format"
|
||||
CodeNotFound = "uploads.not_found"
|
||||
CodeWrongStatus = "uploads.wrong_status"
|
||||
CodeNoTarget = "uploads.no_target"
|
||||
CodeOffset = "uploads.offset_mismatch"
|
||||
CodeSizeMismatch = "uploads.size_mismatch"
|
||||
CodeHashMismatch = "uploads.hash_mismatch"
|
||||
CodeMissingField = "uploads.missing_field"
|
||||
CodeImportFailed = "uploads.import_failed"
|
||||
CodeTemplate = "uploads.no_template"
|
||||
)
|
||||
|
||||
// Уровни дублей.
|
||||
const (
|
||||
DupSHA256 = "sha256"
|
||||
DupMetadata = "metadata"
|
||||
DupFingerprint = "fingerprint"
|
||||
)
|
||||
|
||||
// Meta — итоговые поля трека загрузки; их видит и правит админ, по ним строится путь в хранилище.
|
||||
type Meta struct {
|
||||
Artist string `json:"artist"`
|
||||
AlbumArtist string `json:"albumartist"`
|
||||
Album string `json:"album"`
|
||||
Title string `json:"title"`
|
||||
Genre string `json:"genre"`
|
||||
Year *int `json:"year,omitempty"`
|
||||
Track *int `json:"track,omitempty"`
|
||||
Disc *int `json:"disc,omitempty"`
|
||||
}
|
||||
|
||||
// Fields — значения для шаблона пути.
|
||||
func (m Meta) Fields() meta.Fields {
|
||||
num := func(p *int) string {
|
||||
if p == nil || *p <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(*p)
|
||||
}
|
||||
return meta.Fields{
|
||||
Artist: m.Artist, AlbumArtist: m.AlbumArtist, Album: m.Album, Title: m.Title, Genre: m.Genre,
|
||||
Year: num(m.Year), Track: num(m.Track), Disc: num(m.Disc),
|
||||
}
|
||||
}
|
||||
|
||||
// FromMerged — Meta из результата слияния тегов и пути.
|
||||
func FromMerged(m meta.Meta) Meta {
|
||||
ptr := func(v int) *int {
|
||||
if v <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
out := Meta{Artist: m.Artist, AlbumArtist: m.AlbumArtist, Album: m.Album, Title: m.Title, Year: ptr(m.Year),
|
||||
Track: ptr(m.Track), Disc: ptr(m.Disc)}
|
||||
if len(m.Genres) > 0 {
|
||||
out.Genre = m.Genres[0]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// File — файл очереди.
|
||||
type File struct {
|
||||
ID uuid.UUID
|
||||
BatchID uuid.UUID
|
||||
AuthorID uuid.UUID
|
||||
AuthorName string
|
||||
BatchComment *string
|
||||
Status Status
|
||||
OriginalName string
|
||||
ClientRelPath *string
|
||||
Size int64
|
||||
Received int64
|
||||
SHA256 []byte
|
||||
StagingPath string
|
||||
Format *string
|
||||
Codec *string
|
||||
Bitrate *int
|
||||
SampleRate *int
|
||||
BitDepth *int
|
||||
DurationMs *int
|
||||
Tags map[string]string
|
||||
Meta *Meta
|
||||
MetadataEdited bool
|
||||
MatchedArtistID *uuid.UUID
|
||||
MatchedAlbumID *uuid.UUID
|
||||
DuplicateOfTrackID *uuid.UUID
|
||||
DuplicateLevel *string
|
||||
PossibleDuplicate bool
|
||||
TargetSourceID *uuid.UUID
|
||||
TargetRelPath *string
|
||||
StatusReason *string
|
||||
ReviewedBy *uuid.UUID
|
||||
ReviewedAt *time.Time
|
||||
ImportedFileID *uuid.UUID
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Settings — лимиты очереди и хранилище по умолчанию (app_settings, меняет админ).
|
||||
type Settings struct {
|
||||
QueueLimitBytes int64 `json:"queue_limit_bytes"`
|
||||
PerUserLimitBytes *int64 `json:"per_user_limit_bytes,omitempty"` // nil — выключен
|
||||
MaxFileBytes int64 `json:"max_file_bytes"`
|
||||
DefaultSourceID *uuid.UUID `json:"default_source_id,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultSettings — значения по умолчанию (docs/uploads.md#3-квота-очереди).
|
||||
var DefaultSettings = Settings{QueueLimitBytes: 10 << 30, MaxFileBytes: 2 << 30}
|
||||
|
||||
// AllowedExtensions — форматы, которые принимает очередь (содержимое всё равно проверяет ffprobe).
|
||||
var AllowedExtensions = map[string]bool{
|
||||
"flac": true, "mp3": true, "m4a": true, "ogg": true, "opus": true, "wav": true, "aiff": true, "aif": true, "wv": true,
|
||||
}
|
||||
|
||||
// Пределы длительности (docs/uploads.md#5-загрузка).
|
||||
const (
|
||||
MinDurationMs = 5_000
|
||||
MaxDurationMs = 3 * 60 * 60 * 1000
|
||||
)
|
||||
|
||||
// ExpireAfter — недокачанная загрузка истекает (docs/uploads.md#11-очистка).
|
||||
const ExpireAfter = 24 * time.Hour
|
||||
@@ -0,0 +1,664 @@
|
||||
package uploads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/library"
|
||||
"pipu/api/internal/library/meta"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
// Config — пути и бинарники очереди.
|
||||
type Config struct {
|
||||
StagingDir string // каталог staging (тот же, что /uploads у storage-agent)
|
||||
FFmpeg string // запись правленых тегов при импорте
|
||||
}
|
||||
|
||||
// Service — очередь на добавление.
|
||||
type Service struct {
|
||||
store *Store
|
||||
lib *library.Store
|
||||
libSvc *library.Service
|
||||
scanner *library.Scanner
|
||||
agent library.Agent
|
||||
prober library.Prober
|
||||
fpcalc library.FPCalc
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
|
||||
wakeProcess chan struct{}
|
||||
wakeImport chan struct{}
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService создаёт сервис очереди.
|
||||
func NewService(store *Store, lib *library.Store, libSvc *library.Service, scanner *library.Scanner, agent library.Agent,
|
||||
prober library.Prober, fpcalc library.FPCalc, cfg Config, log *slog.Logger) *Service {
|
||||
return &Service{
|
||||
store: store, lib: lib, libSvc: libSvc, scanner: scanner, agent: agent, prober: prober, fpcalc: fpcalc, cfg: cfg,
|
||||
log: log, wakeProcess: make(chan struct{}, 1), wakeImport: make(chan struct{}, 1),
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
||||
func wake(ch chan struct{}) {
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func principal(ctx context.Context, perm auth.Permission) (auth.Principal, error) {
|
||||
p, ok := auth.PrincipalFrom(ctx)
|
||||
if !ok {
|
||||
return auth.Principal{}, apperr.Unauthorized(auth.CodeUnauthorized, "not authenticated")
|
||||
}
|
||||
if !auth.Can(p.Role, perm) {
|
||||
return auth.Principal{}, apperr.Forbidden(auth.CodeForbidden, "not allowed")
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func notFound(err error) error {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return apperr.NotFound(CodeNotFound, "upload not found")
|
||||
}
|
||||
if errors.Is(err, ErrWrongStatus) {
|
||||
return apperr.Conflict(CodeWrongStatus, "upload is in another status")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Квота и предпроверка ---
|
||||
|
||||
// Quota — лимиты и занятое место (видят модераторы и админ).
|
||||
type Quota struct {
|
||||
Settings
|
||||
UsedBytes int64
|
||||
UserUsedBytes int64
|
||||
}
|
||||
|
||||
// Quota — состояние очереди для текущего пользователя.
|
||||
func (s *Service) Quota(ctx context.Context) (Quota, error) {
|
||||
p, err := principal(ctx, auth.PermSuggestTracks)
|
||||
if err != nil {
|
||||
return Quota{}, err
|
||||
}
|
||||
st, err := s.store.Settings(ctx)
|
||||
if err != nil {
|
||||
return Quota{}, err
|
||||
}
|
||||
used, userUsed, err := s.store.Usage(ctx, p.UserID)
|
||||
if err != nil {
|
||||
return Quota{}, err
|
||||
}
|
||||
return Quota{Settings: st, UsedBytes: used, UserUsedBytes: userUsed}, nil
|
||||
}
|
||||
|
||||
// PrecheckFile — файл перед загрузкой: хэш и теги считает клиент.
|
||||
type PrecheckFile struct {
|
||||
ClientID string
|
||||
Size int64
|
||||
SHA256 string
|
||||
Artist string
|
||||
Title string
|
||||
DurationMs int
|
||||
}
|
||||
|
||||
// PrecheckResult — итог по файлу: accepted / duplicate / too_large; possible — совпали только метаданные.
|
||||
type PrecheckResult struct {
|
||||
ClientID string
|
||||
Result string
|
||||
Track *uuid.UUID
|
||||
Possible bool
|
||||
}
|
||||
|
||||
// Precheck отсеивает дубли до загрузки (ничего не резервирует, docs/uploads.md#4).
|
||||
func (s *Service) Precheck(ctx context.Context, files []PrecheckFile) ([]PrecheckResult, error) {
|
||||
if _, err := principal(ctx, auth.PermSuggestTracks); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, err := s.store.Settings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]PrecheckResult, 0, len(files))
|
||||
seen := map[string]bool{}
|
||||
for _, f := range files {
|
||||
r := PrecheckResult{ClientID: f.ClientID, Result: "accepted"}
|
||||
sum, _ := hex.DecodeString(f.SHA256)
|
||||
switch {
|
||||
case f.Size > st.MaxFileBytes:
|
||||
r.Result = "too_large"
|
||||
case len(sum) == 32 && seen[string(sum)]:
|
||||
r.Result = "duplicate"
|
||||
default:
|
||||
track, dup, err := s.store.DuplicateBySHA(ctx, sum, uuid.Nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dup {
|
||||
r.Result, r.Track = "duplicate", track
|
||||
} else if f.Artist != "" && f.Title != "" && f.DurationMs > 0 {
|
||||
track, err := s.store.MetadataMatch(ctx, meta.Key(f.Artist), meta.Key(f.Title), f.DurationMs, 2000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Possible, r.Track = track != nil, track
|
||||
}
|
||||
}
|
||||
if len(sum) == 32 {
|
||||
seen[string(sum)] = true
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// --- Создание пачки ---
|
||||
|
||||
// CreateFile — файл новой пачки.
|
||||
type CreateFile struct {
|
||||
ClientID string
|
||||
Name string
|
||||
RelPath *string
|
||||
Size int64
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
// Create создаёт пачку и резервирует место; не поместившиеся и дубли отклоняются с кодом.
|
||||
func (s *Service) Create(ctx context.Context, comment *string, files []CreateFile) (uuid.UUID, []Reserved, error) {
|
||||
p, err := principal(ctx, auth.PermSuggestTracks)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, err
|
||||
}
|
||||
if len(files) == 0 || len(files) > 500 {
|
||||
return uuid.Nil, nil, apperr.Validation("files", "validation.files_count", "1..500 files", map[string]any{"max": 500})
|
||||
}
|
||||
st, err := s.store.Settings(ctx)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, err
|
||||
}
|
||||
var (
|
||||
accepted []NewFile
|
||||
rejected []Reserved
|
||||
)
|
||||
for _, f := range files {
|
||||
sum, err := hex.DecodeString(f.SHA256)
|
||||
name := path.Base(strings.ReplaceAll(f.Name, "\\", "/"))
|
||||
ext := strings.TrimPrefix(strings.ToLower(path.Ext(name)), ".")
|
||||
switch {
|
||||
case err != nil || len(sum) != 32:
|
||||
return uuid.Nil, nil, apperr.Validation("sha256", "validation.sha256_invalid", "sha256 must be 64 hex characters", nil)
|
||||
case f.Size <= 0:
|
||||
return uuid.Nil, nil, apperr.Validation("size", "validation.size_invalid", "size must be positive", nil)
|
||||
case !AllowedExtensions[ext]:
|
||||
rejected = append(rejected, Reserved{ClientID: f.ClientID, Rejected: CodeBadExtension})
|
||||
continue
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, err
|
||||
}
|
||||
return batch, append(res, rejected...), nil
|
||||
}
|
||||
|
||||
// cleanClientPath — относительный путь из перетащенной папки (только для подсказки метаданных).
|
||||
func cleanClientPath(p *string) *string {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
v := strings.Trim(strings.ReplaceAll(*p, "\\", "/"), "/")
|
||||
if v == "" || len(v) > 1000 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
// Complete — tus принял файл целиком: в обработку.
|
||||
func (s *Service) Complete(ctx context.Context, f File) error {
|
||||
err := s.store.Transition(ctx, f.ID, []Status{StatusUploading}, StatusProcessing, nil)
|
||||
if err == nil {
|
||||
wake(s.wakeProcess)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Списки ---
|
||||
|
||||
// ListMine — предложения текущего пользователя.
|
||||
func (s *Service) ListMine(ctx context.Context, statuses []Status, cursor string, limit int) ([]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})
|
||||
}
|
||||
|
||||
// ListAll — вся очередь (админ).
|
||||
func (s *Service) ListAll(ctx context.Context, fl ListFilter) ([]File, string, error) {
|
||||
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return s.list(ctx, fl)
|
||||
}
|
||||
|
||||
func (s *Service) list(ctx context.Context, fl ListFilter) ([]File, string, error) {
|
||||
files, next, err := s.store.List(ctx, fl)
|
||||
if err != nil && strings.Contains(err.Error(), "bad cursor") {
|
||||
return nil, "", apperr.Validation("cursor", "validation.cursor_invalid", "bad cursor", nil)
|
||||
}
|
||||
return files, next, err
|
||||
}
|
||||
|
||||
// --- Действия автора ---
|
||||
|
||||
// Withdraw — автор отзывает файл до решения.
|
||||
func (s *Service) Withdraw(ctx context.Context, id uuid.UUID) error {
|
||||
p, err := principal(ctx, auth.PermSuggestTracks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := s.store.File(ctx, id)
|
||||
if err != nil || f.AuthorID != p.UserID {
|
||||
return apperr.NotFound(CodeNotFound, "upload not found")
|
||||
}
|
||||
if err := s.store.Transition(ctx, id, []Status{StatusUploading, StatusProcessing, StatusPendingReview}, StatusWithdrawn, nil); err != nil {
|
||||
return notFound(err)
|
||||
}
|
||||
s.removeStaging(f)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Админ ---
|
||||
|
||||
// UpdateInput — правка файла админом. nil — не менять.
|
||||
type UpdateInput struct {
|
||||
Meta *Meta
|
||||
TargetSourceID *uuid.UUID
|
||||
}
|
||||
|
||||
// Update правит метаданные и хранилище; путь в хранилище пересчитывается.
|
||||
func (s *Service) Update(ctx context.Context, id uuid.UUID, in UpdateInput) (File, error) {
|
||||
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
f, err := s.store.File(ctx, id)
|
||||
if err != nil {
|
||||
return File{}, notFound(err)
|
||||
}
|
||||
if f.Status != StatusPendingReview && f.Status != StatusImportFailed {
|
||||
return File{}, apperr.Conflict(CodeWrongStatus, "only files waiting for review can be edited")
|
||||
}
|
||||
m := Meta{}
|
||||
if f.Meta != nil {
|
||||
m = *f.Meta
|
||||
}
|
||||
edited := f.MetadataEdited
|
||||
if in.Meta != nil {
|
||||
next := normalizeMeta(*in.Meta)
|
||||
if next.Title == "" || next.Artist == "" {
|
||||
return File{}, apperr.Validation("metadata", CodeMissingField, "artist and title are required", nil)
|
||||
}
|
||||
edited = edited || next != m
|
||||
m = next
|
||||
}
|
||||
target := f.TargetSourceID
|
||||
if in.TargetSourceID != nil {
|
||||
target = in.TargetSourceID
|
||||
}
|
||||
pl := s.plan(ctx, f, m, target)
|
||||
raw, _ := json.Marshal(m)
|
||||
_, err = s.store.pool.Exec(ctx, `
|
||||
UPDATE upload_files SET metadata = $2, metadata_edited = $3, target_source_id = $4, target_rel_path = $5,
|
||||
status_reason = $6, matched_artist_id = $7, matched_album_id = $8, updated_at = now()
|
||||
WHERE id = $1 AND status IN ('pending_review', 'import_failed')`,
|
||||
id, raw, edited, pl.SourceID, pl.RelPath, nullable(pl.Reason), pl.ArtistID, pl.AlbumID)
|
||||
if err != nil {
|
||||
return File{}, fmt.Errorf("uploads: update: %w", err)
|
||||
}
|
||||
return s.store.File(ctx, id)
|
||||
}
|
||||
|
||||
func normalizeMeta(m Meta) Meta {
|
||||
m.Artist, m.AlbumArtist, m.Album = meta.Clean(m.Artist), meta.Clean(m.AlbumArtist), meta.Clean(m.Album)
|
||||
m.Title, m.Genre = meta.Clean(m.Title), meta.Clean(m.Genre)
|
||||
if m.AlbumArtist == "" {
|
||||
m.AlbumArtist = m.Artist
|
||||
}
|
||||
for _, p := range []**int{&m.Year, &m.Track, &m.Disc} {
|
||||
if *p != nil && **p <= 0 {
|
||||
*p = nil
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func nullable(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// ReviewResult — итог решения по одному файлу.
|
||||
type ReviewResult struct {
|
||||
ID uuid.UUID
|
||||
Error error
|
||||
}
|
||||
|
||||
// Review — массовое решение: approve / reject (с причиной, её видит автор).
|
||||
func (s *Service) Review(ctx context.Context, ids []uuid.UUID, approve bool, reason string) ([]ReviewResult, error) {
|
||||
p, err := principal(ctx, auth.PermReviewUploads)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
out := make([]ReviewResult, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, ReviewResult{ID: id, Error: s.reviewOne(ctx, p.UserID, id, approve, reason)})
|
||||
}
|
||||
if approve {
|
||||
wake(s.wakeImport)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) reviewOne(ctx context.Context, reviewer, id uuid.UUID, approve bool, reason string) error {
|
||||
f, err := s.store.File(ctx, id)
|
||||
if err != nil {
|
||||
return notFound(err)
|
||||
}
|
||||
mark := func(tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx, `UPDATE upload_files SET reviewed_by = $2, reviewed_at = now(), status_reason = $3 WHERE id = $1`,
|
||||
id, reviewer, nullable(reason))
|
||||
return err
|
||||
}
|
||||
if !approve {
|
||||
if err := s.store.Transition(ctx, id, []Status{StatusPendingReview, StatusImportFailed}, StatusRejected, mark); err != nil {
|
||||
return notFound(err)
|
||||
}
|
||||
s.removeStaging(f)
|
||||
s.store.Audit(ctx, &reviewer, "upload.reject", id, map[string]any{"reason": reason})
|
||||
return nil
|
||||
}
|
||||
if f.Meta == nil {
|
||||
return apperr.Conflict(CodeMissingField, "file has no metadata")
|
||||
}
|
||||
pl := s.plan(ctx, f, *f.Meta, f.TargetSourceID)
|
||||
if pl.Reason != "" {
|
||||
return apperr.Conflict(pl.Reason, "cannot place the file into a storage")
|
||||
}
|
||||
reason = ""
|
||||
if err := s.store.Transition(ctx, id, []Status{StatusPendingReview}, StatusApproved, mark); err != nil {
|
||||
return notFound(err)
|
||||
}
|
||||
s.store.Audit(ctx, &reviewer, "upload.approve", id, map[string]any{"target": pl.RelPath})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Retry — повторить импорт после import_failed.
|
||||
func (s *Service) Retry(ctx context.Context, id uuid.UUID) (File, error) {
|
||||
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
if err := s.store.Transition(ctx, id, []Status{StatusImportFailed}, StatusApproved, nil); err != nil {
|
||||
return File{}, notFound(err)
|
||||
}
|
||||
wake(s.wakeImport)
|
||||
return s.store.File(ctx, id)
|
||||
}
|
||||
|
||||
// GetSettings — настройки очереди (админ).
|
||||
func (s *Service) GetSettings(ctx context.Context) (Settings, error) {
|
||||
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return s.store.Settings(ctx)
|
||||
}
|
||||
|
||||
// SettingsInput — правка настроек; nil — не менять. ClearPerUser / ClearDefault — сбросить.
|
||||
type SettingsInput struct {
|
||||
QueueLimitBytes *int64
|
||||
PerUserLimitBytes *int64
|
||||
MaxFileBytes *int64
|
||||
DefaultSourceID *uuid.UUID
|
||||
ClearPerUser bool
|
||||
ClearDefault bool
|
||||
}
|
||||
|
||||
// UpdateSettings меняет лимиты; уменьшение ниже занятого ничего не удаляет (docs/uploads.md#3).
|
||||
func (s *Service) UpdateSettings(ctx context.Context, in SettingsInput) (Settings, error) {
|
||||
p, err := principal(ctx, auth.PermReviewUploads)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
st, err := s.store.Settings(ctx)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
if in.QueueLimitBytes != nil {
|
||||
if *in.QueueLimitBytes < 0 {
|
||||
return Settings{}, apperr.Validation("queue_limit_bytes", "validation.limit_invalid", "must be ≥ 0", nil)
|
||||
}
|
||||
st.QueueLimitBytes = *in.QueueLimitBytes
|
||||
}
|
||||
if in.MaxFileBytes != nil {
|
||||
if *in.MaxFileBytes <= 0 {
|
||||
return Settings{}, apperr.Validation("max_file_bytes", "validation.limit_invalid", "must be > 0", nil)
|
||||
}
|
||||
st.MaxFileBytes = *in.MaxFileBytes
|
||||
}
|
||||
switch {
|
||||
case in.ClearPerUser:
|
||||
st.PerUserLimitBytes = nil
|
||||
case in.PerUserLimitBytes != nil:
|
||||
if *in.PerUserLimitBytes <= 0 {
|
||||
return Settings{}, apperr.Validation("per_user_limit_bytes", "validation.limit_invalid", "must be > 0", nil)
|
||||
}
|
||||
st.PerUserLimitBytes = in.PerUserLimitBytes
|
||||
}
|
||||
switch {
|
||||
case in.ClearDefault:
|
||||
st.DefaultSourceID = nil
|
||||
case in.DefaultSourceID != nil:
|
||||
src, err := s.lib.GetSource(ctx, *in.DefaultSourceID)
|
||||
if err != nil || !src.AcceptsUploads {
|
||||
return Settings{}, apperr.Validation("default_source_id", CodeNoTarget, "storage must accept uploads", nil)
|
||||
}
|
||||
st.DefaultSourceID = in.DefaultSourceID
|
||||
}
|
||||
if err := s.store.SaveSettings(ctx, st); err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
s.store.Audit(ctx, &p.UserID, "uploads.settings", uuid.Nil, map[string]any{"settings": st})
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// --- Раскладка в хранилище ---
|
||||
|
||||
// placement — куда ляжет файл; Reason — почему не получится (код), путь тогда nil.
|
||||
type placement struct {
|
||||
SourceID *uuid.UUID
|
||||
RelPath *string
|
||||
Reason string
|
||||
ArtistID *uuid.UUID
|
||||
AlbumID *uuid.UUID
|
||||
src library.Source
|
||||
root string
|
||||
}
|
||||
|
||||
// pickSource — хранилище: выбранное админом → по умолчанию → единственное принимающее загрузки.
|
||||
func (s *Service) pickSource(ctx context.Context, chosen *uuid.UUID) (library.Source, string) {
|
||||
id := chosen
|
||||
if id == nil {
|
||||
st, err := s.store.Settings(ctx)
|
||||
if err == nil {
|
||||
id = st.DefaultSourceID
|
||||
}
|
||||
}
|
||||
if id != nil {
|
||||
src, err := s.lib.GetSource(ctx, *id)
|
||||
if err != nil || !src.AcceptsUploads {
|
||||
return library.Source{}, CodeNoTarget
|
||||
}
|
||||
return src, ""
|
||||
}
|
||||
sources, err := s.lib.ListSources(ctx)
|
||||
if err != nil {
|
||||
return library.Source{}, CodeNoTarget
|
||||
}
|
||||
var found []library.Source
|
||||
for _, src := range sources {
|
||||
if src.AcceptsUploads {
|
||||
found = append(found, src)
|
||||
}
|
||||
}
|
||||
if len(found) != 1 {
|
||||
return library.Source{}, CodeNoTarget
|
||||
}
|
||||
return found[0], ""
|
||||
}
|
||||
|
||||
// plan считает целевой путь по write_template хранилища; существующие папки исполнителя / альбома
|
||||
// переиспользуются в их написании (docs/uploads.md#очистка-имён).
|
||||
func (s *Service) plan(ctx context.Context, f File, m Meta, chosen *uuid.UUID) placement {
|
||||
var (
|
||||
pl placement
|
||||
discs int // дисков в альбоме: в каталоге, в пачке, у самого файла
|
||||
)
|
||||
if artist, album, maxDisc, err := s.store.MatchCatalog(ctx, meta.Key(m.AlbumArtist), meta.Key(m.Album)); err == nil {
|
||||
pl.ArtistID, pl.AlbumID, discs = artist, album, maxDisc
|
||||
}
|
||||
src, reason := s.pickSource(ctx, chosen)
|
||||
if reason != "" {
|
||||
pl.Reason = reason
|
||||
return pl
|
||||
}
|
||||
pl.SourceID, pl.src = &src.ID, src
|
||||
if src.WriteTemplate == nil {
|
||||
pl.Reason = CodeTemplate
|
||||
return pl
|
||||
}
|
||||
tpl, err := meta.ParseTemplate(*src.WriteTemplate)
|
||||
if err != nil {
|
||||
pl.Reason = CodeTemplate
|
||||
return pl
|
||||
}
|
||||
if n, err := s.store.BatchMaxDisc(ctx, f.BatchID, m.Album); err == nil {
|
||||
discs = max(discs, n)
|
||||
}
|
||||
if m.Disc != nil {
|
||||
discs = max(discs, *m.Disc)
|
||||
}
|
||||
rel, err := tpl.Render(meta.TemplateInput{Fields: m.Fields(), DiscCount: discs, Ext: extFor(f)})
|
||||
if err != nil {
|
||||
var mf *meta.MissingFieldError
|
||||
if errors.As(err, &mf) {
|
||||
pl.Reason = CodeMissingField
|
||||
} else {
|
||||
pl.Reason = CodeTemplate
|
||||
}
|
||||
return pl
|
||||
}
|
||||
root, err := s.libSvc.Root(src)
|
||||
if err != nil {
|
||||
pl.Reason = CodeNoTarget
|
||||
return pl
|
||||
}
|
||||
rel = reuseExisting(root, rel)
|
||||
pl.RelPath, pl.root = &rel, root
|
||||
return pl
|
||||
}
|
||||
|
||||
// extFor — расширение по реальному формату (кодек из ffprobe), иначе — по имени файла.
|
||||
func extFor(f File) string {
|
||||
orig := strings.TrimPrefix(strings.ToLower(path.Ext(f.OriginalName)), ".")
|
||||
if f.Codec == nil {
|
||||
return orig
|
||||
}
|
||||
switch c := *f.Codec; {
|
||||
case c == "flac":
|
||||
return "flac"
|
||||
case c == "mp3":
|
||||
return "mp3"
|
||||
case c == "aac" || c == "alac":
|
||||
return "m4a"
|
||||
case c == "vorbis":
|
||||
return "ogg"
|
||||
case c == "opus":
|
||||
return "opus"
|
||||
case c == "wavpack":
|
||||
return "wv"
|
||||
case strings.HasPrefix(c, "pcm_"):
|
||||
if orig == "aiff" || orig == "aif" {
|
||||
return orig
|
||||
}
|
||||
return "wav"
|
||||
}
|
||||
return orig
|
||||
}
|
||||
|
||||
// reuseExisting заменяет сегменты каталогов на уже существующие с тем же ключом имени («КИНО» → «Кино»).
|
||||
// Сравнение по списку каталога, а не os.Stat: на ФС без учёта регистра Stat нашёл бы папку, не дав её написания.
|
||||
func reuseExisting(root, rel string) string {
|
||||
segs := strings.Split(rel, "/")
|
||||
cur := root
|
||||
for i := 0; i < len(segs)-1; i++ {
|
||||
entries, err := os.ReadDir(cur)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
key, match := meta.Key(segs[i]), ""
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if e.Name() == segs[i] {
|
||||
match = e.Name()
|
||||
break
|
||||
}
|
||||
if match == "" && meta.Key(e.Name()) == key {
|
||||
match = e.Name()
|
||||
}
|
||||
}
|
||||
if match == "" {
|
||||
break // дальше каталогов ещё нет
|
||||
}
|
||||
segs[i] = match
|
||||
cur = filepath.Join(cur, match)
|
||||
}
|
||||
return strings.Join(segs, "/")
|
||||
}
|
||||
|
||||
// removeStaging удаляет файл staging (и временные копии с тегами).
|
||||
func (s *Service) removeStaging(f File) {
|
||||
if f.StagingPath == "" {
|
||||
return
|
||||
}
|
||||
p := filepath.Join(s.cfg.StagingDir, filepath.FromSlash(f.StagingPath))
|
||||
matches, _ := filepath.Glob(p + "*")
|
||||
for _, m := range matches {
|
||||
_ = os.Remove(m)
|
||||
}
|
||||
_ = os.Remove(filepath.Dir(p)) // каталог пачки, если опустел
|
||||
}
|
||||
|
||||
// TusURL — адрес tus для файла.
|
||||
func TusURL(id uuid.UUID) string { return "/api/v1/uploads/files/" + id.String() }
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package uploads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/platform/httpx"
|
||||
)
|
||||
|
||||
// Ядро протокола tus 1.0.0 (https://tus.io/protocols/resumable-upload): HEAD — сколько принято, PATCH — следующий
|
||||
// кусок с Upload-Offset. Создание (extension creation) не нужно: файл заводит POST /uploads вместе с резервом квоты,
|
||||
// а клиент получает готовый tus_url. Принимаемое смещение — фактический размер файла в staging.
|
||||
|
||||
const tusVersion = "1.0.0"
|
||||
|
||||
// MaxChunkBytes — предел одного PATCH (клиент шлёт по 8 МБ; nginx пропускает до 16 МБ).
|
||||
const MaxChunkBytes = 64 << 20
|
||||
|
||||
type tusFiles interface {
|
||||
File(ctx context.Context, id uuid.UUID) (File, error)
|
||||
SetReceived(ctx context.Context, id uuid.UUID, offset int64) error
|
||||
}
|
||||
|
||||
// TUS — приём файлов очереди.
|
||||
type TUS struct {
|
||||
files tusFiles
|
||||
dir string // каталог staging
|
||||
onComplete func(ctx context.Context, f File) error // файл принят целиком
|
||||
log *slog.Logger
|
||||
locks sync.Map // id → *sync.Mutex: один PATCH на файл одновременно
|
||||
}
|
||||
|
||||
// NewTUS создаёт приёмник.
|
||||
func NewTUS(files tusFiles, dir string, onComplete func(ctx context.Context, f File) error, log *slog.Logger) *TUS {
|
||||
return &TUS{files: files, dir: dir, onComplete: onComplete, log: log}
|
||||
}
|
||||
|
||||
// Mount регистрирует маршруты tus на роутере (`/api/v1/uploads/files/{id}`).
|
||||
func (t *TUS) Mount(r chi.Router) {
|
||||
r.Options("/api/v1/uploads/files/{id}", t.options)
|
||||
r.Head("/api/v1/uploads/files/{id}", t.head)
|
||||
r.Patch("/api/v1/uploads/files/{id}", t.patch)
|
||||
}
|
||||
|
||||
func (t *TUS) options(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Tus-Resumable", tusVersion)
|
||||
w.Header().Set("Tus-Version", tusVersion)
|
||||
w.Header().Set("Tus-Max-Size", strconv.FormatInt(MaxChunkBytes, 10))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// own — файл автора запроса в статусе, где tus имеет смысл.
|
||||
func (t *TUS) own(w http.ResponseWriter, r *http.Request) (File, bool) {
|
||||
w.Header().Set("Tus-Resumable", tusVersion)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
p, ok := auth.PrincipalFrom(r.Context())
|
||||
if !ok {
|
||||
httpx.WriteProblem(w, http.StatusUnauthorized, auth.CodeUnauthorized, "not authenticated")
|
||||
return File{}, false
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
httpx.WriteProblem(w, http.StatusNotFound, CodeNotFound, "upload not found")
|
||||
return File{}, false
|
||||
}
|
||||
f, err := t.files.File(r.Context(), id)
|
||||
if err != nil || f.AuthorID != p.UserID {
|
||||
httpx.WriteProblem(w, http.StatusNotFound, CodeNotFound, "upload not found")
|
||||
return File{}, false
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
|
||||
func (t *TUS) path(f File) string { return filepath.Join(t.dir, filepath.FromSlash(f.StagingPath)) }
|
||||
|
||||
// onDisk — сколько байт уже лежит в staging.
|
||||
func (t *TUS) onDisk(f File) int64 {
|
||||
info, err := os.Stat(t.path(f))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
func (t *TUS) head(w http.ResponseWriter, r *http.Request) {
|
||||
f, ok := t.own(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
offset := f.Size // принят целиком и уже обрабатывается
|
||||
switch {
|
||||
case f.Status == StatusUploading:
|
||||
offset = t.onDisk(f)
|
||||
case !f.Status.Occupies():
|
||||
w.WriteHeader(http.StatusGone)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Upload-Offset", strconv.FormatInt(offset, 10))
|
||||
w.Header().Set("Upload-Length", strconv.FormatInt(f.Size, 10))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (t *TUS) patch(w http.ResponseWriter, r *http.Request) {
|
||||
f, ok := t.own(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Tus-Resumable") != tusVersion {
|
||||
w.Header().Set("Tus-Version", tusVersion)
|
||||
w.WriteHeader(http.StatusPreconditionFailed)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/offset+octet-stream" {
|
||||
httpx.WriteProblem(w, http.StatusUnsupportedMediaType, "common.bad_request", "content type must be application/offset+octet-stream")
|
||||
return
|
||||
}
|
||||
if f.Status != StatusUploading {
|
||||
httpx.WriteProblem(w, http.StatusConflict, CodeWrongStatus, "upload is not accepting data")
|
||||
return
|
||||
}
|
||||
mu, _ := t.locks.LoadOrStore(f.ID, &sync.Mutex{})
|
||||
lock := mu.(*sync.Mutex)
|
||||
if !lock.TryLock() {
|
||||
httpx.WriteProblem(w, http.StatusLocked, CodeOffset, "another request is writing this upload")
|
||||
return
|
||||
}
|
||||
defer lock.Unlock()
|
||||
|
||||
offset, err := strconv.ParseInt(r.Header.Get("Upload-Offset"), 10, 64)
|
||||
have := t.onDisk(f)
|
||||
if err != nil || offset != have {
|
||||
w.Header().Set("Upload-Offset", strconv.FormatInt(have, 10))
|
||||
httpx.WriteProblem(w, http.StatusConflict, CodeOffset, "offset does not match")
|
||||
return
|
||||
}
|
||||
remaining := f.Size - have
|
||||
if r.ContentLength > remaining || r.ContentLength > MaxChunkBytes {
|
||||
httpx.WriteProblem(w, http.StatusRequestEntityTooLarge, CodeSizeMismatch, "chunk exceeds the declared size")
|
||||
return
|
||||
}
|
||||
|
||||
n, werr := t.append(f, r.Body, min(remaining, MaxChunkBytes))
|
||||
now := have + n
|
||||
if err := t.files.SetReceived(r.Context(), f.ID, now); err != nil {
|
||||
t.log.ErrorContext(r.Context(), "tus: save offset", "upload_id", f.ID, "err", err)
|
||||
}
|
||||
if werr != nil {
|
||||
if errors.Is(werr, errTooMuch) {
|
||||
httpx.WriteProblem(w, http.StatusRequestEntityTooLarge, CodeSizeMismatch, "data exceeds the declared size")
|
||||
return
|
||||
}
|
||||
// Обрыв посреди куска: принятое сохранено, клиент спросит HEAD и продолжит.
|
||||
t.log.WarnContext(r.Context(), "tus: write interrupted", "upload_id", f.ID, "offset", now, "err", werr)
|
||||
httpx.WriteProblem(w, http.StatusInternalServerError, "common.internal", "write interrupted")
|
||||
return
|
||||
}
|
||||
if now == f.Size {
|
||||
if err := t.onComplete(r.Context(), f); err != nil {
|
||||
t.log.ErrorContext(r.Context(), "tus: complete", "upload_id", f.ID, "err", err)
|
||||
httpx.WriteProblem(w, http.StatusInternalServerError, "common.internal", "cannot finish upload")
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Upload-Offset", strconv.FormatInt(now, 10))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
var errTooMuch = errors.New("uploads: more data than declared")
|
||||
|
||||
// append дописывает тело в конец файла staging, не больше limit байт; лишние данные — ошибка.
|
||||
func (t *TUS) append(f File, body io.Reader, limit int64) (int64, error) {
|
||||
p := t.path(f)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
out, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o640)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, err := io.Copy(out, io.LimitReader(body, limit))
|
||||
if cerr := out.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
var extra [1]byte
|
||||
if m, _ := body.Read(extra[:]); m > 0 {
|
||||
// Лишнее на диск не попало — его отсёк LimitReader.
|
||||
return n, errTooMuch
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package uploads
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"pipu/api/internal/auth"
|
||||
)
|
||||
|
||||
type fakeFiles struct{ f File }
|
||||
|
||||
func (m *fakeFiles) File(_ context.Context, id uuid.UUID) (File, error) {
|
||||
if id != m.f.ID {
|
||||
return File{}, ErrNotFound
|
||||
}
|
||||
return m.f, nil
|
||||
}
|
||||
|
||||
func (m *fakeFiles) SetReceived(_ context.Context, _ uuid.UUID, offset int64) error {
|
||||
m.f.Received = offset
|
||||
return nil
|
||||
}
|
||||
|
||||
type tusEnv struct {
|
||||
h http.Handler
|
||||
files *fakeFiles
|
||||
dir string
|
||||
author uuid.UUID
|
||||
complete int
|
||||
}
|
||||
|
||||
func newTusEnv(t *testing.T, size int64) *tusEnv {
|
||||
e := &tusEnv{dir: t.TempDir(), author: uuid.New()}
|
||||
id := uuid.New()
|
||||
e.files = &fakeFiles{f: File{ID: id, AuthorID: e.author, Status: StatusUploading, Size: size, StagingPath: "batch/" + id.String()}}
|
||||
tus := NewTUS(e.files, e.dir, func(_ context.Context, f File) error {
|
||||
e.complete++
|
||||
e.files.f.Status = StatusProcessing
|
||||
return nil
|
||||
}, slog.New(slog.DiscardHandler))
|
||||
r := chi.NewRouter()
|
||||
tus.Mount(r)
|
||||
e.h = r
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *tusEnv) do(user uuid.UUID, method string, offset int64, body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, TusURL(e.files.f.ID), bytes.NewBufferString(body))
|
||||
req.Header.Set("Tus-Resumable", tusVersion)
|
||||
if method == http.MethodPatch {
|
||||
req.Header.Set("Content-Type", "application/offset+octet-stream")
|
||||
req.Header.Set("Upload-Offset", strconv.FormatInt(offset, 10))
|
||||
}
|
||||
req = req.WithContext(auth.WithPrincipal(req.Context(), auth.Principal{UserID: user, Role: auth.RoleModerator}))
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestTusResumableUpload(t *testing.T) {
|
||||
e := newTusEnv(t, 10)
|
||||
|
||||
rec := e.do(e.author, http.MethodHead, 0, "")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "0", rec.Header().Get("Upload-Offset"))
|
||||
assert.Equal(t, "10", rec.Header().Get("Upload-Length"))
|
||||
|
||||
rec = e.do(e.author, http.MethodPatch, 0, "hello")
|
||||
require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
|
||||
assert.Equal(t, "5", rec.Header().Get("Upload-Offset"))
|
||||
|
||||
// Повтор с устаревшим смещением (клиент не получил ответ) — 409 и текущее смещение.
|
||||
rec = e.do(e.author, http.MethodPatch, 0, "hello")
|
||||
assert.Equal(t, http.StatusConflict, rec.Code)
|
||||
assert.Equal(t, "5", rec.Header().Get("Upload-Offset"))
|
||||
|
||||
// Больше заявленного размера — отказ, на диск ничего лишнего не попадает.
|
||||
rec = e.do(e.author, http.MethodPatch, 5, "world!!")
|
||||
assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code)
|
||||
assert.Equal(t, 0, e.complete)
|
||||
|
||||
rec = e.do(e.author, http.MethodPatch, 5, "world")
|
||||
require.Equal(t, http.StatusNoContent, rec.Code)
|
||||
assert.Equal(t, 1, e.complete, "full file goes to processing")
|
||||
got, err := os.ReadFile(filepath.Join(e.dir, "batch", e.files.f.ID.String()))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "helloworld", string(got))
|
||||
|
||||
// Принятый файл: HEAD сообщает, что всё на месте; PATCH больше не принимается.
|
||||
rec = e.do(e.author, http.MethodHead, 0, "")
|
||||
assert.Equal(t, "10", rec.Header().Get("Upload-Offset"))
|
||||
assert.Equal(t, http.StatusConflict, e.do(e.author, http.MethodPatch, 10, "x").Code)
|
||||
}
|
||||
|
||||
func TestTusOnlyAuthor(t *testing.T) {
|
||||
e := newTusEnv(t, 10)
|
||||
assert.Equal(t, http.StatusNotFound, e.do(uuid.New(), http.MethodHead, 0, "").Code)
|
||||
assert.Equal(t, http.StatusNotFound, e.do(uuid.New(), http.MethodPatch, 0, "hello").Code)
|
||||
|
||||
e.files.f.Status = StatusRejected
|
||||
assert.Equal(t, http.StatusGone, e.do(e.author, http.MethodHead, 0, "").Code)
|
||||
}
|
||||
|
||||
func TestQuotaStatuses(t *testing.T) {
|
||||
for _, s := range []Status{StatusUploading, StatusProcessing, StatusPendingReview, StatusApproved, StatusImporting, StatusImportFailed} {
|
||||
assert.True(t, s.Occupies(), s)
|
||||
}
|
||||
for _, s := range []Status{StatusImported, StatusDuplicate, StatusInvalid, StatusRejected, StatusWithdrawn, StatusExpired} {
|
||||
assert.False(t, s.Occupies(), s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtFor(t *testing.T) {
|
||||
codec := func(c string) *string { return &c }
|
||||
cases := []struct {
|
||||
name string
|
||||
codec *string
|
||||
want string
|
||||
}{
|
||||
{"track.FLAC", codec("flac"), "flac"},
|
||||
{"track.mp4", codec("aac"), "m4a"}, // по содержимому, а не по имени
|
||||
{"track.ogg", codec("opus"), "opus"},
|
||||
{"track.aiff", codec("pcm_s16be"), "aiff"},
|
||||
{"track.raw", codec("pcm_s16le"), "wav"},
|
||||
{"track.Mp3", nil, "mp3"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.want, extFor(File{OriginalName: c.name, Codec: c.codec}), c.name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReuseExistingFolders(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "Кино", "1988 - Группа крови"), 0o755))
|
||||
assert.Equal(t, "Кино/1988 - Группа крови/01 - x.flac", reuseExisting(root, "КИНО/1988 - группа крови/01 - x.flac"))
|
||||
assert.Equal(t, "Кино/1990 - Чёрный/01 - x.flac", reuseExisting(root, "кино/1990 - Чёрный/01 - x.flac"))
|
||||
assert.Equal(t, "Ария/x.flac", reuseExisting(root, "Ария/x.flac"))
|
||||
}
|
||||
|
||||
func TestBestMatch(t *testing.T) {
|
||||
a := make([]uint32, 200)
|
||||
for i := range a {
|
||||
a[i] = uint32(i) * 2654435761
|
||||
}
|
||||
other := make([]uint32, 200)
|
||||
for i := range other {
|
||||
other[i] = ^a[i]
|
||||
}
|
||||
track := uuid.New()
|
||||
c, ok := bestMatch(a, []PrintCandidate{{Points: other}, {TrackID: &track, Points: a}})
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, &track, c.TrackID)
|
||||
_, ok = bestMatch(a, []PrintCandidate{{Points: other}})
|
||||
assert.False(t, ok)
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package uploads
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"pipu/api/internal/library"
|
||||
"pipu/api/internal/library/meta"
|
||||
"pipu/api/internal/storageagent"
|
||||
)
|
||||
|
||||
// Фоновые задачи очереди: обработка принятых файлов, импорт одобренных, истечение недокачанных.
|
||||
// По одному файлу за раз — диск и NFS не перегружаются, а порядок понятен админу.
|
||||
|
||||
const (
|
||||
pollEvery = 30 * time.Second
|
||||
cleanupEvery = 10 * time.Minute
|
||||
// FingerprintTolerance — разница длительностей, при которой отпечатки ещё сравниваются.
|
||||
FingerprintTolerance = 5000
|
||||
// MetadataTolerance — «та же длительность» для совпадения метаданных (docs/uploads.md#4).
|
||||
MetadataTolerance = 2000
|
||||
)
|
||||
|
||||
// Run запускает фоновые задачи до отмены ctx.
|
||||
func (s *Service) Run(ctx context.Context) {
|
||||
s.reconcile(ctx)
|
||||
go s.loop(ctx, s.wakeProcess, pollEvery, func(ctx context.Context) bool { return s.processNext(ctx) })
|
||||
go s.loop(ctx, s.wakeImport, pollEvery, func(ctx context.Context) bool { return s.importNext(ctx) })
|
||||
go s.loop(ctx, nil, cleanupEvery, func(ctx context.Context) bool { s.expire(ctx); return false })
|
||||
wake(s.wakeProcess)
|
||||
wake(s.wakeImport)
|
||||
}
|
||||
|
||||
// loop вызывает step, пока тот находит работу, затем ждёт сигнала или таймера.
|
||||
func (s *Service) loop(ctx context.Context, signal chan struct{}, every time.Duration, step func(ctx context.Context) bool) {
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
for {
|
||||
for ctx.Err() == nil && step(ctx) {
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
case <-signal:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) stagingAbs(rel string) string {
|
||||
return filepath.Join(s.cfg.StagingDir, filepath.FromSlash(rel))
|
||||
}
|
||||
|
||||
// --- Обработка ---
|
||||
|
||||
// processNext обрабатывает один файл в статусе processing; false — работы нет.
|
||||
func (s *Service) processNext(ctx context.Context) bool {
|
||||
f, err := s.store.NextWithStatus(ctx, StatusProcessing)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
s.log.Error("uploads: next to process", "err", err)
|
||||
return false
|
||||
}
|
||||
if err := s.process(ctx, f); err != nil {
|
||||
s.log.Error("uploads: process", "upload_id", f.ID, "err", err)
|
||||
// Сбой БД / диска: файл остаётся в processing, повтор — со следующим тиком.
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// outcome — итог обработки для записи в БД.
|
||||
type outcome struct {
|
||||
status Status
|
||||
reason string
|
||||
probe library.Probe
|
||||
sha []byte
|
||||
print *library.Fingerprint
|
||||
tags map[string]string
|
||||
meta *Meta
|
||||
dupTrack *uuid.UUID
|
||||
dupLevel string
|
||||
possible bool
|
||||
place placement
|
||||
}
|
||||
|
||||
func (s *Service) process(ctx context.Context, f File) error {
|
||||
abs := s.stagingAbs(f.StagingPath)
|
||||
o := s.analyze(ctx, f, abs)
|
||||
if err := s.saveOutcome(ctx, f, o); err != nil {
|
||||
return err
|
||||
}
|
||||
if o.status != StatusPendingReview {
|
||||
s.removeStaging(f)
|
||||
}
|
||||
s.log.Info("upload processed", "upload_id", f.ID, "status", o.status, "reason", o.reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
// analyze — шаги process_upload_file (docs/uploads.md#6): ffprobe, SHA-256, Chromaprint → дубли; метаданные; путь.
|
||||
func (s *Service) analyze(ctx context.Context, f File, abs string) outcome {
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil || info.Size() != f.Size {
|
||||
return outcome{status: StatusInvalid, reason: CodeSizeMismatch}
|
||||
}
|
||||
probe, err := s.prober.Probe(ctx, abs)
|
||||
if err != nil || probe.DurationMs < MinDurationMs || probe.DurationMs > MaxDurationMs {
|
||||
return outcome{status: StatusInvalid, reason: CodeInvalidAudio}
|
||||
}
|
||||
o := outcome{probe: probe, tags: tagsMap(probe.Tags)}
|
||||
if o.sha, err = library.FileSHA256(abs); err != nil {
|
||||
return outcome{status: StatusInvalid, reason: CodeInvalidAudio, probe: probe}
|
||||
}
|
||||
if len(f.SHA256) > 0 && !bytes.Equal(f.SHA256, o.sha) {
|
||||
o.status, o.reason = StatusInvalid, CodeHashMismatch
|
||||
return o
|
||||
}
|
||||
|
||||
// 1. Точная копия.
|
||||
if track, dup, err := s.store.DuplicateBySHA(ctx, o.sha, f.ID); err == nil && dup {
|
||||
o.status, o.reason, o.dupTrack, o.dupLevel = StatusDuplicate, CodeDuplicate, track, DupSHA256
|
||||
return o
|
||||
}
|
||||
// 3. Акустический отпечаток (fpcalc может отсутствовать — тогда уровень пропускается).
|
||||
if fp, err := s.fpcalc.Fingerprint(ctx, abs); err == nil {
|
||||
o.print = &fp
|
||||
cands, err := s.store.FingerprintCandidates(ctx, probe.DurationMs, FingerprintTolerance, f.ID)
|
||||
if err == nil {
|
||||
if c, ok := bestMatch(fp.Points, cands); ok {
|
||||
o.status, o.reason, o.dupTrack, o.dupLevel = StatusDuplicate, CodeDuplicate, c.TrackID, DupFingerprint
|
||||
return o
|
||||
}
|
||||
}
|
||||
} else if !errors.Is(err, library.ErrNoFPCalc) {
|
||||
s.log.Warn("uploads: fpcalc", "upload_id", f.ID, "err", err)
|
||||
}
|
||||
|
||||
// Метаданные: теги → путь из перетащенной папки по шаблонам целевого хранилища → имя файла.
|
||||
src, _ := s.pickSource(ctx, f.TargetSourceID)
|
||||
rel := f.OriginalName
|
||||
if f.ClientRelPath != nil {
|
||||
rel = *f.ClientRelPath
|
||||
}
|
||||
var fromPath meta.Fields
|
||||
if src.ID != uuid.Nil {
|
||||
if patterns := s.sourcePatterns(ctx, src.ID); len(patterns) > 0 {
|
||||
fromPath = meta.FromPath(meta.MatchPath(patterns, rel).Fields)
|
||||
}
|
||||
}
|
||||
m := normalizeMeta(FromMerged(meta.Merge(probe.Tags, fromPath, true, rel)))
|
||||
if m.Album == "" {
|
||||
m.Album = library.UnknownAlbum
|
||||
}
|
||||
o.meta = &m
|
||||
|
||||
// 2. Совпадение метаданных — не отказ, а пометка для админа.
|
||||
if track, err := s.store.MetadataMatch(ctx, meta.Key(m.Artist), meta.Key(m.Title), probe.DurationMs, MetadataTolerance); err == nil && track != nil {
|
||||
o.possible, o.dupTrack, o.dupLevel = true, track, DupMetadata
|
||||
}
|
||||
|
||||
codec := probe.Codec
|
||||
ff := f
|
||||
ff.Codec = &codec
|
||||
o.place = s.plan(ctx, ff, m, f.TargetSourceID)
|
||||
o.status, o.reason = StatusPendingReview, o.place.Reason
|
||||
return o
|
||||
}
|
||||
|
||||
// bestMatch — отпечаток, похожий не меньше порога library.SameRecording.
|
||||
func bestMatch(points []uint32, cands []PrintCandidate) (PrintCandidate, bool) {
|
||||
best, score := PrintCandidate{}, 0.0
|
||||
for _, c := range cands {
|
||||
if sim := library.Similarity(points, c.Points); sim > score {
|
||||
best, score = c, sim
|
||||
}
|
||||
}
|
||||
return best, score >= library.SameRecording
|
||||
}
|
||||
|
||||
func tagsMap(t meta.Fields) map[string]string {
|
||||
out := map[string]string{}
|
||||
for k, v := range map[string]string{
|
||||
"artist": t.Artist, "albumartist": t.AlbumArtist, "album": t.Album, "title": t.Title, "genre": t.Genre,
|
||||
"year": t.Year, "track": t.Track, "disc": t.Disc,
|
||||
} {
|
||||
if v != "" {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) sourcePatterns(ctx context.Context, id uuid.UUID) []meta.Pattern {
|
||||
rows, err := s.lib.Patterns(ctx, id)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var exprs []string
|
||||
for _, r := range rows {
|
||||
if r.IsEnabled {
|
||||
exprs = append(exprs, r.Regex)
|
||||
}
|
||||
}
|
||||
patterns, _ := meta.CompilePatterns(exprs)
|
||||
return patterns
|
||||
}
|
||||
|
||||
func (s *Service) saveOutcome(ctx context.Context, f File, o outcome) error {
|
||||
return s.store.Transition(ctx, f.ID, []Status{StatusProcessing}, o.status, func(tx pgx.Tx) error {
|
||||
var (
|
||||
tags, metaJSON []byte
|
||||
points []int32
|
||||
)
|
||||
if o.tags != nil {
|
||||
tags, _ = json.Marshal(o.tags)
|
||||
}
|
||||
if o.meta != nil {
|
||||
metaJSON, _ = json.Marshal(o.meta)
|
||||
}
|
||||
if o.print != nil {
|
||||
points = make([]int32, len(o.print.Points))
|
||||
for i, p := range o.print.Points {
|
||||
points[i] = int32(p)
|
||||
}
|
||||
}
|
||||
p := o.probe
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE upload_files SET sha256 = coalesce($2, sha256), format = NULLIF($3, ''), codec = NULLIF($4, ''),
|
||||
bitrate = NULLIF($5, 0), sample_rate = NULLIF($6, 0), bit_depth = NULLIF($7, 0), duration_ms = NULLIF($8, 0),
|
||||
tags = $9, metadata = $10, fingerprint = $11, duplicate_of_track_id = $12, duplicate_level = NULLIF($13, ''),
|
||||
possible_duplicate = $14, target_source_id = $15, target_rel_path = $16, status_reason = NULLIF($17, ''),
|
||||
matched_artist_id = $18, matched_album_id = $19
|
||||
WHERE id = $1`,
|
||||
f.ID, o.sha, p.Format, p.Codec, p.Bitrate, p.SampleRate, p.BitDepth, p.DurationMs, tags, metaJSON, points,
|
||||
o.dupTrack, o.dupLevel, o.possible, o.place.SourceID, o.place.RelPath, o.reason, o.place.ArtistID, o.place.AlbumID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// --- Импорт ---
|
||||
|
||||
// importNext импортирует один одобренный файл; false — работы нет.
|
||||
func (s *Service) importNext(ctx context.Context) bool {
|
||||
f, err := s.store.NextWithStatus(ctx, StatusApproved)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
s.log.Error("uploads: next to import", "err", err)
|
||||
return false
|
||||
}
|
||||
if err := s.store.Transition(ctx, f.ID, []Status{StatusApproved}, StatusImporting, nil); err != nil {
|
||||
// Файл успели отклонить — берём следующий; сбой БД — ждём тика.
|
||||
return errors.Is(err, ErrWrongStatus)
|
||||
}
|
||||
if err := s.importFile(ctx, f); err != nil {
|
||||
s.log.Warn("upload import failed", "upload_id", f.ID, "err", err)
|
||||
msg := err.Error()
|
||||
if ferr := s.store.Transition(ctx, f.ID, []Status{StatusImporting}, StatusImportFailed, func(tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx, `UPDATE upload_files SET status_reason = $2 WHERE id = $1`, f.ID, CodeImportFailed+": "+msg)
|
||||
return err
|
||||
}); ferr != nil {
|
||||
s.log.Error("uploads: mark import failed", "upload_id", f.ID, "err", ferr)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// importFile — import_upload_file (docs/uploads.md#9): путь по финальным метаданным → (теги) → запись агентом →
|
||||
// точечный скан → imported. Повторяемо: если файл с тем же хэшем уже лежит по пути — запись пропускается.
|
||||
func (s *Service) importFile(ctx context.Context, f File) error {
|
||||
if f.Meta == nil {
|
||||
return errors.New("no metadata")
|
||||
}
|
||||
pl := s.plan(ctx, f, *f.Meta, f.TargetSourceID)
|
||||
if pl.Reason != "" {
|
||||
return errors.New(pl.Reason)
|
||||
}
|
||||
rel := *pl.RelPath
|
||||
stagingRel, sum := f.StagingPath, f.SHA256
|
||||
if f.MetadataEdited {
|
||||
tagged, tsum, err := s.writeTags(ctx, f, *f.Meta, path.Ext(rel))
|
||||
if err != nil {
|
||||
return fmt.Errorf("write tags: %w", err)
|
||||
}
|
||||
if tagged != "" {
|
||||
stagingRel, sum = tagged, tsum
|
||||
}
|
||||
}
|
||||
|
||||
target := filepath.Join(pl.root, filepath.FromSlash(rel))
|
||||
if have, err := library.FileSHA256(target); err != nil || !bytes.Equal(have, sum) {
|
||||
req := storageagent.WriteRequest{SourceID: pl.src.ID.String(), RelPath: rel, StagingPath: stagingRel, SHA256: hex.EncodeToString(sum)}
|
||||
if pl.src.Type == library.SourceLocal {
|
||||
lp := ""
|
||||
if pl.src.LocalPath != nil {
|
||||
lp = *pl.src.LocalPath
|
||||
}
|
||||
req.LocalPath = &lp
|
||||
}
|
||||
res, err := s.agent.WriteFile(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage-agent: %w", err)
|
||||
}
|
||||
rel = res.RelPath
|
||||
}
|
||||
|
||||
fileID, err := s.scanner.ScanFile(ctx, pl.src, pl.root, rel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("scan: %w", err)
|
||||
}
|
||||
if err := s.store.Transition(ctx, f.ID, []Status{StatusImporting}, StatusImported, func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, `UPDATE upload_files SET target_source_id = $2, target_rel_path = $3 WHERE id = $1`,
|
||||
f.ID, pl.src.ID, rel); err != nil {
|
||||
return err
|
||||
}
|
||||
return MarkImported(ctx, tx, f.ID, fileID)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
s.removeStaging(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
|
||||
}
|
||||
|
||||
// tagFormats — контейнеры, в которые ffmpeg пишет теги без перекодирования.
|
||||
var tagFormats = map[string]bool{".flac": true, ".mp3": true, ".m4a": true, ".ogg": true, ".opus": true}
|
||||
|
||||
// writeTags записывает правленые метаданные в копию файла рядом в staging (`ffmpeg -map 0 -c copy -metadata …`).
|
||||
// "" — формат без поддержки тегов, импортируется исходный файл.
|
||||
func (s *Service) writeTags(ctx context.Context, f File, m Meta, ext string) (string, []byte, error) {
|
||||
if !tagFormats[strings.ToLower(ext)] {
|
||||
return "", nil, nil
|
||||
}
|
||||
rel := f.StagingPath + ".tagged" + strings.ToLower(ext)
|
||||
out := s.stagingAbs(rel)
|
||||
args := []string{"-v", "error", "-y", "-i", s.stagingAbs(f.StagingPath), "-map", "0", "-c", "copy", "-map_metadata", "0"}
|
||||
set := func(k, v string) { args = append(args, "-metadata", k+"="+v) }
|
||||
set("title", m.Title)
|
||||
set("artist", m.Artist)
|
||||
set("album_artist", m.AlbumArtist)
|
||||
set("album", m.Album)
|
||||
set("genre", m.Genre)
|
||||
num := func(p *int) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(*p)
|
||||
}
|
||||
set("date", num(m.Year))
|
||||
set("track", num(m.Track))
|
||||
set("disc", num(m.Disc))
|
||||
args = append(args, "--", out)
|
||||
bin := s.cfg.FFmpeg
|
||||
if bin == "" {
|
||||
bin = "ffmpeg"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
if msg, err := exec.CommandContext(ctx, bin, args...).CombinedOutput(); err != nil {
|
||||
_ = os.Remove(out)
|
||||
return "", nil, fmt.Errorf("ffmpeg: %s", strings.TrimSpace(string(msg)))
|
||||
}
|
||||
sum, err := library.FileSHA256(out)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return rel, sum, nil
|
||||
}
|
||||
|
||||
// --- Очистка ---
|
||||
|
||||
// expire переводит недокачанные за 24 ч загрузки в expired и удаляет их файлы.
|
||||
func (s *Service) expire(ctx context.Context) {
|
||||
ids, err := s.store.Stale(ctx, s.now().Add(-ExpireAfter))
|
||||
if err != nil {
|
||||
s.log.Error("uploads: stale", "err", err)
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
f, err := s.store.File(ctx, id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := s.store.Transition(ctx, id, []Status{StatusUploading}, StatusExpired, nil); err == nil {
|
||||
s.removeStaging(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reconcile при старте: прерванный импорт — снова в approved; осиротевшие файлы staging удаляются.
|
||||
func (s *Service) reconcile(ctx context.Context) {
|
||||
if _, err := s.store.pool.Exec(ctx, `UPDATE upload_files SET status = 'approved', updated_at = now() WHERE status = 'importing'`); err != nil {
|
||||
s.log.Error("uploads: reset importing", "err", err)
|
||||
}
|
||||
inUse, err := s.store.StagingInUse(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("uploads: staging in use", "err", err)
|
||||
return
|
||||
}
|
||||
removed := 0
|
||||
_ = filepath.WalkDir(s.cfg.StagingDir, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(s.cfg.StagingDir, p)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if base, _, ok := strings.Cut(rel, ".tagged"); ok {
|
||||
rel = base
|
||||
}
|
||||
if !inUse[rel] {
|
||||
if os.Remove(p) == nil {
|
||||
removed++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if entries, err := os.ReadDir(s.cfg.StagingDir); err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
_ = os.Remove(filepath.Join(s.cfg.StagingDir, e.Name())) // только пустые
|
||||
}
|
||||
}
|
||||
}
|
||||
if removed > 0 {
|
||||
s.log.Info("uploads: removed orphan staging files", "count", removed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
-- +goose Up
|
||||
-- Очередь на добавление (docs/uploads.md, docs/data-model.md#очередь-на-добавление-загрузки).
|
||||
CREATE TYPE upload_status AS ENUM (
|
||||
'uploading', 'processing', 'pending_review', 'approved', 'importing', 'imported',
|
||||
'import_failed', 'duplicate', 'invalid', 'rejected', 'withdrawn', 'expired');
|
||||
|
||||
CREATE TABLE upload_batches (
|
||||
id uuid PRIMARY KEY,
|
||||
author_id uuid NOT NULL REFERENCES users(id),
|
||||
comment text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE upload_files (
|
||||
id uuid PRIMARY KEY,
|
||||
batch_id uuid NOT NULL REFERENCES upload_batches(id) ON DELETE CASCADE,
|
||||
author_id uuid NOT NULL REFERENCES users(id),
|
||||
status upload_status NOT NULL,
|
||||
original_name text NOT NULL,
|
||||
client_rel_path text,
|
||||
size_bytes bigint NOT NULL,
|
||||
received_bytes bigint NOT NULL DEFAULT 0, -- смещение tus
|
||||
sha256 bytea, -- заявленный клиентом, после обработки — фактический
|
||||
staging_path text, -- относительно каталога staging
|
||||
format text,
|
||||
codec text,
|
||||
bitrate int,
|
||||
sample_rate int,
|
||||
bit_depth int,
|
||||
duration_ms int,
|
||||
tags jsonb,
|
||||
metadata jsonb,
|
||||
metadata_edited boolean NOT NULL DEFAULT false,
|
||||
fingerprint int[],
|
||||
matched_artist_id uuid REFERENCES artists(id) ON DELETE SET NULL,
|
||||
matched_album_id uuid REFERENCES albums(id) ON DELETE SET NULL,
|
||||
duplicate_of_track_id uuid REFERENCES tracks(id) ON DELETE SET NULL,
|
||||
duplicate_level text,
|
||||
possible_duplicate boolean NOT NULL DEFAULT false,
|
||||
target_source_id uuid REFERENCES library_sources(id) ON DELETE SET NULL,
|
||||
target_rel_path text,
|
||||
status_reason text,
|
||||
reviewed_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
reviewed_at timestamptz,
|
||||
imported_file_id uuid REFERENCES media_files(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX upload_files_status_idx ON upload_files (status, updated_at);
|
||||
CREATE INDEX upload_files_author_idx ON upload_files (author_id, created_at DESC, id DESC);
|
||||
CREATE INDEX upload_files_batch_idx ON upload_files (batch_id);
|
||||
CREATE INDEX upload_files_sha256_idx ON upload_files (sha256);
|
||||
|
||||
-- Занятое место очереди (одна строка): меняется в той же транзакции, что и статус.
|
||||
CREATE TABLE upload_quota (
|
||||
id boolean PRIMARY KEY DEFAULT true CHECK (id),
|
||||
used_bytes bigint NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO upload_quota (id, used_bytes) VALUES (true, 0);
|
||||
|
||||
ALTER TABLE media_files ADD FOREIGN KEY (upload_file_id) REFERENCES upload_files(id) ON DELETE SET NULL;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE media_files DROP CONSTRAINT media_files_upload_file_id_fkey;
|
||||
DROP TABLE upload_quota;
|
||||
DROP TABLE upload_files;
|
||||
DROP TABLE upload_batches;
|
||||
DROP TYPE upload_status;
|
||||
Reference in new issue
Block a user