Added new methods for intersecting and subtracting time intervals in the GridCoverage class to enhance scheduling capabilities. Updated the GridPlan and GridPlanDto classes to replace string notes with a structured PlanNote type, improving note management. Enhanced the GridPlanner class to utilize these new features, ensuring better handling of scheduling conflicts and notes during grid generation. Adjusted related tests to validate the new functionality.
993 lines
74 KiB
Markdown
993 lines
74 KiB
Markdown
# Планировщик эфира: архитектура
|
||
|
||
Спецификация переработки системы планирования расписания канала и админского 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 новых позиций», добавление — по кнопке.
|
||
|
||
```json
|
||
{
|
||
"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
|
||
```
|
||
|
||
```json
|
||
{
|
||
"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`** — повтор того, что уже играло. Одна из самых узнаваемых примет того ТВ:
|
||
вечерний фильм идёт утром в субботу, вчерашний выпуск — сегодня днём. Слот ссылается не на группу,
|
||
а на прошлое той же ленты:
|
||
|
||
```json
|
||
{ "daysAgo": 1, "time": "20:00", "durationMinutes": 90 }
|
||
```
|
||
|
||
Читается готовое расписание канала за указанную точку, найденные программы ставятся заново. Ни
|
||
стратегии, ни курсора, ни остывания — самый дешёвый тип слота из всех. Если в источнике ничего не
|
||
нашлось (эфир ещё не шёл, окно почищено ретеншном) — слот отдаётся фоновому слою с предупреждением.
|
||
|
||
**`slotKind = signOff`** — конец вещания. Каналы тогда не работали круглосуточно: в два часа ночи
|
||
настроечная таблица, гимн или «не забудьте выключить телевизор», в шесть утра эфир возобновлялся.
|
||
Слот заполняется зацикленным ассетом и помечается в программе как «эфир не ведётся».
|
||
|
||
Сделано слотом, а не флагом на канале, намеренно: так конец вещания можно поставить только в будни,
|
||
или только зимой, или не ставить вовсе. Детский канал, который никогда не заканчивается, — это
|
||
законная конфигурация, а не обход правила.
|
||
|
||
**`daypart` — это метка, а не диапазон времени.** Границы дейпартов нигде не задаются: у детского
|
||
канала прайм в 17:00, у развлекательного в 21:00, и заводить на канале четыре пары времён значило бы
|
||
держать состояние, которое всё равно дублирует `targetStart` слота. Метка нужна для цвета в календаре,
|
||
группировки в UI и области действия правил («потолок врезок в прайме»); что считать праймом, решает
|
||
администратор, ставя её на нужные слоты.
|
||
|
||
**Фоновый слой.** Отдельной сущности «заполнитель» нет. Заполнение пустот — это слой с `priority = 0`,
|
||
покрывающий сутки целиком, с обычными слотами и группами внутри: утро одна группа, день другая, ночь
|
||
третья. Любая дыра в расписании — просто место, где сверху ничего не легло, и играет фон. Тонкость
|
||
настройки получается тем же механизмом, без второго UI и без отдельной ветки в генераторе.
|
||
|
||
На самом краю остаётся `ScheduleTemplate.fallbackGroupId` и зацикленный аварийный ассет канала — на
|
||
случай, когда пуст и фон.
|
||
|
||
### 3.5. Стратегии
|
||
|
||
Хранятся в слоте как JSON с полем `type`. Стратегия отвечает за выбор **элемента** группы; внутри
|
||
элемента единицы всегда идут по порядку от курсора — сериал не должен прыгать по сериям.
|
||
|
||
**sequential** — элементы группы по порядку `GroupItem.position`:
|
||
```json
|
||
{ "type": "sequential", "onGroupEnd": "restart" }
|
||
```
|
||
`onGroupEnd`: `restart` | `stop`.
|
||
|
||
**randomWithCooldown** — случайный элемент, не показанный последние N дней. Основной рабочий режим:
|
||
«один случайный боевик по пятницам, по возможности не тот же, что в прошлую»:
|
||
```json
|
||
{ "type": "randomWithCooldown", "cooldownDays": 28, "fallback": "oldestFirst" }
|
||
```
|
||
`fallback` — что делать, когда остывание отсекло всех: `oldestFirst` (взять самый давний) или
|
||
`ignoreCooldown`.
|
||
|
||
**fixed** — всегда один и тот же элемент:
|
||
```json
|
||
{ "type": "fixed", "elementKind": "show", "elementId": "..." }
|
||
```
|
||
|
||
Размер блока (`blockMode`/`blockValue`) — свойство слота, ортогональное стратегии: любую стратегию
|
||
можно комбинировать с «1 единица», «4 единицы подряд» или «пока не наберётся 120 минут».
|
||
Марафон — это `blockMode = fillSlot`, отдельной стратегии не нужно.
|
||
|
||
### 3.6. Состояние слота
|
||
|
||
```
|
||
SlotState
|
||
slotId
|
||
currentElementKind enum? — show | collection
|
||
currentElementId uuid?
|
||
nextUnitIndex int — позиция следующей единицы внутри элемента
|
||
```
|
||
|
||
Курсор хранит **ссылку на элемент**, а не числовой индекс в группе: при удалении позиции из группы
|
||
курсор корректно переезжает на следующую, а не сдвигает всё.
|
||
|
||
**История показов для остывания** берётся из материализованного расписания (`ScheduleEntry` с
|
||
`Kind = Program`, индекс по `channelId + showId + startsAtUtc`), отдельного журнала не заводим — одна
|
||
правда, и при пересборке хвоста будущие показы удаляются вместе с записями.
|
||
|
||
Из этого следует: `SchedulerOptions.RetentionHours` (сейчас 24 часа) заменяется на `RetentionDays`,
|
||
дефолт 90. Значение обязано покрывать максимальный `cooldownDays` среди правил канала и максимальный
|
||
`daysAgo` среди слотов повтора — иначе остывание начнёт врать, а повторы находить нечего. Валидация
|
||
предупреждает, если правило требует истории глубже, чем хранится.
|
||
|
||
**Чистка.** Раз история стала длинной, обслуживание перестаёт быть бесплатным и выносится в фоновую
|
||
задачу — вместе с тем, что и сегодня накапливается молча:
|
||
|
||
- записи расписания старше `RetentionDays`;
|
||
- отрендеренные ассеты заставок (`BumperAsset`), на которые не ссылается ни одна запись в пределах
|
||
окна хранения, — сейчас они копятся при каждой смене пары шоу и при каждой правке блока заставки;
|
||
- временные файлы предпросмотра.
|
||
|
||
Чистка идёт по расписанию, не в транзакции генерации: генератор и так держит advisory-lock канала,
|
||
и подмешивать в него удаление по всей таблице не нужно.
|
||
|
||
### 3.7. Шаблон стыка
|
||
|
||
Реклама и заставки перестают быть настройками канала и становятся элементами стыка. Это то, чего
|
||
сейчас нет: в прайм три ролика и заставка, ночью один длинный — сегодня врезки одинаковы круглые сутки.
|
||
|
||
```
|
||
JunctionTemplate
|
||
id
|
||
channelId
|
||
name — «Прайм», «День», «Ночь», «Внутри блока»
|
||
elements [JunctionElement]
|
||
|
||
JunctionElement
|
||
position int
|
||
kind enum — ad | promo | bumper | filler
|
||
groupId uuid? — для ad/promo/filler: откуда брать
|
||
bumperTemplateId uuid? — для bumper: какой блок заставки
|
||
amountMode enum — count | duration
|
||
amountValue int — единиц либо минут
|
||
isRequired bool — нельзя выбросить при нехватке времени
|
||
conditions jsonb
|
||
```
|
||
|
||
```json
|
||
{
|
||
"onlyOnElementChange": true,
|
||
"minMinutesSinceSameKind": 30,
|
||
"dayparts": ["prime", "day"]
|
||
}
|
||
```
|
||
|
||
Условия — **структурированные поля, а не выражения-строки**: выражения потребовали бы парсера,
|
||
валидации и отдельного UI, а покрывают те же три-четыре реальных случая.
|
||
|
||
**Рекламные ролики — это тоже группы.** Ролик регистрируется как `Show(Kind = Interstitial)` — новое
|
||
значение `ShowKind` — и складывается в группу. Это убирает `ChannelAd` и бесплатно даёт рекламе всё,
|
||
что есть у контента: остывание (не крутить один ролик дважды подряд), разные группы на утро и прайм,
|
||
статистику. Библиотека фильтруется по типу, служебные ролики не мешают на экране шоу.
|
||
|
||
**Рекламный блок — это коллекция.** Реклама здесь не служебный элемент, а половина обаяния: её помнят
|
||
лучше передач. Поэтому важно уметь собрать блок целиком, а не только ротировать ролики по одному —
|
||
и это получается само, без единой строчки специального кода: коллекция из шести `Interstitial`-шоу
|
||
и есть «рекламный блок ОРТ, осень 1998». Играется по порядку целиком, лежит в группе «Рекламные блоки
|
||
90-х», выбирается с остыванием, как любой другой контент.
|
||
|
||
Оба способа собрать врезку работают одним механизмом, и их можно смешивать в одной группе:
|
||
|
||
- **готовым блоком** — группа из коллекций, элемент стыка берёт одну единицу, в эфир идут шесть
|
||
роликов подряд в исходном порядке;
|
||
- **сборкой из отдельных роликов** — группа из одиночных `Interstitial`-шоу с весами, элемент стыка
|
||
набирает несколько, редкие и любимые выпадают чаще;
|
||
- **вперемешку** — группа содержит и целые блоки, и отдельные ролики.
|
||
|
||
Ради последнего случая у элемента стыка два режима бюджета. `count` даёт предсказуемое число единиц,
|
||
но в смешанной группе длина врезки скачет: одна «единица» — это то ли ролик на 20 секунд, то ли блок
|
||
на три минуты. `duration` («врезка примерно две минуты») набирает единицы, пока не наберётся бюджет,
|
||
и коллекция при этом всегда входит целиком — блок не разрезается. Для смешанных групп правильный
|
||
режим — `duration`.
|
||
|
||
**Заставки-переходы переиспользуются как есть.** `BumperTemplate` / `BumperTextVariant`, рендер через
|
||
ffmpeg с кэшем по паре шоу, `ScheduleBumperResolver` — вся эта подсистема не меняется, меняется только
|
||
точка вызова: раньше вероятности и минимальный интервал жили на канале, теперь это `conditions`
|
||
элемента стыка. Настройки `Channel.BumperSelection` / `NextBumperIndex` / `BumperMinIntervalMinutes` /
|
||
`BumperShowChangeChance` / `BumperEpisodeChangeChance` уходят, `BumperFont` остаётся общим для канала.
|
||
|
||
Важно для пайплайна: ассет заставки зависит от **пары соседей**, а пара известна только после наполнения
|
||
слотов. Значит рендер — обязательный шаг между сборкой ленты и записью в БД, он долгий и может упасть.
|
||
В предпросмотре заставка показывается плейсхолдером известной длины, реальный рендер — только при
|
||
применении.
|
||
|
||
### 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` пишется в момент генерации и питает экран «почему это здесь»:
|
||
|
||
```json
|
||
{
|
||
"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.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. Редактор стыка
|
||
|
||
Горизонтальная цепочка с перетаскиванием:
|
||
|
||
```
|
||
[КОНЕЦ] → [Реклама ×2] → [Заставка] → [Промо ×1] → [НАЧАЛО]
|
||
обязательно если смена если прайм
|
||
```
|
||
|
||
Под цепочкой — линейка суммарной длительности. Клик по элементу — параметры (тип, группа, количество,
|
||
обязательность, условия).
|
||
|
||
### 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.9. Автосборка сетки по профилю
|
||
|
||
Собрать неделю руками — это несколько десятков слотов, и до первого эфира новый канал не доживает.
|
||
Кнопка «Собрать сетку» делает первый проход за админа; дальше сетка правится как обычно.
|
||
|
||
**Профиль** — описание вещательных суток полосами: с какого времени по какое, какой длины блок,
|
||
сколько единиц он берёт, какой рейтинг допустим. Профили списаны с реальных каналов («как у 2×2»,
|
||
«как у Paramount Comedy», «как у MTV») — такое админ проверяет по памяти, а «универсальный алгоритм
|
||
раскладки» проверить нельзя никак. Это данные (`GridProfiles`), не алгоритм: новый профиль — это
|
||
новый список полос.
|
||
|
||
**Подбор группы под полосу** объяснимый и в том же порядке, что проверки из 5.1: рейтинг отсекает
|
||
жёстко (в детское время строгое не ставится, ночной блок 18+ без взрослого содержимого бессмыслен),
|
||
дальше баллы за тип контента, длину единицы и запас серий, минус за повторное использование в тех же
|
||
сутках. Не нашлось кандидата — полоса остаётся незакрытой, и это написано в предпросмотре, а не
|
||
обнаруживается потом списком ошибок.
|
||
|
||
**Два режима.** «Заполнить дыры» застраивает только непокрытое время (дыры считаются тем же
|
||
`GridCoverage`, что и в 5.1) и не трогает ручные слоты; интервалы, свободные во все семь дней,
|
||
схлопываются в один слот «каждый день». «Собрать неделю с нуля» сносит все слоты шаблона и строит
|
||
будни слоем `Основная сетка`, а выходные — слоем `Выходные` поверх: слой имеет смысл, только когда
|
||
покрывает день целиком, поэтому отдельные выходные бывают лишь в этом режиме.
|
||
|
||
**Длину слота задаёт контент, а не профиль.** `BlockMinutes`/`UnitsPerBlock` — замысел полосы;
|
||
фактическая длина считается из средней длительности единицы выбранной группы. Иначе двухчасовой
|
||
слот с «тремя сериями по 22 минуты» отдаёт 54 минуты фону, а трёхчасовой прайм с одним фильмом на
|
||
100 минут — восемьдесят. Остаток, не влезающий под ещё одну единицу, достаётся следующему слоту, и
|
||
под него подбирается группа подходящей длины: кандидат, чья единица длиннее оставшегося места,
|
||
штрафуется — до ближайшего якоря он всё равно не влезет.
|
||
|
||
**Ёмкость считается на неделю.** Слот «каждый день» выходит семь раз, поэтому бюджет группы
|
||
(`PlanRun`) списывается сразу на все выходы. Исчерпанная группа уступает нетронутой, а когда
|
||
свежего контента не остаётся вовсе — полоса закрывается **повтором вечернего блока**
|
||
(`SlotKind.Repeat` на начало прайма прошлых суток), как это делают настоящие каналы. Тем же
|
||
повтором закрывается полоса, под которую подходящей группы нет вовсе (например ночной блок 18+
|
||
на канале без взрослого контента) — это честнее дыры в эфире.
|
||
|
||
**Прайм чередуется по дням недели.** Полосы с `Rotate` раскладываются поимённо по будням и берут
|
||
не лучшего кандидата, а следующего по кругу: иначе неделя выглядит одним повторяющимся днём.
|
||
Остальные полосы остаются слотами «каждый день» — сетка должна читаться.
|
||
|
||
**Жанр не занимает две полосы подряд**, а при заданном `MaxGenreSharePercent` — и больше своей
|
||
доли суток. Это тот же потолок, который потом проверяет пост-проверка из 3.8, но соблюдаемый
|
||
на раскладке, а не констатируемый после.
|
||
|
||
**Предпросмотр обязателен**: он считается тем же `GridPlanner`, что и создание, поэтому показанное
|
||
и созданное совпадают по построению. Там же показываются замечания — незакрытое время (красным),
|
||
нехватка состава с точными числами («84 выхода в неделю при 40 позициях») и конфликты с детским
|
||
временем: всё это считается по тому же бюджету, по которому раскладывалось, и потому совпадает
|
||
с тем, что потом скажут проверки сетки. Эфир генерация не двигает — как любая правка сетки, она
|
||
только поднимает ревизию шаблона, а хвост пересобирает кнопка применения.
|
||
|
||
---
|
||
|
||
## 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 / bumperFont`
|
||
плюс новые `utcOffsetMinutes`, `dayStartTime`, `templateId`, `number` и настройки зрительской
|
||
части (`logoImageId`, `showClock`, `analogFilterStrength`) — см. 6.8.
|
||
- `ScheduleEntry` — новые `collectionId`, `slotId`, `trace`, расширенный `kind`.
|
||
- `SchedulerOptions.RetentionHours` → `RetentionDays` (дефолт 90) — история нужна для остывания
|
||
и для слотов `repeat`.
|
||
- `SchedulerOptions.HorizonDays` — дефолт 3 → 7.
|
||
|
||
**Добавляется:** фоновая задача обслуживания (чистка расписания сверх ретеншна, осиротевших ассетов
|
||
заставок, временных файлов предпросмотра) — см. 3.6.
|