Files
TeleWave/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs
T

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,
}