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

23 KiB
Raw Blame History

Хранение медиа и линейный эфир

Проектное решение для следующего этапа 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 не применим:

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.

InfrastructureFileSystemMediaStorage, 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 (скрывать врезки или показывать «Реклама»).