Files
TeleWave/docs/tv-scheduler-architecture.md
T
Leonid Pershin fbfc6e677e
ci / build-backend (push) Successful in 1m21s
ci / build-frontend (push) Successful in 51s
ci / tests (push) Successful in 1m40s
ci / sonar (push) Successful in 3m46s
Enhance GridScheduleGenerator and related components for improved slot handling and cursor management
Updated the GridScheduleGenerator to support rebuilding future schedules while correctly managing aired positions. Introduced a new AiredPosition record to track the last aired state of slots, ensuring that the cursor rewinds to the correct position during rebuilds. Refactored the BuildInputAsync method to accept aired positions, and modified the LoadAiredPositionsAsync method for accurate retrieval of past entries. Enhanced the SchedulePlanner to utilize shared cursor states between main and background loops, preventing duplicate series plays. Updated documentation to reflect these changes and added integration tests to verify the correct behavior of the new functionality.
2026-07-29 08:57:46 +03:00

103 KiB
Raw Blame History

Планировщик эфира: архитектура

Спецификация переработки системы планирования расписания канала и админского UI. Заменяет текущую взвешенную ротацию шоу (SchedulePlanner, ChannelShow, ProgrammingOverride) на сетку из слоёв и слотов с переиспользуемыми группами контента.

Документ описывает целевое состояние. Раздел 9 — что при этом удаляется из текущего кода.


1. Что строим

1.0. Зачем

Проект ностальгический: телевидение, каким оно было до того, как стало осторожным и одинаковым. Мультики, о которых ты знал, что они будут в 16:00. Шоу, которое крутилось вечно. Смешная реклама, которую помнишь лучше, чем передачи. Ночной блок, который смотрели тайком.

Отсюда два следствия, определяющих решения ниже.

Эфир общий и неперематываемый. Нельзя начать сначала, нельзя отмотать, нельзя поставить на паузу. Опоздал — опоздал. Это не техническое ограничение раздачи, а суть: ровно этим проект отличается от очередного медиасервера с плейлистами. Соблазн добавить DVR или «смотреть с начала» надо гасить.

Конструктор должен быть удобным, а не мощным. Администратор собирает канал таким, каким его помнит, и упирается он не в нехватку возможностей, а в неудобство. Приоритет UI — комфорт и прямое манипулирование, а не полнота настроек. Готовых пресетов и «упрощённых режимов» при этом не делаем: канал создаётся с нуля, но так, чтобы это было приятно.

Возрастных ограничений на стороне зрителя нет. Что показывает канал — то и смотришь, как и было. Возрастной рейтинг — инструмент планирования (не поставить взрослое в детский эфир), а не гейт.

1.1. От чего уходим

Сейчас эфир канала — это взвешенный случайный выбор шоу из плоского списка с блоками серий и временными override'ами. Расписание непредсказуемо: в 20:00 каждый день разное, «поставить фильм по пятницам» невозможно, «утром детское, ночью взрослое» выражается только грубым бустом весов по часам.

Целевая модель — сетка: канал имеет шаблон, шаблон состоит из слоёв, слой из слотов, слот ссылается на группу контента и стратегию выбора. Гибкость даёт не «конструктор расписаний», а разделение на независимые переиспользуемые части:

Слой Сущность Отвечает за
1 Группа Что может попасть в эфир
2 Шаблон → слой → слот Когда и в каком порядке
3 Стратегия Как выбирается конкретный элемент
4 Правила Чего нельзя допускать

Группы общие для всех каналов, слоты и стратегии — свои у каждого.


2. Принципы

2.1. Эластичная сетка

Времена слотов — цели, а не жёсткие границы. Контент идёт встык, слот считается исчерпанным, когда набран его бюджет времени; расхождение переносится на следующий слот. Это сохраняет главный инвариант раздачи: лента непрерывна, длительности кратны сегменту, LiveWindowCalculator работает без изменений.

Дрейф компенсируется якорями — слотами с жёстким стартом (прайм в 20:00). Перед якорем генератор не начинает единицу контента, которая через него перелезет: остаток до якоря добирается врезками и фоновым слоем. Так сетка «дышит» в течение дня, но опорные точки не уползают.

У каждого слота есть допуск дрейфа. Превышение — предупреждение в валидации, не ошибка.

2.2. Кратность сегменту

Сегмент раздачи — 2 секунды (StreamingOptions.SegmentSeconds). Все длительности в эфирной ленте кратны ему: это условие того, что MEDIA-SEQUENCE = floor((now epoch)/seg) остаётся монотонным и live-край не дрейфует.

Следствия: врезки и фоновые заполнители округляются вверх до сегмента; аварийный филлер канала — это зацикливаемый ассет в 1–2 сегмента, а не один ассет фиксированной длины; целевые времена слотов задаются с точностью до минуты (60 кратно 2).

2.3. Курсорная генерация

Расписание материализуется на горизонт вперёд и хранится в БД. Состояние (где остановились в сериале, что играли недавно) — часть модели, а не вычисляется заново.

Это осознанный отказ от «чистой функции schedule(channel, date)». При эластичной сетке число единиц контента, потреблённых за один выход слота, зависит от фактических длительностей — блок «пока не наберётся два часа» съест то ли три серии, то ли четыре. Не просимулировав от известной точки, не узнать.

Следствия, которые принимаем:

  • предпросмотр — это симуляция вперёд от текущего момента, а не «покажи 25 декабря» из ничего;
  • «почему это здесь» строится на трейсе, который генератор пишет в момент генерации (поле ScheduleEntry.Trace), а не восстанавливается задним числом;
  • граница now жёсткая: прошлое и текущая запись неизменяемы, пересобирается только хвост.

2.4. Время канала

Channel.UtcOffsetMinutes — фиксированный оффсет, по умолчанию 180 (московское время). Все времена слотов, окна правил и вещательные сутки трактуются в нём; в БД всё по-прежнему хранится в UTC.

Фиксированный оффсет, а не IANA-зона: у Москвы нет перевода часов, а с DST сутки перевода становятся 23- или 25-часовыми, что требует отдельного правила подрезки/добора в сетке. Если понадобятся каналы в зонах с переводом часов — переходим на IANA и добавляем это правило; пока это лишняя сложность.

Channel.DayStartTime — начало вещательных суток, по умолчанию 06:00. У телеканалов сутки начинаются утром: ночной блок с 00:00 до 06:00 относится к предыдущему дню. Влияет на то, какому дню недели принадлежит слот (ночь с пятницы на субботу — это пятница) и на отображение сетки в UI.


3. Модель данных

3.1. Расширения библиотеки

Жанры — справочник, а не свободные строки: жанры из TMDb маппятся в него автоматически при применении метаданных, а в UI фильтр набирается выбором из списка.

Genre
  id
  name                           — «Боевик», «Комедия», «Анимация»
  externalIds        jsonb       — маппинг на id провайдеров (tmdb: 28, ...)

ShowGenre                        — многие-ко-многим
  showId
  genreId
  isPrimary          bool        — основной жанр шоу (ровно один), для отображения

Отдельных свободных тегов не заводим: их роль («ретро», «новогоднее») выполняют группы с ручным составом, а это одна сущность вместо двух.

Возрастной рейтинг. ShowAudience — шкала MPAA, упорядоченная по возрастанию строгости: правила формулируются как «до 23:00 не строже PG-13», поэтому порядок в enum значим.

G      — без ограничений
PG     — с родителями
PG-13  — не рекомендуется до 13
R      — до 17 со взрослым
NC-17  — только взрослым

Шкала взята не абстрактная, а ровно та, которой оперируют внешние источники (Rated у OMDb, сертификации у TMDb): рейтинг из метаданных ложится в поле без промежуточной классификации, которую пришлось бы придумывать и объяснять.

Отсутствие рейтинга — null, а не значение шкалы. «Не проставлен» и «подходит всем» — разные факты; в одной ячейке теряются оба. Контент без рейтинга планировщик не отсекает: источники проставляют рейтинг далеко не всему (на неамериканском кино чаще всего не проставляют вовсе), и трактовка «неизвестное значит взрослое» вымела бы из эфира большую часть библиотеки.

