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