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

- Migration 00008: upload_batches, upload_files, upload_quota.
- Quota reserved under a row lock when a batch is created; every status
  change goes through one transition that frees space in the same
  transaction. SHA-256 duplicates are rejected before any upload.
- tus 1.0.0 core (HEAD/PATCH) for the author only; offset is the real
  staging file size, data beyond the declared size is refused (ADR-015).
- Processing worker: ffprobe checks, SHA-256 verification, exact and
  Chromaprint duplicates against library and queue, metadata from tags
  or the dropped folder path, possible_duplicate on metadata match, and
  the target path from write_template reusing existing folder spelling.
- Import worker: edited tags written with ffmpeg stream copy, file
  written by storage-agent, point scan of the new file, imported status;
  idempotent and resumed after restart. Stale uploads expire after 24 h,
  orphan staging files are removed at startup.
- API: quota, precheck, batches, my uploads, withdraw, admin list,
  metadata edit, bulk review, retry and queue settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 17:23:05 +03:00
1 parent f66be2ba0a
commit ca106e8eb1
21 files changed
+5552 -198

No files matched your search

+590
View File
@@ -359,6 +359,220 @@ paths:
$ref: "#/components/schemas/TemporaryPassword" $ref: "#/components/schemas/TemporaryPassword"
default: default:
$ref: "#/components/responses/Problem" $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: /api/v1/admin/audit:
get: get:
operationId: listAudit operationId: listAudit
@@ -1174,6 +1388,16 @@ components:
description: Непрозрачный курсор из `next_cursor` предыдущей страницы description: Непрозрачный курсор из `next_cursor` предыдущей страницы
schema: schema:
type: string type: string
UploadStatusFilter:
name: status
in: query
description: Статусы через запятую
style: form
explode: false
schema:
type: array
items:
$ref: "#/components/schemas/UploadStatus"
UserId: UserId:
name: id name: id
in: path in: path
@@ -1194,6 +1418,372 @@ components:
schema: schema:
$ref: "#/components/schemas/Problem" $ref: "#/components/schemas/Problem"
schemas: 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: HealthStatus:
type: object type: object
required: [status] required: [status]
+3 -2
View File
@@ -135,8 +135,8 @@
| GET | `/uploads/quota` | moderator, admin | `{limit_bytes, used_bytes, per_user_limit_bytes, user_used_bytes, max_file_bytes}` | | 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/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` для не поместившихся | | 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`) | | tus | `/uploads/files/{upload_file_id}` | автор | Resumable-загрузка (`HEAD`/`PATCH`, ядро tus 1.0.0 — ADR-015) |
| GET | `/uploads/mine?status=&cursor=` | moderator, admin | Мои предложения | | GET | `/uploads/mine?status=&cursor=` | moderator, admin | Мои предложения (`status` — через запятую) |
| DELETE | `/uploads/files/{id}` | автор | Отозвать (`withdrawn`), только до решения | | DELETE | `/uploads/files/{id}` | автор | Отозвать (`withdrawn`), только до решения |
| GET | `/admin/uploads?status=&author=&possible_duplicate=&cursor=` | admin | Очередь, сгруппированная по пачкам | | GET | `/admin/uploads?status=&author=&possible_duplicate=&cursor=` | admin | Очередь, сгруппированная по пачкам |
| GET | `/admin/uploads/files/{id}/stream?sig=` | admin | Прослушивание из staging | | GET | `/admin/uploads/files/{id}/stream?sig=` | admin | Прослушивание из staging |
@@ -144,6 +144,7 @@
| PATCH | `/admin/uploads/batches/{id}` | admin | Правка общих полей для всех файлов пачки (исполнитель, альбом, год, обложка) | | PATCH | `/admin/uploads/batches/{id}` | admin | Правка общих полей для всех файлов пачки (исполнитель, альбом, год, обложка) |
| POST | `/admin/uploads/review` | admin | `{file_ids:[], decision: "approve"|"reject", reason?}` — массовое решение | | POST | `/admin/uploads/review` | admin | `{file_ids:[], decision: "approve"|"reject", reason?}` — массовое решение |
| POST | `/admin/uploads/files/{id}/retry` | admin | Повторить импорт после `import_failed` | | POST | `/admin/uploads/files/{id}/retry` | admin | Повторить импорт после `import_failed` |
| GET · PATCH | `/admin/settings/uploads` | admin | Лимиты очереди и хранилище по умолчанию |
| GET | `/admin/audit?cursor=` | admin | | GET | `/admin/audit?cursor=` | admin |
## Внутренние API ## Внутренние API
+16
View File
@@ -187,3 +187,19 @@ Valence — грубая оценка (лад + яркость); `mood_*`, `arou
**Последствия.** Анализ работает на любом CPU без моделей. Контексты используют энергию, темп, танцевальность **Последствия.** Анализ работает на любом CPU без моделей. Контексты используют энергию, темп, танцевальность
и valence; настроения «весёлое» / «грустное» пока приблизительны. Переход на Essentia — новая версия и valence; настроения «весёлое» / «грустное» пока приблизительны. Переход на Essentia — новая версия
`analyzer_ver`: воркер сам ставит пересчёт всех треков. `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 файла сверяется с заявленным клиентом.
+23
View File
@@ -175,6 +175,29 @@
- Автор видит статусы в «Мои предложения» (раздел админки модератора), бейдж с числом решений с последнего визита. - Автор видит статусы в «Мои предложения» (раздел админки модератора), бейдж с числом решений с последнего визита.
- Админ видит бейдж «N на рассмотрении» в сайдбаре. - Админ видит бейдж «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. Очистка ## 11. Очистка
- `uploading` старше 24 ч → `expired`, файлы удаляются. - `uploading` старше 24 ч → `expired`, файлы удаляются.
- Записи завершённых загрузок (`imported`, `rejected`, …) хранятся 180 дней для истории, без файлов. - Записи завершённых загрузок (`imported`, `rejected`, …) хранятся 180 дней для истории, без файлов.
+650
View File
@@ -305,6 +305,199 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/v1/admin/audit": {
parameters: { parameters: {
query?: never; query?: never;
@@ -969,6 +1162,191 @@ export interface paths {
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { 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: { HealthStatus: {
/** @enum {string} */ /** @enum {string} */
status: "ok"; status: "ok";
@@ -1635,6 +2013,8 @@ export interface components {
Limit: number; Limit: number;
/** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */ /** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */
Cursor: string; Cursor: string;
/** @description Статусы через запятую */
UploadStatusFilter: components["schemas"]["UploadStatus"][];
UserId: string; UserId: string;
}; };
requestBodies: never; requestBodies: never;
@@ -2064,6 +2444,276 @@ export interface operations {
default: components["responses"]["Problem"]; 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: { listAudit: {
parameters: { parameters: {
query?: { query?: {
+17
View File
@@ -76,5 +76,22 @@
}, },
"app": { "app": {
"update_unavailable": "Update is unavailable" "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"
} }
} }
+17
View File
@@ -76,5 +76,22 @@
}, },
"app": { "app": {
"update_unavailable": "Обновление недоступно" "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": "Не удалось записать файл в хранилище"
} }
} }
+10 -1
View File
@@ -30,6 +30,7 @@ import (
"pipu/api/internal/search" "pipu/api/internal/search"
"pipu/api/internal/server" "pipu/api/internal/server"
"pipu/api/internal/system" "pipu/api/internal/system"
"pipu/api/internal/uploads"
"pipu/api/internal/wave" "pipu/api/internal/wave"
) )
@@ -94,7 +95,8 @@ func run() error {
prober := library.FFprobe{Bin: cfg.FFprobePath, Timeout: time.Minute} prober := library.FFprobe{Bin: cfg.FFprobePath, Timeout: time.Minute}
board := library.NewProgressBoard() board := library.NewProgressBoard()
scanner := library.NewScanner(libStore, prober, board, log) 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) prober, library.Config{LibraryRoot: cfg.LibraryRoot, HostRoot: cfg.LibraryHostRoot}, log)
meili := search.NewMeili(cfg.MeiliURL, cfg.MeiliMasterKey) meili := search.NewMeili(cfg.MeiliURL, cfg.MeiliMasterKey)
indexer := search.NewIndexer(pool, meili, log) indexer := search.NewIndexer(pool, meili, log)
@@ -145,6 +147,11 @@ func run() error {
go libSvc.Restore(ctx) go libSvc.Restore(ctx)
go libSvc.RunSchedule(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{ srv := httpx.NewServer(cfg.HTTPAddr, server.NewRouter(server.Deps{
Log: log, Log: log,
System: sys, System: sys,
@@ -157,6 +164,8 @@ func run() error {
Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log), Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log),
App: appupdate.NewHandler(appupdate.Config{GiteaURL: cfg.GiteaURL, Repo: cfg.GiteaRepo, Token: cfg.GiteaToken, App: appupdate.NewHandler(appupdate.Config{GiteaURL: cfg.GiteaURL, Repo: cfg.GiteaRepo, Token: cfg.GiteaToken,
Dir: cfg.UpdatesDir, MinSupported: cfg.MinAndroidVersionCode}, log), 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) log.Info("starting", "version", buildinfo.Version)
return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout) return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
File diff suppressed because it is too large. Load diff
+43
View File
@@ -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()) 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 читает файлы параллельно, а каталог обновляет один писатель — // process читает файлы параллельно, а каталог обновляет один писатель —
// так дубли и новые исполнители не создаются дважды в гонке. // так дубли и новые исполнители не создаются дважды в гонке.
func (sc *Scanner) process(ctx context.Context, src Source, root string, patterns []meta.Pattern, jobs []job) error { func (sc *Scanner) process(ctx context.Context, src Source, root string, patterns []meta.Pattern, jobs []job) error {
+16
View File
@@ -244,6 +244,22 @@ func (st *Store) Files(ctx context.Context, sourceID uuid.UUID) (map[string]File
return out, rows.Err() 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 — случайная выборка путей источника (для предпросмотра шаблонов). // SampleFiles — случайная выборка путей источника (для предпросмотра шаблонов).
func (st *Store) SampleFiles(ctx context.Context, sourceID uuid.UUID, n int) ([]string, error) { func (st *Store) SampleFiles(ctx context.Context, sourceID uuid.UUID, n int) ([]string, error) {
rows, err := st.pool.Query(ctx, ` rows, err := st.pool.Query(ctx, `
@@ -62,6 +62,8 @@ type API struct {
GiteaRepo string `env:"GITEA_REPO"` GiteaRepo string `env:"GITEA_REPO"`
GiteaToken string `env:"GITEA_TOKEN"` GiteaToken string `env:"GITEA_TOKEN"`
UpdatesDir string `env:"UPDATES_DIR" envDefault:"/updates"` UpdatesDir string `env:"UPDATES_DIR" envDefault:"/updates"`
// UploadsDir — staging очереди на добавление (локальный диск, общий со storage-agent на чтение).
UploadsDir string `env:"UPLOADS_DIR" envDefault:"/uploads"`
} }
// StorageAgent — конфигурация внутреннего сервиса storage-agent. // StorageAgent — конфигурация внутреннего сервиса storage-agent.
+10 -1
View File
@@ -16,6 +16,7 @@ import (
"pipu/api/internal/playback" "pipu/api/internal/playback"
"pipu/api/internal/search" "pipu/api/internal/search"
"pipu/api/internal/system" "pipu/api/internal/system"
"pipu/api/internal/uploads"
"pipu/api/internal/wave" "pipu/api/internal/wave"
) )
@@ -32,6 +33,9 @@ type Deps struct {
// Wave — «Моя волна». // Wave — «Моя волна».
Wave *wave.Handler Wave *wave.Handler
App *appupdate.Handler App *appupdate.Handler
// Uploads — очередь на добавление; TUS — приём файлов (вне OpenAPI).
Uploads *uploads.Handler
TUS *uploads.TUS
// AuthService проверяет access-токены в middleware. nil — только для тестов служебных эндпоинтов. // AuthService проверяет access-токены в middleware. nil — только для тестов служебных эндпоинтов.
AuthService *auth.Service AuthService *auth.Service
} }
@@ -47,6 +51,7 @@ type (
playbackHandler = playback.Handler playbackHandler = playback.Handler
waveHandler = wave.Handler waveHandler = wave.Handler
appHandler = appupdate.Handler appHandler = appupdate.Handler
uploadsHandler = uploads.Handler
) )
type handlers struct { type handlers struct {
@@ -57,6 +62,7 @@ type handlers struct {
*playbackHandler *playbackHandler
*waveHandler *waveHandler
*appHandler *appHandler
*uploadsHandler
} }
var _ gen.StrictServerInterface = handlers{} var _ gen.StrictServerInterface = handlers{}
@@ -75,8 +81,11 @@ func NewRouter(d Deps) http.Handler {
} }
r.NotFound(httpx.NotFound) r.NotFound(httpx.NotFound)
r.MethodNotAllowed(httpx.MethodNotAllowed) 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{ []gen.StrictMiddlewareFunc{httpx.StrictRequest}, gen.StrictHTTPServerOptions{
RequestErrorHandlerFunc: httpx.RequestError, RequestErrorHandlerFunc: httpx.RequestError,
ResponseErrorHandlerFunc: httpx.ResponseError(log), ResponseErrorHandlerFunc: httpx.ResponseError(log),
+280
View File
@@ -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 = &params
}
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
}
+174
View File
@@ -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
+664
View File
@@ -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() }
+528
View File
@@ -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
}
+204
View File
@@ -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)
}
+449
View File
@@ -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)
}
}
+68
View File
@@ -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;