Коллекция — франшиза: упорядоченный набор шоу. «Терминатор» — это три Show(Single) в порядке 1→2→3. Живёт в библиотеке рядом с шоу, потому что «одно произведение из трёх частей» — факт о контенте, а не о канале, и его хочется видеть на экране шоу.

Collection
  id
  name
  posterImageId      uuid?       — реестр изображений
  items              [CollectionItem]

CollectionItem
  collectionId
  showId
  position           int

Одно шоу может состоять и в коллекции, и лежать в группе само по себе — это разные способы его использовать.

3.2. Единица воспроизведения

Планировщик оперирует не «шоу», а последовательностью воспроизводимых единиц — это то, что позволяет сериалу и франшизе работать одним кодом:

  • сериал → его серии по ShowEpisode.Position;
  • коллекция → фильмы по CollectionItem.Position (сериал внутри коллекции разворачивается в свои серии);
  • одиночный фильм → ровно одна единица.

Отсюда оба сценария из требований выражаются одним слотом с разным размером блока: «три части подряд в один день» — блок в 3 единицы; «три дня по одному фильму» — блок в 1 единицу в слоте, срабатывающем три дня подряд.

3.3. Группа

Group
  id
  name                           — «Боевики 90-х», «Утренние мультфильмы», «Реклама»
  description
  filter             jsonb?      — правило набора, см. ниже
  items              [GroupItem]
  cachedStats        jsonb       — {itemCount, unitCount, totalDuration, computedAt}

GroupItem
  groupId
  elementKind        enum        — show | collection
  elementId          uuid
  weight             int         — по умолчанию 1
  position           int         — порядок для последовательных стратегий

Группы общие для всех каналов и переиспользуются.

Состав — всегда явный список. filter — это правило быстрого набора («всё с жанром боевик, 1989–1999»), а не запрос, выполняемый при генерации. Нажатие «применить фильтр» добавляет найденное в список, дальше состав правится руками. Живой запрос был бы проще в коде, но тогда добавление одного шоу в библиотеку перетасовывало бы всё будущее расписание всех каналов.

Новые поступления не пропадают: фоновая проверка сравнивает библиотеку с фильтром и показывает в UI «доступно 12 новых позиций», добавление — по кнопке.

{
  "elementKinds": ["show", "collection"],
  "showKinds": ["series", "single"],
  "genres": { "anyOf": ["боевик", "триллер"] },
  "audience": { "max": "PG-13" },
  "year": { "min": 1989, "max": 1999 },
  "unitDurationMinutes": { "min": 20, "max": 25 }
}

Веса. GroupItem.weight смещает вероятность внутри группы при случайном выборе. Нужен для реального случая: в группе на 200 боевиков полтора десятка сильных, остальное — наполнение; с одним только остыванием хорошее утонет в среднем.

Вес и остывание не конфликтуют, если применять их в правильном порядке: сначала остывание отсекает недавно показанное (жёсткий фильтр «нельзя»), потом взвешенный выбор работает среди оставшихся. В обратном порядке вес постоянно упирался бы в кулдаун.

В UI веса спрятаны за «дополнительно»: по умолчанию все единицы, поведение — чистое остывание.

3.4. Шаблон, слой, слот

ScheduleTemplate
  id
  channelId                      — один активный шаблон на канал
  name
  defaultJunctionId  uuid?       — стык по умолчанию для слотов без своего
  fallbackGroupId    uuid?       — аварийная группа, если пуст даже фон
  revision           int         — инкремент при любой правке; входит в кэш-ключи и историю

Один шаблон на канал. Сезонность («зимняя сетка») выражается слоями внутри него, а не вторым шаблоном — иначе получаются два механизма для одного и того же. Копирование на другой канал — глубокая копия (шаблон, слои, слоты); группы не копируются, они общие.

GridLayer
  id
  templateId
  name                           — «Фон», «Базовая», «Выходные», «Новогодняя»
  priority           int         — больше = специфичнее, побеждает. Фон = 0
  applicability      jsonb
  isEnabled          bool
{
  "weekdays": [1, 2, 3, 4, 5],
  "dateRanges": [{ "from": "2026-06-01", "to": "2026-08-31" }],
  "annualRanges": [{ "fromMonth": 12, "fromDay": 20, "toMonth": 12, "toDay": 31 }],
  "specificDates": ["2026-12-31"]
}

annualRanges — ежегодно повторяющийся период («перед Новым годом»), dateRanges — разовый. Пустая applicability — слой действует всегда.

Разрешение конфликтов. Для момента времени берутся применимые на эту дату слои, сортируются по priority убыв., активным считается слот первого слоя, чей интервал накрывает момент.

Slot
  id
  layerId
  weekday            int?        — 0..6 в вещательных сутках; null — каждый день
  targetStart        time        — целевое время старта в сутках канала
  targetDuration     int         — минуты, бюджет слота
  title              string      — «Вечернее кино»
  daypart            enum        — morning | day | prime | night — метка, см. ниже
  slotKind           enum        — content | repeat | signOff

  groupId            uuid?       — для content
  strategy           jsonb       — см. 3.5
  repeatSource       jsonb?      — для repeat, см. ниже
  blockMode          enum        — count | duration | fillSlot
  blockValue         int         — единиц либо минут
  overflowPolicy     enum        — continueNext | extendSlot | skipIfNotFits

  isAnchor           bool        — старт жёсткий, дрейф не допускается
  maxDriftMinutes    int         — допуск отклонения фактического старта
  snapToMinutes      int?        — округлять старт до кратного: 5 | 10 | 15 | 30 | null
  junctionBetweenId  uuid?       — стык между единицами внутри блока
  junctionAfterId    uuid?       — стык в конце блока

overflowPolicy — что делать, когда последовательность (обычно коллекция) не помещается в бюджет:

  • continueNext — играем сколько влезло, курсор помнит середину, доигрываем в следующий выход. Дефолт.
  • extendSlot — играем целиком, слот залезает на соседей; эластичность это позволяет, ближайший якорь подберёт разбег. Для выходного марафона.
  • skipIfNotFits — если целиком не влезает, берём из группы что-то другое.

Настройка на слоте, а не на коллекции: одна и та же трилогия в вечернем блоке может доигрываться по частям, а в марафоне — растягивать слот.

slotKind = repeat — повтор того, что уже играло. Одна из самых узнаваемых примет того ТВ: вечерний фильм идёт утром в субботу, вчерашний выпуск — сегодня днём. Слот ссылается не на группу, а на прошлое той же ленты:

{ "daysAgo": 1, "time": "20:00", "durationMinutes": 90 }

Читается готовое расписание канала за указанную точку, найденные программы ставятся заново. Ни стратегии, ни курсора, ни остывания — самый дешёвый тип слота из всех. Если в источнике ничего не нашлось (эфир ещё не шёл, окно почищено ретеншном) — слот отдаётся фоновому слою с предупреждением.

Две тонкости, без которых повтор находит пустоту:

  • источник ищется по пересечению с окном, а не по попаданию старта внутрь. Сетка эластичная: вечерний фильм законно начинается в 20:50 вместо 21:00, и по старту в собственное окно 21:00 + 2 ч он бы не попал;
  • повтор видит и текущий прогон, не только записанную ленту. Горизонт строится целиком, и вечер понедельника, который повторяют во вторник утром, в этот момент существует только внутри прогона — в базе его ещё нет. Записанное прошлое берётся до начала прогона, остальное — из собираемой ленты; границы не пересекаются, поэтому и в предпросмотре реальный эфир не складывается с симулированным.

slotKind = signOff — конец вещания. Каналы тогда не работали круглосуточно: в два часа ночи настроечная таблица, гимн или «не забудьте выключить телевизор», в шесть утра эфир возобновлялся. Слот заполняется зацикленным ассетом и помечается в программе как «эфир не ведётся».

Сделано слотом, а не флагом на канале, намеренно: так конец вещания можно поставить только в будни, или только зимой, или не ставить вовсе. Детский канал, который никогда не заканчивается, — это законная конфигурация, а не обход правила.

