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:
@@ -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.5–3× realtime (полуторачасовой фильм 30–60 мин в фоне).
|
||||
|
||||
Памяти при 8 ГБ достаточно. Но выделение динамическое, а .NET настраивает GC по памяти при старте,
|
||||
поэтому в compose нужен явный `mem_limit` (например `3g`) — детерминированный cgroup-лимит вместо
|
||||
плавающего значения хоста, заодно предсказуемый выбор OOM-killer. Загрузка стримится на диск без
|
||||
буферизации тела в память: chunked-куски пишутся в `uploads/`, `complete` делает атомарный `move`
|
||||
внутри той же ФС.
|
||||
|
||||
## Открытые эксплуатационные вопросы
|
||||
|
||||
- Удаление ассета/шоу запрещать, пока на них ссылается будущее расписание или пул канала.
|
||||
- Свободное место: проверка перед загрузкой и индикация в админке
|
||||
(`DriveInfo.AvailableFreeSpace`), отказ ниже `Storage__MinFreeSpaceBytes`.
|
||||
- Что показывать зрителю на границе рекламы в EPG (скрывать врезки или показывать «Реклама»).
|
||||
Reference in New Issue
Block a user