Added new endpoints for matching and importing movies, allowing for automated processing of film files from the inbox. Introduced logic to parse file names into titles and years, and integrated metadata matching to streamline the import process. Updated the MediaOptions to include a configuration for automatic movie creation from recognized files. Enhanced the InboxScanner to attempt movie attachment upon asset registration, improving user experience and efficiency. Updated documentation to reflect these new features and their usage.
29 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__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/ # незавершённые загрузки из админки; регистрация уносит файл в 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 не применим:
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, по умолчанию включено). Всё неуверенное молча остаётся ассетом без шоу и попадает в ту же таблицу разбора — автоматика обязана молчать там, где таблица показала бы жёлтым.
Шоу импорт создаёт, а шоу-контент — никогда: файл приносит загрузка, и придуманное источником
название не должно превращаться в запись библиотеки без файла. Франшизы отдельным шагом не
собираются: 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 (скрывать врезки или показывать «Реклама»).