# Хранение медиа и линейный эфир Проектное решение для следующего этапа 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; локальный пояс показывает фронтенд. Раздача встык — типичный случай без филлера; филлер подставляется только при пустом расписании. **Плеер возвращается к живому краю сам.** Окно короткое, а вкладка в фоне живёт по другим правилам: браузер душит таймеры, hls.js не успевает тянуть сегменты, видео доигрывает буфер и встаёт. Пока зритель не смотрит, окно уходит вперёд на сколько угодно — и, вернувшись, он увидел бы программу, которая в эфире давно кончилась. Поэтому отставание больше 30 секунд плеер закрывает прыжком на живой край (`liveSyncPosition`, в нативной ветке — конец `seekable`), а не догоном: эфир общий и неперематываемый, догнать его нельзя, можно только застать. Проверка — на каждом обновлении медиаплейлиста и на возврате вкладки; после сетевого сбоя загрузка тоже возобновляется с края, а не с позиции, на которой всё встало. Это самая ответственная часть системы, покрывается юнит-тестами: границы записей, вставка рекламы, монотонность `MEDIA-SEQUENCE`, правка хвоста на лету, вывод «следующей серии» из расписания. Нагрузка на API при 10 зрителях — ~5 запросов/с за сегментами и ~5 за плейлистами, пренебрежимо. ## Раскладка на диске Хост: `sdb` (700G) — один раздел ext4, смонтирован в `/srv/telewave/media`, проброшен в контейнер как `/media` (см. `docs/server-storage-setup.md`). ``` /media ├── inbox/ # ручная укладка файлов (SFTP/rsync), подбирается сканером ├── uploads/ # незавершённые загрузки из админки; регистрация уносит файл в originals/ ├── 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. ## Разбор фильмов Сериал грузится пачкой файлов **в одно шоу** — этим занят ручной разбор `manual/`. У полнометражек наоборот: файл — это отдельное шоу, и вручную заводить сорок штук никто не станет. Отсюда отдельный разбор, у которого три входа и один общий код. **Разбор имени** (`MovieNameParser`) даёт название и год. Порядок: сначала отрезается хвост релиза по первому техническому маркеру (rip, кодек, разрешение, `by_...`), и только в остатке ищется год — иначе «Edward Scissorhands by_IVAN@1990» дал бы название с ником релизера, а год из ника сошёл бы за год выпуска. Год в начале имени годом не считается: «2012.2009.BDRip» — это фильм «2012» девятого года. Первое слово маркером не считается («Extended Family» существует), а `web` и `rip` из списка маркеров исключены — «Charlotte's Web» дороже лишнего хвоста. Папка релиза разбирается по имени папки: оно чище, чем `video.mkv` внутри, а файлом берётся самый большой (остальное — сэмплы). **Подбор** (`MovieMatcher`) ищет по названию среди полнометражек источника и решает, уверенное ли это совпадение: нормализованное название сошлось дословно, год сошёлся с точностью до года (год проката и год производства расходятся) и такой кандидат ровно один. Правило то же, что у массового обогащения из 6.x, и по той же причине: чужой постер и чужой рейтинг у фильма в эфире хуже, чем их отсутствие. Статусы строки — «совпадение точное», «проверьте», «не найдено», «уже в библиотеке» (по идентификатору источника). **Три входа, одно правило:** - **Кнопка, файлы из `manual/`.** Таблица разбора: строку правят руками (транслитные имена вроде `Shou.Trumana` источник не находит — впишите название и перезапросите одну строку), затем импорт заводит шоу, применяет метаданные и отправляет файл в очередь. - **Кнопка, файлы с диска.** Имена разбираются **до** загрузки: узнать, что половина строк не распозналась, после заливки десятков гигабайт — слишком поздно. Подтверждённые файлы грузятся по одному, и каждый заводится сразу после своего аплоада. - **Автоматика inbox.** Сканер после регистрации ассета пробует опознать фильм и заводит шоу **только при уверенном совпадении** (`Media__InboxAutoMovies`, по умолчанию включено). Всё неуверенное молча остаётся ассетом без шоу и попадает в ту же таблицу разбора — автоматика обязана молчать там, где таблица показала бы жёлтым. **Как называется заведённый фильм.** Поле «Название» в таблице — это строка поиска, а не имя шоу: «Die Hard 2» там стоит ровно для того, чтобы найти «Крепкий орешек 2». В библиотеку попадает название карточки источника, а то, что распозналось в имени файла, становится **оригинальным** названием — по нему потом ищутся релизы и сходятся имена. Если источник отдал своё original title (у TMDb это `original_title`/`original_name`), берётся оно. Уже проставленное оригинальное название не переписывается: его мог вписать человек. Шоу импорт создаёт, а **шоу-контент — никогда**: файл приносит загрузка, и придуманное источником название не должно превращаться в запись библиотеки без файла. Франшизы отдельным шагом не собираются: `FranchiseExternalId` проставляется вместе с метаданными, а коллекции предлагает существующий `CollectionSuggestionBuilder` — там виден состав, и спин-оффы TMDb отсекает человек. ## Слои и 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 Storage__StaleUploadHours=24 Media__FfmpegPath=/usr/bin/ffmpeg Media__FfprobePath=/usr/bin/ffprobe Media__MaxUploadBytes=21474836480 Media__InboxAutoMovies=true 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` внутри той же ФС. `uploads/` — перевалочный каталог: токен загрузки нигде не хранится, поэтому всё, что там осталось после запроса, — мусор, на который никто не сослался. Убирается в два эшелона: недописанный файл сносится на месте (обрыв заливки, отказ регистрации, сбой БД), а `UploadsCleanupBackgroundService` раз в час подметает залежавшееся дольше `Storage__StaleUploadHours` (0 — уборку не делать) — это страховка от аварийного рестарта посреди заливки. Порог считается от времени последней записи, поэтому идущая многочасовая загрузка под него не попадает. ## Открытые эксплуатационные вопросы - Удаление ассета/шоу запрещать, пока на них ссылается будущее расписание или пул канала. - Свободное место: проверка перед загрузкой и индикация в админке (`DriveInfo.AvailableFreeSpace`), отказ ниже `Storage__MinFreeSpaceBytes`. - Что показывать зрителю на границе рекламы в EPG (скрывать врезки или показывать «Реклама»).