Implement media storage and processing features: update configuration in .env.example and appsettings files, enhance Docker setup for media storage, and add media-related services and database entities. Include ffmpeg for media handling and adjust Kestrel settings for large file uploads.

This commit is contained in:
Leonid Pershin
2026-07-24 08:33:11 +03:00
parent 1dd6991174
commit e15ecbdb29
47 changed files with 2599 additions and 1 deletions
+264
View File
@@ -0,0 +1,264 @@
# Хранение медиа и линейный эфир
Проектное решение для следующего этапа TeleWave: библиотека контента, автоматическое
программирование каналов и раздача линейного эфира. Документ описывает согласованный дизайн —
реализации пока нет.
## Что строим
Админ работает не со слотами вручную, а с **библиотекой шоу** и **правилами канала**:
1. Загружает контент в библиотеку: **шоу** (сериал с упорядоченными сериями либо полнометражка) и
**рекламные врезки**. Загруженное переиспользуется — одно шоу можно ставить на разные каналы.
2. Создаёт **канал** (например «Мультики»), добавляет в него несколько шоу с **весами** и размером
**блока** (сколько серий подряд крутить за раз), плюс пул рекламы и политику её вставки.
3. **Планировщик** сам строит расписание на несколько дней вперёд: взвешенно-случайно выбирает
шоу, ставит блок из K серий подряд (продолжая сериал с того места, где он остановился), между
блоками/сериями вставляет рекламу — и так встык, без дыр.
Пример эфира «Мультиков»: 3 серии Симпсонов подряд → реклама → 2 серии другого шоу → реклама →
полнометражка → реклама → снова Симпсоны (следующие невиденные серии) — и дальше по кругу.
## Принятые решения
| Вопрос | Решение |
| --- | --- |
| Модель канала | Линейное вещание; программы идут встык, расписание строит система |
| Доставка | Пре-сегментированный HLS; эфирный `m3u8` генерируется математикой, ffmpeg в рантайме не запущен |
| Программирование | Взвешенная случайная ротация шоу; блок подряд за выбор |
| Размер блока | На выбор: по числу серий (K) или по времени (~M минут, набор серий по их длине) |
| Марафоны / override | Отдельная сущность на окне [от, до]: подменённые веса или эксклюзив шоу |
| Библиотека | Общая: шоу и реклама переиспользуются между каналами |
| Порядок серий | Серии сериала идут строго по порядку; «следующая» выводится из хвоста расписания |
| Конец сериала | Дошли до последней серии — по кругу с первой |
| Реклама | Пул на канале, политика вставки настраивается (между блоками / между сериями) |
| Дыры в эфире | В норме их нет (встык); филлер — аварийная подстраховка |
| Длина сегмента | 2 секунды — вход в эфир ≈6с |
| Исходники | Разношёрстные (mkv/avi, HEVC, AC3) — нормализация транскодом обязательна |
| Наполнение | Загрузка через админку **и** ручная укладка в `inbox/` со сканером |
| Путь хранилища | Корень — в env, относительные подпути — в БД |
| Качество | Одно: 1080p, ~4 Мбит/с |
| Оригиналы | Удаляются после успешной нарезки |
| Защита потока | Отдельная httpOnly cookie с ограниченным `Path` |
| Обработка | Сразу после загрузки, с `nice` и `-threads 3` |
| Масштаб | 1–5 каналов, до 10 одновременных зрителей |
## Доменная модель наполнения
Всё в стиле существующего `RefreshToken`: rich model, приватные сеттеры, фабрики, поведенческие
методы.
**Domain**
- `Media/MediaAsset` — один видеофайл. Статус (`Pending|Processing|Ready|Failed`), точная
длительность (кратная 2с), `SegmentCount`, относительный путь, метаданные (разрешение, кодеки,
битрейт), исходное имя файла. Роль (серия/реклама) определяется тем, откуда на него ссылаются, а
не полем самого ассета; для фильтрации в админке допустима мягкая метка `kind`.
- `Library/Show` — переиспользуемое шоу в общей библиотеке. Имя, описание, `ShowKind`
(`Series` | `Single`), **упорядоченный** список серий (`ShowEpisode`: позиция + `MediaAssetId`).
Полнометражка — это `Single` с одной серией.
- `Broadcast/Channel` — slug, название, вкл/выкл, `EpochUtc`, политика рекламы
(`AdInsertion: BetweenBlocks | BetweenEpisodes`, `AdsPerBreak`), `FillerAssetId` (подстраховка).
- `Broadcast/ChannelShow` — связка канал↔шоу: `Weight` (частота в случайном выборе), `BlockMode`
(`Count` | `Duration`) и `BlockValue` (число серий K либо бюджет в минутах M), вкл/выкл. Для
`Single` блок всегда = 1 серия. «Шоу по одной серии» = `Count`/K=1.
- `Broadcast/ChannelAd` — связка канал↔рекламный ассет: пул врезок канала.
- `Broadcast/ProgrammingOverride` — временный override на канале: окно `[StartsAtUtc, EndsAtUtc]`,
режим (`Exclusive` — только это шоу; `Boost` — подменённые веса), ссылка(и) на шоу. На пересечении
окна с генерируемым временем планировщик использует override вместо базовой ротации. Марафон на
день — это `Exclusive`-override на 24ч с большим временным блоком. Повторяемость (напр. «каждую
субботу») — задел на будущее, в первой версии override разовый.
- `Broadcast/ScheduleEntry`**материализованная** запись расписания: канал, `MediaAssetId`,
`StartsAtUtc`, `EntryKind` (`Program | Ad`), для программ — `ShowId` и номер серии (для EPG).
Генерируется планировщиком на несколько дней вперёд.
Курсоров как отдельного состояния **нет**. «Следующая серия шоу X на канале Y» — это чистая
функция от расписания: берём последнюю запланированную серию этого шоу на этом канале, находим её
позицию в упорядоченном списке серий, следующая — `(pos + 1) % count` (отсюда же цикл с начала).
Первая генерация (записей ещё нет) стартует с первой серии. Рекламная ротация выводится так же —
из последней врезки. Единственное персистентное состояние эфира — сами строки `ScheduleEntry`,
рассинхронизироваться нечему.
## Планировщик
Доменный сервис `SchedulePlanner` + `BackgroundService`, который держит у каждого включённого
канала расписание, покрывающее `now + HorizonDays` (например 3 дня). Тик раз в
`Scheduler__TickMinutes` дописывает хвост от времени окончания последней записи.
Генерация одного шага для канала (от `lastEnd`):
1. **Определить активную политику** на момент `lastEnd`: если время попадает в окно
`ProgrammingOverride` — берём его (`Exclusive` фиксирует одно шоу, `Boost` подменяет веса), иначе
базовую ротацию канала.
2. **Взвешенно-случайный выбор** включённого `ChannelShow` по действующим весам (`System.Random`,
seed на генерацию логируется для воспроизводимости; отдельного состояния не требует, т.к.
результат материализуется).
3. Набрать блок серий подряд от «следующей» (см. вывод из хвоста выше), с заворотом на первую по
достижении конца:
- `BlockMode = Count` — ровно `BlockValue` серий;
- `BlockMode = Duration` — добавлять серии, пока суммарная длительность не достигнет `BlockValue`
минут (учитывается реальная длина каждой серии; последняя серия, переваливающая за бюджет,
либо входит целиком, либо отсекается по правилу «ближе к бюджету» — фиксируем «входит целиком»,
чтобы не резать серии).
Каждую серию дописать как `Program` встык.
4. Вставить рекламу по политике канала: `BetweenBlocks` — после блока; `BetweenEpisodes` — после
каждой серии. Взять `AdsPerBreak` врезок из ротации пула, дописать как `Ad`.
5. Повторять, пока не покрыт горизонт.
Ключевой инвариант — **встык**: `StartsAt` каждой следующей записи равен `StartsAt + Duration`
предыдущей. Так как каждая длительность кратна 2с (см. ниже), все старты автоматически кратны 2с
от эпохи — выравнивание на сегмент держится само собой, без часовой сетки и без дыр.
**Правка на лету.** Меняются веса/блоки/реклама, состав шоу или добавляется/снимается override →
перегенерируется только будущий хвост от безопасной точки (конец текущей программы + буфер), прошлое
не трогаем. Поскольку «курсор» выводится из хвоста, обрезав будущие записи, планировщик просто
продолжит от последней оставшейся — двойного проигрывания серий не будет. Override, начинающийся
позже, подхватится штатным тиком; override «начиная прямо сейчас» инициирует немедленную
перегенерацию хвоста.
**Ретеншн.** Прошедшие `ScheduleEntry` старше `Scheduler__RetentionHours` можно удалять (оставляя
небольшое окно назад для EPG «что только что было»). Сегменты ассетов при этом не трогаются —
ассеты переиспользуются, чистится только расписание.
**Дыры.** В норме их нет. Если у канала нет ни одного включённого шоу с готовыми сериями (или
планировщик отстал) — эфир играет `FillerAssetId` по кругу, пока не появится расписание.
## Ключевой инвариант: выравнивание на длину сегмента
Длина сегмента — 2 секунды (`Storage__SegmentSeconds`). При нарезке **длительность каждого ассета
дополняется до кратности 2с** (хвост добивается чёрным кадром). Тогда встык-программирование даёт
старты, кратные 2с, а `EXT-X-MEDIA-SEQUENCE = (now - epoch) / 2` монотонен по построению.
Без выравнивания хвосты программ были бы некратны сегменту, а счётчик `MEDIA-SEQUENCE` «съезжал» бы
относительно фактического числа сегментов — плееры реагируют пропуском или повтором сегмента. Плата
— до 2с чёрного экрана на стыке, практически незаметно.
## Построение эфирного окна
```
BuildLiveWindow(расписание, филлер, now, windowSegments, segmentSeconds)
→ (mediaSequence, discontinuitySequence, сегменты[])
```
Окно — 10 сегментов (20с). Идём назад от `now`, для каждого 2-секундного слота находим запись
расписания (программу или рекламу) и позицию сегмента в её ассете как `(now - StartsAt) / 2`. На
стыке записей ставим `EXT-X-DISCONTINUITY` (склейка разнородного контента и рекламы). Всё время —
UTC; локальный пояс показывает фронтенд. Раздача встык — типичный случай без филлера; филлер
подставляется только при пустом расписании.
Это самая ответственная часть системы, покрывается юнит-тестами: границы записей, вставка рекламы,
монотонность `MEDIA-SEQUENCE`, правка хвоста на лету, вывод «следующей серии» из расписания.
Нагрузка на API при 10 зрителях — ~5 запросов/с за сегментами и ~5 за плейлистами, пренебрежимо.
## Раскладка на диске
Хост: `sdb` (700G) — один раздел ext4, смонтирован в `/srv/telewave/media`, проброшен в контейнер
как `/media` (см. `docs/server-storage-setup.md`).
```
/media
├── inbox/ # ручная укладка файлов (SFTP/rsync), подбирается сканером
├── uploads/ # незавершённые chunked-загрузки из админки
├── originals/{assetId}/ # исходник; удаляется после успешной нарезки
└── assets/{assetId}/
├── seg00000.ts … # сегменты строго по 2с
└── index.m3u8 # VOD-плейлист ассета (превью в админке)
```
Сегменты — **MPEG-TS** (стыки через `EXT-X-DISCONTINUITY` без `EXT-X-MAP`). Ёмкость при 1080p
~4 Мбит/с: час ≈ 1.8 ГБ → **~390 часов** на 700G (оригиналы удаляются). Ассеты переиспользуются
многими каналами и записями расписания, поэтому библиотека растёт медленнее, чем «часы эфира».
## Обработка при загрузке
Единственное место, где работает ffmpeg. Исходники разношёрстные, `-c copy` не применим:
```bash
nice -n 10 ffmpeg -i src \
-threads 3 \
-c:v libx264 -preset veryfast -crf 21 -maxrate 4500k -bufsize 9000k \
-vf "scale='min(1920,iw)':-2" \
-force_key_frames "expr:gte(t,n_forced*2)" -sc_threshold 0 \
-c:a aac -b:a 128k -ac 2 -ar 48000 \
-f hls -hls_time 2 -hls_playlist_type vod -hls_list_size 0 \
-hls_segment_filename seg%05d.ts index.m3u8
```
- `scale='min(1920,iw)':-2` — не апскейлим то, что меньше 1080p.
- `-force_key_frames expr:gte(t,n_forced*2)` надёжнее фиксированного `-g` при нецелых fps.
- `-sc_threshold 0` убирает сцен-детект, иначе сегменты перестанут быть ровными.
- `maxrate 4500k` — ключевой кадр каждые 2с стоит ~5–10% битрейта.
- `-threads 3` + `nice` оставляют ядро эфиру. Финальный шаг — добивка хвоста до кратности 2с.
Очередь обработки: `System.Threading.Channels` + `BackgroundService`, строго одна задача за раз.
Inbox-сканер — второй `BackgroundService`: замечает новый файл, ждёт стабилизации размера, заводит
`MediaAsset` в `Pending` и ставит в очередь. Из inbox файлы попадают в библиотеку как отдельные
ассеты; привязка к шоу/сериям — уже действие админа в UI.
## Слои и API
**Application** — порты `IMediaStorage` (резолв путей, запись, удаление, свободное место),
`IMediaProcessor` (ffprobe + нарезка), `IMediaProcessingQueue`. Команды: `CreateShow`,
`AddEpisode`, `CreateChannel`, `AddChannelShow` (вес + режим блока), `SetAdPolicy`, `AddChannelAd`,
`CreateProgrammingOverride`, `DeleteProgrammingOverride`, `DeleteMediaAsset`, `RegenerateSchedule`,
`ScanInbox`. Запросы: `GetLivePlaylist`, `GetChannelEpg`, `ListChannels`, `ListShows`,
`ListMediaAssets`.
**Infrastructure**`FileSystemMediaStorage`, `FfmpegMediaProcessor`, `SchedulePlanner`, фоновые
сервисы очереди, сканера и планировщика.
**Api** — публичное: `GET /api/channels`, `GET /api/channels/{slug}/live.m3u8`,
`GET /api/channels/{slug}/epg`, `GET /api/stream/{assetId}/{segment}.ts`. Админское:
`/api/admin/media/*`, `/api/admin/shows/*`, `/api/admin/channels/*`.
## Конфигурация
```
Storage__RootPath=/media
Storage__SegmentSeconds=2
Storage__LiveWindowSegments=10
Storage__KeepOriginals=false
Storage__MinFreeSpaceBytes=10737418240
Media__FfmpegPath=/usr/bin/ffmpeg
Media__FfprobePath=/usr/bin/ffprobe
Media__MaxUploadBytes=21474836480
Scheduler__HorizonDays=3
Scheduler__RetentionHours=24
Scheduler__TickMinutes=30
```
В БД хранятся только **относительные** пути (`assets/{id}`), никогда абсолютные. Смена корня — это
перемонтирование volume и рестарт, поэтому корню место в env, а не в настройках админки.
`IMediaStorage.Resolve(relative)` обязан проверять, что итоговый путь физически внутри корня —
часть путей приходит из имён загруженных файлов, это прямой вектор path traversal.
## Отдача и защита
Сегменты иммутабельны → `Cache-Control: public, max-age=31536000, immutable`. Плейлист →
`no-cache`. Отдача через `SendFileAsync`; отдельный nginx не вводится (нарушило бы единый
контейнер). Узкое место на 10 зрителях — сеть (~40 Мбит/с), не CPU.
Защита — короткоживущая httpOnly cookie `tw_stream` (`Path=/api/stream`, `SameSite=Strict`,
подписанный токен с id пользователя). Работает и с hls.js, и с нативным HLS в Safari/iOS, где
`Authorization` поставить некуда. Продлевается на каждом ответе плейлиста — а тот запрашивается
каждые ~2с, поэтому cookie живёт, пока зритель смотрит.
## Ресурсы сервера
Замеры на `tvvm`: **4 vCPU**, память динамическая до 8 ГБ, диск `sdb` 700G. Видеоадаптер —
`1234:1111` (эмулируемый QEMU/Bochs stdvga), в `/dev/dri` только `card0` без `renderD128`:
**аппаратное кодирование недоступно**, транскод только на CPU. `veryfast` 1080p при `-threads 3`
~1.53× realtime (полуторачасовой фильм 30–60 мин в фоне).
Памяти при 8 ГБ достаточно. Но выделение динамическое, а .NET настраивает GC по памяти при старте,
поэтому в compose нужен явный `mem_limit` (например `3g`) — детерминированный cgroup-лимит вместо
плавающего значения хоста, заодно предсказуемый выбор OOM-killer. Загрузка стримится на диск без
буферизации тела в память: chunked-куски пишутся в `uploads/`, `complete` делает атомарный `move`
внутри той же ФС.
## Открытые эксплуатационные вопросы
- Удаление ассета/шоу запрещать, пока на них ссылается будущее расписание или пул канала.
- Свободное место: проверка перед загрузкой и индикация в админке
(`DriveInfo.AvailableFreeSpace`), отказ ниже `Storage__MinFreeSpaceBytes`.
- Что показывать зрителю на границе рекламы в EPG (скрывать врезки или показывать «Реклама»).
+200
View File
@@ -0,0 +1,200 @@
# Подготовка диска под медиахранилище (tvvm)
Runbook: разметка, форматирование и монтирование диска `sdb` (700G) под хранилище TeleWave.
Выполняется **один раз** на сервере `tvvm` перед запуском фичи медиа. Все команды — от root
(`sudo -i` или с `sudo` перед каждой).
> ⚠️ Команды разметки **уничтожают данные** на целевом диске. Диск `sdb` сейчас пустой (нет
> разделов), но каждый шаг ниже содержит проверку — не пропускайте их.
Итог: раздел `sdb1` (ext4) смонтирован в `/srv/telewave/media`, внутри созданы рабочие каталоги,
запись из контейнера (сейчас работает под root) возможна.
---
## 0. Проверить, что это тот самый диск
```bash
lsblk -o NAME,SIZE,TYPE,MOUNTPOINTS,FSTYPE /dev/sdb
```
Ожидается: `sdb` размером `700G`, тип `disk`, **без разделов и без точек монтирования**. Убедитесь
дополнительно, что на диске нет файловой системы и подписей:
```bash
sudo wipefs -n /dev/sdb # -n = «сухой прогон», ничего не меняет
```
Если вывод пустой — диск чист, продолжаем. Если что-то нашлось (следы старой ФС/RAID) —
**остановитесь** и разберитесь, что это, прежде чем идти дальше.
---
## 1. Создать таблицу разделов и один раздел
GPT + один раздел на весь диск:
```bash
sudo parted -s /dev/sdb mklabel gpt
sudo parted -s -a optimal /dev/sdb mkpart primary ext4 0% 100%
sudo partprobe /dev/sdb
lsblk /dev/sdb
```
Должен появиться `sdb1` размером ~700G.
---
## 2. Отформатировать в ext4
`-m 1` уменьшает резерв под root с 5% до 1% (на диске данных резерв в 35 ГБ не нужен), `-L` даёт
метку тома:
```bash
sudo mkfs.ext4 -m 1 -L telewave-media /dev/sdb1
```
---
## 3. Создать точку монтирования
```bash
sudo mkdir -p /srv/telewave/media
```
---
## 4. Прописать в /etc/fstab (монтирование по UUID)
Монтируем по UUID, а не по имени `sdb1` — имя может измениться при добавлении дисков. Узнать UUID:
```bash
sudo blkid /dev/sdb1
```
Скопируйте значение `UUID="..."` и добавьте строку в `/etc/fstab` (подставьте свой UUID):
```
UUID=ВАШ-UUID /srv/telewave/media ext4 defaults,noatime,nofail,x-systemd.device-timeout=10 0 2
```
Пояснения к опциям:
- `noatime` — не обновлять время доступа при чтении сегментов; заметно снижает лишние записи при
раздаче видео.
- `nofail` — если диск не подключился, система всё равно загрузится (приложение просто не сможет
писать, а не «висит» на загрузке).
- `x-systemd.device-timeout=10` — не ждать диск дольше 10с при старте.
Проверить, что fstab корректен, и смонтировать:
```bash
sudo systemctl daemon-reload
sudo mount -a
findmnt /srv/telewave/media
```
`findmnt` должен показать смонтированный `ext4` на `/dev/sdb1`. **Ошибка на этом шаге лучше, чем
на следующей перезагрузке** — если `mount -a` ругается, чините fstab сейчас.
---
## 5. Рабочие каталоги и права
Создать структуру, ожидаемую приложением:
```bash
sudo mkdir -p /srv/telewave/media/{inbox,uploads,originals,assets}
```
**Владелец.** Контейнер сейчас работает под `root` (в Dockerfile нет `USER`), а bind-mount
пробрасывает права хоста внутрь как есть. Поэтому достаточно оставить владельцем root:
```bash
sudo chown -R root:root /srv/telewave/media
sudo chmod -R 755 /srv/telewave/media
```
Чтобы вы могли класть файлы в `inbox/` вручную (SFTP/rsync) под своим пользователем, откройте на
запись именно этот каталог вашей группе:
```bash
sudo chown root:$(id -gn) /srv/telewave/media/inbox
sudo chmod 775 /srv/telewave/media/inbox
```
> При переходе контейнера на non-root пользователя (если позже добавим `USER` в Dockerfile —
> в aspnet-образе это обычно uid `1654`), сменить владельца на этот uid:
> `sudo chown -R 1654:1654 /srv/telewave/media` (кроме `inbox`, оставленного вам).
---
## 6. Проброс в контейнер (bind mount)
В `docker-compose.yml`, в сервис `app`, добавить том (этого пока **нет** в репозитории — появится
вместе с реализацией фичи, здесь для справки):
```yaml
services:
app:
# ... существующая конфигурация ...
volumes:
- /srv/telewave/media:/media
mem_limit: 3g
```
И переменные окружения в `.env` (см. `docs/media-storage-and-streaming.md`):
```
Storage__RootPath=/media
```
Выбран **bind mount**: диск смонтирован в ОС через fstab (шаги 1–5) и виден всегда, независимо от
Docker — это важно, потому что файлы в `inbox/` кладутся вручную по SFTP/rsync, в том числе когда
контейнер остановлен.
> **Альтернатива — named volume, монтируемый самим Docker** (не используем, для справки). Позволяет
> пропустить шаги 3–5, но диск тогда доступен только при запущенном контейнере, а подкаталоги всё
> равно надо создавать заранее. Пример:
>
> ```yaml
> services:
> app:
> volumes:
> - media:/media
> mem_limit: 3g
> volumes:
> media:
> driver: local
> driver_opts:
> type: ext4
> device: /dev/disk/by-uuid/ВАШ-UUID
> o: noatime
> ```
>
> Разметка и `mkfs.ext4` (шаги 1–2) обязательны в любом случае.
---
## 7. Проверка записи из контейнера
После добавления тома и пересборки образа убедиться, что контейнер пишет на диск:
```bash
docker compose exec app sh -c 'touch /media/.wtest && ls -l /media/.wtest && rm /media/.wtest'
```
Команда должна отработать без ошибок доступа. На этом подготовка хоста завершена — дальнейшее
(создание `MediaAsset`, нарезка ffmpeg) делает уже само приложение.
---
## Приложение: если диск нужно расширить в будущем
Если VM отдаст диску больше места (например `sdb` вырастет с 700G), после увеличения на стороне
гипервизора:
```bash
sudo growpart /dev/sdb 1 # расширить раздел на весь диск
sudo resize2fs /dev/sdb1 # расширить ext4 (можно на смонтированном)
df -h /srv/telewave/media
```