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

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

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

---

## 1. Что строим

### 1.0. Зачем

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

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

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

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

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

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

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

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

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

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

---

## 2. Принципы

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---

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

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

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

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

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

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

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

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

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

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

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

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

CollectionItem
  collectionId
  showId
  position           int
```

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

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

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

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

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

### 3.3. Группа

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

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

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

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

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

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

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

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

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

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

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

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

```
GridLayer
  id
  templateId
  name                           — «Фон», «Базовая», «Выходные», «Новогодняя»
  priority           int         — больше = специфичнее, побеждает. Фон = 0
  applicability      jsonb
  isEnabled          bool
```

```json
{
  "weekdays": [1, 2, 3, 4, 5],
  "dateRanges": [{ "from": "2026-06-01", "to": "2026-08-31" }],
  "annualRanges": [{ "fromMonth": 12, "fromDay": 20, "toMonth": 12, "toDay": 31 }],
  "specificDates": ["2026-12-31"]
}
```

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

На самом краю остаётся `ScheduleTemplate.fallbackGroupId` и зацикленный аварийный ассет канала — на
случай, когда пуст и фон.

### 3.5. Стратегии

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

**sequential** — элементы группы по порядку `GroupItem.position`:
```json
{ "type": "sequential", "onGroupEnd": "restart" }
```
`onGroupEnd`: `restart` | `stop`.

**randomWithCooldown** — случайный элемент, не показанный последние N дней. Основной рабочий режим:
«один случайный боевик по пятницам, по возможности не тот же, что в прошлую»:
```json
{ "type": "randomWithCooldown", "cooldownDays": 28, "fallback": "oldestFirst" }
```
`fallback` — что делать, когда остывание отсекло всех: `oldestFirst` (взять самый давний) или
`ignoreCooldown`.

**fixed** — всегда один и тот же элемент:
```json
{ "type": "fixed", "elementKind": "show", "elementId": "..." }
```

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

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

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

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

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

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

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

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

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

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

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

```
JunctionTemplate
  id
  channelId
  name                           — «Прайм», «День», «Ночь», «Внутри блока»
  elements           [JunctionElement]

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

```json
{
  "onlyOnElementChange": true,
  "minMinutesSinceSameKind": 30,
  "dayparts": ["prime", "day"]
}
```

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

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

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

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

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

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

**Заставки-переходы переиспользуются как есть.** `BumperTemplate` / `BumperTextVariant`, рендер через
ffmpeg с кэшем по паре шоу, `ScheduleBumperResolver` — вся эта подсистема не меняется, меняется только
точка вызова: раньше вероятности и минимальный интервал жили на канале, теперь это `conditions`
элемента стыка. Настройки `Channel.BumperSelection` / `NextBumperIndex` / `BumperMinIntervalMinutes` /
`BumperShowChangeChance` / `BumperEpisodeChangeChance` уходят, `BumperFont` остаётся общим для канала.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---

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

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

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

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

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

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

---

## 6. UI

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---

## 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.
