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`, рядом с флагом регистрации): у кого-то это главный +способ навигации, у кого-то лишняя механика. Сетка каналов остаётся вторым способом всегда. + +**Экранные оверлеи** — рисуются на клиенте поверх `