Files
TeleWave/docs/tv-scheduler-architecture.md
T

692 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Автоматическое построение расписания линейного канала
Спецификация для разработки. Система эмулирует линейный телеканал: непрерывный общий
эфир, привязанный к календарю, с публикуемой программой передач на 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)?
Если да — это тонкая надстройка над той же лентой со сдвигом, а не отдельный канал.
- Нужны ли «прямые эфиры» / премьеры по расписанию как отдельный тип якоря?
- Хранить ли плейаут-ленту материализованно или считать на лету из программной сетки
при запросе. Зависит от нагрузки на плеер.