Files
pipu-music/docs/uploads.md
T
mrleo1nidandClaude Opus 5.5 74da457362 feat(auth): make upload review a separate grant instead of an admin privilege
Reviewing the upload queue is now a per-user flag (reviews_uploads), not part
of the admin role: several people can hold it, a moderator can have it, and an
admin may not. Only admins grant or revoke it; existing admins keep it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:07:05 +03:00

22 KiB
Raw Blame History

Предложение треков (загрузка с модерацией)

Модераторы (и админы) предлагают новые треки. Файлы попадают в очередь на добавление с общим лимитом размера, дубли отбрасываются сразу, аудитор загрузок одобряет или отклоняет, одобренные файлы раскладываются в хранилище по той же структуре папок, что принята в этом хранилище.

1. Роли

Действие user moderator admin
Загрузить треки в очередь — ✅ ✅
Видеть свои предложения и их статус, отозвать до рассмотрения — ✅ ✅
Настраивать лимит очереди, целевое хранилище и шаблон раскладки — — ✅

Видеть всю очередь, слушать, править метаданные, одобрять и отклонять может только тот, у кого есть право аудита загрузок (users.reviews_uploads, в API — reviews_uploads в Me / AdminUser). Это не роль: право выдаёт админ, оно может быть у нескольких людей сразу, у модератора или даже у обычного пользователя, а у админа его может не быть. Без него админ по-прежнему настраивает лимиты и хранилища, но очередь не видит.

Загрузки админа проходят ту же проверку на дубли; в очередь они тоже попадают, но админ может одобрить их сразу.

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/<upload_id>/<file_id> (не 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).
  • Хранилище по умолчанию — в настройках; при одобрении админ может выбрать другое.

Шаблон раскладки

Регэкспы умеют только разбирать путь, поэтому для записи у хранилища есть шаблон пути (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-каталога с БД: осиротевшие файлы удаляются.