daypart — это метка, а не диапазон времени. Границы дейпартов нигде не задаются: у детского канала прайм в 17:00, у развлекательного в 21:00, и заводить на канале четыре пары времён значило бы держать состояние, которое всё равно дублирует targetStart слота. Метка нужна для цвета в календаре, группировки в UI и области действия правил («потолок врезок в прайме»); что считать праймом, решает администратор, ставя её на нужные слоты.

Фоновый слой. Отдельной сущности «заполнитель» нет. Заполнение пустот — это слой с priority = 0, покрывающий сутки целиком, с обычными слотами и группами внутри: утро одна группа, день другая, ночь третья. Любая дыра в расписании — просто место, где сверху ничего не легло, и играет фон. Тонкость настройки получается тем же механизмом, без второго UI и без отдельной ветки в генераторе.

Дыра — это не только непокрытый интервал. Фон играет и там, где слот сверху есть, но не заполнил своё время: остаток короче одной серии, добор до якоря или до круглой отметки, слот-повтор, которому нечего повторять. Поэтому генератор получает слоты фонового слоя и перекрытые тоже — в эфир сами они не идут, но их контент закрывает паузы, продолжая свой сериал от паузы к паузе (курсор у фонового слота свой, как у обычного).

Записи фона — обычные программы (kind = program) со своим слотом в трейсе: в ленте это настоящий контент, а не затычка, и «почему это здесь» показывает фоновый слот.

На самом краю остаётся ScheduleTemplate.fallbackGroupId и зацикленный аварийный ассет канала — на случай, когда пуст и фон. Только это и считается пост-проверкой «доля фона»: зацикленный филлер в эфире выглядит поломкой, а фоновый слот — ночным блоком.

Аварийная группа выбирается в карточке правил канала и снимается там же. Настройка обязана быть видимой: её проставляет ещё и автосборка сетки (если своя не выбрана), а сетка без слотов отдаёт эфир целиком ей — и в предпросмотре это выглядит так, будто пустая сетка что-то построила. Удаление группы этой ссылкой не блокируется: слот и врезка — осознанная настройка сетки, а аварийная снимается сама, шаблон помечается изменённым и после применения край опускается на филлер канала.

3.5. Стратегии

Хранятся в слоте как JSON с полем type. Стратегия отвечает за выбор элемента группы; внутри элемента единицы всегда идут по порядку от курсора — сериал не должен прыгать по сериям.

sequential — элементы группы по порядку GroupItem.position:

{ "type": "sequential", "onGroupEnd": "restart" }

onGroupEnd: restart | stop.

randomWithCooldown — случайный элемент, не показанный последние N дней. Основной рабочий режим: «один случайный боевик по пятницам, по возможности не тот же, что в прошлую»:

{ "type": "randomWithCooldown", "cooldownDays": 28, "fallback": "oldestFirst" }

fallback — что делать, когда остывание отсекло всех: oldestFirst (взять самый давний) или ignoreCooldown.

fixed — всегда один и тот же элемент:

{ "type": "fixed", "elementKind": "show", "elementId": "..." }

Размер блока (blockMode/blockValue) — свойство слота, ортогональное стратегии: любую стратегию можно комбинировать с «1 единица», «4 единицы подряд» или «пока не наберётся 120 минут». Марафон — это blockMode = fillSlot, отдельной стратегии не нужно.

3.6. Состояние слота

SlotState
  slotId
  currentElementKind enum?       — show | collection
  currentElementId   uuid?
  nextUnitIndex      int         — позиция следующей единицы внутри элемента

Курсор хранит ссылку на элемент, а не числовой индекс в группе: при удалении позиции из группы курсор корректно переезжает на следующую, а не сдвигает всё.

Курсор один на слот в пределах прогона. SlotState — это снимок на начало прогона, а слот попадает в планировщик по экземпляру на каждые свои вещательные сутки; горизонт в неделю строится целиком. Позиция поэтому живёт в накопителе прогона, а снимок только задаёт её начальное значение. Иначе вторник начинался бы ровно с того места, что и понедельник. По той же причине позиция общая с фоновым слоем: слот, перекрытый в одни сутки и свободный в другие, идёт то основным циклом, то в паузах, и второй счётчик переигрывал бы уже поставленные серии. Берётся позиция в момент выбора элемента, а не при входе в слот: паузу перед стартом слота закрывает фон, и он мог сдвинуть её только что.

История показов для остывания берётся из материализованного расписания (ScheduleEntry с Kind = Program, индекс по channelId + showId + startsAtUtc), отдельного журнала не заводим — одна правда, и при пересборке хвоста будущие показы удаляются вместе с записями. К ней прибавляются показы текущего прогона: лента на момент сборки входа содержит только прошлое, и без этого остывание с потолком повторов не видели бы собственный горизонт — на свежем канале оба правила не срабатывали бы ни разу.

Пересборка отматывает курсор к отыгранному. Снос будущего хвоста выбрасывает выходы, которые курсор уже прошёл; без отмотки каждое применение проматывало бы библиотеку на горизонт вперёд, теряя серии, которые так и не вышли. Точка берётся по последней уцелевшей записи каждого слота: элемент — из её трейса, позиция — поиском единицы в развёрнутом элементе (у коллекции серии нумеруются внутри каждой части, и episodeIndex как индекс в общей последовательности не годится).

Из этого следует: SchedulerOptions.RetentionHours (сейчас 24 часа) заменяется на RetentionDays, дефолт 90. Значение обязано покрывать максимальный cooldownDays среди правил канала и максимальный daysAgo среди слотов повтора — иначе остывание начнёт врать, а повторы находить нечего. Валидация предупреждает, если правило требует истории глубже, чем хранится.

Чистка. Раз история стала длинной, обслуживание перестаёт быть бесплатным и выносится в фоновую задачу — вместе с тем, что и сегодня накапливается молча:

  • записи расписания старше RetentionDays;
  • отрендеренные ассеты заставок (BumperAsset), на которые не ссылается ни одна запись в пределах окна хранения, — сейчас они копятся при каждой смене пары шоу и при каждой правке блока заставки;
  • временные файлы предпросмотра.

Чистка идёт по расписанию, не в транзакции генерации: генератор и так держит advisory-lock канала, и подмешивать в него удаление по всей таблице не нужно.

3.7. Шаблон стыка

Реклама и заставки перестают быть настройками канала и становятся элементами стыка. Это то, чего сейчас нет: в прайм три ролика и заставка, ночью один длинный — сегодня врезки одинаковы круглые сутки.

JunctionTemplate
  id
  name                           — «Прайм», «День», «Ночь», «Внутри блока»
  maxTotalSeconds    int?        — потолок длины стыка целиком
  elements           [JunctionElement]

JunctionElement
  position           int         — порядок показа в эфире
  kind               enum        — ad | promo | bumper | filler
  groupId            uuid?       — для ad/promo/filler: откуда брать
  bumperTemplateId   uuid?       — для bumper: какой блок заставки
  bumperVariantId    uuid?       — для bumper: конкретный подблок; null — по триггеру и весам
  amountMode         enum        — count | duration
  amountValue        int         — единиц либо минут
  isRequired         bool        — нельзя выбросить при нехватке времени
  choiceKey          string?     — метка развилки: из врезок с одной меткой играет одна
  choiceWeight       int         — вес внутри развилки
  conditions         jsonb

Стык общий для всех каналов, как группа. Своя цепочка врезок у каждого канала — это ровно та ошибка, из-за которой копирование сетки сопоставляло заставки по имени и рапортовало о потерянных ссылках. «Рекламный блок на две минуты с заставкой в конце» — такой же переиспользуемый ресурс, как «Боевики 90-х»; канальным остаётся только выбор, какой стык поставить в слот.

Порядок показа — это position, и только он. Обязательность (isRequired) участвует исключительно в отборе «кого выбросить, если до якоря не влезает»: сначала считается, что помещается, потом уцелевшее играет в исходном порядке. Иначе галочка «обязательно» молча поднимала бы рекламу перед заставкой, и цепочка в редакторе перестала бы соответствовать эфиру.

