Add audience categorization for shows: implement ShowAudience type, update related API endpoints, and enhance frontend components for audience selection and display. Modify data structures to support audience information in show creation and retrieval processes.
build / backend (push) Successful in 3m35s
build / frontend (push) Successful in 52s
tests / backend-tests (push) Successful in 3m27s

This commit is contained in:
Leonid Pershin
2026-07-26 02:06:08 +03:00
parent 525fd2ee01
commit 8962840654
19 changed files with 1900 additions and 8 deletions
+691
View File
@@ -0,0 +1,691 @@
# Автоматическое построение расписания линейного канала
Спецификация для разработки. Система эмулирует линейный телеканал: непрерывный общий
эфир, привязанный к календарю, с публикуемой программой передач на 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)?
Если да — это тонкая надстройка над той же лентой со сдвигом, а не отдельный канал.
- Нужны ли «прямые эфиры» / премьеры по расписанию как отдельный тип якоря?
- Хранить ли плейаут-ленту материализованно или считать на лету из программной сетки
при запросе. Зависит от нагрузки на плеер.