273 lines
24 KiB
Markdown
273 lines
24 KiB
Markdown
# Хранение медиа и линейный эфир
|
||
|
||
Проектное решение для следующего этапа 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), вкл/выкл, а также
|
||
`NextEpisodeIndex` — **персональный для этого канала курсор серий**. Для `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).
|
||
Генерируется планировщиком на несколько дней вперёд.
|
||
|
||
**Курсоры серий и рекламы — персистентные** (`ChannelShow.NextEpisodeIndex`, `Channel.NextAdIndex`),
|
||
их двигает планировщик по мере постановки в расписание. Так серии идут по порядку и продолжаются
|
||
через дни, а курсор переживает чистку старых `ScheduleEntry` по ретеншну (вывод «из хвоста» этого не
|
||
гарантировал бы для редко играемых шоу). Заворот на первую серию — `(index + 1) % count`.
|
||
|
||
Компромисс: при **перегенерации** будущего хвоста (правка конфигурации канала) удалённые из будущего
|
||
серии уже «отмотаны» курсором вперёд и однократно пропускаются — для домашнего канала это незаметно,
|
||
зато код без сложной «перемотки» курсоров. Расширение горизонта (обычный тик) ничего не удаляет и
|
||
пропусков не даёт.
|
||
|
||
## Планировщик
|
||
|
||
Доменный сервис `SchedulePlanner` + `BackgroundService`, который держит у каждого включённого
|
||
канала расписание, покрывающее `now + HorizonDays` (например 3 дня). Тик раз в
|
||
`Scheduler__TickMinutes` дописывает хвост от времени окончания последней записи.
|
||
|
||
Генерация одного шага для канала (от `lastEnd`):
|
||
|
||
1. **Определить активную политику** на момент `lastEnd`: если время попадает в окно
|
||
`ProgrammingOverride` — берём его (`Exclusive` фиксирует одно шоу, `Boost` подменяет веса), иначе
|
||
базовую ротацию канала.
|
||
2. **Взвешенно-случайный выбор** включённого `ChannelShow` по действующим весам (`System.Random`,
|
||
seed на генерацию логируется для воспроизводимости; отдельного состояния не требует, т.к.
|
||
результат материализуется).
|
||
3. Набрать блок серий подряд от «следующей» (курсор `NextEpisodeIndex`), с заворотом на первую по
|
||
достижении конца:
|
||
- `BlockMode = Count` — ровно `BlockValue` серий;
|
||
- `BlockMode = Duration` — добавлять серии, пока суммарная длительность не достигнет `BlockValue`
|
||
минут (учитывается реальная длина каждой серии; последняя серия, переваливающая за бюджет,
|
||
либо входит целиком, либо отсекается по правилу «ближе к бюджету» — фиксируем «входит целиком»,
|
||
чтобы не резать серии).
|
||
Каждую серию дописать как `Program` встык.
|
||
4. Вставить рекламу по политике канала: `BetweenBlocks` — после блока; `BetweenEpisodes` — после
|
||
каждой серии. Взять `AdsPerBreak` врезок из ротации пула, дописать как `Ad`.
|
||
5. Повторять, пока не покрыт горизонт.
|
||
|
||
Ключевой инвариант — **встык**: `StartsAt` каждой следующей записи равен `StartsAt + Duration`
|
||
предыдущей. Так как каждая длительность кратна 2с (см. ниже), все старты автоматически кратны 2с
|
||
от эпохи — выравнивание на сегмент держится само собой, без часовой сетки и без дыр.
|
||
|
||
**Правка на лету.** После изменения весов/блоков/рекламы/состава шоу или добавления override админ
|
||
дёргает перегенерацию канала (`POST /api/admin/channels/{id}/regenerate`): удаляется ещё не
|
||
стартовавший хвост (записи с `StartsAtUtc >= now`), сохраняется текущая идущая программа, и хвост
|
||
достраивается заново от её конца. Обычный фоновый тик только **расширяет** горизонт (без удаления).
|
||
Override, начинающийся в будущем, подхватится и штатным тиком.
|
||
|
||
**Ретеншн.** Прошедшие `ScheduleEntry` старше `Scheduler__RetentionDays` удаляются. Окно длинное
|
||
(90 суток по умолчанию), потому что история показов берётся из самой ленты: по ней работают остывание
|
||
и потолок повторов, и окно обязано покрывать самое долгое правило канала. Сегменты ассетов при этом
|
||
не трогаются — ассеты переиспользуются, чистится только расписание.
|
||
|
||
**Дыры.** В норме их нет. Если у канала нет ни одного включённого шоу с готовыми сериями (или
|
||
планировщик отстал) — эфир играет `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 \
|
||
-af "loudnorm=I=-16:TP=-1.5:LRA=11" \
|
||
-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% битрейта.
|
||
- `loudnorm=I=-16:TP=-1.5:LRA=11` — нормализация громкости (EBU R128), чтобы все ролики звучали
|
||
одинаково; цель настраивается (`Media__LoudnessTargetLufs`), можно выключить (`Media__NormalizeLoudness`).
|
||
- `-threads 3` + `nice` оставляют ядро эфиру. Финальный шаг — добивка хвоста до кратности 2с
|
||
(`-af ...,apad=pad_dur=PAD -t TARGET`).
|
||
|
||
Очередь обработки: `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=7
|
||
Scheduler__RetentionDays=90
|
||
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 (скрывать врезки или показывать «Реклама»).
|