Files
pipu-music/docs/uploads.md
T
mrleo1nid 82bf015283
ci / module boundaries / boundaries (push) Successful in 6s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 2m1s
ci / ts / ts (packages + web) (push) Failing after 5m13s
ci / api / api (push) Successful in 41s
ci / db / db (push) Failing after 1m33s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 2m25s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 2m19s
Import missing artist portraits from uploaded album folders
2026-10-30 10:46:07 +03:00

350 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Предложение треков (загрузка с модерацией)
Модераторы (и админы) предлагают новые треки. Файлы попадают в **очередь на добавление** с общим лимитом размера,
дубли отбрасываются сразу, аудитор загрузок одобряет или отклоняет, одобренные файлы раскладываются в хранилище
**по той же структуре папок, что принята в этом хранилище**.
## 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 Worker через `js-sha256`, без WebAssembly и загрузки всего файла в память, и отправляет в предпроверке. Сравнение с `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`. Просим присылать lossless — **FLAC, WAV или AIFF** (WavPack тоже считается lossless); MP3 и остальные lossy — крайний случай: форма предупреждает об этом, а очередь на добавление помечает такой файл «обратить внимание». Проверяется не расширение, а содержимое (`ffprobe`):
должен быть ровно один аудиопоток, длительность 5 с – 3 ч. Иначе `invalid` с кодом причины.
- Картинки `cover.jpg`, `cover.png` (также `folder`/`front`, JPEG/PNG/WebP) загружаются как обложка альбома,
а не пропускаются как не-аудио. Если отдельной картинки нет, берётся встроенная обложка из первого трека,
в котором она найдена. На каждую папку альбома создаётся своя пачка с обложкой, поэтому разные альбомы
не смешиваются. Обложка до 8 МБ сохраняется вместе с резервом пачки и встраивается при импорте
в поддерживаемые контейнеры FLAC/MP3/M4A/Ogg/Opus без перекодирования аудио.
Обложки больше 8 МБ клиент автоматически уменьшает до JPEG с длинной стороной не более 2048 пикселей
перед base64-кодированием; это относится и к отдельным файлам, и к встроенной обложке, и к правке пачки.
Меньшие картинки остаются без изменений. Код HTTP 413 от прокси показывается как превышение размера запроса.
- `artist.jpg` (также `artist.jpeg`, `artist.png`, `artist.webp`, регистр не важен) в папке исполнителя
передаётся отдельно от обложки. Ближайшая такая картинка наследуется вложенными папками альбомов;
соседние исполнители не смешиваются. Например `Исполнитель/artist.jpg` и две папки альбомов
дают две отдельные пачки с одной фотографией исполнителя и своими обложками.
После одобрения и импорта трека фотография сохраняется в кеш WebP и ставится исполнителю альбома,
только если у него нет ни локальной аватарки, ни изображения Genius. Гостям она не назначается.
Существующая аватарка сохраняется, в том числе при одновременном импорте нескольких альбомов.
До одобрения каталог не меняется. Лимит фотографии — 8 МБ после уменьшения, как у обложки;
nginx принимает до 32 МБ, чтобы вместить обе картинки в base64. Временная фотография удаляется
после завершения всех файлов пачки, а готовая аватарка остаётся в кеше.
## 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}` |
**Как получить шаблон:**
- **Автоподбор**: система сравнивает пути и метаданные до 200 существующих файлов хранилища, строит варианты
раскладки и выбирает вариант с наибольшим числом точных совпадений. Сохранённые регэкспы также используются
как кандидаты, но не обязательны. В интерфейсе показаны число совпадений и примеры реальных путей.
Если шаблон ещё не задан, предложение заполняется автоматически; администратор может изменить его и сохранить.
- **Проверка обратимости** (обязательна при сохранении): шаблон применяется к 50 существующим трекам хранилища,
полученные пути прогоняются через регэкспы этого хранилища — поля должны распознаться обратно без изменений,
а в ≥ 90% случаев путь должен совпасть с реальным путём файла. Иначе — предупреждение с примерами расхождений.
Так гарантируется, что новые файлы лежат так же, как старые, и сканер разберёт их правильно.
### Очистка имён
- Запрещённые символы `/ \ : * ? " < > |` и управляющие → `_`; точки и пробелы в конце сегмента удаляются.
- Длина сегмента ≤ 200 байт UTF-8, полный путь ≤ 1000 байт (обрезка по границе символа с сохранением расширения).
- Unicode NFC. Регистр не меняется.
- Существующая папка исполнителя / альбома переиспользуется, если её имя совпадает с нормализованным значением
(`Кино` ↔ `КИНО`) — берётся **уже существующее написание**, чтобы не плодить соседние папки.
### Конфликты
- Если файл по целевому пути уже существует — добавляется суффикс ` (2)`, а в задаче — предупреждение.
- Путь проверяется на выход за корень хранилища (`..`, абсолютные пути) — ошибка.
## 8. Рассмотрение (аудит загрузок)
Экран «Очередь на добавление»:
- Группировка по пачке загрузки / по альбому; фильтры: статус, автор, `possible_duplicate`.
Пачки по умолчанию свёрнуты; кнопка в заголовке раскрывает файлы, выбор всей пачки доступен и без раскрытия.
В заголовке указано наличие общей обложки пачки: «Есть обложка» / «Без обложки».
Там же показаны количества файлов по форматам и диапазоны битрейта, частоты и разрядности;
одинаковые значения не повторяются, отсутствующие измерения не учитываются как нули.
- Для каждого файла: прослушивание (стрим из staging), метаданные с возможностью правки, обложка, качество
(формат / битрейт / sample rate), флаги (возможный дубль — со ссылкой на похожий трек), **итоговый путь в хранилище**
с названием хранилища и путём относительно его корня. Полный адрес назначения (локальный путь API
или `server:/export/path` для NFS) доступен при наведении на путь. Для ожидающих рассмотрения, одобренных и ошибочных импортов
предпросмотр рассчитывается при чтении очереди по текущему шаблону — тем же планировщиком, что и импорт.
Сохранение шаблона обновляет кеш очереди. Уже импортированные файлы сохраняют фактический путь.
Если рассчитать путь нельзя, показаны причина и действие для исправления вместо старого пути.
- Прослушивание staging выдаёт сам API с поддержкой Range: приватные файлы tus (`0640`, каталоги `0750`)
недоступны пользователю nginx. Выдача не зависит от `X_ACCEL`; права существующих файлов менять не нужно.
Аудио и его метаданные загружаются только после Play (`preload="none"`); раскрытие пачки не запускает запросы к файлам.
При сбое показана ошибка (включая HTTP-статус, если доступен)
и кнопка повторной попытки с обновлением подписанных ссылок.
Одновременно играет только один предпросмотр: запуск другого ставит предыдущий на паузу с сохранением позиции.
Сворачивание пачки или уход со страницы также останавливает её прослушивание.
- Действия: одобрить / отклонить (с причиной — видна автору), массовые действия для выбранных, правка «на весь альбом»
(исполнитель, альбом, год, обложка одной операцией).
- Если админ изменил метаданные — они **записываются в теги файла** при импорте (`ffmpeg -map 0 -c copy -metadata …`
без перекодирования; для FLAC/MP3/M4A/Ogg), чтобы теги и путь не противоречили друг другу.
### Сопоставление релиза и внешние обложки
После обработки всех файлов пачки API ставит её в отдельную фоновую очередь. MusicBrainz ищет
конкретные издания по сохранённому MusicBrainz Release ID либо исполнителю альбома и названию.
Внешнему сервису передаются только поисковые метаданные и идентификаторы, аудиофайлы не отправляются.
Для смешанной пачки или отсутствующих тегов показывается предложение уточнить поиск.
В заголовке пачки видно состояние поиска. Если найдено несколько изданий, администратор сначала
выбирает конкретное. Проверка сопоставляет дорожки по идентификаторам, названиям, исполнителям,
длительностям и нумерации дисков. «Полный состав» означает все дорожки выбранного издания с уверенными
совпадениями и без несопоставленных файлов. Один файл не считается синглом без типа Single в каталоге.
Несколько форматов одной песни не увеличивают число совпавших дорожек. Предположительные и неоднозначные
совпадения отмечаются отдельно. Для отсутствующих дорожек отдельно показаны совпадения с доступной
библиотекой по тегам и длительности; они не делают загруженную пачку полной.
Число файлов в пачке и число сопоставленных дорожек — разные показатели. «Файл не сопоставлен»
не доказывает отсутствие дорожки: она может быть среди файлов с отличающимися тегами. Для
предположительного совпадения допускается разная запись состава исполнителей (запятая, `feat.`, `;`)
и пометки `feat.` / `Prod.` в конце названия при совпадении номера, диска и длительности с допуском
2 секунды. Пометки версий (`live`, `remix`, `instrumental`) сохраняются. Такие совпадения требуют
проверки и сами по себе не подтверждают «Полный состав». При изменении правил сопоставления
сохранённые результаты поиска пересчитываются при открытии пачки.
Поиск сам не меняет теги, путь или обложку. В диалоге можно отдельно применить метаданные к совпавшим
редактируемым файлам и/или обложку. Обложка загружается из Cover Art Archive только после запроса
предпросмотра. При отсутствии обложки конкретного издания возможна обложка группы изданий с явной пометкой.
Замена уже загруженной обложки требует отдельного выбора. Применение фиксируется одной транзакцией
и отвергает изменившуюся пачку (409); статусы одобрения и квота не меняются. MusicBrainz ID записываются
в теги при импорте вместе с остальными подтверждёнными метаданными.
`MUSICBRAINZ_ENABLED=true` включает поиск (можно отключить в конфигурации сервера). Нужен исходящий HTTPS
к MusicBrainz, Cover Art Archive и archive.org для обложек. Ключ API для MusicBrainz не требуется;
запросы ограничены одним за 1,1 секунды, результат кешируется на 7 дней, просроченный кеш удаляется.
Сбой провайдера не мешает загрузке или импорту: повтор через 10 минут, не более трёх автоматических попыток,
после чего доступен ручной поиск. Очередь и кеш сохраняются в PostgreSQL, незавершённая задача
после перезапуска повторяется по истечении пятиминутной аренды.
Источники: [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API),
[Cover Art Archive API](https://musicbrainz.org/doc/Cover_Art_Archive/API).
### Несколько релизов в одной пачке
После завершения обработки всей пачки треки получают постоянные группы релизов внутри исходной пачки.
Группировка использует MusicBrainz Release ID, затем album artist + album + год. Единственный известный
Release ID подхватывается треками того же альбома без идентификатора; разные известные издания разделяются.
При неполных тегах используется исходная папка альбома. Подпапки CD1/CD2, Disc 1/2 и «Диск 1/2»
относятся к общей родительской папке. Album artist сохраняет сборники с разными исполнителями треков;
без него помогает общая папка. Файлы без альбомных тегов и без папок остаются одной группой для ручной проверки.
У групп отдельные фоновые задачи, аренды, результаты MusicBrainz/AcoustID, выбранные издания и обложки.
После первичного разделения подтверждение тегов не меняет принадлежность файлов и не объединяет группы.
Правка метаданных или отзыв файла сбрасывает только поиск его группы. Ошибка одной группы не блокирует
поиск соседних. В очереди сохраняется исходная пачка с общими действиями и вложенными заголовками релизов;
выбор строк релиза действует на загруженные строки страницы. В диалоге сначала выбирается релиз внутри пачки,
затем его издание. Проверка состава учитывает все файлы группы, включая другие страницы очереди.
HTTP: `group_id` в запросах поиска/применения и query-параметр `group_id` при чтении отчёта/обложки.
Для единственной группы параметр можно опустить; в смешанной пачке сервер требует явного выбора и проверяет
принадлежность группы пачке. Применение блокирует и проверяет только файлы выбранной группы и её задачу.
Квота и статусы файлов не меняются. При импорте используется обложка своей группы; staging-очистка сохраняет
общую обложку, пока она нужна хотя бы одной активной группе.
В одиночном релизе сохраняется исходная обложка пачки. В смешанной пачке одну общую обложку нельзя надёжно
приписать всем альбомам, поэтому группы начинают без неё; исходная обложка остаётся в пачке.
Обложку из каталога можно применить отдельно к нужному релизу. Явное изменение обложки «На всю пачку»
по-прежнему меняет обложки всех её групп.
### Распознавание по звуку
Кнопка «Распознать по звуку» запускает отдельный запрос для пачки до 50 редактируемых треков.
Нужен ключ **приложения** AcoustID: зарегистрируйте приложение на [AcoustID](https://acoustid.org/webservice),
задайте секрет `ACOUSTID_API_KEY` в конфигурации сервера и примените изменения. Пользовательский ключ
для отправки записей не нужен. При пустом ключе кнопка недоступна, поиск по тегам продолжает работать.
`fpcalc` локально вычисляет сжатый Chromaprint первых 120 секунд. В AcoustID отправляются только
отпечаток и полная длительность из ffprobe; аудиофайл, теги, имя файла и ключи пользователя не передаются.
Автоматического распознавания всех загрузок и отправки отпечатков в базу AcoustID нет.
Ключ хранится как секрет; POST не помещает его и отпечаток в URL, редиректы запрещены.
Результат используется при оценке не ниже 0,90 и отрыве от следующей **другой** записи не менее 0,05.
Для каждого файла показаны распознавание, неоднозначность, отсутствие совпадений или недоступный отпечаток.
Слабые результаты не исправляют теги. Уверенное распознавание имеет приоритет над ошибочными тегами;
для подтверждения полного состава нужна также совместимая длительность дорожки (±5 секунд).
Повтор одной записи на разных позициях остаётся неоднозначным.
MusicBrainz ищет издания по первым восьми уникальным распознанным записям (до 100 изданий на запись),
выбирает до пяти кандидатов по числу совпадений и сопоставляет с ними все файлы. Это ограниченный набор
кандидатов, не гарантия нахождения нужного издания. Отпечаток не определяет конкретный мастеринг или издание;
выбор и применение метаданных/обложки остаются за администратором. Поиск имеет общий таймаут 4 минуты,
лимит AcoustID — один запрос за 350 мс, кеш — 7 дней. Нужен исходящий HTTPS к `api.acoustid.org`.
Условия бесплатного сервиса AcoustID допускают некоммерческое использование; для коммерческого нужен
отдельный договор с провайдером (см. документацию AcoustID).
## 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: `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 (`js-sha256`); теги читает `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-каталога с БД: осиротевшие файлы удаляются.