33 KiB
Автоматическое построение расписания линейного канала
Спецификация для разработки. Система эмулирует линейный телеканал: непрерывный общий эфир, привязанный к календарю, с публикуемой программой передач на 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:
{
"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:
{
"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 — очень узнаваемая примета настоящего ТВ и бесплатное
расширение объёма контента:
{
"slot_type": "repeat",
"repeat_source": { "days_ago": 1, "time": "20:00" }
}
2.5. Стратегия (слой 3)
Хранится внутри слота как JSON с полем type.
sequential — сериал по порядку:
{
"type": "sequential",
"on_season_end": "next_season", // next_season | restart | stop
"start_from": { "season": 1, "episode": 1 }
}
marathon — блок серий подряд:
{ "type": "marathon", "count": 4, "continue_across_days": true }
rotation — случайный выбор с остыванием:
{ "type": "rotation", "cooldown_days": 7 }
weighted — вероятность по весу:
{
"type": "weighted",
"weight_by": "popularity", // popularity | recency | manual
"cooldown_days": 3
}
fixed — всегда один и тот же тайтл:
{ "type": "fixed", "item_ref": "movie:12345" }
playlist — жёсткий порядок:
{ "type": "playlist", "items": ["show:1:s1e1", "movie:99"], "loop": true }
2.6. Шаблон стыка
Последовательность врезок между программами. Достаточно 3–4 шаблонов на канал.
JunctionTemplate
id
name — «Прайм», «День», «Ночь», «Внутри марафона»
elements jsonb[]
{
"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 явно.
- Стабильность сетки. Настоящее ТВ предсказуемо: одно шоу всегда в одно время.
Слоты прибиты, а не рандомятся каждый день. Дефолт —
grid_mode = hard. - Характер дейпартов. Утро — короткие детские; день — повторы и дешёвый контент; прайм — премьеры и полнометражки; ночь — старое кино, документалки, длинные блоки. Реализуется просто разными пулами и шаблонами стыков.
- Повторы как фича, а не баг. Вечерний блок повторяется утром следующего дня —
очень узнаваемо. Тип слота
repeat. - Вещательные сутки с 06:00, а не с полуночи.
- Ритм врезок: чем ближе к прайму, тем плотнее. Ночью реже и длиннее.
- Ротация айдентов с остыванием — не крутить один и тот же чаще раза в час.
- Сезонные слои: летняя сетка, новогодняя, тематические дни.
7. Порядок реализации
Этап 1 — ядро Канал, пул (kind=query), базовый слой сетки, слоты, стратегии sequential и rotation, генератор без врезок, материализация, отдача программы.
Этап 2 — врезки Шаблоны стыков, подгонка длительности, плотность по дейпартам, плейаут-лента.
Этап 3 — гибкость
Слои сетки с приоритетами, остальные стратегии, ограничения, тип слота repeat.
Этап 4 — админка Календарь с drag & drop, инспектор слота, редактор пула со статистикой, предпросмотр, «почему это здесь».
Этап 5 — эксплуатация Патчи, диф, версионирование и откат, клонирование канала, валидация, тепловые карты.
8. Открытые вопросы
- Нужна ли поддержка нескольких таймзон для одного канала (условные «орбиты» +2/+4)? Если да — это тонкая надстройка над той же лентой со сдвигом, а не отдельный канал.
- Нужны ли «прямые эфиры» / премьеры по расписанию как отдельный тип якоря?
- Хранить ли плейаут-ленту материализованно или считать на лету из программной сетки при запросе. Зависит от нагрузки на плеер.