920 lines
65 KiB
Markdown
920 lines
65 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` расширяется до пяти значений, **упорядоченных по возрастанию
|
||
строгости** — правила формулируются как «до 23:00 не строже подросткового», поэтому порядок в enum
|
||
значим:
|
||
|
||
```
|
||
Kids — детское
|
||
Family — семейное
|
||
Teen — подростковое
|
||
General — общее
|
||
Adult — взрослое
|
||
```
|
||
|
||
Текущие три значения (`General=0, Kids=1, Adult=2`) не отсортированы и переписываются.
|
||
|
||
**Коллекция** — франшиза: упорядоченный набор шоу. «Терминатор» — это три `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": "Teen" },
|
||
"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 не строже подросткового |
|
||
| `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 позициях» |
|
||
| Возрастной конфликт | группа содержит строже, чем позволяет окно | «В группе есть 18+, слот стоит в детском времени» |
|
||
|
||
Запас группы показывается прямо в инспекторе слота: «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-м», но
|
||
переборщить очень легко.
|
||
|
||
---
|
||
|
||
## 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.
|