# Автоматическое построение расписания линейного канала Спецификация для разработки. Система эмулирует линейный телеканал: непрерывный общий эфир, привязанный к календарю, с публикуемой программой передач на 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)? Если да — это тонкая надстройка над той же лентой со сдвигом, а не отдельный канал. - Нужны ли «прямые эфиры» / премьеры по расписанию как отдельный тип якоря? - Хранить ли плейаут-ленту материализованно или считать на лету из программной сетки при запросе. Зависит от нагрузки на плеер.