using TeleWave.Domain.Library;
namespace TeleWave.Domain.Programming.Planning;
///
/// Единица воспроизведения — серия или фильм с готовым ассетом. Планировщик оперирует ими, а не
/// шоу: у фильма единица одна, у сериала их столько же, сколько серий, у коллекции — сумма по частям.
///
public sealed record PlanningUnit(Guid MediaAssetId, TimeSpan Duration, Guid ShowId, int UnitIndex);
///
/// Элемент группы, развёрнутый в последовательность единиц. — когда он
/// в последний раз выходил в этом канале; по нему работает остывание.
///
public sealed record PlanningElement(
GroupElementKind Kind,
Guid ElementId,
int Weight,
int Position,
IReadOnlyList Units,
DateTimeOffset? LastPlayedUtc = null,
/// Категория аудитории (у коллекции — строжайшая из частей); по ней работает детское время.
ShowAudience? Audience = null,
/// Старты недавних показов в этом канале — по ним считается потолок повторов за период.
IReadOnlyList? RecentPlaysUtc = null
);
/// Потолок повторов: не чаще раз за суток.
public sealed record RepeatLimit(int WindowDays, int Max);
/// Стратегия выбора элемента, приведённая к виду, понятному чистому планировщику.
public sealed record PlanningStrategy(
SlotStrategyKind Kind,
bool RestartOnEnd = true,
int CooldownDays = 0,
bool IgnoreCooldownWhenExhausted = false,
Guid? FixedElementId = null
);
/// Как слот выбирает элемент. Дублирует прикладной enum, чтобы домен не зависел от Application.
public enum SlotStrategyKind
{
Sequential = 0,
RandomWithCooldown = 1,
Fixed = 2,
}
/// Где остановились в текущем элементе на момент начала прогона.
public sealed record PlanningCursor(
GroupElementKind? ElementKind,
Guid? ElementId,
int NextUnitIndex
);
///
/// Слот, привязанный к конкретному моменту эфира. Применимость слоёв уже разрешена: сюда попадают
/// только те слоты, которые реально действуют в эти сутки, каждый со своим целевым временем в UTC.
///
public sealed record PlanningSlot(
Guid SlotId,
DateTimeOffset TargetStartUtc,
int TargetDurationMinutes,
SlotKind SlotKind,
bool IsAnchor,
int MaxDriftMinutes,
int? SnapToMinutes,
SlotBlockMode BlockMode,
int BlockValue,
OverflowPolicy OverflowPolicy,
PlanningStrategy Strategy,
IReadOnlyList Elements,
PlanningCursor? Cursor,
/// Готовые записи для — что играло в источнике повтора.
IReadOnlyList? RepeatUnits = null,
/// Врезки между единицами внутри блока.
PlanningJunction? JunctionBetween = null,
/// Врезки в конце блока.
PlanningJunction? JunctionAfter = null,
/// Возрастной потолок в это время суток (null — без ограничения). Жёсткий фильтр.
ShowAudience? MaxAudience = null,
/// Потолок повторов за период (null — без ограничения). Жёсткий фильтр.
RepeatLimit? RepeatLimit = null
)
{
public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
}
/// Окно времени суток в часах канала; допускает переход через полночь (22:00 → 06:00).
public sealed record PlanningTimeWindow(TimeOnly From, TimeOnly To)
{
public bool Contains(TimeOnly moment) =>
From <= To ? moment >= From && moment < To : moment >= From || moment < To;
}
///
/// Врезка стыка, развёрнутая для планировщика: единицы уже подобраны оркестратором, домену остаётся
/// решить, сколько их поставить и влезают ли они.
///
public sealed record PlanningJunctionElement(
Guid ElementId,
JunctionElementKind Kind,
IReadOnlyList Units,
JunctionAmountMode AmountMode,
int AmountValue,
bool IsRequired,
/// Ставить только при смене элемента (иначе — и между единицами одного).
bool OnlyOnElementChange = false,
/// Не ставить чаще, чем раз в N минут (0 — без ограничения).
int MinMinutesBetween = 0,
/// Вероятность показа в процентах (100 — всегда).
int Chance = 100,
/// Окно времени суток, вне которого врезка не ставится (null — всегда).
PlanningTimeWindow? TimeWindow = null,
/// Метка развилки: из врезок с одной меткой ставится одна, выбранная по весам.
string? ChoiceKey = null,
int ChoiceWeight = 1,
/// Блок заставки — ассет рендерится позже, планировщик резервирует длительность.
Guid? BumperTemplateId = null,
/// Конкретный подблок заставки; null — выберет резолвер по триггеру и весам.
Guid? BumperVariantId = null,
/// Длительность резерва под заставку.
TimeSpan BumperDuration = default
);
/// Стык: последовательность врезок между программами.
public sealed record PlanningJunction(
Guid JunctionId,
IReadOnlyList Elements,
/// Потолок длины стыка целиком (null — ограничен только якорем).
TimeSpan? MaxTotal = null
);
/// Полный вход одного прогона генератора.
public sealed record PlanningInput(
Guid ChannelId,
DateTimeOffset StartUtc,
DateTimeOffset HorizonEndUtc,
IReadOnlyList Slots,
/// Чем закрывать место, не покрытое слотами и не заполненное контентом.
IReadOnlyList FallbackUnits,
int SegmentSeconds,
/// Смещение времени канала от UTC — по нему считаются окна суток у врезок стыка.
int UtcOffsetMinutes = 0
);
/// Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно.
public sealed record PlannedItem(
Guid MediaAssetId,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
Guid? ShowId,
int? UnitIndex,
Guid? SlotId,
PlannedItemKind Kind,
PlanTrace? Trace = null,
/// Для заставки: блок, пара «из/в» и место под ассет, который отрендерят позже.
Guid? BumperTemplateId = null,
Guid? FromShowId = null,
Guid? ToShowId = null,
/// Подблок заставки, если врезка задала его жёстко; null — выберет резолвер.
Guid? BumperVariantId = null,
/// Коллекция, частью которой шла единица (null — шоу играло само по себе).
Guid? CollectionId = null
);
public enum PlannedItemKind
{
Program = 0,
Fallback = 1,
SignOff = 2,
Ad = 3,
Promo = 4,
///
/// Заставка-переход. Ассет пуст: он зависит от пары соседей и рендерится после того, как лента
/// собрана, — планировщик лишь резервирует под неё длительность.
///
Bumper = 5,
}
/// Цепочка происхождения записи — питает экран «почему это здесь».
public sealed record PlanTrace(
Guid? SlotId,
SlotKind SlotKind,
GroupElementKind? ElementKind,
Guid? ElementId,
SlotStrategyKind? Strategy,
/// Сколько кандидатов осталось после остывания (null — выбор без остывания).
int? CandidatesAfterCooldown,
/// Насколько фактический старт разошёлся с целевым, минуты.
int DriftMinutes,
/// Старт сдвинут вперёд округлением до круглого времени.
bool Snapped
);
/// Новое состояние слота после прогона — оркестратор сохраняет его в БД.
public sealed record PlanningCursorUpdate(
Guid SlotId,
GroupElementKind? ElementKind,
Guid? ElementId,
int NextUnitIndex
);
/// Результат прогона: лента, новые курсоры и предупреждения для админа.
public sealed record PlanningResult(
IReadOnlyList Items,
IReadOnlyList Cursors,
IReadOnlyList Warnings
);
/// Предупреждение по результату генерации: не ошибка, но админу это видеть нужно.
public sealed record PlanningWarning(PlanningWarningKind Kind, Guid? SlotId, string Details);
public enum PlanningWarningKind
{
/// Слот не дал контента — место закрыл фон.
SlotEmpty = 0,
/// Фактический старт ушёл дальше допуска.
DriftExceeded = 1,
/// Остывание отсекло всех кандидатов.
CooldownExhausted = 2,
/// Не нашлось, что повторить.
RepeatSourceEmpty = 3,
/// Пусто даже в фоне — в ленте образуется дыра.
FallbackEmpty = 4,
/// Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата.
CandidatesFiltered = 5,
// ── Пост-проверки: считаются по готовой ленте и ничего не переигрывают (см. 3.8). ──
/// Врезок в часе больше заданного потолка.
BreakLimitExceeded = 6,
/// Доля одного жанра за сутки выше заданной.
GenreShareExceeded = 7,
/// Фон занял больше эфира, чем считается нормой.
FallbackShareExceeded = 8,
}