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

Спецификация переработки системы планирования расписания канала и админского 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 }
}
```

`showKinds` принимает и `interstitial`: группа рекламы собирается тем же правилом, что и группа кино,
иначе набрать её можно было бы только руками. Но **не указанный `showKinds` означает «кино и
сериалы», а не «всё подряд»**: ролики живут отдельной библиотекой (см. 6.7), и сотня рекламных
вставок, молча просочившаяся в группу фильмов, — это не широкий отбор, а сломанный эфир. Чтобы они
попали в набор, тип нужно отметить явно.

**Веса.** `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 }
```

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

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

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

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

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

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

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

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

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

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

**Рекламный шов ограничен целиком, а не по частям.** Добор до круглой отметки и остаток, куда
программа уже не влезла, — это два конца одного шва, и потолок (`maxPadDuration`, по умолчанию
10 минут) считается на них общий. Когда фону нечего играть, они сходятся встык, и раздельный
потолок давал бы вдвое больше рекламы подряд, чем настроено. Поставил фон программу — шов после неё
начинается заново.

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

### 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         — позиция следующей единицы внутри элемента
```

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

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

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

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

Слот, за которым **не осталось ни одного отыгранного выхода**, начинает с начала, а сохранённое
состояние забывается (`SlotState.Reset`): оно целиком набрано из только что снесённого хвоста.
Случай не краевой, а обычный — так выглядит любой новый канал и любой добавленный слот, и без
сброса второе «Применить» до первого эфира начинало бы сериал с середины, а франшизу — с третьей
части. Отмотка не удалась иначе (единицы больше нет в элементе — серию удалили, ассет перестал быть
готовым) — остаётся сохранённое состояние: слот в эфире уже был, и откат в начало группы виден
зрителю.

**Выход, который уже состоялся, не переигрывается.** Курсора для этого мало: он говорит, на чём
слот остановился, но не говорит, что этот его выход уже был. Пересборка посреди длинного фильма
попадала ровно в эту щель — кино объявленных 21:00 идёт до 23:14, окно слота 21:00–23:15, точка
продолжения 23:14 внутри окна, и планировщик открывал слот заново, ставя вторую полнометражку
подряд. Поэтому экземпляры слотов, чьи отрезки уже заняты записями в уцелевшей ленте, из плана
выбрасываются: сравниваются отрезки, а не старты (сетка эластичная, и кино законно начинается
в 20:55), а вид записи не важен — слот, чьё время закрыл фон, свой выход тоже отработал.
Фоновых слотов это не касается: они играют не по своему окну, а в чужих паузах.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

**Жребий бросается после выбора варианта, а не до.** Порядок здесь и есть смысл настройки: развилка
из пяти заставок с `chance = 60` — это «заставка в шести случаях из десяти, каждый раз разная».
Бросай кубик за каждую врезку отдельно, и получилось бы «какая-нибудь заставка почти всегда»
(1 − 0,4⁵ ≈ 99 %). Поэтому сначала отсекают условия (смена шоу, интервал, окно суток, круглый час),
потом развилка схлопывается в один вариант по весам, и уже он бросает единственный жребий своим
`chance`. Разные шансы у вариантов допустимы и означают ровно то, что написано: сыграет шанс
выбранного.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

**Фон из готового ролика** (`background = clip`, `backgroundClipShowId`) — текст пишется поверх
видео из библиотеки, а не поверх картинки. Отличается от прочих источников тем, что задаёт время:

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

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

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

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

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

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

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

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

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

### 3.8. Правила

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

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

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

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

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

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

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

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

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

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

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

```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 }
}
```

**Отладочный дамп канала.** Трейс объясняет одну запись, но «сетка собралась не так» — вопрос
не про запись, а про весь вход планировщика сразу. Кнопка в карточке канала собирает его архивом:
настройки канала, шаблон со слотами и разобранными стратегиями, курсоры слотов, эффективная сетка
на горизонт (включая перекрытые слоты фона), развёрнутые группы **вплоть до отдельных единиц**,
стыки с условиями, записанная лента с трейсами и сухой прогон от текущего состояния. Всё, что
хранится JSON-строкой, разбирается в объекты, а рядом с каждым идентификатором стоит имя: дамп
читают глазами чаще, чем кодом.

Сухой прогон в дампе принципиален: рядом с записанной лентой он отвечает на первый вопрос
разбора — данные плохие или расчёт. Архив всегда уезжает в браузер, а копия ложится в каталог
`Debug:ExportDirectory` (в разработке — папка `debug-exports/` репозитория, в гите игнорируется);
пустая настройка означает «копию не хранить», и на сервере это состояние по умолчанию.

---

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

### 4.1. Пайплайн

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### 4.5. Граница `now`

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

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

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

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

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

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

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

---

## 5. Валидация

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

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

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

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

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

---

## 6. UI

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

**Выгрузка для внешних IPTV-плееров.** Кнопка на экране эфира отдаёт `telewave.m3u` — список всех
включённых каналов и ссылка на XMLTV с программой передач (`/api/iptv/epg.xml`, горизонт 7 суток,
подряд идущие серии одного шоу склеены в одну передачу, врезки и заставки в гид не попадают).

Плеер не умеет ни Bearer, ни httpOnly-cookie — только открыть URL. Поэтому пропуск зашит прямо
в ссылки файла долгим stream-токеном (`Storage:IptvTokenDays`, по умолчанию 30 суток), и его же
принимают плейлист канала и сегменты — рядом с cookie, тем же форматом и той же проверкой подписи.
Токен тянется дальше в адреса сегментов внутри `live.m3u8`: плеер разрешает их относительно
плейлиста и query при этом теряет.

Из этого следует прямо: **файл и есть доступ к эфиру**, и делиться им нельзя — о чём говорится
в момент скачивания. Отозвать отдельный файл нечем: помогает блокировка зрителя (её сверяет
плейлист канала на каждой перезагрузке — для IPTV это единственная точка, где она вообще
срабатывает) и смена `Jwt:SigningKey`, которая обнуляет все выданные токены разом. Реестра
выданных файлов не заводим, пока не появится запрос на отзыв поштучно.

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

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

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

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

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

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

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

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

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

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

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

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

**Группы роликов в сетку не идут.** Отсев такой же жёсткий, как по рейтингу, и по той же причине:
ролики — материал для стыков (6.7), а не для эфира, и слот по такой группе выдаёт полосу рекламы,
подписанную в программе как обычная передача. Отсев обязателен, а не желателен: в группе роликов
сотни коротких единиц, то есть самый большой запас на канале, — по баллам она выигрывала бы любую
полосу без предпочтения по типу и становилась бы аварийной группой. Тип группы считает
`GroupCatalog` (`DominantKind = Interstitial`, когда роликов половина единиц и больше), поэтому его
же видит запрос к ИИ — там про это сказано отдельной строкой правил.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---

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

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

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

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

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

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

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

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

---

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

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

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

---

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

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

**Удаляется:**

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

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

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

**Меняется:**

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

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