# Предложение треков (загрузка с модерацией) Модераторы (и админы) предлагают новые треки. Файлы попадают в **очередь на добавление** с общим лимитом размера, дубли отбрасываются сразу, админ одобряет или отклоняет, одобренные файлы раскладываются в хранилище **по той же структуре папок, что принята в этом хранилище**. ## 1. Роли | Действие | user | moderator | admin | |---|:-:|:-:|:-:| | Загрузить треки в очередь | — | ✅ | ✅ | | Видеть свои предложения и их статус, отозвать до рассмотрения | — | ✅ | ✅ | | Видеть всю очередь | — | — | ✅ | | Править метаданные, слушать, одобрять / отклонять | — | — | ✅ | | Настраивать лимит очереди, целевое хранилище и шаблон раскладки | — | — | ✅ | Загрузки админа проходят ту же проверку на дубли; в очередь они тоже попадают, но админ может одобрить их сразу. ## 2. Жизненный цикл загрузки ``` предпроверка (хэш + теги на клиенте) │ дубль → отказ сразу, файл не загружается, квота не тратится ▼ uploading ──► processing ──► pending_review ──► approved ──► importing ──► imported │ │ │ │ │ ├─► duplicate ├─► rejected (причина) └─► import_failed │ └─► invalid └─► withdrawn (отозвал автор) (можно повторить) └─► expired (не докачан за 24 ч) ``` | Статус | Занимает квоту | Файл в staging | |---|:-:|:-:| | `uploading` | ✅ (резерв по заявленному размеру) | частично | | `processing` | ✅ | ✅ | | `pending_review` | ✅ | ✅ | | `approved`, `importing` | ✅ (до окончания копирования) | ✅ | | `imported`, `import_failed`→после ретрая | — | удаляется | | `duplicate`, `invalid`, `rejected`, `withdrawn`, `expired` | — | удаляется сразу | ## 3. Квота очереди - Глобальный лимит `uploads.queue_limit_bytes` (по умолчанию **10 ГБ**), меняет только админ в настройках админки. - Опционально — лимит на одного модератора `uploads.per_user_limit_bytes` (по умолчанию выключен) и максимальный размер одного файла `uploads.max_file_bytes` (по умолчанию 2 ГБ). - **Занято** = сумма `size_bytes` загрузок в статусах, отмеченных в таблице выше. Дубли и отклонённые не учитываются никогда. - Перед загрузкой клиент запрашивает резерв (`POST /uploads` со списком файлов и размерами). Сервер в транзакции (`SELECT … FOR UPDATE` строки счётчика) проверяет `занято + запрошено ≤ лимит` и резервирует место. Не помещающиеся файлы отклоняются с кодом `uploads.queue_full` (клиент показывает, сколько места осталось). - Снижение лимита ниже занятого не удаляет существующие загрузки — просто новые не принимаются, пока очередь не освободится. - Индикатор заполненности очереди виден модераторам и админу («6.2 из 10 ГБ»). ## 4. Проверка на дубли Три уровня, от дешёвого к дорогому. Дубль на любом уровне = статус `duplicate`, файл удаляется, квота освобождается, автор видит, с каким существующим треком совпало (ссылка на трек). | Уровень | Когда | Как | |---|---|---| | 1. Точная копия файла | **До загрузки** (на клиенте) и после | SHA-256 всего файла: клиент считает в браузере (Web Crypto, потоково в Web Worker) и отправляет в предпроверке. Сравнение с `media_files.sha256` и с другими загрузками в очереди. | | 2. Совпадение метаданных | **До загрузки** (теги читаются на клиенте библиотекой `music-metadata`) и после (на сервере по `ffprobe`) | Нормализованные `artist + title` (+ `album`, если есть) и длительность ±2 с против `tracks`. | | 3. Акустический отпечаток | После загрузки | Chromaprint (`fpcalc`), сравнение с `track_fingerprints` библиотеки и очереди. Ловит тот же трек в другом формате / битрейте / с другими тегами. | Правила: - Совпадение уровня 1 или 3 — дубль безусловно. - Совпадение только уровня 2 (метаданные совпали, отпечаток — нет: ремастер, live, другая версия) — **не отбрасывается**, а помечается флагом `possible_duplicate` для решения админа. - Если загружаемый файл того же трека **лучшего качества** (lossless вместо MP3) — дубль всё равно отбрасывается автоматически, но в настройках можно включить режим `uploads.allow_quality_upgrade`: тогда такой файл идёт на рассмотрение с пометкой «замена на лучшее качество». - Дубли внутри одной пачки загрузки (два одинаковых файла) — второй отбрасывается. - Отпечатки для существующей библиотеки считает сканер (задача `fingerprint_track`, ~0.3 с на трек) — выполняется при первом скане и для новых файлов. ## 5. Загрузка - Протокол **tus** (resumable upload): сервер — `tusd` как библиотека внутри `api` (`/api/v1/uploads/files/`), клиент — `tus-js-client`. Докачка после обрыва, чанки по 8 МБ. - Веб: drag-and-drop файлов и **папок** (сохраняются относительные пути — помогают угадать альбом), до 4 параллельных загрузок, прогресс по каждому файлу. - Мобильный клиент загрузку не поддерживает (первая версия). - Staging — локальный диск сервера `/srv/pipu/uploads//` (не NFS). - Разрешённые форматы: `flac mp3 m4a ogg opus wav aiff wv`. Проверяется не расширение, а содержимое (`ffprobe`): должен быть ровно один аудиопоток, длительность 5 с – 3 ч. Иначе `invalid` с кодом причины. - Допускается отдельная картинка обложки (`cover.jpg|png`) в той же пачке — привязывается к файлам той же папки. ## 6. Обработка (`processing`) Задача River `process_upload_file`: 1. `ffprobe` → формат, длительность, битрейт, теги. 2. SHA-256, `content_hash`, `fpcalc` → проверки дублей (раздел 4). 3. Метаданные-кандидаты: теги → если пусто, разбор относительного пути из браузера шаблонами целевого хранилища → если пусто, имя файла. 4. Нормализация и сопоставление с существующими исполнителями / альбомами каталога (чтобы «Кино» не превратилось во второго исполнителя «КИНО»): найденные id подставляются, админ видит «будет добавлено к существующему альбому» или «новый альбом». 5. Расчёт **предварительного целевого пути** (раздел 7). 6. Статус `pending_review`. ## 7. Раскладка в хранилище «как принято на сервере» ### Целевое хранилище - Админ отмечает одно или несколько хранилищ как **принимающие загрузки** (`accepts_uploads = true`). Только они монтируются на запись (см. [library-and-storage.md](library-and-storage.md#запись-в-хранилище)). - Хранилище по умолчанию — в настройках; при одобрении админ может выбрать другое. ### Шаблон раскладки Регэкспы умеют только *разбирать* путь, поэтому для *записи* у хранилища есть **шаблон пути** (`write_template`): ``` {albumartist}/{year} - {album}/{disc:1-}{track:02} - {title}.{ext} ``` | Плейсхолдер | Значение | |---|---| | `{artist}`, `{albumartist}` | Исполнитель трека / альбома (если нет — исполнитель трека) | | `{album}`, `{title}`, `{genre}` | | | `{year}` | 4 цифры | | `{track:02}` | Номер с дополнением нулями | | `{disc:1-}` | Номер диска с суффиксом `-`, выводится только если дисков > 1 | | `{ext}` | Расширение по реальному формату | | `[ ... ]` | Необязательный фрагмент: опускается целиком, если любой плейсхолдер внутри пуст, например `[{year} - ]{album}` | **Как получить шаблон:** - **Автоподбор**: система берёт выборку существующих путей хранилища, определяет, какой регэксп совпадает чаще всего, и предлагает шаблон, порождающий такие же пути (группы регэкспа → плейсхолдеры, литералы между группами → текст шаблона). Админ подтверждает или правит. - **Проверка обратимости** (обязательна при сохранении): шаблон применяется к 50 существующим трекам хранилища, полученные пути прогоняются через регэкспы этого хранилища — поля должны распознаться обратно без изменений, а в ≥ 90% случаев путь должен совпасть с реальным путём файла. Иначе — предупреждение с примерами расхождений. Так гарантируется, что новые файлы лежат так же, как старые, и сканер разберёт их правильно. ### Очистка имён - Запрещённые символы `/ \ : * ? " < > |` и управляющие → `_`; точки и пробелы в конце сегмента удаляются. - Длина сегмента ≤ 200 байт UTF-8, полный путь ≤ 1000 байт (обрезка по границе символа с сохранением расширения). - Unicode NFC. Регистр не меняется. - Существующая папка исполнителя / альбома переиспользуется, если её имя совпадает с нормализованным значением (`Кино` ↔ `КИНО`) — берётся **уже существующее написание**, чтобы не плодить соседние папки. ### Конфликты - Если файл по целевому пути уже существует — добавляется суффикс ` (2)`, а в задаче — предупреждение. - Путь проверяется на выход за корень хранилища (`..`, абсолютные пути) — ошибка. ## 8. Рассмотрение (админ) Экран «Очередь на добавление»: - Группировка по пачке загрузки / по альбому; фильтры: статус, автор, `possible_duplicate`. - Для каждого файла: прослушивание (стрим из staging), метаданные с возможностью правки, обложка, качество (формат / битрейт / sample rate), флаги (возможный дубль — со ссылкой на похожий трек), **итоговый путь в хранилище** (пересчитывается live при правке метаданных). - Действия: одобрить / отклонить (с причиной — видна автору), массовые действия для выбранных, правка «на весь альбом» (исполнитель, альбом, год, обложка одной операцией). - Если админ изменил метаданные — они **записываются в теги файла** при импорте (`ffmpeg -map 0 -c copy -metadata …` без перекодирования; для FLAC/MP3/M4A/Ogg), чтобы теги и путь не противоречили друг другу. ## 9. Импорт (`importing`) Задача River `import_upload_file` (повторяемая, идемпотентная): 1. Пересчитать целевой путь с финальными метаданными. 2. Запросить у `storage-agent` запись: `POST /internal/files` `{source_id, rel_path, staging_path}`. Агент: - создаёт каталоги; - создаёт файл сразу под итоговым именем (`O_EXCL`, существующий не перезаписывается), копирует, `fsync`; - сверяет SHA-256 копии со staging; при ошибке удаляет свой файл и созданные пустые каталоги — на музыкальный диск не попадает ничего, кроме треков. 3. Запустить точечный скан файла (`scan_path`) → трек появляется в каталоге и поиске, ставится в аудиоанализ. В `media_files` сохраняется `upload_id` — история происхождения. 4. Удалить файл из staging, освободить квоту, статус `imported`, запись в `audit_log`. При ошибке (NFS недоступен, нет места) — `import_failed` с сообщением, квота не освобождается, файл остаётся в staging; админ может повторить или отклонить. ## 10. Уведомления - Автор видит статусы в «Мои предложения» (раздел админки модератора), бейдж с числом решений с последнего визита. - Админ видит бейдж «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`). - Веб (`apps/web/src/features/uploads`, `pages/uploads`, `pages/admin/UploadQueuePage.tsx`): - «Предложить треки» (`/uploads/new`): drag-and-drop файлов и папок (FileSystemEntry API, относительные пути), выбор файлов / папки; SHA-256 потоком в Web Worker (`hash-wasm`); теги читает `music-metadata` и отдаёт их в предпроверку; `POST /uploads`; `tus-js-client` с готовым `uploadUrl`, куски по 8 МБ, 4 файла параллельно, 401 → обновление токена и повтор. Загрузчик — синглтон модуля: переходы по приложению не прерывают загрузку. Незавершённые tus-ссылки пишутся в `localStorage`; после перезагрузки пользователь выбирает те же файлы, клиент сверяет SHA-256 и продолжает с принятого смещения. - «Мои предложения» (`/uploads/mine`): статусы, причины отказа, отзыв до решения, квота. Бейдж — число решений новее `upload_visits`; открытие страницы вызывает `POST /uploads/seen`. - «Очередь на добавление» (`/admin/uploads`, admin): вкладки по статусам, фильтр возможных дублей, группировка по пачкам, правка метаданных и хранилища, правка исполнителя / альбома / года / обложки на всю пачку, прослушивание staging по подписанной ссылке, флаг улучшения качества, массовое одобрение / отклонение с причиной, повтор импорта. Настройки очереди — лимиты, хранилище и `allow_quality_upgrade`. - Сверка квоты: `used_bytes` обновляется в транзакции смены статуса и дополнительно пересчитывается по `SUM(size_bytes)` занимающих статусов при старте `api` и в цикле очистки (раз в 10 минут). ## 11. Очистка - `uploading` старше 24 ч → `expired`, файлы удаляются. - Записи завершённых загрузок (`imported`, `rejected`, …) хранятся 180 дней для истории, без файлов. - При старте `api` — сверка staging-каталога с БД: осиротевшие файлы удаляются.