{
  "onlyOnElementChange": true,
  "minMinutesBetween": 30,
  "dayparts": ["prime", "day"],
  "timeWindow": { "from": "20:00", "to": "23:00" },
  "chance": 40,
  "nearHourMinutes": 2
}

Условия — структурированные поля, а не выражения-строки: выражения потребовали бы парсера, валидации и отдельного UI, а покрывают те же три-четыре реальных случая.

chance — вероятность показа врезки в процентах, самый дешёвый источник разнообразия: «в 40% стыков ставим анонс». minMinutesBetween считается по конкретной врезке, а не по её виду: две рекламные врезки в разных стыках — это разные ограничения, общий счётчик на вид склеил бы их.

nearHourMinutes — допуск от круглого часа по времени канала. Сигнал точного времени и джингл тем и узнаются, что звучат в :00; врезка с допуском ставится только в его окрестности, а не «когда-нибудь в этом часе». Ноль — привязки нет.

Развилка (choiceKey) — несколько врезок с одной меткой, из которых играет одна, выбранная по весам: «иногда заставка, иногда короткий рекламный блок». Врезки одной развилки обязаны занимать непрерывный отрезок позиций (иначе неясно, куда встаёт выбранная), обязательность и условия относятся к развилке целиком.

Оба жребия — chance и выбор внутри развилки — берутся из seed генерации (4.4), а не из живого Random. Иначе пересборка хвоста тасовала бы врезки на каждое применение, и диф из 6.6 показывал бы изменения там, где ничего не менялось.

Рекламные ролики — это тоже группы. Ролик регистрируется как Show(Kind = Interstitial) — новое значение ShowKind — и складывается в группу. Это убирает ChannelAd и бесплатно даёт рекламе всё, что есть у контента: остывание (не крутить один ролик дважды подряд), разные группы на утро и прайм, статистику. Библиотека фильтруется по типу, служебные ролики не мешают на экране шоу.

Рекламный блок — это коллекция. Реклама здесь не служебный элемент, а половина обаяния: её помнят лучше передач. Поэтому важно уметь собрать блок целиком, а не только ротировать ролики по одному — и это получается само, без единой строчки специального кода: коллекция из шести Interstitial-шоу и есть «рекламный блок ОРТ, осень 1998». Играется по порядку целиком, лежит в группе «Рекламные блоки 90-х», выбирается с остыванием, как любой другой контент.

Оба способа собрать врезку работают одним механизмом, и их можно смешивать в одной группе:

  • готовым блоком — группа из коллекций, элемент стыка берёт одну единицу, в эфир идут шесть роликов подряд в исходном порядке;
  • сборкой из отдельных роликов — группа из одиночных Interstitial-шоу с весами, элемент стыка набирает несколько, редкие и любимые выпадают чаще;
  • вперемешку — группа содержит и целые блоки, и отдельные ролики.

Ради последнего случая у элемента стыка два режима бюджета. count даёт предсказуемое число единиц, но в смешанной группе длина врезки скачет: одна «единица» — это то ли ролик на 20 секунд, то ли блок на три минуты. duration («врезка примерно две минуты») набирает единицы, пока не наберётся бюджет, и коллекция при этом всегда входит целиком — блок не разрезается. Для смешанных групп правильный режим — duration.

Заставка-переход — это врезка стыка, а не настройка канала: Channel.BumperSelection / NextBumperIndex / BumperMinIntervalMinutes / BumperShowChangeChance / BumperEpisodeChangeChance / BumpersEnabled уходят с канала, вероятность и интервал выражаются conditions, а выбор подблока — полем bumperVariantId врезки. Сама подсистема заставок описана в 3.7.1.

Важно для пайплайна: ассет заставки зависит от пары соседей, а пара известна только после наполнения слотов. Значит рендер — обязательный шаг между сборкой ленты и записью в БД, он долгий и может упасть. В предпросмотре заставка показывается плейсхолдером известной длины, реальный рендер — только при применении.

3.7.1. Заставки: блок, подблок, текст

Блок заставки (BumperTemplate) — это оформление и звук: палитра, фон-картинка, шрифт, джингл. Подблок (BumperTextVariant) — то, что на нём написано, плюс правило показа и вес. Как и стык, оба общие для всех каналов: файлы звука и фона и так лежат по templateId, канал в них не участвовал никогда.

BumperTemplate                   — оформление и звук, общий
  id
  name
  font               enum        — sans | serif (было на канале)
  backgroundColor, backgroundColor2, accentColor, textColor
  backgroundImageId  uuid?
  audioExtension, audioDurationSeconds
  revision           int         — версия файлов; входит в сигнатуру рендера
  variants           [BumperTextVariant]

BumperTextVariant
  position, name
  trigger            enum        — onShowChange | betweenEpisodes | both
  weight             int
  background         enum        — template | nextPoster | nowPoster
  lines              [BumperLine]

BumperLine
  position           int
  style              enum        — label | title | caption
  color              enum        — accent | text
  text               string      — с плейсхолдерами

Шрифт переезжает с канала на блок не ради симметрии: он не входил в сигнатуру кэша, и его смена не пересобирала уже отрендеренные заставки. На общем блоке эта дыра стала бы видимой сразу — два канала с разным шрифтом делили бы один ассет.

Текст — список строк, а не два фиксированных режима. Прежние NowNext (две подписи + названия шоу) и Free (две произвольные строки) схлопываются: «СЕЙЧАС / {now.title} / ДАЛЕЕ / {next.title}» — это просто четыре строки, и оно же превращается в «ДАЛЕЕ В 21:30 / {next.title}» правкой текста, а не переключением режима. Готовые наборы строк вставляются пресетами — это данные редактора, не сущность.

Источник фона становится явным полем подблока. Раньше постер шоу подставлялся молча и только в режиме NowNext; при свободных строках такой связи взяться неоткуда.

Плейсхолдеры подставляются в момент планирования — там уже известны канал, пара соседей и точное время врезки:

{channel} {channel.number} канал
{now.title} {next.title} шоу до и после стыка
{now.episode} {next.episode} «с5э12» либо название серии
{next.year} {next.genre} из метаданных
{next.time} во сколько начнётся следующая программа
{tonight.title} {tonight.time} первая программа прайма этих вещательных суток
{tomorrow.title} {tomorrow.time} то же для следующих суток
{time} {date} {weekday} момент показа заставки во времени канала
{slot} название слота — «Вечернее кино»

Анонсы смотрят вперёд по собранной ленте и только на программы, которые ещё не начались: «сегодня в 20:00 — Терминатор», сказанное в девять вечера, хуже, чем молчание. Ночь до dayStartTime относится к предыдущим вещательным суткам, иначе «сегодня вечером» в час ночи означало бы уже следующий вечер.

Неизвестное значение подставляется пустым. Строка, в которой не подставился ни один плейсхолдер, не рисуется целиком: она писалась ради данных, и «ДАЛЕЕ В» без времени — это не подпись, а мусор в кадре. Постоянного текста без плейсхолдеров это не касается. Неизвестный плейсхолдер — ошибка валидации при сохранении, а не сюрприз в эфире.

Цена гибкости — кэш: {time} и {date} делают каждый показ уникальным, а рендер это ffmpeg на несколько секунд, помноженный на недельный горизонт. Запрещать нечего, но редактор обязан предупреждать у таких полей, а предпросмотр — показывать, сколько новых рендеров потребуется. {next.time} при работающих якорях и snapToMinutes даёт круглые времена и кэшируется нормально.

Кэш ассетов ключуется содержимым, а не ссылками.

BumperAsset
  id
  signature          — sha256(templateId | revision | variantId | подставленные строки | фон | posterShowId)
  templateId, variantId          — оформление рендера и чистка осиротевших
  renderedLinesJson              — уже подставленный текст
  posterShowId       uuid?       — если фоном стоит постер шоу
  mediaAssetId

channelId и пара шоу из ключа уходят. Так одинаковая заставка на трёх каналах рендерится один раз, а {channel} в тексте разводит их по разным сигнатурам сам собой — без единого спецправила. Подставленный текст приходится хранить: время показа из ссылок задним числом не восстанавливается, а фоновый рендерер запускается уже после того, как лента записана.

3.8. Правила

