23 KiB
Хранение медиа и линейный эфир
Проектное решение для следующего этапа TeleWave: библиотека контента, автоматическое программирование каналов и раздача линейного эфира. Документ описывает согласованный дизайн — реализации пока нет.
Что строим
Админ работает не со слотами вручную, а с библиотекой шоу и правилами канала:
- Загружает контент в библиотеку: шоу (сериал с упорядоченными сериями либо полнометражка) и рекламные врезки. Загруженное переиспользуется — одно шоу можно ставить на разные каналы.
- Создаёт канал (например «Мультики»), добавляет в него несколько шоу с весами и размером блока (сколько серий подряд крутить за раз), плюс пул рекламы и политику её вставки.
- Планировщик сам строит расписание на несколько дней вперёд: взвешенно-случайно выбирает шоу, ставит блок из 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):
- Определить активную политику на момент
lastEnd: если время попадает в окноProgrammingOverride— берём его (Exclusiveфиксирует одно шоу,Boostподменяет веса), иначе базовую ротацию канала. - Взвешенно-случайный выбор включённого
ChannelShowпо действующим весам (System.Random, seed на генерацию логируется для воспроизводимости; отдельного состояния не требует, т.к. результат материализуется). - Набрать блок серий подряд от «следующей» (курсор
NextEpisodeIndex), с заворотом на первую по достижении конца:BlockMode = Count— ровноBlockValueсерий;BlockMode = Duration— добавлять серии, пока суммарная длительность не достигнетBlockValueминут (учитывается реальная длина каждой серии; последняя серия, переваливающая за бюджет, либо входит целиком, либо отсекается по правилу «ближе к бюджету» — фиксируем «входит целиком», чтобы не резать серии). Каждую серию дописать какProgramвстык.
- Вставить рекламу по политике канала:
BetweenBlocks— после блока;BetweenEpisodes— после каждой серии. ВзятьAdsPerBreakврезок из ротации пула, дописать какAd. - Повторять, пока не покрыт горизонт.
Ключевой инвариант — встык: StartsAt каждой следующей записи равен StartsAt + Duration
предыдущей. Так как каждая длительность кратна 2с (см. ниже), все старты автоматически кратны 2с
от эпохи — выравнивание на сегмент держится само собой, без часовой сетки и без дыр.
Правка на лету. После изменения весов/блоков/рекламы/состава шоу или добавления override админ
дёргает перегенерацию канала (POST /api/admin/channels/{id}/regenerate): удаляется ещё не
стартовавший хвост (записи с StartsAtUtc >= now), сохраняется текущая идущая программа, и хвост
достраивается заново от её конца. Обычный фоновый тик только расширяет горизонт (без удаления).
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 не применим:
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 (скрывать врезки или показывать «Реклама»).