From c4ef954dea01bc6e4ee893135104a06a47f68887 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 26 Jul 2026 04:18:33 +0300 Subject: [PATCH] Update TV scheduler architecture documentation: expand on data model details, including channel, pool, grid layer, and slot structures. Enhance descriptions of scheduling strategies and constraints, and clarify the role of epoch and timezone in scheduling. Improve overall clarity and completeness of the specification. --- docs/tv-scheduler-architecture.md | 1610 ++++++++++++++++------------- docs/tv-scheduler-tasks.md | 411 ++++++++ 2 files changed, 1330 insertions(+), 691 deletions(-) create mode 100644 docs/tv-scheduler-tasks.md diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index 63bc89b..cb461c5 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -1,691 +1,919 @@ -# Автоматическое построение расписания линейного канала - -Спецификация для разработки. Система эмулирует линейный телеканал: непрерывный общий -эфир, привязанный к календарю, с публикуемой программой передач на N дней вперёд. - ---- - -## 1. Основные принципы - -### 1.1. Четыре слоя правил - -Гибкость достигается не «конструктором расписаний», а разделением на четыре -независимых, переиспользуемых слоя: - -| Слой | Сущность | Отвечает за | -|------|----------|-------------| -| 1 | **Пул** | Что может попасть в эфир | -| 2 | **Сетка** | Когда и в каком порядке | -| 3 | **Стратегия** | Как выбирается конкретный элемент | -| 4 | **Ограничение** | Чего нельзя допускать | - -Слои ссылаются друг на друга, но редактируются отдельно. Один пул используется -многими слотами, одна стратегия — многими каналами. - -### 1.2. Детерминированность - -Генерация — чистая функция: - -``` -schedule(channel_id, date) → [элементы] -``` - -Одинаковые входные данные всегда дают одинаковый результат. Следствия: - -- предпросмотр гарантированно совпадает с эфиром; -- перегенерация не «прыгает» без причины; -- можно посчитать любую дату независимо от соседних; -- нет накопленного состояния, которое рассинхронизируется. - -### 1.3. Модель времени - -- `channel.epoch` — дата запуска канала, точка отсчёта для последовательных стратегий. -- `channel.timezone` — таймзона канала. Эфир общий: в 20:00 все зрители видят одно и то же. -- `now` — единственная жёсткая граница между неизменяемым прошлым и пересчитываемым будущим. - ---- - -## 2. Модель данных - -### 2.1. Канал - -``` -Channel - id - name - slug - epoch date — точка отсчёта - timezone string - era_profile jsonb — профиль эпохи (см. 2.2) - grid_mode enum — hard | elastic - day_start_time time — вещательные сутки, обычно 06:00, не 00:00 - status enum — draft | active | archived - rules_version int — инкремент при любой правке правил -``` - -**`day_start_time`** — важная деталь. У телеканалов сутки начинаются утром, а не в полночь. -Ночной блок с 00:00 до 06:00 относится к предыдущему дню. Это влияет на слои сетки -(«пятничная ночь» = ночь с пятницы на субботу) и на отображение программы. - -**`grid_mode`**: -- `hard` — старты слотов фиксированы (:00, :30). Разница добирается врезками. Правильный - выбор для правдоподобной эмуляции. -- `elastic` — всё встык, времена слотов — цели с допуском дрейфа, выравнивание на якорях. - -### 2.2. Профиль эпохи - -Фильтр верхнего уровня, накрывающий все пулы канала сразу. Один переключатель задаёт -атмосферу и не даёт просочиться современному контенту. - -``` -era_profile - year_max int — ничего новее - year_min int — опционально - aspect_ratio enum — 4:3 | 16:9 | any - ident_pack_ids [uuid] — паки заставок - ad_pack_ids [uuid] — паки рекламных роликов - promo_pack_ids [uuid] — паки анонсов -``` - -Применяется как AND к любому пулу этого канала. - -### 2.3. Пул (слой 1) - -``` -Pool - id - name - kind enum — query | manual | composite - filters jsonb — для kind=query - manual_items [item_ref] — для kind=manual - composite jsonb — для kind=composite: {include:[], exclude:[]} - cached_stats jsonb — {count, total_duration_ms, computed_at} -``` - -`filters` для `kind=query`: - -```json -{ - "content_types": ["show", "movie"], - "genres": { "any_of": ["comedy", "animation"] }, - "tags": { "all_of": ["retro"], "none_of": ["holiday"] }, - "year": { "min": 1989, "max": 1999 }, - "duration_ms": { "min": 1200000, "max": 1500000 }, - "age_rating": { "max": "12+" }, - "languages": ["ru"], - "channels": ["cartoon-network"] -} -``` - -`cached_stats` пересчитывается фоново при изменении каталога — нужно для UI -(«подходит 342 позиции, 118 часов»). - -### 2.4. Сетка и слот (слой 2) - -``` -GridLayer - id - channel_id - name — «Базовая», «Выходные», «Летняя», «Новый год» - priority int — больше = специфичнее, побеждает - applicability jsonb — когда действует - enabled bool -``` - -`applicability`: - -```json -{ - "weekdays": [1,2,3,4,5], - "date_ranges": [{"from": "2026-06-01", "to": "2026-08-31"}], - "specific_dates": ["2026-12-31"], - "recurring_dates": [{"month": 10, "day": 31}] -} -``` - -Разрешение конфликтов: для каждой минуты берётся слот из слоя с наибольшим `priority` -среди применимых. Базовый слой имеет `priority = 0`. - -``` -Slot - id - layer_id - weekday int — 0..6, null если слой привязан к датам - start_time time — в вещательных сутках канала - duration_ms int - title string — отображаемое имя блока («Вечернее кино») - slot_type enum — content | repeat | anchor - pool_id uuid - strategy jsonb — см. 2.5 - junction_template_id uuid - break_load jsonb — плотность врезок, см. 4.3 - is_anchor bool — старт не сдвигается ни при каких условиях - daypart enum — morning | day | prime | night -``` - -**`slot_type = repeat`** — очень узнаваемая примета настоящего ТВ и бесплатное -расширение объёма контента: - -```json -{ - "slot_type": "repeat", - "repeat_source": { "days_ago": 1, "time": "20:00" } -} -``` - -### 2.5. Стратегия (слой 3) - -Хранится внутри слота как JSON с полем `type`. - -**sequential** — сериал по порядку: -```json -{ - "type": "sequential", - "on_season_end": "next_season", // next_season | restart | stop - "start_from": { "season": 1, "episode": 1 } -} -``` - -**marathon** — блок серий подряд: -```json -{ "type": "marathon", "count": 4, "continue_across_days": true } -``` - -**rotation** — случайный выбор с остыванием: -```json -{ "type": "rotation", "cooldown_days": 7 } -``` - -**weighted** — вероятность по весу: -```json -{ - "type": "weighted", - "weight_by": "popularity", // popularity | recency | manual - "cooldown_days": 3 -} -``` - -**fixed** — всегда один и тот же тайтл: -```json -{ "type": "fixed", "item_ref": "movie:12345" } -``` - -**playlist** — жёсткий порядок: -```json -{ "type": "playlist", "items": ["show:1:s1e1", "movie:99"], "loop": true } -``` - -### 2.6. Шаблон стыка - -Последовательность врезок между программами. Достаточно 3–4 шаблонов на канал. - -``` -JunctionTemplate - id - name — «Прайм», «День», «Ночь», «Внутри марафона» - elements jsonb[] -``` - -```json -{ - "name": "Прайм", - "elements": [ - { "kind": "ad", "duration_ms": 120000, "required": true, - "flex": { "min": 60000, "max": 180000 } }, - { "kind": "ident", "required": false, - "condition": "minutes_since_last_ident > 30" }, - { "kind": "bumper_nextup", "required": false, - "condition": "next_slot.slot_type != 'repeat'" }, - { "kind": "promo", "required": false, "fill_remaining": true } - ] -} -``` - -- `required` — элемент нельзя выбросить при нехватке времени. -- `flex` — диапазон, внутри которого элемент поглощает лишнее/недостающее время. -- `fill_remaining` — элемент растягивается на весь остаток слота. -- `condition` — выражение над контекстом (соседние слоты, время суток, счётчики). - -### 2.7. Ограничения (слой 4) - -``` -Constraint - id - channel_id - scope enum — channel | daypart | slot - scope_ref uuid - type enum - params jsonb - severity enum — hard | soft -``` - -`hard` — генератор обязан соблюсти или упасть с ошибкой. -`soft` — старается соблюсти, при невозможности пишет предупреждение. - -Типы: - -| type | params | Смысл | -|------|--------|-------| -| `age_rating_by_time` | `{from, to, max_rating}` | Детское время | -| `max_title_repeats` | `{window_days, max}` | Не чаще N раз за период | -| `min_repeat_gap` | `{hours}` | Минимальный интервал между повторами | -| `max_break_minutes_per_hour` | `{minutes}` | Потолок врезок | -| `max_genre_share` | `{genre, share, window}` | Доля жанра в сутках | -| `forbidden_adjacency` | `{tags}` | Что нельзя ставить встык | - -### 2.8. Результат генерации - -Два представления одного расписания. - -**Программная сетка** — то, что отдаётся клиентам: - -``` -ProgrammeEntry - id - channel_id - start_at timestamptz - end_at timestamptz - title - item_ref — show:id:s1e5 | movie:id - slot_id — что породило - layer_id — из какого слоя сетки - origin enum — generated | manual | fallback - is_frozen bool — прошедшее -``` - -**Плейаут-лента** — реальная последовательность воспроизведения: - -``` -PlayoutItem - id - channel_id - programme_entry_id — null для врезок между программами - start_at timestamptz - duration_ms - kind enum — programme | ad | promo | ident | bumper | filler - asset_id - source_ref — slot_id | junction_template_id -``` - -Плейаут выводится из программной сетки при генерации. Клиентам отдаётся только первое. - -### 2.9. Ручные правки - -Отдельная таблица, переживает перегенерацию. - -``` -Patch - id - channel_id - target_date date - target_time time - op enum — pin | replace | shift | remove | insert - payload jsonb - status enum — active | orphaned - created_by - created_at -``` - -- `pin` — закрепить конкретный элемент в этом времени. -- `replace` — заменить то, что сгенерировалось. -- `shift` — сдвинуть границу слота. -- `orphaned` — правила изменились так, что патч потерял смысл. Не применяется молча - и не выбрасывается молча — показывается админу списком. - ---- - -## 3. Алгоритм генерации - -### 3.1. Пайплайн - -``` -1. RESOLVE GRID — собрать эффективную сетку на дату из слоёв -2. FILL SLOTS — для каждого слота выбрать контент по стратегии -3. APPLY CONSTRAINTS — проверить ограничения, при нарушении — пересобрать слот -4. BUILD PLAYOUT — разложить врезки по шаблонам стыков, подогнать длительности -5. APPLY PATCHES — наложить ручные правки -6. VALIDATE — финальная проверка, собрать предупреждения -7. MATERIALIZE — записать в кэш будущего -``` - -### 3.2. Seed: гранулярность на уровне слота - -**Критично для UX.** Если считать seed на уровне дня, любая мелкая правка -перетасовывает всю неделю, включая слоты, которых правка не касалась. Админ поменял -шаблон стыка в ночном блоке — переехало дневное вещание. Диф становится -бесполезным. - -``` -seed = hash(channel_id, date, slot_id) -``` - -**Правило: seed зависит только от координат (канал + дата + слот), но не от -содержания правил.** Изменилось правило — изменился результат его применения, но не -жребий соседей. Диф читается как «изменилось 4 элемента из 180». - -### 3.3. Последовательные стратегии без состояния - -Курсор в БД не нужен. Номер выхода вычисляется арифметически: - -``` -occurrences = количество срабатываний слота в интервале [channel.epoch, date) -episode_index = (occurrences + start_offset) mod total_episodes -``` - -`occurrences` считается по правилу повторения слоя (например, «будни» → количество -рабочих дней между датами) с вычетом дат, где слот был перекрыт слоем с более высоким -приоритетом. - -Даёт: строго последовательный порядок + мгновенную перемотку на любую дату + нулевое -состояние. - -Для `on_season_end = next_season` — тот же принцип, но по плоскому списку серий всех -сезонов. - -### 3.4. Ротация с остыванием без состояния - -Вместо хранения истории показов — детерминированная колода: - -``` -period = floor(days_since_epoch / cooldown_days) -deck = shuffle(pool_items, seed = hash(channel_id, slot_id, period)) -index = day_within_period -item = deck[index mod len(deck)] -``` - -Колода перетасовывается раз в период, внутри периода повторов нет by design. - -### 3.5. Подгонка длительности — главная рабочая часть - -Слот 30 мин, серия 22 мин → 8 минут добора. Алгоритм: - -``` -gap = slot.duration_ms - content.duration_ms - -if gap > 0: - заполнить по шаблону стыка: - 1. required-элементы (сумма их min) - 2. расширить flex-элементы до max в порядке приоритета - 3. добавить optional-элементы, пока проходят условия - 4. остаток отдать fill_remaining-элементу - 5. если остаток всё ещё > 0 → filler - -if gap < 0: # контент длиннее слота - grid_mode = hard: - а) искать в пуле элемент подходящей длительности (best-fit) - б) сжать врезки до min - в) если не помещается → расширить слот, сдвинув следующий - (но не якорь — перед якорем сжимается эластичная зона) - grid_mode = elastic: - сдвинуть последующие слоты, выровняться на ближайшем якоре -``` - -**Best-fit важнее «лучшего»**: при подборе в пул часто правильнее взять элемент, -ближе всего подходящий по длительности, чем элемент с наивысшим весом. - -**Внутренние разрывы** (реклама внутри программы) ставятся по маркерам из метаданных, -при их отсутствии — через равные интервалы с запретом первых и последних 5 минут. - -### 3.6. Плотность врезок по дейпартам - -Реальный канал не идёт встык, и плотность меняется по времени суток. - -``` -break_load: - morning: { target_ratio: 0.12, max_break_ms: 90000 } - day: { target_ratio: 0.18, max_break_ms: 120000 } - prime: { target_ratio: 0.22, max_break_ms: 180000 } - night: { target_ratio: 0.10, max_break_ms: 240000 } -``` - -Ночью врезок меньше, но они длиннее — это узнаваемо. - -### 3.7. Граница `now` и перегенерация - -``` -past → snapshot, immutable, никогда не пересчитывается -current → текущий элемент не вырезается из-под зрителя; - в grid_mode=hard применение начинается со следующего слота -future → materialized cache, пересобирается по требованию -patches → отдельная таблица, переживает перегенерацию -``` - -Перегенерация = удалить кэш будущего от границы применения → посчитать заново → -наложить патчи. Операция идемпотентна. - -Прошлое замораживается снапшотом обязательно: иначе история будет врать — пользователь -смотрел вчера в 20:00 одно, а в архиве другое. - -Фоновая задача каждую ночь достраивает горизонт (например, держим 14 дней вперёд). - -### 3.8. Отдача клиентам - -- Запрос программы — простой диапазон по `ProgrammeEntry`. -- Возвращать `version` / `ETag`, чтобы клиент не показывал устаревшую сетку из кэша. -- Плейаут-лента наружу не отдаётся. - ---- - -## 4. Валидация и предупреждения - -Считаются до генерации (по правилам) и после (по результату). - -### 4.1. До генерации - -| Проверка | Формула | Сообщение | -|----------|---------|-----------| -| Нехватка контента | `pool.count < выходов_слота_в_неделю` | «В пуле 3 позиции при потребности 7 в неделю — повторы каждые полнедели» | -| Пустой пул | `pool.count == 0` | «Пул пуст, слот будет заполнен филлером» | -| Дыра в сетке | непокрытый интервал | «Не покрыто: вт 03:00–06:00» | -| Пересечение слотов | overlap в одном слое | «Слоты пересекаются» | -| Недостижимое ограничение | hard-constraint vs объём пула | «Ограничение „не чаще 1 раза в неделю" невыполнимо при 3 позициях» | - -**Расчёт достаточности пула:** - -``` -потребность = выходов_в_неделю -запас = pool.count / потребность # в неделях до полного цикла -``` - -Показывать в UI: «полный цикл без повторов — 6.2 недели». - -### 4.2. После генерации - -- частота повторов тайтла (тепловая карта); -- фактическая доля врезок по часам против лимита; -- элементы с `origin = fallback` (не нашлось контента); -- осиротевшие патчи; -- нарушенные soft-constraints. - ---- - -## 5. UI: формы и экраны - -### 5.1. Редактор сетки — основной экран - -**Календарь недели с drag & drop**, как в Google Calendar. Не список форм. - -- ось X — дни недели, ось Y — время вещательных суток (от `day_start_time`); -- слоты тянутся мышкой, изменяются за края; -- копирование дня на другие дни, копирование недели; -- цвет блока = дейпарт или тип слота; -- полупрозрачная штриховка на слотах, перекрытых слоем выше; -- панель слоёв слева: список с чекбоксами видимости и приоритетом, drag для - переупорядочивания. - -**Инспектор слота** (правая панель, открывается по клику): - -``` -Название блока [Вечернее кино ] -Время / длительность [20:00] [90 мин] □ Якорь -Дейпарт (○ утро ○ день ● прайм ○ ночь) -Тип слота (● контент ○ повтор ○ якорь) - -Пул [Фильмы 90-х ▾] 342 поз. · 118 ч · цикл 6.2 нед - [Открыть пул] [Создать новый] - -Стратегия [Ротация с остыванием ▾] - Остывание [7] дней - -Шаблон стыка [Прайм ▾] ~4 мин врезок -Плотность врезок [наследовать от дейпарта ▾] - -Ограничения слота + Добавить -``` - -Ключевое: статистика пула («342 поз. · цикл 6.2 нед») видна прямо здесь, без перехода. - -### 5.2. Редактор пула - -Двухпанельный: слева конструктор фильтров, справа — живой список результатов с -пересчётом на каждое изменение. - -``` -Тип контента [x] Шоу [x] Фильмы -Жанры любой из: [комедия ×] [анимация ×] + -Теги все из: [ретро ×] + - ни одного: [праздничное ×] + -Год от [1989] до [1999] -Длительность от [20] до [25] мин -Возраст не выше [12+ ▾] - -───────────────────────────────── -Найдено: 342 позиции · 118 ч 40 мин -Самое старое: 1989 · Самое новое: 1999 - -[список результатов с превью] -``` - -Профиль эпохи канала показывается как неотключаемый бейдж сверху: «Фильтр канала: -не новее 1999». - -### 5.3. Редактор шаблона стыка - -Визуальная горизонтальная цепочка, перетаскиванием. - -``` -[КОНЕЦ] → [Реклама 60–180с] → [Айдент 10с] → [Далее 15с] → [Промо ⇥] → [НАЧАЛО] - required if >30min if !repeat fill -``` - -Под цепочкой — линейка суммарной длительности: «мин 85с / типично 155с / макс 205с». -Клик по элементу — попап с параметрами (kind, длительность, flex, condition, required). - -### 5.4. Предпросмотр - -Кнопка «Предпросмотр на N дней» доступна из редактора **без сохранения** — считает по -текущему черновику правил. - -Три вкладки: - -**Программа** — список как увидит зритель, по дням. - -**Плейаут** — посекундный таймлайн с цветовыми слоями (программа / реклама / айдент / -анонс / филлер). Сверху — гистограмма нагрузки врезок по часам с горизонтальной линией -лимита; превышения красным. - -**Проблемы** — сгруппированный список предупреждений с переходом к источнику. - -Дополнительно: тепловая карта повторов — матрица «тайтл × день», где яркость = -количество показов. Мгновенно видно, что один фильм крутится четыре раза за неделю. - -### 5.5. «Почему это здесь» - -У каждого элемента расписания — иконка «i», открывающая цепочку происхождения: - -``` -Симпсоны, с5э12 · пн 18:00 - -Слой: Базовая сетка (priority 0) -Слот: Дневная анимация · будни 18:00 · 30 мин -Пул: Мультсериалы 90-х (128 позиций) -Стратегия: Последовательная - выход №247 с 01.01.2024 → серия 247 mod 178 = 69 -Врезки: шаблон «День», 8 мин добора -Патчи: нет -``` - -Это экономит редакторам часы отладки — обязательный, не опциональный элемент. - -### 5.6. Диф перед применением - -Уведомлений клиентам нет, но админу нужен предохранитель. - -``` -Изменения затронут: - 180 элементов всего, из них 12 изменятся - 3 — в ближайшие 24 часа ⚠ - - пн 20:00 «Терминатор» → «Чужой» - пн 21:40 реклама 120с → реклама 180с - вт 18:00 без изменений - ... - - [Применить] [Отмена] -``` - -Отдельно подсвечивать изменения в ближайшие сутки — самая частая причина случайного -ущерба. - -### 5.7. Прочие экраны - -- **Список каналов** с кнопкой «Клонировать» — сделал один канал, скопировал, поменял - пулы, получил второй. Основной способ масштабирования. -- **История версий правил**: кто, когда, что изменил; откат к версии. -- **Патчи**: список ручных правок, фильтр по статусу, массовое снятие осиротевших. -- **Расписание (обзор)**: чтение на любую дату, включая архив прошлого. - -### 5.8. Люк вниз - -Правила хранятся в JSONB со схемой. Визуальный редактор и «сырой» JSON правят одну и ту -же структуру. Для продвинутых пользователей — вкладка с JSON-редактором и валидацией по -схеме. Это же даёт нормальный дифф в истории версий. - ---- - -## 6. Что делает эмуляцию правдоподобной - -Отдельный раздел, потому что это влияет на дефолты и на то, что вынести в UI явно. - -1. **Стабильность сетки.** Настоящее ТВ предсказуемо: одно шоу всегда в одно время. - Слоты прибиты, а не рандомятся каждый день. Дефолт — `grid_mode = hard`. -2. **Характер дейпартов.** Утро — короткие детские; день — повторы и дешёвый контент; - прайм — премьеры и полнометражки; ночь — старое кино, документалки, длинные блоки. - Реализуется просто разными пулами и шаблонами стыков. -3. **Повторы как фича, а не баг.** Вечерний блок повторяется утром следующего дня — - очень узнаваемо. Тип слота `repeat`. -4. **Вещательные сутки с 06:00**, а не с полуночи. -5. **Ритм врезок**: чем ближе к прайму, тем плотнее. Ночью реже и длиннее. -6. **Ротация айдентов с остыванием** — не крутить один и тот же чаще раза в час. -7. **Сезонные слои**: летняя сетка, новогодняя, тематические дни. - ---- - -## 7. Порядок реализации - -**Этап 1 — ядро** -Канал, пул (kind=query), базовый слой сетки, слоты, стратегии sequential и rotation, -генератор без врезок, материализация, отдача программы. - -**Этап 2 — врезки** -Шаблоны стыков, подгонка длительности, плотность по дейпартам, плейаут-лента. - -**Этап 3 — гибкость** -Слои сетки с приоритетами, остальные стратегии, ограничения, тип слота `repeat`. - -**Этап 4 — админка** -Календарь с drag & drop, инспектор слота, редактор пула со статистикой, -предпросмотр, «почему это здесь». - -**Этап 5 — эксплуатация** -Патчи, диф, версионирование и откат, клонирование канала, валидация, тепловые карты. - ---- - -## 8. Открытые вопросы - -- Нужна ли поддержка нескольких таймзон для одного канала (условные «орбиты» +2/+4)? - Если да — это тонкая надстройка над той же лентой со сдвигом, а не отдельный канал. -- Нужны ли «прямые эфиры» / премьеры по расписанию как отдельный тип якоря? -- Хранить ли плейаут-ленту материализованно или считать на лету из программной сетки - при запросе. Зависит от нагрузки на плеер. +# Планировщик эфира: архитектура + +Спецификация переработки системы планирования расписания канала и админского UI. +Заменяет текущую взвешенную ротацию шоу (`SchedulePlanner`, `ChannelShow`, `ProgrammingOverride`) +на сетку из слоёв и слотов с переиспользуемыми группами контента. + +Документ описывает целевое состояние. Раздел 9 — что при этом удаляется из текущего кода. + +--- + +## 1. Что строим + +### 1.0. Зачем + +Проект ностальгический: телевидение, каким оно было до того, как стало осторожным и одинаковым. +Мультики, о которых ты знал, что они будут в 16:00. Шоу, которое крутилось вечно. Смешная реклама, +которую помнишь лучше, чем передачи. Ночной блок, который смотрели тайком. + +Отсюда два следствия, определяющих решения ниже. + +**Эфир общий и неперематываемый.** Нельзя начать сначала, нельзя отмотать, нельзя поставить на паузу. +Опоздал — опоздал. Это не техническое ограничение раздачи, а суть: ровно этим проект отличается от +очередного медиасервера с плейлистами. Соблазн добавить DVR или «смотреть с начала» надо гасить. + +**Конструктор должен быть удобным, а не мощным.** Администратор собирает канал таким, каким его помнит, +и упирается он не в нехватку возможностей, а в неудобство. Приоритет UI — комфорт и прямое +манипулирование, а не полнота настроек. Готовых пресетов и «упрощённых режимов» при этом не делаем: +канал создаётся с нуля, но так, чтобы это было приятно. + +Возрастных ограничений на стороне зрителя нет. Что показывает канал — то и смотришь, как и было. +Возрастная категория — инструмент планирования (не поставить взрослое в детский эфир), а не гейт. + +### 1.1. От чего уходим + +Сейчас эфир канала — это взвешенный случайный выбор шоу из плоского списка с блоками серий и +временными override'ами. Расписание непредсказуемо: в 20:00 каждый день разное, «поставить фильм по +пятницам» невозможно, «утром детское, ночью взрослое» выражается только грубым бустом весов по часам. + +Целевая модель — **сетка**: канал имеет шаблон, шаблон состоит из слоёв, слой из слотов, слот ссылается +на группу контента и стратегию выбора. Гибкость даёт не «конструктор расписаний», а разделение +на независимые переиспользуемые части: + +| Слой | Сущность | Отвечает за | +|------|----------|-------------| +| 1 | **Группа** | Что может попасть в эфир | +| 2 | **Шаблон → слой → слот** | Когда и в каком порядке | +| 3 | **Стратегия** | Как выбирается конкретный элемент | +| 4 | **Правила** | Чего нельзя допускать | + +Группы общие для всех каналов, слоты и стратегии — свои у каждого. + +--- + +## 2. Принципы + +### 2.1. Эластичная сетка + +Времена слотов — **цели, а не жёсткие границы**. Контент идёт встык, слот считается исчерпанным, когда +набран его бюджет времени; расхождение переносится на следующий слот. Это сохраняет главный инвариант +раздачи: лента непрерывна, длительности кратны сегменту, `LiveWindowCalculator` работает без изменений. + +Дрейф компенсируется **якорями** — слотами с жёстким стартом (прайм в 20:00). Перед якорем генератор +не начинает единицу контента, которая через него перелезет: остаток до якоря добирается врезками +и фоновым слоем. Так сетка «дышит» в течение дня, но опорные точки не уползают. + +У каждого слота есть допуск дрейфа. Превышение — предупреждение в валидации, не ошибка. + +### 2.2. Кратность сегменту + +Сегмент раздачи — 2 секунды (`StreamingOptions.SegmentSeconds`). Все длительности в эфирной ленте +кратны ему: это условие того, что `MEDIA-SEQUENCE = floor((now − epoch)/seg)` остаётся монотонным +и live-край не дрейфует. + +Следствия: врезки и фоновые заполнители округляются вверх до сегмента; аварийный филлер канала — это +**зацикливаемый** ассет в 1–2 сегмента, а не один ассет фиксированной длины; целевые времена слотов +задаются с точностью до минуты (60 кратно 2). + +### 2.3. Курсорная генерация + +Расписание материализуется на горизонт вперёд и хранится в БД. Состояние (где остановились в сериале, +что играли недавно) — часть модели, а не вычисляется заново. + +Это осознанный отказ от «чистой функции `schedule(channel, date)`». При эластичной сетке число единиц +контента, потреблённых за один выход слота, зависит от фактических длительностей — блок «пока не +наберётся два часа» съест то ли три серии, то ли четыре. Не просимулировав от известной точки, не узнать. + +Следствия, которые принимаем: + +- предпросмотр — это симуляция вперёд от текущего момента, а не «покажи 25 декабря» из ничего; +- «почему это здесь» строится на трейсе, который генератор пишет **в момент генерации** (поле + `ScheduleEntry.Trace`), а не восстанавливается задним числом; +- граница `now` жёсткая: прошлое и текущая запись неизменяемы, пересобирается только хвост. + +### 2.4. Время канала + +`Channel.UtcOffsetMinutes` — фиксированный оффсет, по умолчанию 180 (московское время). Все времена +слотов, окна правил и вещательные сутки трактуются в нём; в БД всё по-прежнему хранится в UTC. + +Фиксированный оффсет, а не IANA-зона: у Москвы нет перевода часов, а с DST сутки перевода становятся +23- или 25-часовыми, что требует отдельного правила подрезки/добора в сетке. Если понадобятся каналы +в зонах с переводом часов — переходим на IANA и добавляем это правило; пока это лишняя сложность. + +`Channel.DayStartTime` — начало вещательных суток, по умолчанию 06:00. У телеканалов сутки начинаются +утром: ночной блок с 00:00 до 06:00 относится к предыдущему дню. Влияет на то, какому дню недели +принадлежит слот (ночь с пятницы на субботу — это пятница) и на отображение сетки в UI. + +--- + +## 3. Модель данных + +### 3.1. Расширения библиотеки + +**Жанры** — справочник, а не свободные строки: жанры из TMDb маппятся в него автоматически при +применении метаданных, а в UI фильтр набирается выбором из списка. + +``` +Genre + id + name — «Боевик», «Комедия», «Анимация» + externalIds jsonb — маппинг на id провайдеров (tmdb: 28, ...) + +ShowGenre — многие-ко-многим + showId + genreId + isPrimary bool — основной жанр шоу (ровно один), для отображения +``` + +Отдельных свободных тегов не заводим: их роль («ретро», «новогоднее») выполняют группы с ручным +составом, а это одна сущность вместо двух. + +**Возрастная категория.** `ShowAudience` расширяется до пяти значений, **упорядоченных по возрастанию +строгости** — правила формулируются как «до 23:00 не строже подросткового», поэтому порядок в enum +значим: + +``` +Kids — детское +Family — семейное +Teen — подростковое +General — общее +Adult — взрослое +``` + +Текущие три значения (`General=0, Kids=1, Adult=2`) не отсортированы и переписываются. + +**Коллекция** — франшиза: упорядоченный набор шоу. «Терминатор» — это три `Show(Single)` в порядке +1→2→3. Живёт в библиотеке рядом с шоу, потому что «одно произведение из трёх частей» — факт о контенте, +а не о канале, и его хочется видеть на экране шоу. + +``` +Collection + id + name + posterImageId uuid? — реестр изображений + items [CollectionItem] + +CollectionItem + collectionId + showId + position int +``` + +Одно шоу может состоять и в коллекции, и лежать в группе само по себе — это разные способы его +использовать. + +### 3.2. Единица воспроизведения + +Планировщик оперирует не «шоу», а **последовательностью воспроизводимых единиц** — это то, что +позволяет сериалу и франшизе работать одним кодом: + +- сериал → его серии по `ShowEpisode.Position`; +- коллекция → фильмы по `CollectionItem.Position` (сериал внутри коллекции разворачивается в свои серии); +- одиночный фильм → ровно одна единица. + +Отсюда оба сценария из требований выражаются одним слотом с разным размером блока: «три части подряд +в один день» — блок в 3 единицы; «три дня по одному фильму» — блок в 1 единицу в слоте, срабатывающем +три дня подряд. + +### 3.3. Группа + +``` +Group + id + name — «Боевики 90-х», «Утренние мультфильмы», «Реклама» + description + filter jsonb? — правило набора, см. ниже + items [GroupItem] + cachedStats jsonb — {itemCount, unitCount, totalDuration, computedAt} + +GroupItem + groupId + elementKind enum — show | collection + elementId uuid + weight int — по умолчанию 1 + position int — порядок для последовательных стратегий +``` + +Группы **общие для всех каналов** и переиспользуются. + +**Состав — всегда явный список.** `filter` — это правило быстрого набора («всё с жанром боевик, +1989–1999»), а не запрос, выполняемый при генерации. Нажатие «применить фильтр» добавляет найденное +в список, дальше состав правится руками. Живой запрос был бы проще в коде, но тогда добавление одного +шоу в библиотеку перетасовывало бы всё будущее расписание всех каналов. + +Новые поступления не пропадают: фоновая проверка сравнивает библиотеку с фильтром и показывает в UI +«доступно 12 новых позиций», добавление — по кнопке. + +```json +{ + "elementKinds": ["show", "collection"], + "showKinds": ["series", "single"], + "genres": { "anyOf": ["боевик", "триллер"] }, + "audience": { "max": "Teen" }, + "year": { "min": 1989, "max": 1999 }, + "unitDurationMinutes": { "min": 20, "max": 25 } +} +``` + +**Веса.** `GroupItem.weight` смещает вероятность внутри группы при случайном выборе. Нужен для +реального случая: в группе на 200 боевиков полтора десятка сильных, остальное — наполнение; с одним +только остыванием хорошее утонет в среднем. + +Вес и остывание не конфликтуют, если применять их в правильном порядке: **сначала остывание отсекает** +недавно показанное (жёсткий фильтр «нельзя»), **потом взвешенный выбор** работает среди оставшихся. +В обратном порядке вес постоянно упирался бы в кулдаун. + +В UI веса спрятаны за «дополнительно»: по умолчанию все единицы, поведение — чистое остывание. + +### 3.4. Шаблон, слой, слот + +``` +ScheduleTemplate + id + channelId — один активный шаблон на канал + name + defaultJunctionId uuid? — стык по умолчанию для слотов без своего + fallbackGroupId uuid? — аварийная группа, если пуст даже фон + revision int — инкремент при любой правке; входит в кэш-ключи и историю +``` + +Один шаблон на канал. Сезонность («зимняя сетка») выражается слоями внутри него, а не вторым шаблоном — +иначе получаются два механизма для одного и того же. Копирование на другой канал — глубокая копия +(шаблон, слои, слоты); группы не копируются, они общие. + +``` +GridLayer + id + templateId + name — «Фон», «Базовая», «Выходные», «Новогодняя» + priority int — больше = специфичнее, побеждает. Фон = 0 + applicability jsonb + isEnabled bool +``` + +```json +{ + "weekdays": [1, 2, 3, 4, 5], + "dateRanges": [{ "from": "2026-06-01", "to": "2026-08-31" }], + "annualRanges": [{ "fromMonth": 12, "fromDay": 20, "toMonth": 12, "toDay": 31 }], + "specificDates": ["2026-12-31"] +} +``` + +`annualRanges` — ежегодно повторяющийся период («перед Новым годом»), `dateRanges` — разовый. +Пустая `applicability` — слой действует всегда. + +**Разрешение конфликтов.** Для момента времени берутся применимые на эту дату слои, сортируются по +`priority` убыв., активным считается слот первого слоя, чей интервал накрывает момент. + +``` +Slot + id + layerId + weekday int? — 0..6 в вещательных сутках; null — каждый день + targetStart time — целевое время старта в сутках канала + targetDuration int — минуты, бюджет слота + title string — «Вечернее кино» + daypart enum — morning | day | prime | night — метка, см. ниже + slotKind enum — content | repeat | signOff + + groupId uuid? — для content + strategy jsonb — см. 3.5 + repeatSource jsonb? — для repeat, см. ниже + blockMode enum — count | duration | fillSlot + blockValue int — единиц либо минут + overflowPolicy enum — continueNext | extendSlot | skipIfNotFits + + isAnchor bool — старт жёсткий, дрейф не допускается + maxDriftMinutes int — допуск отклонения фактического старта + snapToMinutes int? — округлять старт до кратного: 5 | 10 | 15 | 30 | null + junctionBetweenId uuid? — стык между единицами внутри блока + junctionAfterId uuid? — стык в конце блока +``` + +**`overflowPolicy`** — что делать, когда последовательность (обычно коллекция) не помещается в бюджет: + +- `continueNext` — играем сколько влезло, курсор помнит середину, доигрываем в следующий выход. Дефолт. +- `extendSlot` — играем целиком, слот залезает на соседей; эластичность это позволяет, ближайший якорь + подберёт разбег. Для выходного марафона. +- `skipIfNotFits` — если целиком не влезает, берём из группы что-то другое. + +Настройка на слоте, а не на коллекции: одна и та же трилогия в вечернем блоке может доигрываться +по частям, а в марафоне — растягивать слот. + +**`slotKind = repeat`** — повтор того, что уже играло. Одна из самых узнаваемых примет того ТВ: +вечерний фильм идёт утром в субботу, вчерашний выпуск — сегодня днём. Слот ссылается не на группу, +а на прошлое той же ленты: + +```json +{ "daysAgo": 1, "time": "20:00", "durationMinutes": 90 } +``` + +Читается готовое расписание канала за указанную точку, найденные программы ставятся заново. Ни +стратегии, ни курсора, ни остывания — самый дешёвый тип слота из всех. Если в источнике ничего не +нашлось (эфир ещё не шёл, окно почищено ретеншном) — слот отдаётся фоновому слою с предупреждением. + +**`slotKind = signOff`** — конец вещания. Каналы тогда не работали круглосуточно: в два часа ночи +настроечная таблица, гимн или «не забудьте выключить телевизор», в шесть утра эфир возобновлялся. +Слот заполняется зацикленным ассетом и помечается в программе как «эфир не ведётся». + +Сделано слотом, а не флагом на канале, намеренно: так конец вещания можно поставить только в будни, +или только зимой, или не ставить вовсе. Детский канал, который никогда не заканчивается, — это +законная конфигурация, а не обход правила. + +**`daypart` — это метка, а не диапазон времени.** Границы дейпартов нигде не задаются: у детского +канала прайм в 17:00, у развлекательного в 21:00, и заводить на канале четыре пары времён значило бы +держать состояние, которое всё равно дублирует `targetStart` слота. Метка нужна для цвета в календаре, +группировки в UI и области действия правил («потолок врезок в прайме»); что считать праймом, решает +администратор, ставя её на нужные слоты. + +**Фоновый слой.** Отдельной сущности «заполнитель» нет. Заполнение пустот — это слой с `priority = 0`, +покрывающий сутки целиком, с обычными слотами и группами внутри: утро одна группа, день другая, ночь +третья. Любая дыра в расписании — просто место, где сверху ничего не легло, и играет фон. Тонкость +настройки получается тем же механизмом, без второго UI и без отдельной ветки в генераторе. + +На самом краю остаётся `ScheduleTemplate.fallbackGroupId` и зацикленный аварийный ассет канала — на +случай, когда пуст и фон. + +### 3.5. Стратегии + +Хранятся в слоте как JSON с полем `type`. Стратегия отвечает за выбор **элемента** группы; внутри +элемента единицы всегда идут по порядку от курсора — сериал не должен прыгать по сериям. + +**sequential** — элементы группы по порядку `GroupItem.position`: +```json +{ "type": "sequential", "onGroupEnd": "restart" } +``` +`onGroupEnd`: `restart` | `stop`. + +**randomWithCooldown** — случайный элемент, не показанный последние N дней. Основной рабочий режим: +«один случайный боевик по пятницам, по возможности не тот же, что в прошлую»: +```json +{ "type": "randomWithCooldown", "cooldownDays": 28, "fallback": "oldestFirst" } +``` +`fallback` — что делать, когда остывание отсекло всех: `oldestFirst` (взять самый давний) или +`ignoreCooldown`. + +**fixed** — всегда один и тот же элемент: +```json +{ "type": "fixed", "elementKind": "show", "elementId": "..." } +``` + +Размер блока (`blockMode`/`blockValue`) — свойство слота, ортогональное стратегии: любую стратегию +можно комбинировать с «1 единица», «4 единицы подряд» или «пока не наберётся 120 минут». +Марафон — это `blockMode = fillSlot`, отдельной стратегии не нужно. + +### 3.6. Состояние слота + +``` +SlotState + slotId + currentElementKind enum? — show | collection + currentElementId uuid? + nextUnitIndex int — позиция следующей единицы внутри элемента +``` + +Курсор хранит **ссылку на элемент**, а не числовой индекс в группе: при удалении позиции из группы +курсор корректно переезжает на следующую, а не сдвигает всё. + +**История показов для остывания** берётся из материализованного расписания (`ScheduleEntry` с +`Kind = Program`, индекс по `channelId + showId + startsAtUtc`), отдельного журнала не заводим — одна +правда, и при пересборке хвоста будущие показы удаляются вместе с записями. + +Из этого следует: `SchedulerOptions.RetentionHours` (сейчас 24 часа) заменяется на `RetentionDays`, +дефолт 90. Значение обязано покрывать максимальный `cooldownDays` среди правил канала и максимальный +`daysAgo` среди слотов повтора — иначе остывание начнёт врать, а повторы находить нечего. Валидация +предупреждает, если правило требует истории глубже, чем хранится. + +**Чистка.** Раз история стала длинной, обслуживание перестаёт быть бесплатным и выносится в фоновую +задачу — вместе с тем, что и сегодня накапливается молча: + +- записи расписания старше `RetentionDays`; +- отрендеренные ассеты заставок (`BumperAsset`), на которые не ссылается ни одна запись в пределах + окна хранения, — сейчас они копятся при каждой смене пары шоу и при каждой правке блока заставки; +- временные файлы предпросмотра. + +Чистка идёт по расписанию, не в транзакции генерации: генератор и так держит advisory-lock канала, +и подмешивать в него удаление по всей таблице не нужно. + +### 3.7. Шаблон стыка + +Реклама и заставки перестают быть настройками канала и становятся элементами стыка. Это то, чего +сейчас нет: в прайм три ролика и заставка, ночью один длинный — сегодня врезки одинаковы круглые сутки. + +``` +JunctionTemplate + id + channelId + name — «Прайм», «День», «Ночь», «Внутри блока» + elements [JunctionElement] + +JunctionElement + position int + kind enum — ad | promo | bumper | filler + groupId uuid? — для ad/promo/filler: откуда брать + bumperTemplateId uuid? — для bumper: какой блок заставки + amountMode enum — count | duration + amountValue int — единиц либо минут + isRequired bool — нельзя выбросить при нехватке времени + conditions jsonb +``` + +```json +{ + "onlyOnElementChange": true, + "minMinutesSinceSameKind": 30, + "dayparts": ["prime", "day"] +} +``` + +Условия — **структурированные поля, а не выражения-строки**: выражения потребовали бы парсера, +валидации и отдельного UI, а покрывают те же три-четыре реальных случая. + +**Рекламные ролики — это тоже группы.** Ролик регистрируется как `Show(Kind = Interstitial)` — новое +значение `ShowKind` — и складывается в группу. Это убирает `ChannelAd` и бесплатно даёт рекламе всё, +что есть у контента: остывание (не крутить один ролик дважды подряд), разные группы на утро и прайм, +статистику. Библиотека фильтруется по типу, служебные ролики не мешают на экране шоу. + +**Рекламный блок — это коллекция.** Реклама здесь не служебный элемент, а половина обаяния: её помнят +лучше передач. Поэтому важно уметь собрать блок целиком, а не только ротировать ролики по одному — +и это получается само, без единой строчки специального кода: коллекция из шести `Interstitial`-шоу +и есть «рекламный блок ОРТ, осень 1998». Играется по порядку целиком, лежит в группе «Рекламные блоки +90-х», выбирается с остыванием, как любой другой контент. + +Оба способа собрать врезку работают одним механизмом, и их можно смешивать в одной группе: + +- **готовым блоком** — группа из коллекций, элемент стыка берёт одну единицу, в эфир идут шесть + роликов подряд в исходном порядке; +- **сборкой из отдельных роликов** — группа из одиночных `Interstitial`-шоу с весами, элемент стыка + набирает несколько, редкие и любимые выпадают чаще; +- **вперемешку** — группа содержит и целые блоки, и отдельные ролики. + +Ради последнего случая у элемента стыка два режима бюджета. `count` даёт предсказуемое число единиц, +но в смешанной группе длина врезки скачет: одна «единица» — это то ли ролик на 20 секунд, то ли блок +на три минуты. `duration` («врезка примерно две минуты») набирает единицы, пока не наберётся бюджет, +и коллекция при этом всегда входит целиком — блок не разрезается. Для смешанных групп правильный +режим — `duration`. + +**Заставки-переходы переиспользуются как есть.** `BumperTemplate` / `BumperTextVariant`, рендер через +ffmpeg с кэшем по паре шоу, `ScheduleBumperResolver` — вся эта подсистема не меняется, меняется только +точка вызова: раньше вероятности и минимальный интервал жили на канале, теперь это `conditions` +элемента стыка. Настройки `Channel.BumperSelection` / `NextBumperIndex` / `BumperMinIntervalMinutes` / +`BumperShowChangeChance` / `BumperEpisodeChangeChance` уходят, `BumperFont` остаётся общим для канала. + +Важно для пайплайна: ассет заставки зависит от **пары соседей**, а пара известна только после наполнения +слотов. Значит рендер — обязательный шаг между сборкой ленты и записью в БД, он долгий и может упасть. +В предпросмотре заставка показывается плейсхолдером известной длины, реальный рендер — только при +применении. + +### 3.8. Правила + +Делятся на два вида по способу применения — это принципиально, потому что определяет, ломается ли +воспроизводимость генерации. + +**Фильтры кандидатов** — применяются при выборе элемента, жёсткие. Отсекают недопустимое до жребия, +поэтому не требуют пересборки и ничего не ломают: + +| Правило | Параметры | Смысл | +|---------|-----------|-------| +| `maxAudienceByTime` | `{from, to, maxAudience}` | Детское время: до 23:00 не строже подросткового | +| `cooldown` | `{days}` | Не показывать элемент, игравший последние N дней | +| `maxRepeatsInWindow` | `{windowDays, max}` | Не чаще N раз за период | + +**Пост-проверки** — считаются по готовому расписанию и дают предупреждения, ничего не переигрывая: + +| Проверка | Смысл | +|----------|-------| +| `maxBreakMinutesPerHour` | Потолок врезок в час превышен | +| `maxGenreShare` | Доля жанра за сутки выше заданной | +| дрейф | Фактический старт слота ушёл за `maxDriftMinutes` | +| фон | Доля эфира, отданная фоновому слою, выше нормы | + +Пересборки слота при нарушении нет намеренно: она делает результат зависимым от порядка проверок, +превращает отладку в угадывание и обесценивает «почему это здесь». + +Область действия правила — канал или конкретный дейпарт. + +### 3.9. Результат генерации + +Одна лента, как сейчас — `ScheduleEntry` с типом записи. Программная сетка для EPG — это фильтр +по `Kind = Program`, отдельной таблицы не заводим: разделение на «сетку» и «плейаут» стоило бы вдвое +дороже и дало бы мало. + +``` +ScheduleEntry — существующая сущность, расширяется + id + channelId + mediaAssetId + kind enum — program | ad | promo | bumper | filler | signOff + startsAtUtc + endsAtUtc + showId uuid? + episodeIndex int? + collectionId uuid? — новое: если единица пришла из коллекции + bumperVariantId uuid? + slotId uuid? — новое: что породило + trace jsonb — новое: цепочка происхождения +``` + +`trace` пишется в момент генерации и питает экран «почему это здесь»: + +```json +{ + "layer": { "id": "...", "name": "Базовая", "priority": 10 }, + "slot": { "id": "...", "title": "Дневная анимация", "targetStart": "18:00" }, + "group": { "id": "...", "name": "Мультсериалы 90-х", "itemCount": 128 }, + "strategy": { "type": "randomWithCooldown", "cooldownDays": 14 }, + "pick": { "reason": "weightedRandom", "candidatesAfterCooldown": 96 }, + "junction": { "id": "...", "name": "День" }, + "drift": { "targetStart": "18:00", "actualStart": "18:04", "minutes": 4 } +} +``` + +--- + +## 4. Алгоритм генерации + +### 4.1. Пайплайн + +``` +1. RESOLVE — на каждый момент определить активный слой и слот +2. FILL — наполнить слот единицами по стратегии, с фильтрами кандидатов +3. JUNCTION — разложить врезки по шаблонам стыков +4. ANCHOR — выровняться на якорях, добить остаток фоном +5. RENDER — отрендерить/достать из кэша ассеты заставок +6. MATERIALIZE — записать ленту с трейсом, сдвинуть курсоры +7. VALIDATE — пост-проверки, собрать предупреждения +``` + +Шаги 1–4 — чистая функция без БД и ФС, юнит-тестируемая, по образцу текущего `SchedulePlanner`. +Шаги 5–7 — оркестратор, по образцу текущего `ScheduleGenerator`. + +### 4.2. Наполнение слота + +``` +курсор = конец предыдущей записи +слот = активный слот на этот момент + +элемент = стратегия.выбрать(группа, фильтры кандидатов, seed) +пока не исчерпан бюджет слота: + единица = следующая единица элемента от курсора состояния + если следующий якорь − курсор < длительность единицы: + не начинаем: остаток до якоря добираем стыком и фоном + применяем overflowPolicy + выходим + ставим единицу, двигаем курсор и состояние слота + если это не последняя единица блока и есть junctionBetween: + раскладываем врезки +ставим junctionAfter +``` + +Бюджет считается по `blockMode`: `count` — N единиц, `duration` — пока сумма не превысит M минут +(последняя входит целиком), `fillSlot` — пока не исчерпан `targetDuration`. + +Слоты `repeat` и `signOff` минуют стратегии и состояние целиком: первый читает уже записанную ленту +за точку из `repeatSource` и переносит найденные программы, второй заполняет бюджет зацикленным +ассетом. Обоим нужны только якорь и правила стыков. + +### 4.3. Округление стартов + +Настоящее ТВ не начинает фильм в 19:47. Программа передач состоит из круглых времён — 19:45, 20:00, +20:30 — и именно это подсознательно отличает телепрограмму от выгрузки плейлиста. + +Перед началом слота с заданным `snapToMinutes` генератор добирает время до ближайшей кратной отметки +врезками и фоновым слоем: + +``` +отметка = округлить_вверх(курсор, slot.snapToMinutes) +если отметка − курсор > 0: + добираем стыком, затем фоном, до отметки + старт слота = отметка +``` + +Отличие от якоря принципиальное: якорь — жёсткая точка, к которой сетка обязана прийти, и ради +которой генератор не начинает контент, способный через неё перелезть. Округление мягкое: оно +не заставляет ничего обрезать, только сдвигает старт вперёд до круглой отметки. Если добирать пришлось +бы больше, чем `maxDriftMinutes`, округление пропускается — лучше начать в 19:47, чем девять минут +крутить фон. + +На практике большинству слотов хватает округления, и якорь остаётся только на опорных точках +вроде прайма. + +### 4.4. Seed и воспроизводимость + +Жребий берётся из seed, зависящего **только от координат**, а не от содержания правил: + +``` +seed = hash(channelId, date, slotId, occurrenceInDay) +``` + +Смысл: правка ночного слота не должна перетасовывать дневной эфир. Изменилось правило — изменился +результат его применения, но не жребий соседей. Иначе диф перед применением бесполезен: «изменилось +180 элементов из 180» вместо «изменилось 4». + +### 4.5. Граница `now` + +``` +прошлое → снапшот, неизменяемо +текущая → запись, идущая в эфире, не вырезается из-под зрителя +будущее → материализованный кэш, пересобирается по требованию +``` + +Перегенерация: удалить будущее от границы применения → пересчитать от состояния на этой границе → +записать. Идемпотентно. Прошлое замораживается обязательно — иначе архив будет врать: зритель смотрел +вчера в 20:00 одно, а в истории другое. + +**Изменения применяются по явной кнопке**, а не при сохранении слота. Правка правил помечает шаблон +как изменённый и не трогает эфир; администратор правит сетку сколько нужно, смотрит предпросмотр, +затем жмёт «Применить» — и только тогда пересобирается хвост. + +Иначе десять правок подряд дают десять пересборок, и хвост дёргается на каждое сохранение — при живых +зрителях это заметно. Плюс это естественно ложится под диф из среза 4: применение всегда имеет одну +точку, в которой можно показать, что именно изменится. + +В UI — постоянный индикатор «правила изменены, эфир идёт по старым» с кнопкой применения и подсказкой, +сколько записей затронет. + +Фоновая задача (`SchedulingBackgroundService`) достраивает горизонт, как и сейчас. + +**Горизонт — 7 дней по умолчанию** (`SchedulerOptions.HorizonDays`, сейчас 3), настраиваемый. Программа +на неделю вперёд — часть замысла: «знать, что мультики будут в субботу в 9:30» работает, только если +расписание известно заранее. Отдельного экрана телепрограммы для зрителя пока не делаем, публичный EPG +просто отдаёт больший диапазон. + +--- + +## 5. Валидация + +### 5.1. До генерации, по правилам + +| Проверка | Формула | Сообщение | +|----------|---------|-----------| +| Нехватка контента | `group.itemCount < выходов слота в неделю` | «В группе 3 позиции при 7 выходах в неделю — повтор каждые полнедели» | +| Пустая группа | `itemCount == 0` | «Группа пуста, слот заполнит фон» | +| Дыра в сетке | интервал не покрыт даже фоном | «Не покрыто: вт 03:00–06:00» | +| Пересечение слотов | overlap внутри одного слоя | «Слоты пересекаются» | +| Недостижимый кулдаун | `cooldownDays × выходов в день > itemCount` | «Остывание 28 дней невыполнимо при 3 позициях» | +| Возрастной конфликт | группа содержит строже, чем позволяет окно | «В группе есть 18+, слот стоит в детском времени» | + +Запас группы показывается прямо в инспекторе слота: «342 позиции · 118 ч · полный цикл 6.2 недели». + +### 5.2. После генерации + +- элементы, отданные фону (не нашлось контента); +- пост-проверки из 3.8; +- тепловая карта повторов: матрица «элемент × день», яркость = число показов — сразу видно, что один + фильм крутится четыре раза за неделю; +- фактическая доля врезок по часам против лимита. + +--- + +## 6. UI + +Текущий экран канала (`ChannelDetail`) — набор карточек с формами — переписывается целиком. + +### 6.1. Редактор сетки — основной экран + +**Календарь недели**, ось X — дни, ось Y — время вещательных суток от `DayStartTime`. + +- слоты перетаскиваются мышкой, меняют длительность за края; +- копирование дня на другие дни, копирование недели целиком; +- цвет блока — по дейпарту, иконка якоря на жёстких слотах; +- слоты, перекрытые слоем выше, показаны полупрозрачной штриховкой; +- панель слоёв слева: список с видимостью, приоритетом, drag для переупорядочивания; переключение + «редактируемого» слоя, остальные — фоном; +- переключатель даты для слоёв с датами: «показать сетку на 25 декабря» — видно, что реально ляжет. + +Реализация календаря — своя, не готовая библиотека: нужны слои с перекрытием, вещательные сутки +с 06:00 и якоря, ни один готовый компонент это не покрывает. + +**Инспектор слота** — правая панель по клику: + +``` +Название блока [Вечернее кино ] +Время / бюджет [20:00] [90 мин] ☑ Якорь +Допуск дрейфа [5] мин +Дейпарт ( ) утро ( ) день (•) прайм ( ) ночь +Тип слота (•) контент ( ) повтор ( ) конец вещания + +Группа [Фильмы 90-х ▾] + 342 позиции · 118 ч · цикл 6.2 нед + [Открыть] [Создать новую] + +Стратегия [Случайно с остыванием ▾] + Остывание [28] дней +Блок [Единиц ▾] [1] +Если не помещается [Доиграть в следующий раз ▾] + +Стык между единицами [— нет — ▾] +Стык в конце блока [Прайм ▾] ~4 мин врезок +``` + +Статистика группы видна прямо здесь, без перехода на другой экран — это то, ради чего считается +`cachedStats`. + +### 6.2. Редактор группы + +Двухпанельный: слева конструктор фильтра, справа — состав. + +- фильтр набирает позиции кнопкой «Добавить найденное», не подменяет состав; +- в состав можно перетаскивать шоу и коллекции из библиотеки; +- порядок внутри группы — drag & drop (важен для `sequential`); +- вес — колонка, спрятанная за «дополнительно»; +- бейдж «доступно 12 новых позиций по фильтру» с кнопкой добавления. + +Выбор группы в списке каналов и слотов — с секциями: **используются в этом канале** → **недавние** → +**все**, плюс поиск. Группы общие, их со временем станет много. + +### 6.3. Редактор стыка + +Горизонтальная цепочка с перетаскиванием: + +``` +[КОНЕЦ] → [Реклама ×2] → [Заставка] → [Промо ×1] → [НАЧАЛО] + обязательно если смена если прайм +``` + +Под цепочкой — линейка суммарной длительности. Клик по элементу — параметры (тип, группа, количество, +обязательность, условия). + +### 6.4. Предпросмотр + +Доступен из редактора **без сохранения** — считает по текущему черновику правил. + +- **Программа** — список по дням, как увидит зритель; +- **Лента** — таймлайн с цветовыми слоями (программа / реклама / заставка / фон), сверху гистограмма + нагрузки врезок по часам с линией лимита, превышения красным; +- **Проблемы** — сгруппированные предупреждения с переходом к источнику; +- тепловая карта повторов. + +### 6.5. «Почему это здесь» + +У каждой записи иконка, открывающая трейс: + +``` +Симпсоны, с5э12 · пн 18:04 + +Слой Базовая (приоритет 10) +Слот Дневная анимация · будни 18:00 · 30 мин · дрейф +4 мин +Группа Мультсериалы 90-х (128 позиций) +Стратегия Случайно с остыванием 14 дней + после остывания оставалось 96 кандидатов +Врезки стык «День» +``` + +Не опциональная фича: без неё отладка сетки превращается в угадывание. + +### 6.6. Диф перед применением + +``` +Изменения затронут 180 записей, из них изменятся 12 +3 — в ближайшие 24 часа ⚠ + +пн 20:00 «Терминатор» → «Чужой» +пн 21:40 реклама 120 с → реклама 180 с + +[Применить] [Отмена] +``` + +Изменения в ближайшие сутки подсвечиваются отдельно — самая частая причина случайного ущерба. + +### 6.7. Ролики и рекламные блоки + +Отдельный раздел админки, хотя под капотом это те же `Show(Kind = Interstitial)` и `Collection`. +Библиотека шоу — про кино и сериалы с постерами, метаданными и сериями; сотня рекламных роликов там +только мешала бы, а фильтр по типу проблему не решает, потому что и сценарии работы разные. + +Экран устроен под свою задачу: + +- плоский список роликов с длительностью и превью, массовая загрузка; +- сборка блока — перетаскиванием роликов в упорядоченный список, тут же суммарная длительность + («блок 2 мин 40 с»), блок сохраняется как коллекция; +- группы роликов собираются здесь же, а не в общем редакторе групп. + +Метаданные из TMDb/OMDb для этого типа не запрашиваются. + +### 6.8. Зрительская часть + +Планировщика не касается, но живёт с ним в одном замысле. Всё перечисленное — **опции**, по умолчанию +выключенные: канал без логотипа и без шума остаётся законной конфигурацией. + +**Номер канала.** `Channel.Number` — на телевизоре канал это номер, а не карточка в сетке. Даёт +переключение вверх-вниз по номерам, с коротким чёрным кадром и номером в углу на секунду. Включается +глобально флагом в настройках сайта (`AppSetting`, рядом с флагом регистрации): у кого-то это главный +способ навигации, у кого-то лишняя механика. Сетка каналов остаётся вторым способом всегда. + +**Экранные оверлеи** — рисуются на клиенте поверх `