Делятся на два вида по способу применения — это принципиально, потому что определяет, ломается ли воспроизводимость генерации.

Фильтры кандидатов — применяются при выборе элемента, жёсткие. Отсекают недопустимое до жребия, поэтому не требуют пересборки и ничего не ломают:

Правило Параметры Смысл
maxAudienceByTime {from, to, maxAudience} Детское время: до 23:00 не строже PG-13
cooldown {days} Не показывать элемент, игравший последние N дней
maxRepeatsInWindow {windowDays, max} Не чаще N раз за период

Пост-проверки — считаются по готовому расписанию и дают предупреждения, ничего не переигрывая:

Проверка Смысл
maxBreakMinutesPerHour Потолок врезок в час превышен
maxGenreShare Доля жанра за сутки выше заданной
дрейф Фактический старт слота ушёл за maxDriftMinutes
фон Доля эфира, отданная фоновому слою, выше нормы

Пересборки слота при нарушении нет намеренно: она делает результат зависимым от порядка проверок, превращает отладку в угадывание и обесценивает «почему это здесь».

Область действия правила — канал или конкретный дейпарт.

3.9. Результат генерации

Одна лента, как сейчас — ScheduleEntry с типом записи. Программная сетка для EPG — это фильтр по Kind = Program, отдельной таблицы не заводим: разделение на «сетку» и «плейаут» стоило бы вдвое дороже и дало бы мало.

ScheduleEntry                    — существующая сущность, расширяется
  id
  channelId
  mediaAssetId
  kind               enum        — program | ad | promo | bumper | filler | signOff
  startsAtUtc
  endsAtUtc
  showId             uuid?
  episodeIndex       int?
  collectionId       uuid?       — новое: если единица пришла из коллекции
  bumperVariantId    uuid?
  slotId             uuid?       — новое: что породило
  trace              jsonb       — новое: цепочка происхождения

trace пишется в момент генерации и питает экран «почему это здесь»:

{
  "layer": { "id": "...", "name": "Базовая", "priority": 10 },
  "slot": { "id": "...", "title": "Дневная анимация", "targetStart": "18:00" },
  "group": { "id": "...", "name": "Мультсериалы 90-х", "itemCount": 128 },
  "strategy": { "type": "randomWithCooldown", "cooldownDays": 14 },
  "pick": { "reason": "weightedRandom", "candidatesAfterCooldown": 96 },
  "junction": { "id": "...", "name": "День" },
  "drift": { "targetStart": "18:00", "actualStart": "18:04", "minutes": 4 }
}

4. Алгоритм генерации

4.1. Пайплайн

1. RESOLVE     — на каждый момент определить активный слой и слот
2. FILL        — наполнить слот единицами по стратегии, с фильтрами кандидатов
3. JUNCTION    — разложить врезки по шаблонам стыков
4. ANCHOR      — выровняться на якорях, добить остаток фоном
5. RENDER      — отрендерить/достать из кэша ассеты заставок
6. MATERIALIZE — записать ленту с трейсом, сдвинуть курсоры
7. VALIDATE    — пост-проверки, собрать предупреждения

Шаги 1–4 — чистая функция без БД и ФС, юнит-тестируемая, по образцу текущего SchedulePlanner. Шаги 5–7 — оркестратор, по образцу текущего ScheduleGenerator.

Любая пауза на шаге 4 закрывается в одном и том же порядке, и порядок этот значим:

фоновый слой (слот этого времени, свой курсор)
  → аварийная группа шаблона
    → зацикленный филлер канала

Конец вещания (slotKind = signOff) — исключение: там играет только зацикленный ассет. Канал не вещает, и подставлять туда программы фона было бы прямым враньём в программе передач.

4.2. Наполнение слота

курсор = конец предыдущей записи
слот   = активный слот на этот момент

элемент = стратегия.выбрать(группа, фильтры кандидатов, seed)
пока не исчерпан бюджет слота:
    единица = следующая единица элемента от курсора состояния
    если следующий якорь − курсор < длительность единицы:
        не начинаем: остаток до якоря добираем стыком и фоном
        применяем overflowPolicy
        выходим
    ставим единицу, двигаем курсор и состояние слота
    если это не последняя единица блока и есть junctionBetween:
        раскладываем врезки
ставим junctionAfter

Бюджет считается по blockMode: count — N единиц, duration — пока сумма не превысит M минут (последняя входит целиком), fillSlot — пока не исчерпан targetDuration.

Слоты repeat и signOff минуют стратегии и состояние целиком: первый читает уже записанную ленту за точку из repeatSource и переносит найденные программы, второй заполняет бюджет зацикленным ассетом. Обоим нужны только якорь и правила стыков.

4.3. Округление стартов

Настоящее ТВ не начинает фильм в 19:47. Программа передач состоит из круглых времён — 19:45, 20:00, 20:30 — и именно это подсознательно отличает телепрограмму от выгрузки плейлиста.

Перед началом слота с заданным snapToMinutes генератор добирает время до ближайшей кратной отметки врезками и фоновым слоем:

отметка = округлить_вверх(курсор, slot.snapToMinutes)
если отметка − курсор > 0:
    добираем стыком, затем фоном, до отметки
    старт слота = отметка

Отличие от якоря принципиальное: якорь — жёсткая точка, к которой сетка обязана прийти, и ради которой генератор не начинает контент, способный через неё перелезть. Округление мягкое: оно не заставляет ничего обрезать, только сдвигает старт вперёд до круглой отметки. Если добирать пришлось бы больше, чем maxDriftMinutes, округление пропускается — лучше начать в 19:47, чем девять минут крутить фон.

На практике большинству слотов хватает округления, и якорь остаётся только на опорных точках вроде прайма.

4.4. Seed и воспроизводимость

Жребий берётся из seed, зависящего только от координат, а не от содержания правил:

seed = hash(channelId, date, slotId, occurrenceInDay)

Смысл: правка ночного слота не должна перетасовывать дневной эфир. Изменилось правило — изменился результат его применения, но не жребий соседей. Иначе диф перед применением бесполезен: «изменилось 180 элементов из 180» вместо «изменилось 4».

4.5. Граница now

прошлое   → снапшот, неизменяемо
текущая   → запись, идущая в эфире, не вырезается из-под зрителя
будущее   → материализованный кэш, пересобирается по требованию

Перегенерация: удалить будущее от границы применения → пересчитать от состояния на этой границе → записать. Идемпотентно. Прошлое замораживается обязательно — иначе архив будет врать: зритель смотрел вчера в 20:00 одно, а в истории другое.

Изменения применяются по явной кнопке, а не при сохранении слота. Правка правил помечает шаблон как изменённый и не трогает эфир; администратор правит сетку сколько нужно, смотрит предпросмотр, затем жмёт «Применить» — и только тогда пересобирается хвост.

Иначе десять правок подряд дают десять пересборок, и хвост дёргается на каждое сохранение — при живых зрителях это заметно. Плюс это естественно ложится под диф из среза 4: применение всегда имеет одну точку, в которой можно показать, что именно изменится.

В UI — постоянный индикатор «правила изменены, эфир идёт по старым» с кнопкой применения и подсказкой, сколько записей затронет.

Фоновая задача (SchedulingBackgroundService) достраивает горизонт, как и сейчас.

Горизонт — 7 дней по умолчанию (SchedulerOptions.HorizonDays, сейчас 3), настраиваемый. Программа на неделю вперёд — часть замысла: «знать, что мультики будут в субботу в 9:30» работает, только если расписание известно заранее. Отдельного экрана телепрограммы для зрителя пока не делаем, публичный EPG просто отдаёт больший диапазон.


5. Валидация

5.1. До генерации, по правилам

Проверка Формула Сообщение
Нехватка контента group.itemCount < выходов слота в неделю «В группе 3 позиции при 7 выходах в неделю — повтор каждые полнедели»
Пустая группа itemCount == 0 «Группа пуста, слот заполнит фон»
Дыра в сетке интервал не покрыт даже фоном «Не покрыто: вт 03:00–06:00»
Пересечение слотов overlap внутри одного слоя «Слоты пересекаются»
Недостижимый кулдаун cooldownDays × выходов в день > itemCount «Остывание 28 дней невыполнимо при 3 позициях»
Возрастной конфликт группа содержит строже, чем позволяет окно «В группе есть NC-17, слот стоит в детском времени»

