Added new BumperEndpoints to the API for managing bumper templates and variants, enhancing the channel management capabilities. Removed outdated bumper-related commands and handlers from the application, streamlining the codebase and improving maintainability. Updated ChannelEndpoints to reflect these changes and ensure proper routing for the new endpoints.
245 lines
12 KiB
C#
245 lines
12 KiB
C#
using TeleWave.Domain.Library;
|
|
|
|
namespace TeleWave.Domain.Programming.Planning;
|
|
|
|
/// <summary>
|
|
/// Единица воспроизведения — серия или фильм с готовым ассетом. Планировщик оперирует ими, а не
|
|
/// шоу: у фильма единица одна, у сериала их столько же, сколько серий, у коллекции — сумма по частям.
|
|
/// </summary>
|
|
public sealed record PlanningUnit(Guid MediaAssetId, TimeSpan Duration, Guid ShowId, int UnitIndex);
|
|
|
|
/// <summary>
|
|
/// Элемент группы, развёрнутый в последовательность единиц. <see cref="LastPlayedUtc"/> — когда он
|
|
/// в последний раз выходил в этом канале; по нему работает остывание.
|
|
/// </summary>
|
|
public sealed record PlanningElement(
|
|
GroupElementKind Kind,
|
|
Guid ElementId,
|
|
int Weight,
|
|
int Position,
|
|
IReadOnlyList<PlanningUnit> Units,
|
|
DateTimeOffset? LastPlayedUtc = null,
|
|
/// <summary>Категория аудитории (у коллекции — строжайшая из частей); по ней работает детское время.</summary>
|
|
ShowAudience? Audience = null,
|
|
/// <summary>Старты недавних показов в этом канале — по ним считается потолок повторов за период.</summary>
|
|
IReadOnlyList<DateTimeOffset>? RecentPlaysUtc = null
|
|
);
|
|
|
|
/// <summary>Потолок повторов: не чаще <paramref name="Max"/> раз за <paramref name="WindowDays"/> суток.</summary>
|
|
public sealed record RepeatLimit(int WindowDays, int Max);
|
|
|
|
/// <summary>Стратегия выбора элемента, приведённая к виду, понятному чистому планировщику.</summary>
|
|
public sealed record PlanningStrategy(
|
|
SlotStrategyKind Kind,
|
|
bool RestartOnEnd = true,
|
|
int CooldownDays = 0,
|
|
bool IgnoreCooldownWhenExhausted = false,
|
|
Guid? FixedElementId = null
|
|
);
|
|
|
|
/// <summary>Как слот выбирает элемент. Дублирует прикладной enum, чтобы домен не зависел от Application.</summary>
|
|
public enum SlotStrategyKind
|
|
{
|
|
Sequential = 0,
|
|
RandomWithCooldown = 1,
|
|
Fixed = 2,
|
|
}
|
|
|
|
/// <summary>Где остановились в текущем элементе на момент начала прогона.</summary>
|
|
public sealed record PlanningCursor(
|
|
GroupElementKind? ElementKind,
|
|
Guid? ElementId,
|
|
int NextUnitIndex
|
|
);
|
|
|
|
/// <summary>
|
|
/// Слот, привязанный к конкретному моменту эфира. Применимость слоёв уже разрешена: сюда попадают
|
|
/// только те слоты, которые реально действуют в эти сутки, каждый со своим целевым временем в UTC.
|
|
/// </summary>
|
|
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<PlanningElement> Elements,
|
|
PlanningCursor? Cursor,
|
|
/// <summary>Готовые записи для <see cref="SlotKind.Repeat"/> — что играло в источнике повтора.</summary>
|
|
IReadOnlyList<PlanningUnit>? RepeatUnits = null,
|
|
/// <summary>Врезки между единицами внутри блока.</summary>
|
|
PlanningJunction? JunctionBetween = null,
|
|
/// <summary>Врезки в конце блока.</summary>
|
|
PlanningJunction? JunctionAfter = null,
|
|
/// <summary>Возрастной потолок в это время суток (null — без ограничения). Жёсткий фильтр.</summary>
|
|
ShowAudience? MaxAudience = null,
|
|
/// <summary>Потолок повторов за период (null — без ограничения). Жёсткий фильтр.</summary>
|
|
RepeatLimit? RepeatLimit = null
|
|
)
|
|
{
|
|
public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
|
|
}
|
|
|
|
/// <summary>Окно времени суток в часах канала; допускает переход через полночь (22:00 → 06:00).</summary>
|
|
public sealed record PlanningTimeWindow(TimeOnly From, TimeOnly To)
|
|
{
|
|
public bool Contains(TimeOnly moment) =>
|
|
From <= To ? moment >= From && moment < To : moment >= From || moment < To;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Врезка стыка, развёрнутая для планировщика: единицы уже подобраны оркестратором, домену остаётся
|
|
/// решить, сколько их поставить и влезают ли они.
|
|
/// </summary>
|
|
public sealed record PlanningJunctionElement(
|
|
Guid ElementId,
|
|
JunctionElementKind Kind,
|
|
IReadOnlyList<PlanningUnit> Units,
|
|
JunctionAmountMode AmountMode,
|
|
int AmountValue,
|
|
bool IsRequired,
|
|
/// <summary>Ставить только при смене элемента (иначе — и между единицами одного).</summary>
|
|
bool OnlyOnElementChange = false,
|
|
/// <summary>Не ставить чаще, чем раз в N минут (0 — без ограничения).</summary>
|
|
int MinMinutesBetween = 0,
|
|
/// <summary>Вероятность показа в процентах (100 — всегда).</summary>
|
|
int Chance = 100,
|
|
/// <summary>Окно времени суток, вне которого врезка не ставится (null — всегда).</summary>
|
|
PlanningTimeWindow? TimeWindow = null,
|
|
/// <summary>Метка развилки: из врезок с одной меткой ставится одна, выбранная по весам.</summary>
|
|
string? ChoiceKey = null,
|
|
int ChoiceWeight = 1,
|
|
/// <summary>Блок заставки — ассет рендерится позже, планировщик резервирует длительность.</summary>
|
|
Guid? BumperTemplateId = null,
|
|
/// <summary>Конкретный подблок заставки; null — выберет резолвер по триггеру и весам.</summary>
|
|
Guid? BumperVariantId = null,
|
|
/// <summary>Длительность резерва под заставку.</summary>
|
|
TimeSpan BumperDuration = default
|
|
);
|
|
|
|
/// <summary>Стык: последовательность врезок между программами.</summary>
|
|
public sealed record PlanningJunction(
|
|
Guid JunctionId,
|
|
IReadOnlyList<PlanningJunctionElement> Elements,
|
|
/// <summary>Потолок длины стыка целиком (null — ограничен только якорем).</summary>
|
|
TimeSpan? MaxTotal = null
|
|
);
|
|
|
|
/// <summary>Полный вход одного прогона генератора.</summary>
|
|
public sealed record PlanningInput(
|
|
Guid ChannelId,
|
|
DateTimeOffset StartUtc,
|
|
DateTimeOffset HorizonEndUtc,
|
|
IReadOnlyList<PlanningSlot> Slots,
|
|
/// <summary>Чем закрывать место, не покрытое слотами и не заполненное контентом.</summary>
|
|
IReadOnlyList<PlanningUnit> FallbackUnits,
|
|
int SegmentSeconds,
|
|
/// <summary>Смещение времени канала от UTC — по нему считаются окна суток у врезок стыка.</summary>
|
|
int UtcOffsetMinutes = 0
|
|
);
|
|
|
|
/// <summary>Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно.</summary>
|
|
public sealed record PlannedItem(
|
|
Guid MediaAssetId,
|
|
DateTimeOffset StartsAtUtc,
|
|
DateTimeOffset EndsAtUtc,
|
|
Guid? ShowId,
|
|
int? UnitIndex,
|
|
Guid? SlotId,
|
|
PlannedItemKind Kind,
|
|
PlanTrace? Trace = null,
|
|
/// <summary>Для заставки: блок, пара «из/в» и место под ассет, который отрендерят позже.</summary>
|
|
Guid? BumperTemplateId = null,
|
|
Guid? FromShowId = null,
|
|
Guid? ToShowId = null,
|
|
/// <summary>Подблок заставки, если врезка задала его жёстко; null — выберет резолвер.</summary>
|
|
Guid? BumperVariantId = null,
|
|
/// <summary>Коллекция, частью которой шла единица (null — шоу играло само по себе).</summary>
|
|
Guid? CollectionId = null
|
|
);
|
|
|
|
public enum PlannedItemKind
|
|
{
|
|
Program = 0,
|
|
Fallback = 1,
|
|
SignOff = 2,
|
|
Ad = 3,
|
|
Promo = 4,
|
|
|
|
/// <summary>
|
|
/// Заставка-переход. Ассет пуст: он зависит от пары соседей и рендерится после того, как лента
|
|
/// собрана, — планировщик лишь резервирует под неё длительность.
|
|
/// </summary>
|
|
Bumper = 5,
|
|
}
|
|
|
|
/// <summary>Цепочка происхождения записи — питает экран «почему это здесь».</summary>
|
|
public sealed record PlanTrace(
|
|
Guid? SlotId,
|
|
SlotKind SlotKind,
|
|
GroupElementKind? ElementKind,
|
|
Guid? ElementId,
|
|
SlotStrategyKind? Strategy,
|
|
/// <summary>Сколько кандидатов осталось после остывания (null — выбор без остывания).</summary>
|
|
int? CandidatesAfterCooldown,
|
|
/// <summary>Насколько фактический старт разошёлся с целевым, минуты.</summary>
|
|
int DriftMinutes,
|
|
/// <summary>Старт сдвинут вперёд округлением до круглого времени.</summary>
|
|
bool Snapped
|
|
);
|
|
|
|
/// <summary>Новое состояние слота после прогона — оркестратор сохраняет его в БД.</summary>
|
|
public sealed record PlanningCursorUpdate(
|
|
Guid SlotId,
|
|
GroupElementKind? ElementKind,
|
|
Guid? ElementId,
|
|
int NextUnitIndex
|
|
);
|
|
|
|
/// <summary>Результат прогона: лента, новые курсоры и предупреждения для админа.</summary>
|
|
public sealed record PlanningResult(
|
|
IReadOnlyList<PlannedItem> Items,
|
|
IReadOnlyList<PlanningCursorUpdate> Cursors,
|
|
IReadOnlyList<PlanningWarning> Warnings
|
|
);
|
|
|
|
/// <summary>Предупреждение по результату генерации: не ошибка, но админу это видеть нужно.</summary>
|
|
public sealed record PlanningWarning(PlanningWarningKind Kind, Guid? SlotId, string Details);
|
|
|
|
public enum PlanningWarningKind
|
|
{
|
|
/// <summary>Слот не дал контента — место закрыл фон.</summary>
|
|
SlotEmpty = 0,
|
|
|
|
/// <summary>Фактический старт ушёл дальше допуска.</summary>
|
|
DriftExceeded = 1,
|
|
|
|
/// <summary>Остывание отсекло всех кандидатов.</summary>
|
|
CooldownExhausted = 2,
|
|
|
|
/// <summary>Не нашлось, что повторить.</summary>
|
|
RepeatSourceEmpty = 3,
|
|
|
|
/// <summary>Пусто даже в фоне — в ленте образуется дыра.</summary>
|
|
FallbackEmpty = 4,
|
|
|
|
/// <summary>Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата.</summary>
|
|
CandidatesFiltered = 5,
|
|
|
|
// ── Пост-проверки: считаются по готовой ленте и ничего не переигрывают (см. 3.8). ──
|
|
|
|
/// <summary>Врезок в часе больше заданного потолка.</summary>
|
|
BreakLimitExceeded = 6,
|
|
|
|
/// <summary>Доля одного жанра за сутки выше заданной.</summary>
|
|
GenreShareExceeded = 7,
|
|
|
|
/// <summary>Фон занял больше эфира, чем считается нормой.</summary>
|
|
FallbackShareExceeded = 8,
|
|
}
|