# Планировщик эфира: архитектура Спецификация переработки системы планирования расписания канала и админского 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`): оно целиком набрано из только что снесённого хвоста. Случай не краевой, а обычный — так выглядит любой новый канал и любой добавленный слот, и без сброса второе «Применить» до первого эфира начинало бы сериал с середины, а франшизу — с третьей части. Отмотка не удалась иначе (единицы больше нет в элементе — серию удалили, ассет перестал быть готовым) — остаётся сохранённое состояние: слот в эфире уже был, и откат в начало группы виден зрителю. Из этого следует: `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` и выбор внутри развилки — берутся из 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 } } ``` --- ## 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`, рядом с флагом регистрации): у кого-то это главный способ навигации, у кого-то лишняя механика. Сетка каналов остаётся вторым способом всегда. **Экранные оверлеи** — рисуются на клиенте поверх `