Запас группы показывается прямо в инспекторе слота: «342 позиции · 118 ч · полный цикл 6.2 недели».

5.2. После генерации

  • элементы, отданные фону (не нашлось контента, либо источник повтора не поместился в слот целиком — снаружи это неотличимо от пустого источника, а чинится совсем другим);
  • пост-проверки из 3.8 — доли считаются только по вещательным суткам, попавшим в прогон целиком: на краях горизонта сутки обрезаны, и один фильм честно занимает в таком огрызке больше половины;
  • тепловая карта повторов: матрица «элемент × день», яркость = число показов — сразу видно, что один фильм крутится четыре раза за неделю;
  • фактическая доля врезок по часам против лимита.

6. UI

Текущий экран канала (ChannelDetail) — набор карточек с формами — переписывается целиком.

6.1. Редактор сетки — основной экран

Календарь недели, ось X — дни, ось Y — время вещательных суток от DayStartTime.

  • слоты перетаскиваются мышкой, меняют длительность за края;
  • копирование дня на другие дни, копирование недели целиком;
  • цвет блока — по дейпарту, иконка якоря на жёстких слотах;
  • слоты, перекрытые слоем выше, показаны полупрозрачной штриховкой;
  • панель слоёв слева: список с видимостью, приоритетом, drag для переупорядочивания; переключение «редактируемого» слоя, остальные — фоном;
  • переключатель даты для слоёв с датами: «показать сетку на 25 декабря» — видно, что реально ляжет.

Реализация календаря — своя, не готовая библиотека: нужны слои с перекрытием, вещательные сутки с 06:00 и якоря, ни один готовый компонент это не покрывает.

Инспектор слота — правая панель по клику:

Название блока        [Вечернее кино                  ]
Время / бюджет        [20:00] [90 мин]   ☑ Якорь
Допуск дрейфа         [5] мин
Дейпарт               ( ) утро ( ) день (•) прайм ( ) ночь
Тип слота             (•) контент ( ) повтор ( ) конец вещания

Группа                [Фильмы 90-х ▾]
                      342 позиции · 118 ч · цикл 6.2 нед
                      [Открыть] [Создать новую]

Стратегия             [Случайно с остыванием ▾]
  Остывание           [28] дней
Блок                  [Единиц ▾] [1]
Если не помещается    [Доиграть в следующий раз ▾]

Стык между единицами  [— нет — ▾]
Стык в конце блока    [Прайм ▾]   ~4 мин врезок

Статистика группы видна прямо здесь, без перехода на другой экран — это то, ради чего считается cachedStats.

6.2. Редактор группы

Двухпанельный: слева конструктор фильтра, справа — состав.

  • фильтр набирает позиции кнопкой «Добавить найденное», не подменяет состав;
  • в состав можно перетаскивать шоу и коллекции из библиотеки;
  • порядок внутри группы — drag & drop (важен для sequential);
  • вес — колонка, спрятанная за «дополнительно»;
  • бейдж «доступно 12 новых позиций по фильтру» с кнопкой добавления.

Выбор группы в списке каналов и слотов — с секциями: используются в этом каналенедавниевсе, плюс поиск. Группы общие, их со временем станет много.

6.3. Редактор стыка

Стыки и блоки заставок общие, поэтому живут не на экране канала, а своим разделом админки — рядом с группами и роликами. На канале остаётся выбор: какой стык поставить в слот и какой считать стыком по умолчанию, с теми же секциями списка, что у групп («используется в этом канале» → «все» + поиск) и счётчиком «используется в 3 каналах» у каждого.

Горизонтальная цепочка с перетаскиванием:

[КОНЕЦ] → [Реклама ×2] → [Заставка] → ⌥[Промо ×1 | Реклама 30с] → [НАЧАЛО]
           обязательно    если смена     развилка 70/30

Под цепочкой — линейка суммарной длительности с потолком стыка. Клик по элементу — параметры (тип, источник, количество, обязательность, условия). Развилка рисуется одной стопкой: врезки внутри переставляются вместе, а веса показаны процентами прямо на цепочке — иначе «иногда так, иногда эдак» невозможно прочитать, не открывая каждый элемент.

Перетаскивание элемента внутрь развилки и наружу — тем же drag & drop, что и переупорядочивание: отдельной кнопки «сгруппировать» нет, метка развилки проставляется самим перетаскиванием.

6.3.1. Редактор заставки

Двухпанельный: слева строки, справа — постоянный предпросмотр кадра, который перерисовывается на каждый ввод (то же оформление, что даёт ffmpeg, но нарисованное в браузере — ждать рендера ради проверки опечатки нельзя). Полный ffmpeg-рендер остаётся кнопкой и играется плеером.

  • строка — это [стиль ▾] [цвет ▾] [текст], порядок перетаскиванием, добавление кнопкой;
  • палитра плейсхолдеров под полем: клик вставляет в позицию курсора, наведение показывает пример подстановки; в самом поле плейсхолдеры подсвечены;
  • пресеты («Сейчас / Далее», «Далее в …», «Логотип канала») — кнопка, заполняющая строки;
  • у полей с {time}/{date} — предупреждение о том, что кэш перестаёт работать;
  • образцы подстановки берутся из реального канала, выбранного тут же: заставка общая, но посмотреть её надо глазами конкретного канала.

6.4. Предпросмотр

Доступен из редактора без сохранения — считает по текущему черновику правил.

  • Программа — список по дням, как увидит зритель;
  • Лента — таймлайн с цветовыми слоями (программа / реклама / заставка / фон), сверху гистограмма нагрузки врезок по часам с линией лимита, превышения красным;
  • Проблемы — сгруппированные предупреждения с переходом к источнику;
  • тепловая карта повторов.

6.5. «Почему это здесь»

У каждой записи иконка, открывающая трейс:

Симпсоны, с5э12 · пн 18:04

Слой        Базовая (приоритет 10)
Слот        Дневная анимация · будни 18:00 · 30 мин · дрейф +4 мин
Группа      Мультсериалы 90-х (128 позиций)
Стратегия   Случайно с остыванием 14 дней
            после остывания оставалось 96 кандидатов
Врезки      стык «День»

Не опциональная фича: без неё отладка сетки превращается в угадывание.

6.6. Диф перед применением

Изменения затронут 180 записей, из них изменятся 12
3 — в ближайшие 24 часа  ⚠

пн 20:00   «Терминатор»     →  «Чужой»
пн 21:40   реклама 120 с    →  реклама 180 с

[Применить]  [Отмена]

Изменения в ближайшие сутки подсвечиваются отдельно — самая частая причина случайного ущерба.

После применения предупреждения планировщика показываются отчётом в окне, сгруппированные по виду, — так же, как на вкладке «Проблемы» предпросмотра. Уведомлением на каждое предупреждение это не показать: на неделе эфира их набираются десятки, и они успевают только перекрыть экран.

6.7. Ролики и рекламные блоки

Отдельный раздел админки, хотя под капотом это те же Show(Kind = Interstitial) и Collection. Библиотека шоу — про кино и сериалы с постерами, метаданными и сериями; сотня рекламных роликов там только мешала бы, а фильтр по типу проблему не решает, потому что и сценарии работы разные.

Экран устроен под свою задачу:

  • плоский список роликов с длительностью и превью, массовая загрузка;
  • сборка блока — перетаскиванием роликов в упорядоченный список, тут же суммарная длительность («блок 2 мин 40 с»), блок сохраняется как коллекция;
  • группы роликов собираются здесь же, а не в общем редакторе групп.

Метаданные из TMDb/OMDb для этого типа не запрашиваются.

6.8. Зрительская часть

Планировщика не касается, но живёт с ним в одном замысле. Всё перечисленное — опции, по умолчанию выключенные: канал без логотипа и без шума остаётся законной конфигурацией.

