Files
TeleWave/docs/media-storage-and-streaming.md
T

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