220 lines
10 KiB
C#
220 lines
10 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>
|
|
/// Врезка стыка, развёрнутая для планировщика: единицы уже подобраны оркестратором, домену остаётся
|
|
/// решить, сколько их поставить и влезают ли они.
|
|
/// </summary>
|
|
public sealed record PlanningJunctionElement(
|
|
JunctionElementKind Kind,
|
|
IReadOnlyList<PlanningUnit> Units,
|
|
JunctionAmountMode AmountMode,
|
|
int AmountValue,
|
|
bool IsRequired,
|
|
/// <summary>Ставить только при смене элемента (иначе — и между единицами одного).</summary>
|
|
bool OnlyOnElementChange = false,
|
|
/// <summary>Не ставить чаще, чем раз в N минут (0 — без ограничения).</summary>
|
|
int MinMinutesBetween = 0,
|
|
/// <summary>Блок заставки — ассет рендерится позже, планировщик резервирует длительность.</summary>
|
|
Guid? BumperTemplateId = null,
|
|
/// <summary>Длительность резерва под заставку.</summary>
|
|
TimeSpan BumperDuration = default
|
|
);
|
|
|
|
/// <summary>Стык: последовательность врезок между программами.</summary>
|
|
public sealed record PlanningJunction(
|
|
Guid JunctionId,
|
|
IReadOnlyList<PlanningJunctionElement> Elements
|
|
);
|
|
|
|
/// <summary>Полный вход одного прогона генератора.</summary>
|
|
public sealed record PlanningInput(
|
|
Guid ChannelId,
|
|
DateTimeOffset StartUtc,
|
|
DateTimeOffset HorizonEndUtc,
|
|
IReadOnlyList<PlanningSlot> Slots,
|
|
/// <summary>Чем закрывать место, не покрытое слотами и не заполненное контентом.</summary>
|
|
IReadOnlyList<PlanningUnit> FallbackUnits,
|
|
int SegmentSeconds
|
|
);
|
|
|
|
/// <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
|
|
);
|
|
|
|
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,
|
|
}
|