Номер канала. Channel.Number — на телевизоре канал это номер, а не карточка в сетке. Даёт переключение вверх-вниз по номерам, с коротким чёрным кадром и номером в углу на секунду. Включается глобально флагом в настройках сайта (AppSetting, рядом с флагом регистрации): у кого-то это главный способ навигации, у кого-то лишняя механика. Сетка каналов остаётся вторым способом всегда.

Экранные оверлеи — рисуются на клиенте поверх <video>, ffmpeg не трогают:

  • логотип канала в углу — картинка из реестра изображений плюс угол и прозрачность;
  • часы;
  • плашка «Далее: …» в конце программы — данные уже есть в EPG.

Настоящий вещательный логотип вжигается в картинку при кодировании; для нас это означало бы перекодирование всей библиотеки при смене логотипа, поэтому только оверлей.

Аналоговый фильтр — лёгкий VHS-шум, дрожание, размытие краёв; CSS-фильтр или шейдер на клиенте. Регулируется по силе, по умолчанию выключен: попадает точно в цель для канала «как в 96-м», но переборщить очень легко.

6.6.1. Откат правок

У плашки «правила изменены» две кнопки: применить и сбросить. Слои и слоты правятся на месте, поэтому «как было» взять неоткуда — при каждом применении шаблон снимается слепком (ScheduleTemplate.AppliedSnapshotJson, схема TemplateSnapshot в Application). Это ровно то состояние, по которому собрана лента, и потому единственная корректная точка отката.

Откат правит слои и слоты на месте по идентификаторам из снимка: у пережившего откат слота сохраняется SlotState, и сериал не начинается заново. Удалённый после применения слот вернётся новым — его состояние ушло вместе с ним. Ссылка на группу или стык, удалённые после применения, не восстанавливается (внешний ключ), слот возвращается без неё, потеря считается и показывается.

Эфир при откате не трогается: он и так идёт по этому состоянию, а шаблон после отката снова ему равен — Revision приравнивается к AppliedRevision, плашка гаснет.

6.6.2. Очистка сетки

Кнопка «Очистить сетку» на вкладке сетки — начать канал заново, не пересоздавая его: снимаются все слоты и все слои кроме фонового (его удалить нельзя), удаляется будущий хвост ленты. Курсоры слотов уходят каскадом вместе со слотами, поэтому сериалы после очистки начинаются с начала.

Прошлое и идущая сейчас запись остаются — инвариант общий с генератором: у зрителя нельзя вырезать программу из-под носа, а история показов нужна остыванию и потолку повторов. Настройки шаблона (аварийная группа, стык по умолчанию, правила) переживают очистку: они не принадлежат конкретной раскладке слотов. Снимок последнего применения тоже остаётся — после очистки «Сбросить изменения» вернёт сетку (но не ленту, её пересоберёт применение).

Команда держит транзакцию с тем же advisory-локом канала, что и генератор: фоновый тик не должен дописывать хвост в момент очистки. Пустая сетка эфир не останавливает — включённый канал достроит горизонт аварийной группой (см. 3.4), и в интерфейсе об этом предупреждают прямо после очистки.

6.9. Автосборка сетки по профилю

Собрать неделю руками — это несколько десятков слотов, и до первого эфира новый канал не доживает. Кнопка «Собрать сетку» делает первый проход за админа; дальше сетка правится как обычно.

Профиль — описание вещательных суток полосами: с какого времени по какое, какой длины блок, сколько единиц он берёт, какой рейтинг допустим. Профили списаны с реальных каналов («как у 2×2», «как у Paramount Comedy», «как у MTV») — такое админ проверяет по памяти, а «универсальный алгоритм раскладки» проверить нельзя никак. Это данные (GridProfiles), не алгоритм: новый профиль — это новый список полос.

Подбор группы под полосу объяснимый и в том же порядке, что проверки из 5.1: рейтинг отсекает жёстко (в детское время строгое не ставится, ночной блок 18+ без взрослого содержимого бессмыслен), дальше баллы за тип контента, длину единицы и запас серий, минус за повторное использование в тех же сутках. Не нашлось кандидата — полоса остаётся незакрытой, и это написано в предпросмотре, а не обнаруживается потом списком ошибок.

Два режима. «Заполнить дыры» застраивает только непокрытое время (дыры считаются тем же GridCoverage, что и в 5.1) и не трогает ручные слоты; интервалы, свободные во все семь дней, схлопываются в один слот «каждый день». «Собрать неделю с нуля» сносит все слоты шаблона и строит будни слоем Основная сетка, а выходные — слоем Выходные поверх: слой имеет смысл, только когда покрывает день целиком, поэтому отдельные выходные бывают лишь в этом режиме.

Длину слота задаёт контент, а не профиль. BlockMinutes/UnitsPerBlock — замысел полосы; фактическая длина считается из средней длительности единицы выбранной группы. Иначе двухчасовой слот с «тремя сериями по 22 минуты» отдаёт 54 минуты фону, а трёхчасовой прайм с одним фильмом на 100 минут — восемьдесят. Остаток, не влезающий под ещё одну единицу, достаётся следующему слоту, и под него подбирается группа подходящей длины: кандидат, чья единица длиннее оставшегося места, штрафуется — до ближайшего якоря он всё равно не влезет.

Ёмкость считается на неделю. Слот «каждый день» выходит семь раз, поэтому бюджет группы (PlanRun) списывается сразу на все выходы. Исчерпанная группа уступает нетронутой, а когда свежего контента не остаётся вовсе — полоса закрывается повтором вечернего блока (SlotKind.Repeat на начало прайма прошлых суток), как это делают настоящие каналы. Тем же повтором закрывается полоса, под которую подходящей группы нет вовсе (например ночной блок 18+ на канале без взрослого контента) — это честнее дыры в эфире.

Прайм чередуется по дням недели. Полосы с Rotate раскладываются поимённо по будням и берут не лучшего кандидата, а следующего по кругу: иначе неделя выглядит одним повторяющимся днём. Остальные полосы остаются слотами «каждый день» — сетка должна читаться.

Праздничная сетка — сезонный слой. В режиме «с нуля» генератор строит ещё один слой поверх основного и выходных, с ежегодной применимостью (25 декабря — 8 января): днём марафон, вечером кино. Он один и тот же у всех профилей намеренно — в праздники каналы сходятся к одному ритму, и различает их библиотека, а не сетка. Слоты слоя идут «каждый день»: период занимает несколько дат подряд, и расписывать их по дням недели значило бы получить сетку, зависящую от того, на какой день выпало 31 декабря. Свой разбор и свой бюджет: праздничная неделя идёт вместо обычной, а не вдобавок, и обеднять ради неё обычную нельзя. Даты и содержимое слоя дальше правятся как у любого другого — повторная генерация применимость уже созданного слоя не переписывает.

Жанр не занимает две полосы подряд, а при заданном MaxGenreSharePercent — и больше своей доли суток. Это тот же потолок, который потом проверяет пост-проверка из 3.8, но соблюдаемый на раскладке, а не констатируемый после.

Предпросмотр обязателен: он считается тем же GridPlanner, что и создание, поэтому показанное и созданное совпадают по построению. Там же показываются замечания — незакрытое время (красным), нехватка состава с точными числами («84 выхода в неделю при 40 позициях») и конфликты с детским временем: всё это считается по тому же бюджету, по которому раскладывалось, и потому совпадает с тем, что потом скажут проверки сетки. Эфир генерация не двигает — как любая правка сетки, она только поднимает ревизию шаблона, а хвост пересобирает кнопка применения.

6.10. Обмен конфигурацией сетки

Сетка переносится файлом: выгрузка, загрузка и запрос к ИИ — три вкладки одного диалога.

Ссылки в файле именные. Группа, стык и аварийная группа записываются названиями, а не идентификаторами: файл едет на другой канал и на другую установку, где тех же GUID нет, а сетку для импорта пишет в том числе языковая модель — идентификаторы она может только выдумать. Импорт разрешает имена сам; незнакомое имя не валит загрузку, а становится замечанием, и админ видит списком, чего в библиотеке не нашлось. Тем же способом пропускаются слоты, которые не проходят проверки (перекрытие, нет группы у содержательного слота).

