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

33 KiB
Raw Blame History

Автоматическое построение расписания линейного канала

Спецификация для разработки. Система эмулирует линейный телеканал: непрерывный общий эфир, привязанный к календарю, с публикуемой программой передач на 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 явно.

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