diff --git a/api/openapi.yaml b/api/openapi.yaml index 09bd737..abf8747 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -359,6 +359,220 @@ paths: $ref: "#/components/schemas/TemporaryPassword" default: $ref: "#/components/responses/Problem" + /api/v1/uploads/quota: + get: + operationId: getUploadQuota + tags: [uploads] + summary: Квота очереди на добавление (moderator, admin) + responses: + "200": + description: Лимиты и занятое место + content: + application/json: + schema: + $ref: "#/components/schemas/UploadQuota" + default: + $ref: "#/components/responses/Problem" + /api/v1/uploads/precheck: + post: + operationId: precheckUploads + tags: [uploads] + summary: Предпроверка дублей до загрузки (moderator, admin) + description: Ничего не резервирует. SHA-256 и теги клиент считает сам (docs/uploads.md#4). + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UploadPrecheckRequest" + responses: + "200": + description: Итог по каждому файлу + content: + application/json: + schema: + $ref: "#/components/schemas/UploadPrecheckResult" + default: + $ref: "#/components/responses/Problem" + /api/v1/uploads: + post: + operationId: createUploadBatch + tags: [uploads] + summary: Создать пачку и зарезервировать место (moderator, admin) + description: | + Для принятых файлов — `tus_url` (tus 1.0.0, только HEAD / PATCH, куски до 64 МБ). Не поместившиеся — + `uploads.queue_full` / `uploads.user_quota_full`, дубли — `uploads.duplicate`, без места в квоте. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UploadBatchRequest" + responses: + "201": + description: Пачка создана + content: + application/json: + schema: + $ref: "#/components/schemas/UploadBatchCreated" + default: + $ref: "#/components/responses/Problem" + /api/v1/uploads/mine: + get: + operationId: listMyUploads + tags: [uploads] + summary: Мои предложения (moderator, admin) + parameters: + - $ref: "#/components/parameters/UploadStatusFilter" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + responses: + "200": + description: Страница предложений + content: + application/json: + schema: + $ref: "#/components/schemas/UploadFilePage" + default: + $ref: "#/components/responses/Problem" + /api/v1/uploads/files/{id}: + parameters: + - $ref: "#/components/parameters/EntityId" + delete: + operationId: withdrawUpload + tags: [uploads] + summary: Отозвать своё предложение до решения + description: HEAD / PATCH по этому же адресу — приём файла по tus (вне контракта OpenAPI). + responses: + "204": + description: Отозвано, место освобождено + default: + $ref: "#/components/responses/Problem" + /api/v1/admin/uploads: + get: + operationId: listUploads + tags: [uploads] + summary: Очередь на добавление (admin) + parameters: + - $ref: "#/components/parameters/UploadStatusFilter" + - name: author + in: query + schema: + type: string + format: uuid + - name: batch + in: query + schema: + type: string + format: uuid + - name: possible_duplicate + in: query + schema: + type: boolean + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + responses: + "200": + description: Страница очереди + content: + application/json: + schema: + $ref: "#/components/schemas/UploadFilePage" + default: + $ref: "#/components/responses/Problem" + /api/v1/admin/uploads/files/{id}: + parameters: + - $ref: "#/components/parameters/EntityId" + patch: + operationId: updateUpload + tags: [uploads] + summary: Править метаданные и хранилище (admin) + description: Путь в хранилище пересчитывается; правленые теги записываются в файл при импорте. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UploadPatch" + responses: + "200": + description: Файл + content: + application/json: + schema: + $ref: "#/components/schemas/UploadFile" + default: + $ref: "#/components/responses/Problem" + /api/v1/admin/uploads/files/{id}/retry: + parameters: + - $ref: "#/components/parameters/EntityId" + post: + operationId: retryUploadImport + tags: [uploads] + summary: Повторить импорт после import_failed (admin) + responses: + "200": + description: Файл снова одобрен + content: + application/json: + schema: + $ref: "#/components/schemas/UploadFile" + default: + $ref: "#/components/responses/Problem" + /api/v1/admin/uploads/review: + post: + operationId: reviewUploads + tags: [uploads] + summary: Одобрить / отклонить файлы (admin) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UploadReviewRequest" + responses: + "200": + description: Итог по каждому файлу + content: + application/json: + schema: + $ref: "#/components/schemas/UploadReviewResult" + default: + $ref: "#/components/responses/Problem" + /api/v1/admin/settings/uploads: + get: + operationId: getUploadSettings + tags: [uploads] + summary: Настройки очереди (admin) + responses: + "200": + description: Настройки + content: + application/json: + schema: + $ref: "#/components/schemas/UploadSettings" + default: + $ref: "#/components/responses/Problem" + patch: + operationId: updateUploadSettings + tags: [uploads] + summary: Изменить настройки очереди (admin) + description: Уменьшение лимита ниже занятого ничего не удаляет — новые загрузки ждут, пока очередь освободится. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UploadSettingsPatch" + responses: + "200": + description: Настройки + content: + application/json: + schema: + $ref: "#/components/schemas/UploadSettings" + default: + $ref: "#/components/responses/Problem" /api/v1/admin/audit: get: operationId: listAudit @@ -1174,6 +1388,16 @@ components: description: Непрозрачный курсор из `next_cursor` предыдущей страницы schema: type: string + UploadStatusFilter: + name: status + in: query + description: Статусы через запятую + style: form + explode: false + schema: + type: array + items: + $ref: "#/components/schemas/UploadStatus" UserId: name: id in: path @@ -1194,6 +1418,372 @@ components: schema: $ref: "#/components/schemas/Problem" schemas: + UserRef: + type: object + required: [id, username] + properties: + id: + type: string + format: uuid + username: + type: string + UploadStatus: + type: string + enum: [uploading, processing, pending_review, approved, importing, imported, import_failed, duplicate, invalid, rejected, withdrawn, expired] + UploadQuota: + type: object + required: [limit_bytes, used_bytes, per_user_limit_bytes, user_used_bytes, max_file_bytes] + properties: + limit_bytes: + type: integer + format: int64 + used_bytes: + type: integer + format: int64 + per_user_limit_bytes: + type: integer + format: int64 + nullable: true + user_used_bytes: + type: integer + format: int64 + max_file_bytes: + type: integer + format: int64 + UploadPrecheckRequest: + type: object + required: [files] + properties: + files: + type: array + maxItems: 500 + items: + type: object + required: [client_id, size, sha256] + properties: + client_id: + type: string + maxLength: 100 + size: + type: integer + format: int64 + sha256: + type: string + artist: + type: string + title: + type: string + duration_ms: + type: integer + UploadPrecheckResult: + type: object + required: [files] + properties: + files: + type: array + items: + type: object + required: [client_id, result, track_id, possible_duplicate] + properties: + client_id: + type: string + result: + type: string + enum: [accepted, duplicate, too_large] + track_id: + type: string + format: uuid + nullable: true + description: С каким треком совпал (дубль или похожие метаданные) + possible_duplicate: + type: boolean + description: Совпали только метаданные — файл примут, админ решит + UploadBatchRequest: + type: object + required: [files] + properties: + comment: + type: string + maxLength: 1000 + files: + type: array + minItems: 1 + maxItems: 500 + items: + type: object + required: [client_id, name, size, sha256] + properties: + client_id: + type: string + maxLength: 100 + name: + type: string + maxLength: 255 + rel_path: + type: string + maxLength: 1000 + description: Относительный путь в перетащенной папке + size: + type: integer + format: int64 + sha256: + type: string + UploadBatchCreated: + type: object + required: [batch_id, files] + properties: + batch_id: + type: string + format: uuid + files: + type: array + items: + type: object + required: [client_id, upload_file_id, tus_url, rejected, track_id] + properties: + client_id: + type: string + upload_file_id: + type: string + format: uuid + nullable: true + tus_url: + type: string + nullable: true + rejected: + type: string + nullable: true + description: Код отказа — uploads.queue_full, uploads.user_quota_full, uploads.too_large, uploads.duplicate, uploads.unsupported_format + track_id: + type: string + format: uuid + nullable: true + UploadMeta: + type: object + required: [artist, albumartist, album, title, genre, year, track, disc] + properties: + artist: + type: string + maxLength: 500 + albumartist: + type: string + maxLength: 500 + album: + type: string + maxLength: 500 + title: + type: string + maxLength: 500 + genre: + type: string + maxLength: 100 + year: + type: integer + nullable: true + track: + type: integer + nullable: true + disc: + type: integer + nullable: true + UploadFile: + type: object + required: [id, batch_id, batch_comment, author, status, original_name, client_rel_path, size_bytes, received_bytes, format, codec, bitrate, sample_rate, bit_depth, duration_ms, tags, metadata, metadata_edited, matched_artist_id, matched_album_id, duplicate_of_track_id, duplicate_level, possible_duplicate, target_source_id, target_rel_path, status_reason, reviewed_at, created_at, updated_at, tus_url] + properties: + id: + type: string + format: uuid + batch_id: + type: string + format: uuid + batch_comment: + type: string + nullable: true + author: + $ref: "#/components/schemas/UserRef" + status: + $ref: "#/components/schemas/UploadStatus" + original_name: + type: string + client_rel_path: + type: string + nullable: true + size_bytes: + type: integer + format: int64 + received_bytes: + type: integer + format: int64 + format: + type: string + nullable: true + codec: + type: string + nullable: true + bitrate: + type: integer + nullable: true + sample_rate: + type: integer + nullable: true + bit_depth: + type: integer + nullable: true + duration_ms: + type: integer + nullable: true + tags: + type: object + additionalProperties: + type: string + metadata: + nullable: true + allOf: + - $ref: "#/components/schemas/UploadMeta" + metadata_edited: + type: boolean + matched_artist_id: + type: string + format: uuid + nullable: true + description: Найден в каталоге — файл добавится к существующему исполнителю + matched_album_id: + type: string + format: uuid + nullable: true + description: Найден в каталоге — «будет добавлено к существующему альбому» + duplicate_of_track_id: + type: string + format: uuid + nullable: true + duplicate_level: + type: string + nullable: true + enum: [sha256, metadata, fingerprint, null] + possible_duplicate: + type: boolean + target_source_id: + type: string + format: uuid + nullable: true + target_rel_path: + type: string + nullable: true + description: Путь в хранилище по write_template (null — не построился, причина в status_reason) + status_reason: + type: string + nullable: true + reviewed_at: + type: string + format: date-time + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + tus_url: + type: string + nullable: true + description: Только автору и пока файл в статусе uploading + UploadFilePage: + type: object + required: [items, next_cursor] + properties: + items: + type: array + items: + $ref: "#/components/schemas/UploadFile" + next_cursor: + type: string + nullable: true + UploadPatch: + type: object + properties: + metadata: + $ref: "#/components/schemas/UploadMeta" + target_source_id: + type: string + format: uuid + UploadReviewRequest: + type: object + required: [file_ids, decision] + properties: + file_ids: + type: array + minItems: 1 + maxItems: 500 + items: + type: string + format: uuid + decision: + type: string + enum: [approve, reject] + reason: + type: string + maxLength: 1000 + description: Причина отклонения — её видит автор + UploadReviewResult: + type: object + required: [items] + properties: + items: + type: array + items: + type: object + required: [id, ok, error] + properties: + id: + type: string + format: uuid + ok: + type: boolean + error: + nullable: true + allOf: + - $ref: "#/components/schemas/Problem" + UploadSettings: + type: object + required: [queue_limit_bytes, per_user_limit_bytes, max_file_bytes, default_source_id] + properties: + queue_limit_bytes: + type: integer + format: int64 + per_user_limit_bytes: + type: integer + format: int64 + nullable: true + max_file_bytes: + type: integer + format: int64 + default_source_id: + type: string + format: uuid + nullable: true + UploadSettingsPatch: + type: object + description: Лимит на пользователя и хранилище по умолчанию передаются всегда (null — выключить / не задано). + required: [per_user_limit_bytes, default_source_id] + properties: + queue_limit_bytes: + type: integer + format: int64 + minimum: 0 + per_user_limit_bytes: + type: integer + format: int64 + nullable: true + description: null — выключить лимит на пользователя + max_file_bytes: + type: integer + format: int64 + minimum: 1 + default_source_id: + type: string + format: uuid + nullable: true + description: null — без хранилища по умолчанию HealthStatus: type: object required: [status] diff --git a/docs/api.md b/docs/api.md index 8adad70..2a7bf91 100644 --- a/docs/api.md +++ b/docs/api.md @@ -135,8 +135,8 @@ | GET | `/uploads/quota` | moderator, admin | `{limit_bytes, used_bytes, per_user_limit_bytes, user_used_bytes, max_file_bytes}` | | POST | `/uploads/precheck` | moderator, admin | `{files:[{client_id, size, sha256, tags:{artist,title,album,duration_ms}}]}` → для каждого `accepted` / `duplicate` (+ `track`) / `too_large`; ничего не резервирует | | POST | `/uploads` | moderator, admin | Создать пачку `{comment, files:[{client_id, name, rel_path, size, sha256}]}` → резерв квоты; `{batch_id, files:[{client_id, upload_file_id, tus_url} | {client_id, rejected: code}]}`; при нехватке места — `uploads.queue_full` для не поместившихся | -| tus | `/uploads/files/{upload_file_id}` | автор | Resumable-загрузка (`HEAD`/`PATCH`) | -| GET | `/uploads/mine?status=&cursor=` | moderator, admin | Мои предложения | +| tus | `/uploads/files/{upload_file_id}` | автор | Resumable-загрузка (`HEAD`/`PATCH`, ядро tus 1.0.0 — ADR-015) | +| GET | `/uploads/mine?status=&cursor=` | moderator, admin | Мои предложения (`status` — через запятую) | | DELETE | `/uploads/files/{id}` | автор | Отозвать (`withdrawn`), только до решения | | GET | `/admin/uploads?status=&author=&possible_duplicate=&cursor=` | admin | Очередь, сгруппированная по пачкам | | GET | `/admin/uploads/files/{id}/stream?sig=` | admin | Прослушивание из staging | @@ -144,6 +144,7 @@ | PATCH | `/admin/uploads/batches/{id}` | admin | Правка общих полей для всех файлов пачки (исполнитель, альбом, год, обложка) | | POST | `/admin/uploads/review` | admin | `{file_ids:[], decision: "approve"|"reject", reason?}` — массовое решение | | POST | `/admin/uploads/files/{id}/retry` | admin | Повторить импорт после `import_failed` | +| GET · PATCH | `/admin/settings/uploads` | admin | Лимиты очереди и хранилище по умолчанию | | GET | `/admin/audit?cursor=` | admin | ## Внутренние API diff --git a/docs/decisions.md b/docs/decisions.md index c829b2d..e81ae6b 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -187,3 +187,19 @@ Valence — грубая оценка (лад + яркость); `mood_*`, `arou **Последствия.** Анализ работает на любом CPU без моделей. Контексты используют энергию, темп, танцевальность и valence; настроения «весёлое» / «грустное» пока приблизительны. Переход на Essentia — новая версия `analyzer_ver`: воркер сам ставит пересчёт всех треков. + +--- + +## ADR-015. Приём загрузок — ядро tus своими силами, без tusd + +**Контекст.** ADR-012 предполагал `tusd` как библиотеку. Но файл очереди создаёт `POST /uploads` вместе с резервом +квоты и проверкой дублей, а права (только автор, только `uploading`) проверяет `api`. От tus остаётся только ядро +протокола: `HEAD` (сколько принято) и `PATCH` (следующий кусок с `Upload-Offset`). + +**Решение.** Ядро tus 1.0.0 — в `internal/uploads/tus.go` (~200 строк): смещение — фактический размер файла в staging, +один `PATCH` на файл одновременно, данные сверх заявленного размера не принимаются, куски до 64 МБ. Расширения +creation / termination / checksum не нужны. Клиент — `tus-js-client` с готовым `uploadUrl` из ответа `POST /uploads`. + +**Последствия.** Нет новой зависимости и отдельного хранилища состояния tus; квота и права — в одной транзакции +с созданием файла. Целостность проверяется после приёма: SHA-256 файла сверяется с заявленным клиентом. + diff --git a/docs/uploads.md b/docs/uploads.md index 7b406fc..42d54e5 100644 --- a/docs/uploads.md +++ b/docs/uploads.md @@ -175,6 +175,29 @@ - Автор видит статусы в «Мои предложения» (раздел админки модератора), бейдж с числом решений с последнего визита. - Админ видит бейдж «N на рассмотрении» в сайдбаре. +## Реализация (`services/api/internal/uploads`) + +- Таблицы — миграция `00008_uploads.sql`; смещение tus — `upload_files.received_bytes`, отпечаток Chromaprint файла + очереди — `upload_files.fingerprint` (дубли внутри очереди ловятся так же, как с библиотекой). +- Квота: `Store.CreateBatch` резервирует под `SELECT … FOR UPDATE` строки `upload_quota`; любая смена статуса идёт через + `transition` — место освобождается в той же транзакции, если новый статус его не занимает (таблица раздела 2). +- Приём — ядро tus (ADR-015): `HEAD` / `PATCH /api/v1/uploads/files/{id}`, только автор и только в `uploading`. + Принят целиком → `processing`. +- Обработка (`worker.go`, по одному файлу): размер → `ffprobe` (5 с – 3 ч) → SHA-256 (сверка с заявленным) → + дубль по SHA-256 → Chromaprint против библиотеки и очереди (длительность ±5 с, `library.Similarity` ≥ 0.75) → + метаданные (теги → путь из папки по регэкспам целевого хранилища → имя файла) → совпадение метаданных + (`possible_duplicate`) → путь по `write_template` с переиспользованием существующих папок → `pending_review`. + Если путь не строится (нет хранилища / шаблона / поля), файл всё равно ждёт рассмотрения, причина — в `status_reason`. +- Хранилище: выбранное админом → `default_source_id` из настроек → единственное с `accepts_uploads`. +- Импорт: правленые метаданные → копия с тегами (`ffmpeg -map 0 -c copy -metadata …`, FLAC/MP3/M4A/Ogg/Opus) → + `storage-agent` `POST /internal/files` → `Scanner.ScanFile` (точечный скан) → `imported`, связь `media_files.upload_file_id`. + Повторяемо: если по пути уже лежит файл с тем же хэшем, запись пропускается. Прерванный импорт при старте + возвращается в `approved`. +- Очистка: раз в 10 минут `uploading` старше 24 ч → `expired`; при старте — удаление файлов staging без записи в БД. +- Настройки: `GET` / `PATCH /admin/settings/uploads` (ключ `uploads.settings` в `app_settings`). +- Пока не сделано: `allow_quality_upgrade`, правка «на всю пачку» (`PATCH /admin/uploads/batches/{id}`), + прослушивание из staging, отдельная обложка пачки, ночная сверка счётчика квоты, экраны веба. + ## 11. Очистка - `uploading` старше 24 ч → `expired`, файлы удаляются. - Записи завершённых загрузок (`imported`, `rejected`, …) хранятся 180 дней для истории, без файлов. diff --git a/packages/api-client/src/generated/schema.ts b/packages/api-client/src/generated/schema.ts index ffe2f46..7221bc5 100644 --- a/packages/api-client/src/generated/schema.ts +++ b/packages/api-client/src/generated/schema.ts @@ -305,6 +305,199 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/uploads/quota": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Квота очереди на добавление (moderator, admin) */ + get: operations["getUploadQuota"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/uploads/precheck": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Предпроверка дублей до загрузки (moderator, admin) + * @description Ничего не резервирует. SHA-256 и теги клиент считает сам (docs/uploads.md#4). + */ + post: operations["precheckUploads"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/uploads": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Создать пачку и зарезервировать место (moderator, admin) + * @description Для принятых файлов — `tus_url` (tus 1.0.0, только HEAD / PATCH, куски до 64 МБ). Не поместившиеся — + * `uploads.queue_full` / `uploads.user_quota_full`, дубли — `uploads.duplicate`, без места в квоте. + */ + post: operations["createUploadBatch"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/uploads/mine": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Мои предложения (moderator, admin) */ + get: operations["listMyUploads"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/uploads/files/{id}": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["EntityId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Отозвать своё предложение до решения + * @description HEAD / PATCH по этому же адресу — приём файла по tus (вне контракта OpenAPI). + */ + delete: operations["withdrawUpload"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/uploads": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Очередь на добавление (admin) */ + get: operations["listUploads"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/uploads/files/{id}": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["EntityId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Править метаданные и хранилище (admin) + * @description Путь в хранилище пересчитывается; правленые теги записываются в файл при импорте. + */ + patch: operations["updateUpload"]; + trace?: never; + }; + "/api/v1/admin/uploads/files/{id}/retry": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["EntityId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** Повторить импорт после import_failed (admin) */ + post: operations["retryUploadImport"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/uploads/review": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Одобрить / отклонить файлы (admin) */ + post: operations["reviewUploads"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/settings/uploads": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Настройки очереди (admin) */ + get: operations["getUploadSettings"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Изменить настройки очереди (admin) + * @description Уменьшение лимита ниже занятого ничего не удаляет — новые загрузки ждут, пока очередь освободится. + */ + patch: operations["updateUploadSettings"]; + trace?: never; + }; "/api/v1/admin/audit": { parameters: { query?: never; @@ -969,6 +1162,191 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + UserRef: { + /** Format: uuid */ + id: string; + username: string; + }; + /** @enum {string} */ + UploadStatus: "uploading" | "processing" | "pending_review" | "approved" | "importing" | "imported" | "import_failed" | "duplicate" | "invalid" | "rejected" | "withdrawn" | "expired"; + UploadQuota: { + /** Format: int64 */ + limit_bytes: number; + /** Format: int64 */ + used_bytes: number; + /** Format: int64 */ + per_user_limit_bytes: number | null; + /** Format: int64 */ + user_used_bytes: number; + /** Format: int64 */ + max_file_bytes: number; + }; + UploadPrecheckRequest: { + files: { + client_id: string; + /** Format: int64 */ + size: number; + sha256: string; + artist?: string; + title?: string; + duration_ms?: number; + }[]; + }; + UploadPrecheckResult: { + files: { + client_id: string; + /** @enum {string} */ + result: "accepted" | "duplicate" | "too_large"; + /** + * Format: uuid + * @description С каким треком совпал (дубль или похожие метаданные) + */ + track_id: string | null; + /** @description Совпали только метаданные — файл примут, админ решит */ + possible_duplicate: boolean; + }[]; + }; + UploadBatchRequest: { + comment?: string; + files: { + client_id: string; + name: string; + /** @description Относительный путь в перетащенной папке */ + rel_path?: string; + /** Format: int64 */ + size: number; + sha256: string; + }[]; + }; + UploadBatchCreated: { + /** Format: uuid */ + batch_id: string; + files: { + client_id: string; + /** Format: uuid */ + upload_file_id: string | null; + tus_url: string | null; + /** @description Код отказа — uploads.queue_full, uploads.user_quota_full, uploads.too_large, uploads.duplicate, uploads.unsupported_format */ + rejected: string | null; + /** Format: uuid */ + track_id: string | null; + }[]; + }; + UploadMeta: { + artist: string; + albumartist: string; + album: string; + title: string; + genre: string; + year: number | null; + track: number | null; + disc: number | null; + }; + UploadFile: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + batch_id: string; + batch_comment: string | null; + author: components["schemas"]["UserRef"]; + status: components["schemas"]["UploadStatus"]; + original_name: string; + client_rel_path: string | null; + /** Format: int64 */ + size_bytes: number; + /** Format: int64 */ + received_bytes: number; + format: string | null; + codec: string | null; + bitrate: number | null; + sample_rate: number | null; + bit_depth: number | null; + duration_ms: number | null; + tags: { + [key: string]: string; + }; + metadata: components["schemas"]["UploadMeta"] | null; + metadata_edited: boolean; + /** + * Format: uuid + * @description Найден в каталоге — файл добавится к существующему исполнителю + */ + matched_artist_id: string | null; + /** + * Format: uuid + * @description Найден в каталоге — «будет добавлено к существующему альбому» + */ + matched_album_id: string | null; + /** Format: uuid */ + duplicate_of_track_id: string | null; + /** @enum {string|null} */ + duplicate_level: "sha256" | "metadata" | "fingerprint" | null; + possible_duplicate: boolean; + /** Format: uuid */ + target_source_id: string | null; + /** @description Путь в хранилище по write_template (null — не построился, причина в status_reason) */ + target_rel_path: string | null; + status_reason: string | null; + /** Format: date-time */ + reviewed_at: string | null; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + /** @description Только автору и пока файл в статусе uploading */ + tus_url: string | null; + }; + UploadFilePage: { + items: components["schemas"]["UploadFile"][]; + next_cursor: string | null; + }; + UploadPatch: { + metadata?: components["schemas"]["UploadMeta"]; + /** Format: uuid */ + target_source_id?: string; + }; + UploadReviewRequest: { + file_ids: string[]; + /** @enum {string} */ + decision: "approve" | "reject"; + /** @description Причина отклонения — её видит автор */ + reason?: string; + }; + UploadReviewResult: { + items: { + /** Format: uuid */ + id: string; + ok: boolean; + error: components["schemas"]["Problem"] | null; + }[]; + }; + UploadSettings: { + /** Format: int64 */ + queue_limit_bytes: number; + /** Format: int64 */ + per_user_limit_bytes: number | null; + /** Format: int64 */ + max_file_bytes: number; + /** Format: uuid */ + default_source_id: string | null; + }; + /** @description Лимит на пользователя и хранилище по умолчанию передаются всегда (null — выключить / не задано). */ + UploadSettingsPatch: { + /** Format: int64 */ + queue_limit_bytes?: number; + /** + * Format: int64 + * @description null — выключить лимит на пользователя + */ + per_user_limit_bytes: number | null; + /** Format: int64 */ + max_file_bytes?: number; + /** + * Format: uuid + * @description null — без хранилища по умолчанию + */ + default_source_id: string | null; + }; HealthStatus: { /** @enum {string} */ status: "ok"; @@ -1635,6 +2013,8 @@ export interface components { Limit: number; /** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */ Cursor: string; + /** @description Статусы через запятую */ + UploadStatusFilter: components["schemas"]["UploadStatus"][]; UserId: string; }; requestBodies: never; @@ -2064,6 +2444,276 @@ export interface operations { default: components["responses"]["Problem"]; }; }; + getUploadQuota: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Лимиты и занятое место */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UploadQuota"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + precheckUploads: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UploadPrecheckRequest"]; + }; + }; + responses: { + /** @description Итог по каждому файлу */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UploadPrecheckResult"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + createUploadBatch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UploadBatchRequest"]; + }; + }; + responses: { + /** @description Пачка создана */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UploadBatchCreated"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + listMyUploads: { + parameters: { + query?: { + /** @description Статусы через запятую */ + status?: components["parameters"]["UploadStatusFilter"]; + limit?: components["parameters"]["Limit"]; + /** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */ + cursor?: components["parameters"]["Cursor"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Страница предложений */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UploadFilePage"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + withdrawUpload: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["EntityId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Отозвано, место освобождено */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; + listUploads: { + parameters: { + query?: { + /** @description Статусы через запятую */ + status?: components["parameters"]["UploadStatusFilter"]; + author?: string; + batch?: string; + possible_duplicate?: boolean; + limit?: components["parameters"]["Limit"]; + /** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */ + cursor?: components["parameters"]["Cursor"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Страница очереди */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UploadFilePage"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + updateUpload: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["EntityId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UploadPatch"]; + }; + }; + responses: { + /** @description Файл */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UploadFile"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + retryUploadImport: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["EntityId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Файл снова одобрен */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UploadFile"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + reviewUploads: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UploadReviewRequest"]; + }; + }; + responses: { + /** @description Итог по каждому файлу */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UploadReviewResult"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + getUploadSettings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Настройки */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UploadSettings"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + updateUploadSettings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UploadSettingsPatch"]; + }; + }; + responses: { + /** @description Настройки */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UploadSettings"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; listAudit: { parameters: { query?: { diff --git a/packages/i18n/locales/en/errors.json b/packages/i18n/locales/en/errors.json index 507e331..61577c9 100644 --- a/packages/i18n/locales/en/errors.json +++ b/packages/i18n/locales/en/errors.json @@ -76,5 +76,22 @@ }, "app": { "update_unavailable": "Update is unavailable" + }, + "uploads": { + "queue_full": "The upload queue is full", + "user_quota_full": "Your upload queue limit is exceeded", + "too_large": "The file is larger than allowed", + "duplicate": "This track is already in the library or in the queue", + "invalid_audio": "The file does not look like audio (one audio stream, 5 s to 3 h)", + "unsupported_format": "Unsupported format: FLAC, MP3, M4A, OGG, Opus, WAV, AIFF and WavPack are accepted", + "not_found": "Upload not found", + "wrong_status": "The file is already in another status; refresh the page", + "no_target": "No storage accepts uploads (or several do and none is the default)", + "no_template": "The storage has no path template", + "missing_field": "Not enough metadata to build the storage path", + "offset_mismatch": "The upload got out of sync; try again", + "size_mismatch": "The file size does not match the declared one", + "hash_mismatch": "The file was corrupted in transit (SHA-256 mismatch)", + "import_failed": "Could not write the file to the storage" } } diff --git a/packages/i18n/locales/ru/errors.json b/packages/i18n/locales/ru/errors.json index 59cf035..9bffa4b 100644 --- a/packages/i18n/locales/ru/errors.json +++ b/packages/i18n/locales/ru/errors.json @@ -76,5 +76,22 @@ }, "app": { "update_unavailable": "Обновление недоступно" + }, + "uploads": { + "queue_full": "В очереди на добавление нет места", + "user_quota_full": "Превышен ваш лимит в очереди на добавление", + "too_large": "Файл больше допустимого размера", + "duplicate": "Такой трек уже есть в библиотеке или в очереди", + "invalid_audio": "Файл не похож на аудио (нужен один аудиопоток длительностью от 5 с до 3 ч)", + "unsupported_format": "Формат не поддерживается: подойдут FLAC, MP3, M4A, OGG, Opus, WAV, AIFF, WavPack", + "not_found": "Загрузка не найдена", + "wrong_status": "Файл уже в другом статусе — обновите страницу", + "no_target": "Нет хранилища, принимающего загрузки (или их несколько и не выбрано по умолчанию)", + "no_template": "У хранилища не задан шаблон пути", + "missing_field": "Не хватает метаданных для пути в хранилище", + "offset_mismatch": "Загрузка рассинхронизировалась — повторите", + "size_mismatch": "Размер файла не совпал с заявленным", + "hash_mismatch": "Файл повредился при загрузке (не совпал SHA-256)", + "import_failed": "Не удалось записать файл в хранилище" } } diff --git a/services/api/cmd/api/main.go b/services/api/cmd/api/main.go index 2929c9c..2c98532 100644 --- a/services/api/cmd/api/main.go +++ b/services/api/cmd/api/main.go @@ -30,6 +30,7 @@ import ( "pipu/api/internal/search" "pipu/api/internal/server" "pipu/api/internal/system" + "pipu/api/internal/uploads" "pipu/api/internal/wave" ) @@ -94,7 +95,8 @@ func run() error { prober := library.FFprobe{Bin: cfg.FFprobePath, Timeout: time.Minute} board := library.NewProgressBoard() scanner := library.NewScanner(libStore, prober, board, log) - libSvc := library.NewService(libStore, scanner, board, library.NewHTTPAgent(cfg.StorageAgentURL, cfg.InternalToken), + agent := library.NewHTTPAgent(cfg.StorageAgentURL, cfg.InternalToken) + libSvc := library.NewService(libStore, scanner, board, agent, prober, library.Config{LibraryRoot: cfg.LibraryRoot, HostRoot: cfg.LibraryHostRoot}, log) meili := search.NewMeili(cfg.MeiliURL, cfg.MeiliMasterKey) indexer := search.NewIndexer(pool, meili, log) @@ -145,6 +147,11 @@ func run() error { go libSvc.Restore(ctx) go libSvc.RunSchedule(ctx) + upSvc := uploads.NewService(uploads.NewStore(pool), libStore, libSvc, scanner, agent, prober, + library.FPCalc{Bin: cfg.FPCalcPath, Timeout: time.Minute}, + uploads.Config{StagingDir: cfg.UploadsDir, FFmpeg: cfg.FFmpegPath}, log) + upSvc.Run(ctx) + srv := httpx.NewServer(cfg.HTTPAddr, server.NewRouter(server.Deps{ Log: log, System: sys, @@ -157,6 +164,8 @@ func run() error { Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log), App: appupdate.NewHandler(appupdate.Config{GiteaURL: cfg.GiteaURL, Repo: cfg.GiteaRepo, Token: cfg.GiteaToken, Dir: cfg.UpdatesDir, MinSupported: cfg.MinAndroidVersionCode}, log), + Uploads: uploads.NewHandler(upSvc), + TUS: uploads.NewTUS(uploads.NewStore(pool), cfg.UploadsDir, upSvc.Complete, log), })) log.Info("starting", "version", buildinfo.Version) return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout) diff --git a/services/api/internal/api/gen/api.gen.go b/services/api/internal/api/gen/api.gen.go index 2d5b1e8..078bc80 100644 --- a/services/api/internal/api/gen/api.gen.go +++ b/services/api/internal/api/gen/api.gen.go @@ -156,6 +156,43 @@ const ( Island UiPrefsWebPlayer = "island" ) +// Defines values for UploadFileDuplicateLevel. +const ( + Fingerprint UploadFileDuplicateLevel = "fingerprint" + LessThannil UploadFileDuplicateLevel = "" + Metadata UploadFileDuplicateLevel = "metadata" + Sha256 UploadFileDuplicateLevel = "sha256" +) + +// Defines values for UploadPrecheckResultFilesResult. +const ( + UploadPrecheckResultFilesResultAccepted UploadPrecheckResultFilesResult = "accepted" + UploadPrecheckResultFilesResultDuplicate UploadPrecheckResultFilesResult = "duplicate" + UploadPrecheckResultFilesResultTooLarge UploadPrecheckResultFilesResult = "too_large" +) + +// Defines values for UploadReviewRequestDecision. +const ( + Approve UploadReviewRequestDecision = "approve" + Reject UploadReviewRequestDecision = "reject" +) + +// Defines values for UploadStatus. +const ( + UploadStatusApproved UploadStatus = "approved" + UploadStatusDuplicate UploadStatus = "duplicate" + UploadStatusExpired UploadStatus = "expired" + UploadStatusImportFailed UploadStatus = "import_failed" + UploadStatusImported UploadStatus = "imported" + UploadStatusImporting UploadStatus = "importing" + UploadStatusInvalid UploadStatus = "invalid" + UploadStatusPendingReview UploadStatus = "pending_review" + UploadStatusProcessing UploadStatus = "processing" + UploadStatusRejected UploadStatus = "rejected" + UploadStatusUploading UploadStatus = "uploading" + UploadStatusWithdrawn UploadStatus = "withdrawn" +) + // Defines values for WaveFeedbackOutcome. const ( Complete WaveFeedbackOutcome = "complete" @@ -879,6 +916,190 @@ type UpdateUserRequest struct { Role *Role `json:"role,omitempty"` } +// UploadBatchCreated defines model for UploadBatchCreated. +type UploadBatchCreated struct { + BatchId openapi_types.UUID `json:"batch_id"` + Files []struct { + ClientId string `json:"client_id"` + + // Rejected Код отказа — uploads.queue_full, uploads.user_quota_full, uploads.too_large, uploads.duplicate, uploads.unsupported_format + Rejected *string `json:"rejected"` + TrackId *openapi_types.UUID `json:"track_id"` + TusUrl *string `json:"tus_url"` + UploadFileId *openapi_types.UUID `json:"upload_file_id"` + } `json:"files"` +} + +// UploadBatchRequest defines model for UploadBatchRequest. +type UploadBatchRequest struct { + Comment *string `json:"comment,omitempty"` + Files []struct { + ClientId string `json:"client_id"` + Name string `json:"name"` + + // RelPath Относительный путь в перетащенной папке + RelPath *string `json:"rel_path,omitempty"` + Sha256 string `json:"sha256"` + Size int64 `json:"size"` + } `json:"files"` +} + +// UploadFile defines model for UploadFile. +type UploadFile struct { + Author UserRef `json:"author"` + BatchComment *string `json:"batch_comment"` + BatchId openapi_types.UUID `json:"batch_id"` + BitDepth *int `json:"bit_depth"` + Bitrate *int `json:"bitrate"` + ClientRelPath *string `json:"client_rel_path"` + Codec *string `json:"codec"` + CreatedAt time.Time `json:"created_at"` + DuplicateLevel *UploadFileDuplicateLevel `json:"duplicate_level"` + DuplicateOfTrackId *openapi_types.UUID `json:"duplicate_of_track_id"` + DurationMs *int `json:"duration_ms"` + Format *string `json:"format"` + Id openapi_types.UUID `json:"id"` + + // MatchedAlbumId Найден в каталоге — «будет добавлено к существующему альбому» + MatchedAlbumId *openapi_types.UUID `json:"matched_album_id"` + + // MatchedArtistId Найден в каталоге — файл добавится к существующему исполнителю + MatchedArtistId *openapi_types.UUID `json:"matched_artist_id"` + Metadata *UploadMeta `json:"metadata"` + MetadataEdited bool `json:"metadata_edited"` + OriginalName string `json:"original_name"` + PossibleDuplicate bool `json:"possible_duplicate"` + ReceivedBytes int64 `json:"received_bytes"` + ReviewedAt *time.Time `json:"reviewed_at"` + SampleRate *int `json:"sample_rate"` + SizeBytes int64 `json:"size_bytes"` + Status UploadStatus `json:"status"` + StatusReason *string `json:"status_reason"` + Tags map[string]string `json:"tags"` + + // TargetRelPath Путь в хранилище по write_template (null — не построился, причина в status_reason) + TargetRelPath *string `json:"target_rel_path"` + TargetSourceId *openapi_types.UUID `json:"target_source_id"` + + // TusUrl Только автору и пока файл в статусе uploading + TusUrl *string `json:"tus_url"` + UpdatedAt time.Time `json:"updated_at"` +} + +// UploadFileDuplicateLevel defines model for UploadFile.DuplicateLevel. +type UploadFileDuplicateLevel string + +// UploadFilePage defines model for UploadFilePage. +type UploadFilePage struct { + Items []UploadFile `json:"items"` + NextCursor *string `json:"next_cursor"` +} + +// UploadMeta defines model for UploadMeta. +type UploadMeta struct { + Album string `json:"album"` + Albumartist string `json:"albumartist"` + Artist string `json:"artist"` + Disc *int `json:"disc"` + Genre string `json:"genre"` + Title string `json:"title"` + Track *int `json:"track"` + Year *int `json:"year"` +} + +// UploadPatch defines model for UploadPatch. +type UploadPatch struct { + Metadata *UploadMeta `json:"metadata,omitempty"` + TargetSourceId *openapi_types.UUID `json:"target_source_id,omitempty"` +} + +// UploadPrecheckRequest defines model for UploadPrecheckRequest. +type UploadPrecheckRequest struct { + Files []struct { + Artist *string `json:"artist,omitempty"` + ClientId string `json:"client_id"` + DurationMs *int `json:"duration_ms,omitempty"` + Sha256 string `json:"sha256"` + Size int64 `json:"size"` + Title *string `json:"title,omitempty"` + } `json:"files"` +} + +// UploadPrecheckResult defines model for UploadPrecheckResult. +type UploadPrecheckResult struct { + Files []struct { + ClientId string `json:"client_id"` + + // PossibleDuplicate Совпали только метаданные — файл примут, админ решит + PossibleDuplicate bool `json:"possible_duplicate"` + Result UploadPrecheckResultFilesResult `json:"result"` + + // TrackId С каким треком совпал (дубль или похожие метаданные) + TrackId *openapi_types.UUID `json:"track_id"` + } `json:"files"` +} + +// UploadPrecheckResultFilesResult defines model for UploadPrecheckResult.Files.Result. +type UploadPrecheckResultFilesResult string + +// UploadQuota defines model for UploadQuota. +type UploadQuota struct { + LimitBytes int64 `json:"limit_bytes"` + MaxFileBytes int64 `json:"max_file_bytes"` + PerUserLimitBytes *int64 `json:"per_user_limit_bytes"` + UsedBytes int64 `json:"used_bytes"` + UserUsedBytes int64 `json:"user_used_bytes"` +} + +// UploadReviewRequest defines model for UploadReviewRequest. +type UploadReviewRequest struct { + Decision UploadReviewRequestDecision `json:"decision"` + FileIds []openapi_types.UUID `json:"file_ids"` + + // Reason Причина отклонения — её видит автор + Reason *string `json:"reason,omitempty"` +} + +// UploadReviewRequestDecision defines model for UploadReviewRequest.Decision. +type UploadReviewRequestDecision string + +// UploadReviewResult defines model for UploadReviewResult. +type UploadReviewResult struct { + Items []struct { + Error *Problem `json:"error"` + Id openapi_types.UUID `json:"id"` + Ok bool `json:"ok"` + } `json:"items"` +} + +// UploadSettings defines model for UploadSettings. +type UploadSettings struct { + DefaultSourceId *openapi_types.UUID `json:"default_source_id"` + MaxFileBytes int64 `json:"max_file_bytes"` + PerUserLimitBytes *int64 `json:"per_user_limit_bytes"` + QueueLimitBytes int64 `json:"queue_limit_bytes"` +} + +// UploadSettingsPatch Лимит на пользователя и хранилище по умолчанию передаются всегда (null — выключить / не задано). +type UploadSettingsPatch struct { + // DefaultSourceId null — без хранилища по умолчанию + DefaultSourceId *openapi_types.UUID `json:"default_source_id"` + MaxFileBytes *int64 `json:"max_file_bytes,omitempty"` + + // PerUserLimitBytes null — выключить лимит на пользователя + PerUserLimitBytes *int64 `json:"per_user_limit_bytes"` + QueueLimitBytes *int64 `json:"queue_limit_bytes,omitempty"` +} + +// UploadStatus defines model for UploadStatus. +type UploadStatus string + +// UserRef defines model for UserRef. +type UserRef struct { + Id openapi_types.UUID `json:"id"` + Username string `json:"username"` +} + // WaveBatch defines model for WaveBatch. type WaveBatch struct { Activity *string `json:"activity"` @@ -994,6 +1215,9 @@ type Limit = int // SourceId defines model for SourceId. type SourceId = openapi_types.UUID +// UploadStatusFilter defines model for UploadStatusFilter. +type UploadStatusFilter = []UploadStatus + // UserId defines model for UserId. type UserId = openapi_types.UUID @@ -1019,6 +1243,19 @@ type ListHostDirsParams struct { Path *string `form:"path,omitempty" json:"path,omitempty"` } +// ListUploadsParams defines parameters for ListUploads. +type ListUploadsParams struct { + // Status Статусы через запятую + Status *UploadStatusFilter `form:"status,omitempty" json:"status,omitempty"` + Author *openapi_types.UUID `form:"author,omitempty" json:"author,omitempty"` + Batch *openapi_types.UUID `form:"batch,omitempty" json:"batch,omitempty"` + PossibleDuplicate *bool `form:"possible_duplicate,omitempty" json:"possible_duplicate,omitempty"` + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + + // Cursor Непрозрачный курсор из `next_cursor` предыдущей страницы + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` +} + // ListUsersParams defines parameters for ListUsers. type ListUsersParams struct { Q *string `form:"q,omitempty" json:"q,omitempty"` @@ -1094,6 +1331,19 @@ type StreamTrackParams struct { Q *StreamQuality `form:"q,omitempty" json:"q,omitempty"` } +// ListMyUploadsParams defines parameters for ListMyUploads. +type ListMyUploadsParams struct { + // Status Статусы через запятую + Status *UploadStatusFilter `form:"status,omitempty" json:"status,omitempty"` + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + + // Cursor Непрозрачный курсор из `next_cursor` предыдущей страницы + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` +} + +// UpdateUploadSettingsJSONRequestBody defines body for UpdateUploadSettings for application/json ContentType. +type UpdateUploadSettingsJSONRequestBody = UploadSettingsPatch + // CreateSourceJSONRequestBody defines body for CreateSource for application/json ContentType. type CreateSourceJSONRequestBody = SourceInput @@ -1112,6 +1362,12 @@ type ScanSourceJSONRequestBody = ScanRequest // CheckWriteTemplateJSONRequestBody defines body for CheckWriteTemplate for application/json ContentType. type CheckWriteTemplateJSONRequestBody = WriteTemplateCheckRequest +// UpdateUploadJSONRequestBody defines body for UpdateUpload for application/json ContentType. +type UpdateUploadJSONRequestBody = UploadPatch + +// ReviewUploadsJSONRequestBody defines body for ReviewUploads for application/json ContentType. +type ReviewUploadsJSONRequestBody = UploadReviewRequest + // CreateUserJSONRequestBody defines body for CreateUser for application/json ContentType. type CreateUserJSONRequestBody = CreateUserRequest @@ -1145,6 +1401,12 @@ type SetTrackReactionJSONRequestBody = ReactionInput // GetTracksBatchJSONRequestBody defines body for GetTracksBatch for application/json ContentType. type GetTracksBatchJSONRequestBody = TrackIds +// CreateUploadBatchJSONRequestBody defines body for CreateUploadBatch for application/json ContentType. +type CreateUploadBatchJSONRequestBody = UploadBatchRequest + +// PrecheckUploadsJSONRequestBody defines body for PrecheckUploads for application/json ContentType. +type PrecheckUploadsJSONRequestBody = UploadPrecheckRequest + // StartWaveJSONRequestBody defines body for StartWave for application/json ContentType. type StartWaveJSONRequestBody = WaveStart @@ -1171,6 +1433,12 @@ type ServerInterface interface { // Пересобрать поисковые индексы (admin) // (POST /api/v1/admin/search/reindex) ReindexSearch(w http.ResponseWriter, r *http.Request) + // Настройки очереди (admin) + // (GET /api/v1/admin/settings/uploads) + GetUploadSettings(w http.ResponseWriter, r *http.Request) + // Изменить настройки очереди (admin) + // (PATCH /api/v1/admin/settings/uploads) + UpdateUploadSettings(w http.ResponseWriter, r *http.Request) // Хранилища (admin) // (GET /api/v1/admin/sources) ListSources(w http.ResponseWriter, r *http.Request) @@ -1207,6 +1475,18 @@ type ServerInterface interface { // Подобрать шаблон пути по существующей структуре (admin) // (POST /api/v1/admin/sources/{id}/write-template/suggest) SuggestWriteTemplate(w http.ResponseWriter, r *http.Request, id SourceId) + // Очередь на добавление (admin) + // (GET /api/v1/admin/uploads) + ListUploads(w http.ResponseWriter, r *http.Request, params ListUploadsParams) + // Править метаданные и хранилище (admin) + // (PATCH /api/v1/admin/uploads/files/{id}) + UpdateUpload(w http.ResponseWriter, r *http.Request, id EntityId) + // Повторить импорт после import_failed (admin) + // (POST /api/v1/admin/uploads/files/{id}/retry) + RetryUploadImport(w http.ResponseWriter, r *http.Request, id EntityId) + // Одобрить / отклонить файлы (admin) + // (POST /api/v1/admin/uploads/review) + ReviewUploads(w http.ResponseWriter, r *http.Request) // Пользователи (admin, moderator) // (GET /api/v1/admin/users) ListUsers(w http.ResponseWriter, r *http.Request, params ListUsersParams) @@ -1315,6 +1595,21 @@ type ServerInterface interface { // Трек // (GET /api/v1/tracks/{id}) GetTrack(w http.ResponseWriter, r *http.Request, id EntityId) + // Создать пачку и зарезервировать место (moderator, admin) + // (POST /api/v1/uploads) + CreateUploadBatch(w http.ResponseWriter, r *http.Request) + // Отозвать своё предложение до решения + // (DELETE /api/v1/uploads/files/{id}) + WithdrawUpload(w http.ResponseWriter, r *http.Request, id EntityId) + // Мои предложения (moderator, admin) + // (GET /api/v1/uploads/mine) + ListMyUploads(w http.ResponseWriter, r *http.Request, params ListMyUploadsParams) + // Предпроверка дублей до загрузки (moderator, admin) + // (POST /api/v1/uploads/precheck) + PrecheckUploads(w http.ResponseWriter, r *http.Request) + // Квота очереди на добавление (moderator, admin) + // (GET /api/v1/uploads/quota) + GetUploadQuota(w http.ResponseWriter, r *http.Request) // Занятия и настроения (ключи для i18n и иконки) // (GET /api/v1/wave/options) GetWaveOptions(w http.ResponseWriter, r *http.Request) @@ -1372,6 +1667,18 @@ func (_ Unimplemented) ReindexSearch(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } +// Настройки очереди (admin) +// (GET /api/v1/admin/settings/uploads) +func (_ Unimplemented) GetUploadSettings(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Изменить настройки очереди (admin) +// (PATCH /api/v1/admin/settings/uploads) +func (_ Unimplemented) UpdateUploadSettings(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // Хранилища (admin) // (GET /api/v1/admin/sources) func (_ Unimplemented) ListSources(w http.ResponseWriter, r *http.Request) { @@ -1444,6 +1751,30 @@ func (_ Unimplemented) SuggestWriteTemplate(w http.ResponseWriter, r *http.Reque w.WriteHeader(http.StatusNotImplemented) } +// Очередь на добавление (admin) +// (GET /api/v1/admin/uploads) +func (_ Unimplemented) ListUploads(w http.ResponseWriter, r *http.Request, params ListUploadsParams) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Править метаданные и хранилище (admin) +// (PATCH /api/v1/admin/uploads/files/{id}) +func (_ Unimplemented) UpdateUpload(w http.ResponseWriter, r *http.Request, id EntityId) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Повторить импорт после import_failed (admin) +// (POST /api/v1/admin/uploads/files/{id}/retry) +func (_ Unimplemented) RetryUploadImport(w http.ResponseWriter, r *http.Request, id EntityId) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Одобрить / отклонить файлы (admin) +// (POST /api/v1/admin/uploads/review) +func (_ Unimplemented) ReviewUploads(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // Пользователи (admin, moderator) // (GET /api/v1/admin/users) func (_ Unimplemented) ListUsers(w http.ResponseWriter, r *http.Request, params ListUsersParams) { @@ -1660,6 +1991,36 @@ func (_ Unimplemented) GetTrack(w http.ResponseWriter, r *http.Request, id Entit w.WriteHeader(http.StatusNotImplemented) } +// Создать пачку и зарезервировать место (moderator, admin) +// (POST /api/v1/uploads) +func (_ Unimplemented) CreateUploadBatch(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Отозвать своё предложение до решения +// (DELETE /api/v1/uploads/files/{id}) +func (_ Unimplemented) WithdrawUpload(w http.ResponseWriter, r *http.Request, id EntityId) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Мои предложения (moderator, admin) +// (GET /api/v1/uploads/mine) +func (_ Unimplemented) ListMyUploads(w http.ResponseWriter, r *http.Request, params ListMyUploadsParams) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Предпроверка дублей до загрузки (moderator, admin) +// (POST /api/v1/uploads/precheck) +func (_ Unimplemented) PrecheckUploads(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Квота очереди на добавление (moderator, admin) +// (GET /api/v1/uploads/quota) +func (_ Unimplemented) GetUploadQuota(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // Занятия и настроения (ключи для i18n и иконки) // (GET /api/v1/wave/options) func (_ Unimplemented) GetWaveOptions(w http.ResponseWriter, r *http.Request) { @@ -1866,6 +2227,46 @@ func (siw *ServerInterfaceWrapper) ReindexSearch(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } +// GetUploadSettings operation middleware +func (siw *ServerInterfaceWrapper) GetUploadSettings(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetUploadSettings(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UpdateUploadSettings operation middleware +func (siw *ServerInterfaceWrapper) UpdateUploadSettings(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UpdateUploadSettings(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ListSources operation middleware func (siw *ServerInterfaceWrapper) ListSources(w http.ResponseWriter, r *http.Request) { @@ -2216,6 +2617,161 @@ func (siw *ServerInterfaceWrapper) SuggestWriteTemplate(w http.ResponseWriter, r handler.ServeHTTP(w, r) } +// ListUploads operation middleware +func (siw *ServerInterfaceWrapper) ListUploads(w http.ResponseWriter, r *http.Request) { + + var err error + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + // Parameter object where we will unmarshal all parameters from the context + var params ListUploadsParams + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameter("form", false, false, "status", r.URL.Query(), ¶ms.Status) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "status", Err: err}) + return + } + + // ------------- Optional query parameter "author" ------------- + + err = runtime.BindQueryParameter("form", true, false, "author", r.URL.Query(), ¶ms.Author) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "author", Err: err}) + return + } + + // ------------- Optional query parameter "batch" ------------- + + err = runtime.BindQueryParameter("form", true, false, "batch", r.URL.Query(), ¶ms.Batch) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "batch", Err: err}) + return + } + + // ------------- Optional query parameter "possible_duplicate" ------------- + + err = runtime.BindQueryParameter("form", true, false, "possible_duplicate", r.URL.Query(), ¶ms.PossibleDuplicate) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "possible_duplicate", Err: err}) + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", r.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + return + } + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameter("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListUploads(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UpdateUpload operation middleware +func (siw *ServerInterfaceWrapper) UpdateUpload(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id EntityId + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UpdateUpload(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RetryUploadImport operation middleware +func (siw *ServerInterfaceWrapper) RetryUploadImport(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id EntityId + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RetryUploadImport(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ReviewUploads operation middleware +func (siw *ServerInterfaceWrapper) ReviewUploads(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ReviewUploads(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ListUsers operation middleware func (siw *ServerInterfaceWrapper) ListUsers(w http.ResponseWriter, r *http.Request) { @@ -3330,6 +3886,146 @@ func (siw *ServerInterfaceWrapper) GetTrack(w http.ResponseWriter, r *http.Reque handler.ServeHTTP(w, r) } +// CreateUploadBatch operation middleware +func (siw *ServerInterfaceWrapper) CreateUploadBatch(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.CreateUploadBatch(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// WithdrawUpload operation middleware +func (siw *ServerInterfaceWrapper) WithdrawUpload(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id EntityId + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.WithdrawUpload(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ListMyUploads operation middleware +func (siw *ServerInterfaceWrapper) ListMyUploads(w http.ResponseWriter, r *http.Request) { + + var err error + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + // Parameter object where we will unmarshal all parameters from the context + var params ListMyUploadsParams + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameter("form", false, false, "status", r.URL.Query(), ¶ms.Status) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "status", Err: err}) + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", r.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + return + } + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameter("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListMyUploads(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// PrecheckUploads operation middleware +func (siw *ServerInterfaceWrapper) PrecheckUploads(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PrecheckUploads(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetUploadQuota operation middleware +func (siw *ServerInterfaceWrapper) GetUploadQuota(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetUploadQuota(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetWaveOptions operation middleware func (siw *ServerInterfaceWrapper) GetWaveOptions(w http.ResponseWriter, r *http.Request) { @@ -3619,6 +4315,12 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/api/v1/admin/search/reindex", wrapper.ReindexSearch) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/admin/settings/uploads", wrapper.GetUploadSettings) + }) + r.Group(func(r chi.Router) { + r.Patch(options.BaseURL+"/api/v1/admin/settings/uploads", wrapper.UpdateUploadSettings) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/admin/sources", wrapper.ListSources) }) @@ -3655,6 +4357,18 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/api/v1/admin/sources/{id}/write-template/suggest", wrapper.SuggestWriteTemplate) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/admin/uploads", wrapper.ListUploads) + }) + r.Group(func(r chi.Router) { + r.Patch(options.BaseURL+"/api/v1/admin/uploads/files/{id}", wrapper.UpdateUpload) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/admin/uploads/files/{id}/retry", wrapper.RetryUploadImport) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/admin/uploads/review", wrapper.ReviewUploads) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/admin/users", wrapper.ListUsers) }) @@ -3763,6 +4477,21 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/tracks/{id}", wrapper.GetTrack) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/uploads", wrapper.CreateUploadBatch) + }) + r.Group(func(r chi.Router) { + r.Delete(options.BaseURL+"/api/v1/uploads/files/{id}", wrapper.WithdrawUpload) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/uploads/mine", wrapper.ListMyUploads) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/uploads/precheck", wrapper.PrecheckUploads) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/uploads/quota", wrapper.GetUploadQuota) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/wave/options", wrapper.GetWaveOptions) }) @@ -3932,6 +4661,63 @@ func (response ReindexSearchdefaultApplicationProblemPlusJSONResponse) VisitRein return json.NewEncoder(w).Encode(response.Body) } +type GetUploadSettingsRequestObject struct { +} + +type GetUploadSettingsResponseObject interface { + VisitGetUploadSettingsResponse(w http.ResponseWriter) error +} + +type GetUploadSettings200JSONResponse UploadSettings + +func (response GetUploadSettings200JSONResponse) VisitGetUploadSettingsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUploadSettingsdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response GetUploadSettingsdefaultApplicationProblemPlusJSONResponse) VisitGetUploadSettingsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type UpdateUploadSettingsRequestObject struct { + Body *UpdateUploadSettingsJSONRequestBody +} + +type UpdateUploadSettingsResponseObject interface { + VisitUpdateUploadSettingsResponse(w http.ResponseWriter) error +} + +type UpdateUploadSettings200JSONResponse UploadSettings + +func (response UpdateUploadSettings200JSONResponse) VisitUpdateUploadSettingsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateUploadSettingsdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response UpdateUploadSettingsdefaultApplicationProblemPlusJSONResponse) VisitUpdateUploadSettingsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + type ListSourcesRequestObject struct { } @@ -4283,6 +5069,123 @@ func (response SuggestWriteTemplatedefaultApplicationProblemPlusJSONResponse) Vi return json.NewEncoder(w).Encode(response.Body) } +type ListUploadsRequestObject struct { + Params ListUploadsParams +} + +type ListUploadsResponseObject interface { + VisitListUploadsResponse(w http.ResponseWriter) error +} + +type ListUploads200JSONResponse UploadFilePage + +func (response ListUploads200JSONResponse) VisitListUploadsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ListUploadsdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response ListUploadsdefaultApplicationProblemPlusJSONResponse) VisitListUploadsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type UpdateUploadRequestObject struct { + Id EntityId `json:"id"` + Body *UpdateUploadJSONRequestBody +} + +type UpdateUploadResponseObject interface { + VisitUpdateUploadResponse(w http.ResponseWriter) error +} + +type UpdateUpload200JSONResponse UploadFile + +func (response UpdateUpload200JSONResponse) VisitUpdateUploadResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateUploaddefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response UpdateUploaddefaultApplicationProblemPlusJSONResponse) VisitUpdateUploadResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type RetryUploadImportRequestObject struct { + Id EntityId `json:"id"` +} + +type RetryUploadImportResponseObject interface { + VisitRetryUploadImportResponse(w http.ResponseWriter) error +} + +type RetryUploadImport200JSONResponse UploadFile + +func (response RetryUploadImport200JSONResponse) VisitRetryUploadImportResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type RetryUploadImportdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response RetryUploadImportdefaultApplicationProblemPlusJSONResponse) VisitRetryUploadImportResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type ReviewUploadsRequestObject struct { + Body *ReviewUploadsJSONRequestBody +} + +type ReviewUploadsResponseObject interface { + VisitReviewUploadsResponse(w http.ResponseWriter) error +} + +type ReviewUploads200JSONResponse UploadReviewResult + +func (response ReviewUploads200JSONResponse) VisitReviewUploadsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ReviewUploadsdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response ReviewUploadsdefaultApplicationProblemPlusJSONResponse) VisitReviewUploadsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + type ListUsersRequestObject struct { Params ListUsersParams } @@ -5348,6 +6251,149 @@ func (response GetTrackdefaultApplicationProblemPlusJSONResponse) VisitGetTrackR return json.NewEncoder(w).Encode(response.Body) } +type CreateUploadBatchRequestObject struct { + Body *CreateUploadBatchJSONRequestBody +} + +type CreateUploadBatchResponseObject interface { + VisitCreateUploadBatchResponse(w http.ResponseWriter) error +} + +type CreateUploadBatch201JSONResponse UploadBatchCreated + +func (response CreateUploadBatch201JSONResponse) VisitCreateUploadBatchResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + + return json.NewEncoder(w).Encode(response) +} + +type CreateUploadBatchdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response CreateUploadBatchdefaultApplicationProblemPlusJSONResponse) VisitCreateUploadBatchResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type WithdrawUploadRequestObject struct { + Id EntityId `json:"id"` +} + +type WithdrawUploadResponseObject interface { + VisitWithdrawUploadResponse(w http.ResponseWriter) error +} + +type WithdrawUpload204Response struct { +} + +func (response WithdrawUpload204Response) VisitWithdrawUploadResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type WithdrawUploaddefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response WithdrawUploaddefaultApplicationProblemPlusJSONResponse) VisitWithdrawUploadResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type ListMyUploadsRequestObject struct { + Params ListMyUploadsParams +} + +type ListMyUploadsResponseObject interface { + VisitListMyUploadsResponse(w http.ResponseWriter) error +} + +type ListMyUploads200JSONResponse UploadFilePage + +func (response ListMyUploads200JSONResponse) VisitListMyUploadsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ListMyUploadsdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response ListMyUploadsdefaultApplicationProblemPlusJSONResponse) VisitListMyUploadsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type PrecheckUploadsRequestObject struct { + Body *PrecheckUploadsJSONRequestBody +} + +type PrecheckUploadsResponseObject interface { + VisitPrecheckUploadsResponse(w http.ResponseWriter) error +} + +type PrecheckUploads200JSONResponse UploadPrecheckResult + +func (response PrecheckUploads200JSONResponse) VisitPrecheckUploadsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PrecheckUploadsdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response PrecheckUploadsdefaultApplicationProblemPlusJSONResponse) VisitPrecheckUploadsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type GetUploadQuotaRequestObject struct { +} + +type GetUploadQuotaResponseObject interface { + VisitGetUploadQuotaResponse(w http.ResponseWriter) error +} + +type GetUploadQuota200JSONResponse UploadQuota + +func (response GetUploadQuota200JSONResponse) VisitGetUploadQuotaResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUploadQuotadefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response GetUploadQuotadefaultApplicationProblemPlusJSONResponse) VisitGetUploadQuotaResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + type GetWaveOptionsRequestObject struct { } @@ -5551,6 +6597,12 @@ type StrictServerInterface interface { // Пересобрать поисковые индексы (admin) // (POST /api/v1/admin/search/reindex) ReindexSearch(ctx context.Context, request ReindexSearchRequestObject) (ReindexSearchResponseObject, error) + // Настройки очереди (admin) + // (GET /api/v1/admin/settings/uploads) + GetUploadSettings(ctx context.Context, request GetUploadSettingsRequestObject) (GetUploadSettingsResponseObject, error) + // Изменить настройки очереди (admin) + // (PATCH /api/v1/admin/settings/uploads) + UpdateUploadSettings(ctx context.Context, request UpdateUploadSettingsRequestObject) (UpdateUploadSettingsResponseObject, error) // Хранилища (admin) // (GET /api/v1/admin/sources) ListSources(ctx context.Context, request ListSourcesRequestObject) (ListSourcesResponseObject, error) @@ -5587,6 +6639,18 @@ type StrictServerInterface interface { // Подобрать шаблон пути по существующей структуре (admin) // (POST /api/v1/admin/sources/{id}/write-template/suggest) SuggestWriteTemplate(ctx context.Context, request SuggestWriteTemplateRequestObject) (SuggestWriteTemplateResponseObject, error) + // Очередь на добавление (admin) + // (GET /api/v1/admin/uploads) + ListUploads(ctx context.Context, request ListUploadsRequestObject) (ListUploadsResponseObject, error) + // Править метаданные и хранилище (admin) + // (PATCH /api/v1/admin/uploads/files/{id}) + UpdateUpload(ctx context.Context, request UpdateUploadRequestObject) (UpdateUploadResponseObject, error) + // Повторить импорт после import_failed (admin) + // (POST /api/v1/admin/uploads/files/{id}/retry) + RetryUploadImport(ctx context.Context, request RetryUploadImportRequestObject) (RetryUploadImportResponseObject, error) + // Одобрить / отклонить файлы (admin) + // (POST /api/v1/admin/uploads/review) + ReviewUploads(ctx context.Context, request ReviewUploadsRequestObject) (ReviewUploadsResponseObject, error) // Пользователи (admin, moderator) // (GET /api/v1/admin/users) ListUsers(ctx context.Context, request ListUsersRequestObject) (ListUsersResponseObject, error) @@ -5695,6 +6759,21 @@ type StrictServerInterface interface { // Трек // (GET /api/v1/tracks/{id}) GetTrack(ctx context.Context, request GetTrackRequestObject) (GetTrackResponseObject, error) + // Создать пачку и зарезервировать место (moderator, admin) + // (POST /api/v1/uploads) + CreateUploadBatch(ctx context.Context, request CreateUploadBatchRequestObject) (CreateUploadBatchResponseObject, error) + // Отозвать своё предложение до решения + // (DELETE /api/v1/uploads/files/{id}) + WithdrawUpload(ctx context.Context, request WithdrawUploadRequestObject) (WithdrawUploadResponseObject, error) + // Мои предложения (moderator, admin) + // (GET /api/v1/uploads/mine) + ListMyUploads(ctx context.Context, request ListMyUploadsRequestObject) (ListMyUploadsResponseObject, error) + // Предпроверка дублей до загрузки (moderator, admin) + // (POST /api/v1/uploads/precheck) + PrecheckUploads(ctx context.Context, request PrecheckUploadsRequestObject) (PrecheckUploadsResponseObject, error) + // Квота очереди на добавление (moderator, admin) + // (GET /api/v1/uploads/quota) + GetUploadQuota(ctx context.Context, request GetUploadQuotaRequestObject) (GetUploadQuotaResponseObject, error) // Занятия и настроения (ключи для i18n и иконки) // (GET /api/v1/wave/options) GetWaveOptions(ctx context.Context, request GetWaveOptionsRequestObject) (GetWaveOptionsResponseObject, error) @@ -5873,6 +6952,61 @@ func (sh *strictHandler) ReindexSearch(w http.ResponseWriter, r *http.Request) { } } +// GetUploadSettings operation middleware +func (sh *strictHandler) GetUploadSettings(w http.ResponseWriter, r *http.Request) { + var request GetUploadSettingsRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetUploadSettings(ctx, request.(GetUploadSettingsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUploadSettings") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetUploadSettingsResponseObject); ok { + if err := validResponse.VisitGetUploadSettingsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// UpdateUploadSettings operation middleware +func (sh *strictHandler) UpdateUploadSettings(w http.ResponseWriter, r *http.Request) { + var request UpdateUploadSettingsRequestObject + + var body UpdateUploadSettingsJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.UpdateUploadSettings(ctx, request.(UpdateUploadSettingsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateUploadSettings") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(UpdateUploadSettingsResponseObject); ok { + if err := validResponse.VisitUpdateUploadSettingsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // ListSources operation middleware func (sh *strictHandler) ListSources(w http.ResponseWriter, r *http.Request) { var request ListSourcesRequestObject @@ -6223,6 +7357,122 @@ func (sh *strictHandler) SuggestWriteTemplate(w http.ResponseWriter, r *http.Req } } +// ListUploads operation middleware +func (sh *strictHandler) ListUploads(w http.ResponseWriter, r *http.Request, params ListUploadsParams) { + var request ListUploadsRequestObject + + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ListUploads(ctx, request.(ListUploadsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ListUploads") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ListUploadsResponseObject); ok { + if err := validResponse.VisitListUploadsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// UpdateUpload operation middleware +func (sh *strictHandler) UpdateUpload(w http.ResponseWriter, r *http.Request, id EntityId) { + var request UpdateUploadRequestObject + + request.Id = id + + var body UpdateUploadJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.UpdateUpload(ctx, request.(UpdateUploadRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateUpload") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(UpdateUploadResponseObject); ok { + if err := validResponse.VisitUpdateUploadResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// RetryUploadImport operation middleware +func (sh *strictHandler) RetryUploadImport(w http.ResponseWriter, r *http.Request, id EntityId) { + var request RetryUploadImportRequestObject + + request.Id = id + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.RetryUploadImport(ctx, request.(RetryUploadImportRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "RetryUploadImport") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(RetryUploadImportResponseObject); ok { + if err := validResponse.VisitRetryUploadImportResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// ReviewUploads operation middleware +func (sh *strictHandler) ReviewUploads(w http.ResponseWriter, r *http.Request) { + var request ReviewUploadsRequestObject + + var body ReviewUploadsJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ReviewUploads(ctx, request.(ReviewUploadsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ReviewUploads") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ReviewUploadsResponseObject); ok { + if err := validResponse.VisitReviewUploadsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // ListUsers operation middleware func (sh *strictHandler) ListUsers(w http.ResponseWriter, r *http.Request, params ListUsersParams) { var request ListUsersRequestObject @@ -7206,6 +8456,144 @@ func (sh *strictHandler) GetTrack(w http.ResponseWriter, r *http.Request, id Ent } } +// CreateUploadBatch operation middleware +func (sh *strictHandler) CreateUploadBatch(w http.ResponseWriter, r *http.Request) { + var request CreateUploadBatchRequestObject + + var body CreateUploadBatchJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.CreateUploadBatch(ctx, request.(CreateUploadBatchRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "CreateUploadBatch") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(CreateUploadBatchResponseObject); ok { + if err := validResponse.VisitCreateUploadBatchResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// WithdrawUpload operation middleware +func (sh *strictHandler) WithdrawUpload(w http.ResponseWriter, r *http.Request, id EntityId) { + var request WithdrawUploadRequestObject + + request.Id = id + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.WithdrawUpload(ctx, request.(WithdrawUploadRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "WithdrawUpload") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(WithdrawUploadResponseObject); ok { + if err := validResponse.VisitWithdrawUploadResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// ListMyUploads operation middleware +func (sh *strictHandler) ListMyUploads(w http.ResponseWriter, r *http.Request, params ListMyUploadsParams) { + var request ListMyUploadsRequestObject + + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ListMyUploads(ctx, request.(ListMyUploadsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ListMyUploads") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ListMyUploadsResponseObject); ok { + if err := validResponse.VisitListMyUploadsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PrecheckUploads operation middleware +func (sh *strictHandler) PrecheckUploads(w http.ResponseWriter, r *http.Request) { + var request PrecheckUploadsRequestObject + + var body PrecheckUploadsJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.PrecheckUploads(ctx, request.(PrecheckUploadsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PrecheckUploads") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(PrecheckUploadsResponseObject); ok { + if err := validResponse.VisitPrecheckUploadsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetUploadQuota operation middleware +func (sh *strictHandler) GetUploadQuota(w http.ResponseWriter, r *http.Request) { + var request GetUploadQuotaRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetUploadQuota(ctx, request.(GetUploadQuotaRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUploadQuota") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetUploadQuotaResponseObject); ok { + if err := validResponse.VisitGetUploadQuotaResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetWaveOptions operation middleware func (sh *strictHandler) GetWaveOptions(w http.ResponseWriter, r *http.Request) { var request GetWaveOptionsRequestObject @@ -7404,200 +8792,239 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/services/api/internal/library/scanner.go b/services/api/internal/library/scanner.go index b0193e3..3d02c0b 100644 --- a/services/api/internal/library/scanner.go +++ b/services/api/internal/library/scanner.go @@ -271,6 +271,49 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool) return sc.store.SetLastScan(ctx, src.ID, sc.now()) } +// ScanFile — точечный скан одного файла (scan_path): после импорта из очереди трек сразу появляется в каталоге. +// Возвращает id записи media_files. Хуки AfterScan (обложки, поиск, отпечатки) вызывает сам. +func (sc *Scanner) ScanFile(ctx context.Context, src Source, root, rel string) (uuid.UUID, error) { + rows, err := sc.store.Patterns(ctx, src.ID) + if err != nil { + return uuid.Nil, err + } + var exprs []string + for _, r := range rows { + if r.IsEnabled { + exprs = append(exprs, r.Regex) + } + } + patterns, err := meta.CompilePatterns(exprs) + if err != nil { + return uuid.Nil, fmt.Errorf("library: patterns: %w", err) + } + info, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel))) + if err != nil { + return uuid.Nil, fmt.Errorf("library: scan file: %w", err) + } + j := job{walked: walked{rel: rel, size: info.Size(), mtime: info.ModTime().UTC().Truncate(time.Microsecond)}} + if f, ok, err := sc.store.FileByPath(ctx, src.ID, rel); err != nil { + return uuid.Nil, err + } else if ok { + j.fileID, j.oldTrack = f.ID, f.TrackID + } + if err := sc.process(ctx, src, root, patterns, []job{j}); err != nil { + return uuid.Nil, err + } + f, ok, err := sc.store.FileByPath(ctx, src.ID, rel) + if err != nil { + return uuid.Nil, err + } + if !ok { + return uuid.Nil, fmt.Errorf("library: scanned file %s not saved", rel) + } + if sc.AfterScan != nil { + go sc.AfterScan(context.WithoutCancel(ctx)) + } + return f.ID, nil +} + // process читает файлы параллельно, а каталог обновляет один писатель — // так дубли и новые исполнители не создаются дважды в гонке. func (sc *Scanner) process(ctx context.Context, src Source, root string, patterns []meta.Pattern, jobs []job) error { diff --git a/services/api/internal/library/store.go b/services/api/internal/library/store.go index cbf2749..0b8b372 100644 --- a/services/api/internal/library/store.go +++ b/services/api/internal/library/store.go @@ -244,6 +244,22 @@ func (st *Store) Files(ctx context.Context, sourceID uuid.UUID) (map[string]File return out, rows.Err() } +// FileByPath — файл источника по пути (точечный скан). +func (st *Store) FileByPath(ctx context.Context, sourceID uuid.UUID, rel string) (FileRow, bool, error) { + var f FileRow + err := st.pool.QueryRow(ctx, ` + SELECT id, rel_path, size_bytes, mtime, content_hash, track_id, missing_since IS NOT NULL + FROM media_files WHERE source_id = $1 AND rel_path = $2`, sourceID, rel). + Scan(&f.ID, &f.RelPath, &f.Size, &f.Mtime, &f.ContentHash, &f.TrackID, &f.Missing) + if errors.Is(err, pgx.ErrNoRows) { + return FileRow{}, false, nil + } + if err != nil { + return FileRow{}, false, fmt.Errorf("library: file by path: %w", err) + } + return f, true, nil +} + // SampleFiles — случайная выборка путей источника (для предпросмотра шаблонов). func (st *Store) SampleFiles(ctx context.Context, sourceID uuid.UUID, n int) ([]string, error) { rows, err := st.pool.Query(ctx, ` diff --git a/services/api/internal/platform/config/config.go b/services/api/internal/platform/config/config.go index 2fd7c22..84dba64 100644 --- a/services/api/internal/platform/config/config.go +++ b/services/api/internal/platform/config/config.go @@ -62,6 +62,8 @@ type API struct { GiteaRepo string `env:"GITEA_REPO"` GiteaToken string `env:"GITEA_TOKEN"` UpdatesDir string `env:"UPDATES_DIR" envDefault:"/updates"` + // UploadsDir — staging очереди на добавление (локальный диск, общий со storage-agent на чтение). + UploadsDir string `env:"UPLOADS_DIR" envDefault:"/uploads"` } // StorageAgent — конфигурация внутреннего сервиса storage-agent. diff --git a/services/api/internal/server/router.go b/services/api/internal/server/router.go index e594548..01d0967 100644 --- a/services/api/internal/server/router.go +++ b/services/api/internal/server/router.go @@ -16,6 +16,7 @@ import ( "pipu/api/internal/playback" "pipu/api/internal/search" "pipu/api/internal/system" + "pipu/api/internal/uploads" "pipu/api/internal/wave" ) @@ -32,6 +33,9 @@ type Deps struct { // Wave — «Моя волна». Wave *wave.Handler App *appupdate.Handler + // Uploads — очередь на добавление; TUS — приём файлов (вне OpenAPI). + Uploads *uploads.Handler + TUS *uploads.TUS // AuthService проверяет access-токены в middleware. nil — только для тестов служебных эндпоинтов. AuthService *auth.Service } @@ -47,6 +51,7 @@ type ( playbackHandler = playback.Handler waveHandler = wave.Handler appHandler = appupdate.Handler + uploadsHandler = uploads.Handler ) type handlers struct { @@ -57,6 +62,7 @@ type handlers struct { *playbackHandler *waveHandler *appHandler + *uploadsHandler } var _ gen.StrictServerInterface = handlers{} @@ -75,8 +81,11 @@ func NewRouter(d Deps) http.Handler { } r.NotFound(httpx.NotFound) r.MethodNotAllowed(httpx.MethodNotAllowed) + if d.TUS != nil { + d.TUS.Mount(r) + } - strict := gen.NewStrictHandlerWithOptions(handlers{systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback, waveHandler: d.Wave, appHandler: d.App}, + strict := gen.NewStrictHandlerWithOptions(handlers{systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback, waveHandler: d.Wave, appHandler: d.App, uploadsHandler: d.Uploads}, []gen.StrictMiddlewareFunc{httpx.StrictRequest}, gen.StrictHTTPServerOptions{ RequestErrorHandlerFunc: httpx.RequestError, ResponseErrorHandlerFunc: httpx.ResponseError(log), diff --git a/services/api/internal/uploads/handler.go b/services/api/internal/uploads/handler.go new file mode 100644 index 0000000..e351fb8 --- /dev/null +++ b/services/api/internal/uploads/handler.go @@ -0,0 +1,280 @@ +package uploads + +import ( + "context" + "errors" + "net/http" + + "github.com/google/uuid" + + "pipu/api/internal/api/gen" + "pipu/api/internal/auth" + "pipu/api/internal/platform/apperr" +) + +// Handler — эндпоинты очереди на добавление (часть gen.StrictServerInterface). +type Handler struct { + svc *Service +} + +// NewHandler создаёт обработчик. +func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } + +func fileDTO(ctx context.Context, f File) gen.UploadFile { + out := gen.UploadFile{ + Id: f.ID, BatchId: f.BatchID, BatchComment: f.BatchComment, + Author: gen.UserRef{Id: f.AuthorID, Username: f.AuthorName}, + Status: gen.UploadStatus(f.Status), OriginalName: f.OriginalName, ClientRelPath: f.ClientRelPath, + SizeBytes: f.Size, ReceivedBytes: f.Received, Format: f.Format, Codec: f.Codec, Bitrate: f.Bitrate, + SampleRate: f.SampleRate, BitDepth: f.BitDepth, DurationMs: f.DurationMs, Tags: f.Tags, + MetadataEdited: f.MetadataEdited, MatchedArtistId: f.MatchedArtistID, MatchedAlbumId: f.MatchedAlbumID, + DuplicateOfTrackId: f.DuplicateOfTrackID, PossibleDuplicate: f.PossibleDuplicate, + TargetSourceId: f.TargetSourceID, TargetRelPath: f.TargetRelPath, StatusReason: f.StatusReason, + ReviewedAt: f.ReviewedAt, CreatedAt: f.CreatedAt, UpdatedAt: f.UpdatedAt, + } + if out.Tags == nil { + out.Tags = map[string]string{} + } + if f.DuplicateLevel != nil { + l := gen.UploadFileDuplicateLevel(*f.DuplicateLevel) + out.DuplicateLevel = &l + } + if f.Meta != nil { + m := metaDTO(*f.Meta) + out.Metadata = &m + } + if p, ok := auth.PrincipalFrom(ctx); ok && p.UserID == f.AuthorID && f.Status == StatusUploading { + u := TusURL(f.ID) + out.TusUrl = &u + } + return out +} + +func metaDTO(m Meta) gen.UploadMeta { + return gen.UploadMeta{ + Artist: m.Artist, Albumartist: m.AlbumArtist, Album: m.Album, Title: m.Title, Genre: m.Genre, + Year: m.Year, Track: m.Track, Disc: m.Disc, + } +} + +func metaFromDTO(m gen.UploadMeta) Meta { + return Meta{ + Artist: m.Artist, AlbumArtist: m.Albumartist, Album: m.Album, Title: m.Title, Genre: m.Genre, + Year: m.Year, Track: m.Track, Disc: m.Disc, + } +} + +func pageDTO(ctx context.Context, files []File, next string) gen.UploadFilePage { + out := gen.UploadFilePage{Items: make([]gen.UploadFile, 0, len(files))} + for _, f := range files { + out.Items = append(out.Items, fileDTO(ctx, f)) + } + if next != "" { + out.NextCursor = &next + } + return out +} + +func statuses(in *gen.UploadStatusFilter) []Status { + if in == nil { + return nil + } + out := make([]Status, 0, len(*in)) + for _, s := range *in { + out = append(out, Status(s)) + } + return out +} + +func deref[T any](p *T, def T) T { + if p == nil { + return def + } + return *p +} + +func settingsDTO(s Settings) gen.UploadSettings { + return gen.UploadSettings{ + QueueLimitBytes: s.QueueLimitBytes, PerUserLimitBytes: s.PerUserLimitBytes, MaxFileBytes: s.MaxFileBytes, + DefaultSourceId: s.DefaultSourceID, + } +} + +// GetUploadQuota реализует gen.StrictServerInterface. +func (h *Handler) GetUploadQuota(ctx context.Context, _ gen.GetUploadQuotaRequestObject) (gen.GetUploadQuotaResponseObject, error) { + q, err := h.svc.Quota(ctx) + if err != nil { + return nil, err + } + return gen.GetUploadQuota200JSONResponse{ + LimitBytes: q.QueueLimitBytes, UsedBytes: q.UsedBytes, PerUserLimitBytes: q.PerUserLimitBytes, + UserUsedBytes: q.UserUsedBytes, MaxFileBytes: q.MaxFileBytes, + }, nil +} + +// PrecheckUploads реализует gen.StrictServerInterface. +func (h *Handler) PrecheckUploads(ctx context.Context, req gen.PrecheckUploadsRequestObject) (gen.PrecheckUploadsResponseObject, error) { + in := make([]PrecheckFile, 0, len(req.Body.Files)) + for _, f := range req.Body.Files { + in = append(in, PrecheckFile{ + ClientID: f.ClientId, Size: f.Size, SHA256: f.Sha256, Artist: deref(f.Artist, ""), Title: deref(f.Title, ""), + DurationMs: deref(f.DurationMs, 0), + }) + } + res, err := h.svc.Precheck(ctx, in) + if err != nil { + return nil, err + } + out := gen.PrecheckUploads200JSONResponse{} + for _, r := range res { + out.Files = append(out.Files, struct { + ClientId string `json:"client_id"` + PossibleDuplicate bool `json:"possible_duplicate"` + Result gen.UploadPrecheckResultFilesResult `json:"result"` + TrackId *uuid.UUID `json:"track_id"` + }{ClientId: r.ClientID, PossibleDuplicate: r.Possible, Result: gen.UploadPrecheckResultFilesResult(r.Result), TrackId: r.Track}) + } + return out, nil +} + +// CreateUploadBatch реализует gen.StrictServerInterface. +func (h *Handler) CreateUploadBatch(ctx context.Context, req gen.CreateUploadBatchRequestObject) (gen.CreateUploadBatchResponseObject, error) { + in := make([]CreateFile, 0, len(req.Body.Files)) + for _, f := range req.Body.Files { + in = append(in, CreateFile{ClientID: f.ClientId, Name: f.Name, RelPath: f.RelPath, Size: f.Size, SHA256: f.Sha256}) + } + batch, res, err := h.svc.Create(ctx, req.Body.Comment, in) + if err != nil { + return nil, err + } + out := gen.CreateUploadBatch201JSONResponse{BatchId: batch} + for _, r := range res { + item := struct { + ClientId string `json:"client_id"` + Rejected *string `json:"rejected"` + TrackId *uuid.UUID `json:"track_id"` + TusUrl *string `json:"tus_url"` + UploadFileId *uuid.UUID `json:"upload_file_id"` + }{ClientId: r.ClientID, TrackId: r.Track} + if r.Rejected != "" { + code := r.Rejected + item.Rejected = &code + } else { + id, u := r.FileID, TusURL(r.FileID) + item.UploadFileId, item.TusUrl = &id, &u + } + out.Files = append(out.Files, item) + } + return out, nil +} + +// ListMyUploads реализует gen.StrictServerInterface. +func (h *Handler) ListMyUploads(ctx context.Context, req gen.ListMyUploadsRequestObject) (gen.ListMyUploadsResponseObject, error) { + files, next, err := h.svc.ListMine(ctx, statuses(req.Params.Status), deref(req.Params.Cursor, ""), deref(req.Params.Limit, 50)) + if err != nil { + return nil, err + } + return gen.ListMyUploads200JSONResponse(pageDTO(ctx, files, next)), nil +} + +// WithdrawUpload реализует gen.StrictServerInterface. +func (h *Handler) WithdrawUpload(ctx context.Context, req gen.WithdrawUploadRequestObject) (gen.WithdrawUploadResponseObject, error) { + if err := h.svc.Withdraw(ctx, req.Id); err != nil { + return nil, err + } + return gen.WithdrawUpload204Response{}, nil +} + +// ListUploads реализует gen.StrictServerInterface. +func (h *Handler) ListUploads(ctx context.Context, req gen.ListUploadsRequestObject) (gen.ListUploadsResponseObject, error) { + p := req.Params + files, next, err := h.svc.ListAll(ctx, ListFilter{ + AuthorID: p.Author, BatchID: p.Batch, Statuses: statuses(p.Status), PossibleDuplicate: p.PossibleDuplicate, + Cursor: deref(p.Cursor, ""), Limit: deref(p.Limit, 50), + }) + if err != nil { + return nil, err + } + return gen.ListUploads200JSONResponse(pageDTO(ctx, files, next)), nil +} + +// UpdateUpload реализует gen.StrictServerInterface. +func (h *Handler) UpdateUpload(ctx context.Context, req gen.UpdateUploadRequestObject) (gen.UpdateUploadResponseObject, error) { + in := UpdateInput{TargetSourceID: req.Body.TargetSourceId} + if req.Body.Metadata != nil { + m := metaFromDTO(*req.Body.Metadata) + in.Meta = &m + } + f, err := h.svc.Update(ctx, req.Id, in) + if err != nil { + return nil, err + } + return gen.UpdateUpload200JSONResponse(fileDTO(ctx, f)), nil +} + +// RetryUploadImport реализует gen.StrictServerInterface. +func (h *Handler) RetryUploadImport(ctx context.Context, req gen.RetryUploadImportRequestObject) (gen.RetryUploadImportResponseObject, error) { + f, err := h.svc.Retry(ctx, req.Id) + if err != nil { + return nil, err + } + return gen.RetryUploadImport200JSONResponse(fileDTO(ctx, f)), nil +} + +// ReviewUploads реализует gen.StrictServerInterface. +func (h *Handler) ReviewUploads(ctx context.Context, req gen.ReviewUploadsRequestObject) (gen.ReviewUploadsResponseObject, error) { + res, err := h.svc.Review(ctx, req.Body.FileIds, req.Body.Decision == "approve", deref(req.Body.Reason, "")) + if err != nil { + return nil, err + } + out := gen.ReviewUploads200JSONResponse{} + for _, r := range res { + item := struct { + Error *gen.Problem `json:"error"` + Id uuid.UUID `json:"id"` + Ok bool `json:"ok"` + }{Id: r.ID, Ok: r.Error == nil} + if r.Error != nil { + item.Error = problem(r.Error) + } + out.Items = append(out.Items, item) + } + return out, nil +} + +// problem — ошибка решения по одному файлу в виде Problem (массовое действие не падает целиком). +func problem(err error) *gen.Problem { + var ae *apperr.Error + if !errors.As(err, &ae) { + return &gen.Problem{Status: http.StatusInternalServerError, Code: "common.internal"} + } + p := &gen.Problem{Status: ae.Status, Code: ae.Code} + if len(ae.Params) > 0 { + params := map[string]any(ae.Params) + p.Params = ¶ms + } + return p +} + +// GetUploadSettings реализует gen.StrictServerInterface. +func (h *Handler) GetUploadSettings(ctx context.Context, _ gen.GetUploadSettingsRequestObject) (gen.GetUploadSettingsResponseObject, error) { + s, err := h.svc.GetSettings(ctx) + if err != nil { + return nil, err + } + return gen.GetUploadSettings200JSONResponse(settingsDTO(s)), nil +} + +// UpdateUploadSettings реализует gen.StrictServerInterface. +func (h *Handler) UpdateUploadSettings(ctx context.Context, req gen.UpdateUploadSettingsRequestObject) (gen.UpdateUploadSettingsResponseObject, error) { + b := req.Body + s, err := h.svc.UpdateSettings(ctx, SettingsInput{ + QueueLimitBytes: b.QueueLimitBytes, MaxFileBytes: b.MaxFileBytes, + PerUserLimitBytes: b.PerUserLimitBytes, ClearPerUser: b.PerUserLimitBytes == nil, + DefaultSourceID: b.DefaultSourceId, ClearDefault: b.DefaultSourceId == nil, + }) + if err != nil { + return nil, err + } + return gen.UpdateUploadSettings200JSONResponse(settingsDTO(s)), nil +} diff --git a/services/api/internal/uploads/model.go b/services/api/internal/uploads/model.go new file mode 100644 index 0000000..3774c56 --- /dev/null +++ b/services/api/internal/uploads/model.go @@ -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 diff --git a/services/api/internal/uploads/service.go b/services/api/internal/uploads/service.go new file mode 100644 index 0000000..fde9741 --- /dev/null +++ b/services/api/internal/uploads/service.go @@ -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() } diff --git a/services/api/internal/uploads/store.go b/services/api/internal/uploads/store.go new file mode 100644 index 0000000..40fa75a --- /dev/null +++ b/services/api/internal/uploads/store.go @@ -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 // "|" последнего элемента + 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 +} diff --git a/services/api/internal/uploads/tus.go b/services/api/internal/uploads/tus.go new file mode 100644 index 0000000..d9908fe --- /dev/null +++ b/services/api/internal/uploads/tus.go @@ -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 +} diff --git a/services/api/internal/uploads/uploads_test.go b/services/api/internal/uploads/uploads_test.go new file mode 100644 index 0000000..a64f272 --- /dev/null +++ b/services/api/internal/uploads/uploads_test.go @@ -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) +} diff --git a/services/api/internal/uploads/worker.go b/services/api/internal/uploads/worker.go new file mode 100644 index 0000000..8806c78 --- /dev/null +++ b/services/api/internal/uploads/worker.go @@ -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) + } +} diff --git a/services/api/migrations/00008_uploads.sql b/services/api/migrations/00008_uploads.sql new file mode 100644 index 0000000..05b3ff9 --- /dev/null +++ b/services/api/migrations/00008_uploads.sql @@ -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;