Слой берётся по имени, поэтому повторная загрузка того же файла даёт ту же сетку, а не «Основная сетка (2)». Режим «снести существующие слоты» работает как пересборка из 6.9. Эфир импорт не двигает: он помечает шаблон изменённым, а хвост пересобирает кнопка применения.

Файл приносит группы с собой. Слот ссылается на группу, а на чужой установке (и на своей, пока библиотека не разобрана) групп нет — поэтому в файле есть разделы groups и collections, и недостающее импорт заводит сам: группа из шоу по названиям, коллекция как цикл или франшиза, которая потом кладётся в группу целиком. Порядок жёсткий: коллекции → группы → слоты, иначе слоту нечего разрешать. Шоу импорт не создаёт никогда — их приносит загрузка медиа, и придуманное моделью название останется замечанием, а не записью в библиотеке. Группа, у которой не нашлось ни одного шоу, всё равно создаётся, но с замечанием: пустая группа — это дыра в эфире, и сказать об этом надо на импорте.

Существующая по имени группа не переписывается: та же группа стоит в слотах других каналов, и молча перекроить её принесённым файлом значит поменять чужой эфир. Экспорт при этом выгружает состав использованных групп именами, чтобы файл был самодостаточным; динамическая группа выгружается снимком вычисленного состава — правило ссылается на жанры и рейтинги этой установки и на чужую переехало бы бессмыслицей.

Запрос к ИИ собирается на сервере и никуда не отправляется. Ключей внешних сервисов проект не хранит, и заводить их ради одной кнопки не нужно: админ вводит референс-каналы («как у 2×2») и пожелания своими словами, получает готовый текст, копирует его в ту модель, которой пользуется, и приносит ответ назад вкладкой импорта.

В запрос входят параметры канала, доступные группы с их ёмкостью и средней длиной единицы (те же числа, по которым раскладывает автосборка, — считает их общий GroupCatalog), библиотека поимённо (модель собирает из неё свои группы, поэтому названия нужны дословные), имена стыков и заставок, правила раскладки и схема ответа. Схема лежит рядом с форматом (GridPromptText в одной папке с GridConfig), а не в шаблоне на фронте: разойдись она с импортом — ответ модели пришлось бы чинить руками.


7. Порядок реализации

Вертикальные срезы: после каждого канал вещает и всё проверяемо руками. Не «сначала бэкенд целиком, потом UI» — иначе три этапа подряд нельзя посмотреть.

Срез 1 — сетка вещает. Жанры и коллекции в библиотеке, расширенный ShowAudience, группы с ручным составом и фильтром набора, шаблон с одним слоем + фоновым, слоты, стратегии sequential и randomWithCooldown, генератор с наполнением слотов и фоном. UI: read-only недельная сетка, инспектор слота формой, добавление слота кнопкой, редактор группы. Реклама и заставки временно как есть.

Здесь же — золотые тесты генератора: шаги 1–4 чистые, значит для набора конфигураций можно зафиксировать эталонные сутки эфира и сравнивать посуточную ленту целиком. Заводить их надо сейчас, пока конфигураций мало и эталоны дёшевы; позже любая правка стратегий или подгонки будет сразу показывать, что именно поехало, вместо того чтобы обнаружиться через неделю в эфире.

Срез 2 — врезки. Шаблоны стыков, реклама как группа Interstitial, переезд заставок в элемент стыка, якоря, округление стартов и выравнивание дрейфа. UI: редактор стыка цепочкой, предпросмотр с вкладкой ленты.

Срез 3 — гибкость и календарь. Слои с приоритетами и применимостью (сезонные, ежегодные, конкретные даты), фильтры кандидатов (возраст по времени, ограничение повторов), слоты repeat и signOff. UI: панель слоёв, drag & drop в календаре, переключатель даты.

Срез 4 — эксплуатация. Пост-проверки и предупреждения, тепловая карта, «почему это здесь», диф перед применением, копирование шаблона на другой канал.

Смежное — зрительская часть (6.8). Номер канала и переключение по номерам, оверлеи, аналоговый фильтр. От планировщика не зависит, делается параллельно в любой момент.


8. Что осознанно не делаем

Из первоначального черновика выброшено намеренно — фиксирую, чтобы не всплывало повторно:

  • Жёсткая сетка (старты слотов прибиты к :00/:30 с точным добором до границы). Эластичная сетка с якорями даёт ту же предсказуемость там, где она нужна, и не требует добивать каждый слот до минуты квантами по 2 секунды.
  • Профиль эпохи на канале — функционально то же, что фильтр группы, поднятый уровнем выше. Оправдан, только если появятся каналы-эпохи с десятком групп каждый.
  • Пересборка слота при нарушении ограничения — см. 3.8.
  • Составные группы (группа включает другие группы) — отложено до реального запроса.
  • Патчи расписания (закрепить/заменить/сдвинуть конкретную запись руками) — отложено; при курсорной генерации это отдельная механика, переживающая пересборку, и она не нужна до того, как сетка начнёт работать.
  • Версионирование правил с откатом — отложено; revision в модели заложен, история пишется позже.
  • Раздельные «программная сетка» и «плейаут-лента» — одна лента с типами записей.
  • Разбиение расписания на несколько таймзон одного канала («орбиты» +2/+4) — не рассматривается.
  • Готовые архетипы каналов и «упрощённый режим» редактора — канал собирается с нуля; работать надо над удобством самого конструктора, а не над обходными путями вокруг него.
  • Возрастной гейт для зрителя (подтверждение возраста, скрытие 18+ из публичного EPG) — что показывает канал, то и смотришь. Возрастная категория нужна планировщику, не зрителю.
  • DVR, «смотреть с начала», пауза — см. 1.0. Не «пока не делаем», а не делаем.
  • Отдельный экран телепрограммы для зрителя — пока лишнее, горизонта в публичном EPG достаточно.

9. Что удаляется из текущего кода

Данными существующих каналов не дорожим — миграция одна, «снести и создать», каналы пересоздаются руками. Режима совместимости со старой ротацией нет.

Удаляется:

  • ChannelShow, ChannelShowHour — веса, предпочтительные часы, BlockMode/BlockValue, курсор серий. Роль переходит к паре «группа + слот».
  • ChannelAd, AdInsertion, AdsPerBreak, Channel.NextAdIndex — реклама становится группой и элементом стыка.
  • ProgrammingOverride, OverrideShow, OverrideMode, OverrideRecurrence — заменяются слоем с приоритетом и применимостью, который выразительнее (сезонные сетки, ежегодные периоды, конкретные даты — сейчас этого нет).
  • SchedulePlanner и все Planner*-модели.
  • Bumper-настройки на канале: BumperSelection, NextBumperIndex, BumperMinIntervalMinutes, BumperShowChangeChance, BumperEpisodeChangeChance.
  • Экран ChannelDetail и компоненты AddShowForm, AddAdForm, ChannelShowRow, OverrideForm.

Переиспользуется без изменений:

  • BumperTemplate, BumperTextVariant, BumperAsset, ScheduleBumperResolver, рендер заставок и его фоновая служба — меняется только точка вызова.
  • LiveWindowCalculator и вся раздача HLS — читает ту же ленту.
  • SchedulingBackgroundService — тот же цикл достраивания горизонта.
  • Библиотека, реестр изображений, метаданные, конвейер обработки медиа.

Меняется:

  • Channel худеет до id / name / slug / isEnabled / epochUtc / fillerAssetId плюс новые utcOffsetMinutes, dayStartTime, templateId, number и настройки зрительской части (logoImageId, showClock, analogFilterStrength) — см. 6.8. Заставок на канале не остаётся вовсе: блоки общие, шрифт переехал на блок, условия показа — в стык (3.7.1).
  • ScheduleEntry — новые collectionId, slotId, trace, расширенный kind.
  • SchedulerOptions.RetentionHoursRetentionDays (дефолт 90) — история нужна для остывания и для слотов repeat.
  • SchedulerOptions.HorizonDays — дефолт 3 → 7.

Добавляется: фоновая задача обслуживания (чистка расписания сверх ретеншна, осиротевших ассетов заставок, временных файлов предпросмотра) — см. 3.6.