Normalize line endings to LF via .gitattributes
Репозиторий хранил фронтенд в CRLF, а часть бэкенда — вперемешку, хотя CI и Docker-сборка работают под Linux. Прибиваем LF атрибутом `* text=auto eol=lf` и разово нормализуем дерево, чтобы форматтеры не переписывали файлы целиком на каждом прогоне. Коммит чисто механический: изменений содержимого нет, только концы строк. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9d1c6d2fc3
commit
0442056367
@@ -1,221 +1,221 @@
|
||||
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,
|
||||
/// <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,
|
||||
}
|
||||
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,
|
||||
/// <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,
|
||||
}
|
||||
|
||||
@@ -1,465 +1,465 @@
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
|
||||
namespace TeleWave.Domain.Programming.Planning;
|
||||
|
||||
/// <summary>
|
||||
/// Чистая эфирная математика: разворачивает сетку в непрерывную ленту от <see cref="PlanningInput.StartUtc"/>
|
||||
/// до горизонта. Без БД, ФС и ffmpeg — полностью юнит-тестируемо.
|
||||
///
|
||||
/// Сетка эластичная: времена слотов — цели, а не границы. Контент идёт встык, слот исчерпывается по
|
||||
/// бюджету, расхождение переносится дальше. Опорные точки держат якоря (жёсткий старт, перед которым
|
||||
/// не начинают то, что через него перелезет) и мягкое округление (сдвиг старта до круглого времени).
|
||||
/// </summary>
|
||||
public static class SchedulePlanner
|
||||
{
|
||||
private const int IterationBackstop = 100_000;
|
||||
|
||||
/// <summary>
|
||||
/// Накопители одного прогона: собираемая лента, новые курсоры слотов, предупреждения, история
|
||||
/// врезок и шоу последней поставленной единицы. Всё это протаскивалось через сигнатуры десятком
|
||||
/// параметров (включая <c>ref</c>), хотя принадлежит прогону целиком, а не отдельному шагу.
|
||||
///
|
||||
/// История врезок общая на прогон намеренно: «не чаще раза в полчаса» должно работать и через
|
||||
/// границу слота.
|
||||
/// </summary>
|
||||
private sealed class PlanningRun(PlanningInput input, IRandomSource random)
|
||||
{
|
||||
public PlanningInput Input { get; } = input;
|
||||
public IRandomSource Random { get; } = random;
|
||||
public List<PlannedItem> Items { get; } = [];
|
||||
public List<PlanningCursorUpdate> Cursors { get; } = [];
|
||||
public List<PlanningWarning> Warnings { get; } = [];
|
||||
public JunctionHistory Junctions { get; } = new();
|
||||
|
||||
/// <summary>Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент.</summary>
|
||||
public Guid? PreviousShowId { get; set; }
|
||||
}
|
||||
|
||||
public static PlanningResult Plan(PlanningInput input, IRandomSource random)
|
||||
{
|
||||
var run = new PlanningRun(input, random);
|
||||
|
||||
var slots = input.Slots.OrderBy(s => s.TargetStartUtc).ToList();
|
||||
var cursor = input.StartUtc;
|
||||
var iterations = 0;
|
||||
|
||||
for (var i = 0; i < slots.Count && cursor < input.HorizonEndUtc; i++)
|
||||
{
|
||||
if (iterations++ > IterationBackstop)
|
||||
break;
|
||||
|
||||
var slot = slots[i];
|
||||
|
||||
// Слот, чьё окно целиком в прошлом относительно курсора, пропускаем: догонять уже нечего,
|
||||
// а поставив его сейчас, мы сдвинули бы всё последующее ещё дальше.
|
||||
if (slot.TargetEndUtc <= cursor)
|
||||
continue;
|
||||
|
||||
var nextAnchor = FindNextAnchor(slots, i + 1);
|
||||
|
||||
cursor = OpenSlot(slot, cursor, run, out var trace);
|
||||
if (cursor >= input.HorizonEndUtc)
|
||||
break;
|
||||
|
||||
cursor = FillSlot(slot, cursor, nextAnchor, trace, run);
|
||||
}
|
||||
|
||||
// Хвост до горизонта закрывает фон: лента обязана быть непрерывной, иначе живой край
|
||||
// упрётся в дыру.
|
||||
if (cursor < input.HorizonEndUtc)
|
||||
cursor = FillWithFallback(cursor, input.HorizonEndUtc, run, null);
|
||||
|
||||
if (cursor < input.HorizonEndUtc && input.FallbackUnits.Count == 0)
|
||||
run.Warnings.Add(
|
||||
new PlanningWarning(
|
||||
PlanningWarningKind.FallbackEmpty,
|
||||
null,
|
||||
"Нет ни одной единицы для заполнения пауз — в ленте останутся дыры."
|
||||
)
|
||||
);
|
||||
|
||||
return new PlanningResult(run.Items, run.Cursors, run.Warnings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Подводит курсор к старту слота: добирает фоном до якоря либо до круглой отметки. Возвращает
|
||||
/// фактический старт и заполняет трейс сведениями о дрейфе.
|
||||
/// </summary>
|
||||
private static DateTimeOffset OpenSlot(
|
||||
PlanningSlot slot,
|
||||
DateTimeOffset cursor,
|
||||
PlanningRun run,
|
||||
out PlanTrace trace
|
||||
)
|
||||
{
|
||||
var snapped = false;
|
||||
|
||||
if (cursor < slot.TargetStartUtc)
|
||||
{
|
||||
// До целевого времени ещё есть место — закрываем его фоном. Для якоря это обязательно,
|
||||
// для обычного слота тоже: иначе он начнётся раньше объявленного в программе времени.
|
||||
cursor = FillWithFallback(cursor, slot.TargetStartUtc, run, slot.SlotId);
|
||||
}
|
||||
else if (slot.SnapToMinutes is { } snap && snap > 0)
|
||||
{
|
||||
// Слот опаздывает. Округление мягкое: если добирать пришлось бы дольше допуска, лучше
|
||||
// начать в 19:47, чем девять минут крутить фон.
|
||||
var target = RoundUp(cursor, TimeSpan.FromMinutes(snap));
|
||||
if (target - cursor <= TimeSpan.FromMinutes(slot.MaxDriftMinutes))
|
||||
{
|
||||
var afterFill = FillWithFallback(cursor, target, run, slot.SlotId);
|
||||
snapped = afterFill > cursor;
|
||||
cursor = afterFill;
|
||||
}
|
||||
}
|
||||
|
||||
var drift = (int)Math.Round((cursor - slot.TargetStartUtc).TotalMinutes);
|
||||
if (Math.Abs(drift) > slot.MaxDriftMinutes)
|
||||
run.Warnings.Add(
|
||||
new PlanningWarning(
|
||||
PlanningWarningKind.DriftExceeded,
|
||||
slot.SlotId,
|
||||
$"Фактический старт разошёлся с целевым на {drift} мин."
|
||||
)
|
||||
);
|
||||
|
||||
trace = new PlanTrace(slot.SlotId, slot.SlotKind, null, null, null, null, drift, snapped);
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/// <summary>Наполняет слот по его типу и возвращает курсор после него.</summary>
|
||||
private static DateTimeOffset FillSlot(
|
||||
PlanningSlot slot,
|
||||
DateTimeOffset cursor,
|
||||
DateTimeOffset? nextAnchor,
|
||||
PlanTrace trace,
|
||||
PlanningRun run
|
||||
)
|
||||
{
|
||||
var limit = Min(run.Input.HorizonEndUtc, nextAnchor);
|
||||
|
||||
switch (slot.SlotKind)
|
||||
{
|
||||
case SlotKind.SignOff:
|
||||
// Конец вещания: место занимает зацикленный фон, но в программе это помечено особо.
|
||||
return FillWithFallback(
|
||||
cursor,
|
||||
Min(slot.TargetEndUtc, limit),
|
||||
run,
|
||||
slot.SlotId,
|
||||
PlannedItemKind.SignOff,
|
||||
trace
|
||||
);
|
||||
|
||||
case SlotKind.Repeat:
|
||||
return FillRepeat(slot, cursor, limit, trace, run);
|
||||
|
||||
default:
|
||||
return FillContent(slot, cursor, limit, trace, run);
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTimeOffset FillRepeat(
|
||||
PlanningSlot slot,
|
||||
DateTimeOffset cursor,
|
||||
DateTimeOffset limit,
|
||||
PlanTrace trace,
|
||||
PlanningRun run
|
||||
)
|
||||
{
|
||||
var units = slot.RepeatUnits ?? [];
|
||||
if (units.Count == 0)
|
||||
{
|
||||
run.Warnings.Add(
|
||||
new PlanningWarning(
|
||||
PlanningWarningKind.RepeatSourceEmpty,
|
||||
slot.SlotId,
|
||||
"В источнике повтора ничего не нашлось — слот закрыт фоном."
|
||||
)
|
||||
);
|
||||
return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
|
||||
}
|
||||
|
||||
var slotEnd = Min(slot.TargetEndUtc, limit);
|
||||
foreach (var unit in units)
|
||||
{
|
||||
if (cursor + unit.Duration > slotEnd)
|
||||
break;
|
||||
run.Items.Add(Program(unit, cursor, slot.SlotId, trace));
|
||||
cursor += unit.Duration;
|
||||
}
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
private static DateTimeOffset FillContent(
|
||||
PlanningSlot slot,
|
||||
DateTimeOffset cursor,
|
||||
DateTimeOffset limit,
|
||||
PlanTrace trace,
|
||||
PlanningRun run
|
||||
)
|
||||
{
|
||||
var pick = ElementSelector.Select(slot, cursor, run.Random);
|
||||
if (pick is null)
|
||||
{
|
||||
run.Warnings.Add(NoCandidatesWarning(slot));
|
||||
return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
|
||||
}
|
||||
|
||||
if (pick.CooldownExhausted)
|
||||
run.Warnings.Add(
|
||||
new PlanningWarning(
|
||||
PlanningWarningKind.CooldownExhausted,
|
||||
slot.SlotId,
|
||||
"Остывание отсекло всех кандидатов — взят самый давний."
|
||||
)
|
||||
);
|
||||
|
||||
var element = pick.Element;
|
||||
var slotTrace = trace with
|
||||
{
|
||||
ElementKind = element.Kind,
|
||||
ElementId = element.ElementId,
|
||||
Strategy = slot.Strategy.Kind,
|
||||
CandidatesAfterCooldown = pick.CandidatesAfterCooldown,
|
||||
};
|
||||
|
||||
var unitIndex = pick.StartUnitIndex;
|
||||
var budgetEnd = Min(slot.TargetEndUtc, limit);
|
||||
var placed = 0;
|
||||
var accumulated = TimeSpan.Zero;
|
||||
|
||||
// SkipIfNotFits решается до постановки: если элемент целиком не помещается, слот не начинают.
|
||||
if (
|
||||
slot.OverflowPolicy == OverflowPolicy.SkipIfNotFits
|
||||
&& !FitsEntirely(element, unitIndex, cursor, budgetEnd)
|
||||
)
|
||||
{
|
||||
run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
|
||||
return FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
|
||||
}
|
||||
|
||||
while (unitIndex < element.Units.Count && cursor < limit)
|
||||
{
|
||||
var unit = element.Units[unitIndex];
|
||||
|
||||
// Врезки между единицами: перед каждой, кроме первой в блоке.
|
||||
if (placed > 0)
|
||||
cursor = JunctionFiller.Fill(
|
||||
slot.JunctionBetween,
|
||||
cursor,
|
||||
limit,
|
||||
new JunctionPlacement(
|
||||
slot.SlotId,
|
||||
run.PreviousShowId,
|
||||
unit.ShowId,
|
||||
ElementChanged: run.PreviousShowId != unit.ShowId
|
||||
),
|
||||
run.Junctions,
|
||||
run.Items,
|
||||
slotTrace
|
||||
);
|
||||
|
||||
// Через якорь не перелезаем: то, что не влезает до него, не начинают вовсе.
|
||||
if (cursor + unit.Duration > limit)
|
||||
break;
|
||||
|
||||
if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd))
|
||||
break;
|
||||
|
||||
run.Items.Add(Program(unit, cursor, slot.SlotId, slotTrace, CollectionOf(element)));
|
||||
cursor += unit.Duration;
|
||||
accumulated += unit.Duration;
|
||||
unitIndex++;
|
||||
placed++;
|
||||
run.PreviousShowId = unit.ShowId;
|
||||
}
|
||||
|
||||
// Врезки в конце блока ставятся до добора фоном: иначе реклама оказалась бы после заполнителя.
|
||||
if (placed > 0)
|
||||
cursor = JunctionFiller.Fill(
|
||||
slot.JunctionAfter,
|
||||
cursor,
|
||||
limit,
|
||||
new JunctionPlacement(slot.SlotId, run.PreviousShowId, null, ElementChanged: true),
|
||||
run.Junctions,
|
||||
run.Items,
|
||||
slotTrace
|
||||
);
|
||||
|
||||
run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
|
||||
|
||||
// Недобор до целевого конца закрываем фоном — только для слотов, чей бюджет привязан ко времени.
|
||||
if (slot.BlockMode == SlotBlockMode.FillSlot && cursor < budgetEnd)
|
||||
cursor = FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Почему слот остался без контента. Пустая группа и отсечённая фильтром — разные беды: во втором
|
||||
/// случае контент есть, но не подходит по правилам, и админу надо чинить правило, а не состав
|
||||
/// группы.
|
||||
/// </summary>
|
||||
private static PlanningWarning NoCandidatesWarning(PlanningSlot slot)
|
||||
{
|
||||
var hasPlayable = slot.Elements.Any(e => e.Units.Count > 0);
|
||||
var hasAllowed = slot.Elements.Any(e =>
|
||||
e.Units.Count > 0 && ElementSelector.IsAllowedByAudience(slot, e)
|
||||
);
|
||||
|
||||
return hasPlayable && !hasAllowed
|
||||
? new PlanningWarning(
|
||||
PlanningWarningKind.CandidatesFiltered,
|
||||
slot.SlotId,
|
||||
"Возрастной потолок отсёк всех кандидатов — место закрыл фон."
|
||||
)
|
||||
: new PlanningWarning(
|
||||
PlanningWarningKind.SlotEmpty,
|
||||
slot.SlotId,
|
||||
"Слот не дал контента — место закрыл фон."
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Влезает ли ещё одна единица в бюджет слота. <see cref="OverflowPolicy.ExtendSlot"/> бюджет
|
||||
/// игнорирует: элемент доигрывается целиком, а разбег подберёт ближайший якорь.
|
||||
/// </summary>
|
||||
private static bool WithinBudget(
|
||||
PlanningSlot slot,
|
||||
int placed,
|
||||
TimeSpan accumulated,
|
||||
DateTimeOffset cursor,
|
||||
PlanningUnit unit,
|
||||
DateTimeOffset budgetEnd
|
||||
)
|
||||
{
|
||||
if (slot.OverflowPolicy == OverflowPolicy.ExtendSlot)
|
||||
return true;
|
||||
|
||||
return slot.BlockMode switch
|
||||
{
|
||||
SlotBlockMode.Count => placed < Math.Max(1, slot.BlockValue),
|
||||
// Последняя единица входит целиком: обрезать видеофайл нельзя.
|
||||
SlotBlockMode.Duration => accumulated
|
||||
< TimeSpan.FromMinutes(Math.Max(1, slot.BlockValue)),
|
||||
_ => cursor + unit.Duration <= budgetEnd,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool FitsEntirely(
|
||||
PlanningElement element,
|
||||
int fromUnitIndex,
|
||||
DateTimeOffset cursor,
|
||||
DateTimeOffset budgetEnd
|
||||
)
|
||||
{
|
||||
var total = element
|
||||
.Units.Skip(fromUnitIndex)
|
||||
.Aggregate(TimeSpan.Zero, (sum, unit) => sum + unit.Duration);
|
||||
return cursor + total <= budgetEnd;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Закрывает интервал зацикленными единицами фона. Ставит только те, что влезают целиком:
|
||||
/// обрезать нельзя, а перехлёст сдвинул бы следующий якорь. Остаток короче одной единицы
|
||||
/// остаётся незакрытым — раздача покажет там аварийный филлер канала.
|
||||
/// </summary>
|
||||
private static DateTimeOffset FillWithFallback(
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset until,
|
||||
PlanningRun run,
|
||||
Guid? slotId,
|
||||
PlannedItemKind kind = PlannedItemKind.Fallback,
|
||||
PlanTrace? trace = null
|
||||
)
|
||||
{
|
||||
var fallback = run.Input.FallbackUnits;
|
||||
if (fallback.Count == 0 || until <= from)
|
||||
return from;
|
||||
|
||||
var cursor = from;
|
||||
var index = 0;
|
||||
var guard = 0;
|
||||
|
||||
while (cursor < until && guard++ < IterationBackstop)
|
||||
{
|
||||
var unit = fallback[index % fallback.Count];
|
||||
index++;
|
||||
|
||||
if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > until)
|
||||
break;
|
||||
|
||||
run.Items.Add(
|
||||
new PlannedItem(
|
||||
unit.MediaAssetId,
|
||||
cursor,
|
||||
cursor + unit.Duration,
|
||||
null,
|
||||
null,
|
||||
slotId,
|
||||
kind,
|
||||
trace
|
||||
)
|
||||
);
|
||||
cursor += unit.Duration;
|
||||
}
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/// <summary>Коллекция элемента или null, если в эфир шло отдельное шоу.</summary>
|
||||
private static Guid? CollectionOf(PlanningElement element) =>
|
||||
element.Kind == GroupElementKind.Collection ? element.ElementId : null;
|
||||
|
||||
private static PlannedItem Program(
|
||||
PlanningUnit unit,
|
||||
DateTimeOffset start,
|
||||
Guid slotId,
|
||||
PlanTrace trace,
|
||||
Guid? collectionId = null
|
||||
) =>
|
||||
new(
|
||||
unit.MediaAssetId,
|
||||
start,
|
||||
start + unit.Duration,
|
||||
unit.ShowId,
|
||||
unit.UnitIndex,
|
||||
slotId,
|
||||
PlannedItemKind.Program,
|
||||
trace,
|
||||
CollectionId: collectionId
|
||||
);
|
||||
|
||||
private static PlanningCursorUpdate CursorUpdate(
|
||||
PlanningSlot slot,
|
||||
PlanningElement element,
|
||||
int nextUnitIndex
|
||||
) => new(slot.SlotId, element.Kind, element.ElementId, nextUnitIndex);
|
||||
|
||||
/// <summary>Ближайший якорь среди последующих слотов — до него нельзя перелезать контентом.</summary>
|
||||
private static DateTimeOffset? FindNextAnchor(IReadOnlyList<PlanningSlot> slots, int fromIndex)
|
||||
{
|
||||
for (var i = fromIndex; i < slots.Count; i++)
|
||||
if (slots[i].IsAnchor)
|
||||
return slots[i].TargetStartUtc;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset Min(DateTimeOffset value, DateTimeOffset? other) =>
|
||||
other is { } o && o < value ? o : value;
|
||||
|
||||
private static DateTimeOffset Min(DateTimeOffset a, DateTimeOffset b) => a < b ? a : b;
|
||||
|
||||
/// <summary>Округление момента вверх до кратного шага — от начала суток UTC.</summary>
|
||||
private static DateTimeOffset RoundUp(DateTimeOffset moment, TimeSpan step)
|
||||
{
|
||||
if (step <= TimeSpan.Zero)
|
||||
return moment;
|
||||
|
||||
var ticks = step.Ticks;
|
||||
var remainder = moment.UtcTicks % ticks;
|
||||
return remainder == 0 ? moment : moment.AddTicks(ticks - remainder);
|
||||
}
|
||||
}
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
|
||||
namespace TeleWave.Domain.Programming.Planning;
|
||||
|
||||
/// <summary>
|
||||
/// Чистая эфирная математика: разворачивает сетку в непрерывную ленту от <see cref="PlanningInput.StartUtc"/>
|
||||
/// до горизонта. Без БД, ФС и ffmpeg — полностью юнит-тестируемо.
|
||||
///
|
||||
/// Сетка эластичная: времена слотов — цели, а не границы. Контент идёт встык, слот исчерпывается по
|
||||
/// бюджету, расхождение переносится дальше. Опорные точки держат якоря (жёсткий старт, перед которым
|
||||
/// не начинают то, что через него перелезет) и мягкое округление (сдвиг старта до круглого времени).
|
||||
/// </summary>
|
||||
public static class SchedulePlanner
|
||||
{
|
||||
private const int IterationBackstop = 100_000;
|
||||
|
||||
/// <summary>
|
||||
/// Накопители одного прогона: собираемая лента, новые курсоры слотов, предупреждения, история
|
||||
/// врезок и шоу последней поставленной единицы. Всё это протаскивалось через сигнатуры десятком
|
||||
/// параметров (включая <c>ref</c>), хотя принадлежит прогону целиком, а не отдельному шагу.
|
||||
///
|
||||
/// История врезок общая на прогон намеренно: «не чаще раза в полчаса» должно работать и через
|
||||
/// границу слота.
|
||||
/// </summary>
|
||||
private sealed class PlanningRun(PlanningInput input, IRandomSource random)
|
||||
{
|
||||
public PlanningInput Input { get; } = input;
|
||||
public IRandomSource Random { get; } = random;
|
||||
public List<PlannedItem> Items { get; } = [];
|
||||
public List<PlanningCursorUpdate> Cursors { get; } = [];
|
||||
public List<PlanningWarning> Warnings { get; } = [];
|
||||
public JunctionHistory Junctions { get; } = new();
|
||||
|
||||
/// <summary>Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент.</summary>
|
||||
public Guid? PreviousShowId { get; set; }
|
||||
}
|
||||
|
||||
public static PlanningResult Plan(PlanningInput input, IRandomSource random)
|
||||
{
|
||||
var run = new PlanningRun(input, random);
|
||||
|
||||
var slots = input.Slots.OrderBy(s => s.TargetStartUtc).ToList();
|
||||
var cursor = input.StartUtc;
|
||||
var iterations = 0;
|
||||
|
||||
for (var i = 0; i < slots.Count && cursor < input.HorizonEndUtc; i++)
|
||||
{
|
||||
if (iterations++ > IterationBackstop)
|
||||
break;
|
||||
|
||||
var slot = slots[i];
|
||||
|
||||
// Слот, чьё окно целиком в прошлом относительно курсора, пропускаем: догонять уже нечего,
|
||||
// а поставив его сейчас, мы сдвинули бы всё последующее ещё дальше.
|
||||
if (slot.TargetEndUtc <= cursor)
|
||||
continue;
|
||||
|
||||
var nextAnchor = FindNextAnchor(slots, i + 1);
|
||||
|
||||
cursor = OpenSlot(slot, cursor, run, out var trace);
|
||||
if (cursor >= input.HorizonEndUtc)
|
||||
break;
|
||||
|
||||
cursor = FillSlot(slot, cursor, nextAnchor, trace, run);
|
||||
}
|
||||
|
||||
// Хвост до горизонта закрывает фон: лента обязана быть непрерывной, иначе живой край
|
||||
// упрётся в дыру.
|
||||
if (cursor < input.HorizonEndUtc)
|
||||
cursor = FillWithFallback(cursor, input.HorizonEndUtc, run, null);
|
||||
|
||||
if (cursor < input.HorizonEndUtc && input.FallbackUnits.Count == 0)
|
||||
run.Warnings.Add(
|
||||
new PlanningWarning(
|
||||
PlanningWarningKind.FallbackEmpty,
|
||||
null,
|
||||
"Нет ни одной единицы для заполнения пауз — в ленте останутся дыры."
|
||||
)
|
||||
);
|
||||
|
||||
return new PlanningResult(run.Items, run.Cursors, run.Warnings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Подводит курсор к старту слота: добирает фоном до якоря либо до круглой отметки. Возвращает
|
||||
/// фактический старт и заполняет трейс сведениями о дрейфе.
|
||||
/// </summary>
|
||||
private static DateTimeOffset OpenSlot(
|
||||
PlanningSlot slot,
|
||||
DateTimeOffset cursor,
|
||||
PlanningRun run,
|
||||
out PlanTrace trace
|
||||
)
|
||||
{
|
||||
var snapped = false;
|
||||
|
||||
if (cursor < slot.TargetStartUtc)
|
||||
{
|
||||
// До целевого времени ещё есть место — закрываем его фоном. Для якоря это обязательно,
|
||||
// для обычного слота тоже: иначе он начнётся раньше объявленного в программе времени.
|
||||
cursor = FillWithFallback(cursor, slot.TargetStartUtc, run, slot.SlotId);
|
||||
}
|
||||
else if (slot.SnapToMinutes is { } snap && snap > 0)
|
||||
{
|
||||
// Слот опаздывает. Округление мягкое: если добирать пришлось бы дольше допуска, лучше
|
||||
// начать в 19:47, чем девять минут крутить фон.
|
||||
var target = RoundUp(cursor, TimeSpan.FromMinutes(snap));
|
||||
if (target - cursor <= TimeSpan.FromMinutes(slot.MaxDriftMinutes))
|
||||
{
|
||||
var afterFill = FillWithFallback(cursor, target, run, slot.SlotId);
|
||||
snapped = afterFill > cursor;
|
||||
cursor = afterFill;
|
||||
}
|
||||
}
|
||||
|
||||
var drift = (int)Math.Round((cursor - slot.TargetStartUtc).TotalMinutes);
|
||||
if (Math.Abs(drift) > slot.MaxDriftMinutes)
|
||||
run.Warnings.Add(
|
||||
new PlanningWarning(
|
||||
PlanningWarningKind.DriftExceeded,
|
||||
slot.SlotId,
|
||||
$"Фактический старт разошёлся с целевым на {drift} мин."
|
||||
)
|
||||
);
|
||||
|
||||
trace = new PlanTrace(slot.SlotId, slot.SlotKind, null, null, null, null, drift, snapped);
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/// <summary>Наполняет слот по его типу и возвращает курсор после него.</summary>
|
||||
private static DateTimeOffset FillSlot(
|
||||
PlanningSlot slot,
|
||||
DateTimeOffset cursor,
|
||||
DateTimeOffset? nextAnchor,
|
||||
PlanTrace trace,
|
||||
PlanningRun run
|
||||
)
|
||||
{
|
||||
var limit = Min(run.Input.HorizonEndUtc, nextAnchor);
|
||||
|
||||
switch (slot.SlotKind)
|
||||
{
|
||||
case SlotKind.SignOff:
|
||||
// Конец вещания: место занимает зацикленный фон, но в программе это помечено особо.
|
||||
return FillWithFallback(
|
||||
cursor,
|
||||
Min(slot.TargetEndUtc, limit),
|
||||
run,
|
||||
slot.SlotId,
|
||||
PlannedItemKind.SignOff,
|
||||
trace
|
||||
);
|
||||
|
||||
case SlotKind.Repeat:
|
||||
return FillRepeat(slot, cursor, limit, trace, run);
|
||||
|
||||
default:
|
||||
return FillContent(slot, cursor, limit, trace, run);
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTimeOffset FillRepeat(
|
||||
PlanningSlot slot,
|
||||
DateTimeOffset cursor,
|
||||
DateTimeOffset limit,
|
||||
PlanTrace trace,
|
||||
PlanningRun run
|
||||
)
|
||||
{
|
||||
var units = slot.RepeatUnits ?? [];
|
||||
if (units.Count == 0)
|
||||
{
|
||||
run.Warnings.Add(
|
||||
new PlanningWarning(
|
||||
PlanningWarningKind.RepeatSourceEmpty,
|
||||
slot.SlotId,
|
||||
"В источнике повтора ничего не нашлось — слот закрыт фоном."
|
||||
)
|
||||
);
|
||||
return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
|
||||
}
|
||||
|
||||
var slotEnd = Min(slot.TargetEndUtc, limit);
|
||||
foreach (var unit in units)
|
||||
{
|
||||
if (cursor + unit.Duration > slotEnd)
|
||||
break;
|
||||
run.Items.Add(Program(unit, cursor, slot.SlotId, trace));
|
||||
cursor += unit.Duration;
|
||||
}
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
private static DateTimeOffset FillContent(
|
||||
PlanningSlot slot,
|
||||
DateTimeOffset cursor,
|
||||
DateTimeOffset limit,
|
||||
PlanTrace trace,
|
||||
PlanningRun run
|
||||
)
|
||||
{
|
||||
var pick = ElementSelector.Select(slot, cursor, run.Random);
|
||||
if (pick is null)
|
||||
{
|
||||
run.Warnings.Add(NoCandidatesWarning(slot));
|
||||
return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
|
||||
}
|
||||
|
||||
if (pick.CooldownExhausted)
|
||||
run.Warnings.Add(
|
||||
new PlanningWarning(
|
||||
PlanningWarningKind.CooldownExhausted,
|
||||
slot.SlotId,
|
||||
"Остывание отсекло всех кандидатов — взят самый давний."
|
||||
)
|
||||
);
|
||||
|
||||
var element = pick.Element;
|
||||
var slotTrace = trace with
|
||||
{
|
||||
ElementKind = element.Kind,
|
||||
ElementId = element.ElementId,
|
||||
Strategy = slot.Strategy.Kind,
|
||||
CandidatesAfterCooldown = pick.CandidatesAfterCooldown,
|
||||
};
|
||||
|
||||
var unitIndex = pick.StartUnitIndex;
|
||||
var budgetEnd = Min(slot.TargetEndUtc, limit);
|
||||
var placed = 0;
|
||||
var accumulated = TimeSpan.Zero;
|
||||
|
||||
// SkipIfNotFits решается до постановки: если элемент целиком не помещается, слот не начинают.
|
||||
if (
|
||||
slot.OverflowPolicy == OverflowPolicy.SkipIfNotFits
|
||||
&& !FitsEntirely(element, unitIndex, cursor, budgetEnd)
|
||||
)
|
||||
{
|
||||
run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
|
||||
return FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
|
||||
}
|
||||
|
||||
while (unitIndex < element.Units.Count && cursor < limit)
|
||||
{
|
||||
var unit = element.Units[unitIndex];
|
||||
|
||||
// Врезки между единицами: перед каждой, кроме первой в блоке.
|
||||
if (placed > 0)
|
||||
cursor = JunctionFiller.Fill(
|
||||
slot.JunctionBetween,
|
||||
cursor,
|
||||
limit,
|
||||
new JunctionPlacement(
|
||||
slot.SlotId,
|
||||
run.PreviousShowId,
|
||||
unit.ShowId,
|
||||
ElementChanged: run.PreviousShowId != unit.ShowId
|
||||
),
|
||||
run.Junctions,
|
||||
run.Items,
|
||||
slotTrace
|
||||
);
|
||||
|
||||
// Через якорь не перелезаем: то, что не влезает до него, не начинают вовсе.
|
||||
if (cursor + unit.Duration > limit)
|
||||
break;
|
||||
|
||||
if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd))
|
||||
break;
|
||||
|
||||
run.Items.Add(Program(unit, cursor, slot.SlotId, slotTrace, CollectionOf(element)));
|
||||
cursor += unit.Duration;
|
||||
accumulated += unit.Duration;
|
||||
unitIndex++;
|
||||
placed++;
|
||||
run.PreviousShowId = unit.ShowId;
|
||||
}
|
||||
|
||||
// Врезки в конце блока ставятся до добора фоном: иначе реклама оказалась бы после заполнителя.
|
||||
if (placed > 0)
|
||||
cursor = JunctionFiller.Fill(
|
||||
slot.JunctionAfter,
|
||||
cursor,
|
||||
limit,
|
||||
new JunctionPlacement(slot.SlotId, run.PreviousShowId, null, ElementChanged: true),
|
||||
run.Junctions,
|
||||
run.Items,
|
||||
slotTrace
|
||||
);
|
||||
|
||||
run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
|
||||
|
||||
// Недобор до целевого конца закрываем фоном — только для слотов, чей бюджет привязан ко времени.
|
||||
if (slot.BlockMode == SlotBlockMode.FillSlot && cursor < budgetEnd)
|
||||
cursor = FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Почему слот остался без контента. Пустая группа и отсечённая фильтром — разные беды: во втором
|
||||
/// случае контент есть, но не подходит по правилам, и админу надо чинить правило, а не состав
|
||||
/// группы.
|
||||
/// </summary>
|
||||
private static PlanningWarning NoCandidatesWarning(PlanningSlot slot)
|
||||
{
|
||||
var hasPlayable = slot.Elements.Any(e => e.Units.Count > 0);
|
||||
var hasAllowed = slot.Elements.Any(e =>
|
||||
e.Units.Count > 0 && ElementSelector.IsAllowedByAudience(slot, e)
|
||||
);
|
||||
|
||||
return hasPlayable && !hasAllowed
|
||||
? new PlanningWarning(
|
||||
PlanningWarningKind.CandidatesFiltered,
|
||||
slot.SlotId,
|
||||
"Возрастной потолок отсёк всех кандидатов — место закрыл фон."
|
||||
)
|
||||
: new PlanningWarning(
|
||||
PlanningWarningKind.SlotEmpty,
|
||||
slot.SlotId,
|
||||
"Слот не дал контента — место закрыл фон."
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Влезает ли ещё одна единица в бюджет слота. <see cref="OverflowPolicy.ExtendSlot"/> бюджет
|
||||
/// игнорирует: элемент доигрывается целиком, а разбег подберёт ближайший якорь.
|
||||
/// </summary>
|
||||
private static bool WithinBudget(
|
||||
PlanningSlot slot,
|
||||
int placed,
|
||||
TimeSpan accumulated,
|
||||
DateTimeOffset cursor,
|
||||
PlanningUnit unit,
|
||||
DateTimeOffset budgetEnd
|
||||
)
|
||||
{
|
||||
if (slot.OverflowPolicy == OverflowPolicy.ExtendSlot)
|
||||
return true;
|
||||
|
||||
return slot.BlockMode switch
|
||||
{
|
||||
SlotBlockMode.Count => placed < Math.Max(1, slot.BlockValue),
|
||||
// Последняя единица входит целиком: обрезать видеофайл нельзя.
|
||||
SlotBlockMode.Duration => accumulated
|
||||
< TimeSpan.FromMinutes(Math.Max(1, slot.BlockValue)),
|
||||
_ => cursor + unit.Duration <= budgetEnd,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool FitsEntirely(
|
||||
PlanningElement element,
|
||||
int fromUnitIndex,
|
||||
DateTimeOffset cursor,
|
||||
DateTimeOffset budgetEnd
|
||||
)
|
||||
{
|
||||
var total = element
|
||||
.Units.Skip(fromUnitIndex)
|
||||
.Aggregate(TimeSpan.Zero, (sum, unit) => sum + unit.Duration);
|
||||
return cursor + total <= budgetEnd;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Закрывает интервал зацикленными единицами фона. Ставит только те, что влезают целиком:
|
||||
/// обрезать нельзя, а перехлёст сдвинул бы следующий якорь. Остаток короче одной единицы
|
||||
/// остаётся незакрытым — раздача покажет там аварийный филлер канала.
|
||||
/// </summary>
|
||||
private static DateTimeOffset FillWithFallback(
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset until,
|
||||
PlanningRun run,
|
||||
Guid? slotId,
|
||||
PlannedItemKind kind = PlannedItemKind.Fallback,
|
||||
PlanTrace? trace = null
|
||||
)
|
||||
{
|
||||
var fallback = run.Input.FallbackUnits;
|
||||
if (fallback.Count == 0 || until <= from)
|
||||
return from;
|
||||
|
||||
var cursor = from;
|
||||
var index = 0;
|
||||
var guard = 0;
|
||||
|
||||
while (cursor < until && guard++ < IterationBackstop)
|
||||
{
|
||||
var unit = fallback[index % fallback.Count];
|
||||
index++;
|
||||
|
||||
if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > until)
|
||||
break;
|
||||
|
||||
run.Items.Add(
|
||||
new PlannedItem(
|
||||
unit.MediaAssetId,
|
||||
cursor,
|
||||
cursor + unit.Duration,
|
||||
null,
|
||||
null,
|
||||
slotId,
|
||||
kind,
|
||||
trace
|
||||
)
|
||||
);
|
||||
cursor += unit.Duration;
|
||||
}
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/// <summary>Коллекция элемента или null, если в эфир шло отдельное шоу.</summary>
|
||||
private static Guid? CollectionOf(PlanningElement element) =>
|
||||
element.Kind == GroupElementKind.Collection ? element.ElementId : null;
|
||||
|
||||
private static PlannedItem Program(
|
||||
PlanningUnit unit,
|
||||
DateTimeOffset start,
|
||||
Guid slotId,
|
||||
PlanTrace trace,
|
||||
Guid? collectionId = null
|
||||
) =>
|
||||
new(
|
||||
unit.MediaAssetId,
|
||||
start,
|
||||
start + unit.Duration,
|
||||
unit.ShowId,
|
||||
unit.UnitIndex,
|
||||
slotId,
|
||||
PlannedItemKind.Program,
|
||||
trace,
|
||||
CollectionId: collectionId
|
||||
);
|
||||
|
||||
private static PlanningCursorUpdate CursorUpdate(
|
||||
PlanningSlot slot,
|
||||
PlanningElement element,
|
||||
int nextUnitIndex
|
||||
) => new(slot.SlotId, element.Kind, element.ElementId, nextUnitIndex);
|
||||
|
||||
/// <summary>Ближайший якорь среди последующих слотов — до него нельзя перелезать контентом.</summary>
|
||||
private static DateTimeOffset? FindNextAnchor(IReadOnlyList<PlanningSlot> slots, int fromIndex)
|
||||
{
|
||||
for (var i = fromIndex; i < slots.Count; i++)
|
||||
if (slots[i].IsAnchor)
|
||||
return slots[i].TargetStartUtc;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset Min(DateTimeOffset value, DateTimeOffset? other) =>
|
||||
other is { } o && o < value ? o : value;
|
||||
|
||||
private static DateTimeOffset Min(DateTimeOffset a, DateTimeOffset b) => a < b ? a : b;
|
||||
|
||||
/// <summary>Округление момента вверх до кратного шага — от начала суток UTC.</summary>
|
||||
private static DateTimeOffset RoundUp(DateTimeOffset moment, TimeSpan step)
|
||||
{
|
||||
if (step <= TimeSpan.Zero)
|
||||
return moment;
|
||||
|
||||
var ticks = step.Ticks;
|
||||
var remainder = moment.UtcTicks % ticks;
|
||||
return remainder == 0 ? moment : moment.AddTicks(ticks - remainder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,110 +1,110 @@
|
||||
namespace TeleWave.Domain.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Шаблон сетки канала: слои со слотами плюс аварийные настройки. Один активный шаблон на канал —
|
||||
/// сезонность выражается слоями внутри него, а не вторым шаблоном, иначе получились бы два механизма
|
||||
/// для одного и того же. На другой канал переносится глубокой копией.
|
||||
///
|
||||
/// <see cref="Revision"/> растёт при любой правке правил. Эфир при этом не меняется: правка помечает
|
||||
/// шаблон изменённым, а хвост пересобирается отдельной командой применения.
|
||||
/// </summary>
|
||||
public class ScheduleTemplate
|
||||
{
|
||||
private readonly List<GridLayer> _layers = [];
|
||||
|
||||
public Guid Id { get; private set; }
|
||||
public Guid ChannelId { get; private set; }
|
||||
public string Name { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>Аварийная группа, если пуст даже фоновый слой.</summary>
|
||||
public Guid? FallbackGroupId { get; private set; }
|
||||
|
||||
/// <summary>Стык по умолчанию — для слотов, у которых свой не задан.</summary>
|
||||
public Guid? DefaultJunctionId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Правила отбора кандидатов (детское время, потолок повторов) в JSON. Домен их не разбирает —
|
||||
/// схема живёт в Application, как и у стратегий слотов и условий стыка.
|
||||
/// </summary>
|
||||
public string? RulesJson { get; private set; }
|
||||
|
||||
/// <summary>Номер правки правил; входит в кэш-ключи и историю.</summary>
|
||||
public int Revision { get; private set; }
|
||||
|
||||
/// <summary>Ревизия, по которой собрано текущее будущее расписание.</summary>
|
||||
public int AppliedRevision { get; private set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; private set; }
|
||||
|
||||
public IReadOnlyList<GridLayer> Layers => _layers;
|
||||
|
||||
/// <summary>Есть ли правки, не применённые к эфиру.</summary>
|
||||
public bool HasPendingChanges => Revision != AppliedRevision;
|
||||
|
||||
private const string BackgroundLayerName = "Фон";
|
||||
|
||||
private ScheduleTemplate() { }
|
||||
|
||||
public static ScheduleTemplate Create(Guid channelId, string name)
|
||||
{
|
||||
var template = new ScheduleTemplate
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChannelId = channelId,
|
||||
Name = name.Trim(),
|
||||
Revision = 0,
|
||||
AppliedRevision = 0,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
// Фоновый слой заводится сразу: без него первая же дыра в сетке осталась бы нечем закрыть.
|
||||
template._layers.Add(
|
||||
GridLayer.Create(
|
||||
template.Id,
|
||||
BackgroundLayerName,
|
||||
GridLayer.BackgroundPriority,
|
||||
isBackground: true
|
||||
)
|
||||
);
|
||||
return template;
|
||||
}
|
||||
|
||||
public void Rename(string name) => Name = name.Trim();
|
||||
|
||||
public void SetFallbackGroup(Guid? groupId) => FallbackGroupId = groupId;
|
||||
|
||||
public void SetDefaultJunction(Guid? junctionId) => DefaultJunctionId = junctionId;
|
||||
|
||||
public void SetRules(string? rulesJson) =>
|
||||
RulesJson = string.IsNullOrWhiteSpace(rulesJson) ? null : rulesJson;
|
||||
|
||||
/// <summary>Отметить, что правила изменились — эфир пойдёт по старым до применения.</summary>
|
||||
public void MarkChanged() => Revision++;
|
||||
|
||||
/// <summary>Отметить, что хвост пересобран по текущей ревизии.</summary>
|
||||
public void MarkApplied() => AppliedRevision = Revision;
|
||||
|
||||
public GridLayer? FindLayer(Guid layerId) => _layers.FirstOrDefault(l => l.Id == layerId);
|
||||
|
||||
public GridLayer? Background => _layers.FirstOrDefault(l => l.IsBackground);
|
||||
|
||||
public GridLayer AddLayer(string name, int priority)
|
||||
{
|
||||
var layer = GridLayer.Create(Id, name, priority);
|
||||
_layers.Add(layer);
|
||||
return layer;
|
||||
}
|
||||
|
||||
/// <summary>Удаляет слой. Фоновый удалить нельзя — вернёт false.</summary>
|
||||
public bool RemoveLayer(Guid layerId)
|
||||
{
|
||||
var layer = _layers.FirstOrDefault(l => l.Id == layerId);
|
||||
if (layer is null || layer.IsBackground)
|
||||
return false;
|
||||
_layers.Remove(layer);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Слой, содержащий слот, или null.</summary>
|
||||
public GridLayer? FindLayerOfSlot(Guid slotId) =>
|
||||
_layers.FirstOrDefault(l => l.FindSlot(slotId) is not null);
|
||||
}
|
||||
namespace TeleWave.Domain.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Шаблон сетки канала: слои со слотами плюс аварийные настройки. Один активный шаблон на канал —
|
||||
/// сезонность выражается слоями внутри него, а не вторым шаблоном, иначе получились бы два механизма
|
||||
/// для одного и того же. На другой канал переносится глубокой копией.
|
||||
///
|
||||
/// <see cref="Revision"/> растёт при любой правке правил. Эфир при этом не меняется: правка помечает
|
||||
/// шаблон изменённым, а хвост пересобирается отдельной командой применения.
|
||||
/// </summary>
|
||||
public class ScheduleTemplate
|
||||
{
|
||||
private readonly List<GridLayer> _layers = [];
|
||||
|
||||
public Guid Id { get; private set; }
|
||||
public Guid ChannelId { get; private set; }
|
||||
public string Name { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>Аварийная группа, если пуст даже фоновый слой.</summary>
|
||||
public Guid? FallbackGroupId { get; private set; }
|
||||
|
||||
/// <summary>Стык по умолчанию — для слотов, у которых свой не задан.</summary>
|
||||
public Guid? DefaultJunctionId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Правила отбора кандидатов (детское время, потолок повторов) в JSON. Домен их не разбирает —
|
||||
/// схема живёт в Application, как и у стратегий слотов и условий стыка.
|
||||
/// </summary>
|
||||
public string? RulesJson { get; private set; }
|
||||
|
||||
/// <summary>Номер правки правил; входит в кэш-ключи и историю.</summary>
|
||||
public int Revision { get; private set; }
|
||||
|
||||
/// <summary>Ревизия, по которой собрано текущее будущее расписание.</summary>
|
||||
public int AppliedRevision { get; private set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; private set; }
|
||||
|
||||
public IReadOnlyList<GridLayer> Layers => _layers;
|
||||
|
||||
/// <summary>Есть ли правки, не применённые к эфиру.</summary>
|
||||
public bool HasPendingChanges => Revision != AppliedRevision;
|
||||
|
||||
private const string BackgroundLayerName = "Фон";
|
||||
|
||||
private ScheduleTemplate() { }
|
||||
|
||||
public static ScheduleTemplate Create(Guid channelId, string name)
|
||||
{
|
||||
var template = new ScheduleTemplate
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChannelId = channelId,
|
||||
Name = name.Trim(),
|
||||
Revision = 0,
|
||||
AppliedRevision = 0,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
// Фоновый слой заводится сразу: без него первая же дыра в сетке осталась бы нечем закрыть.
|
||||
template._layers.Add(
|
||||
GridLayer.Create(
|
||||
template.Id,
|
||||
BackgroundLayerName,
|
||||
GridLayer.BackgroundPriority,
|
||||
isBackground: true
|
||||
)
|
||||
);
|
||||
return template;
|
||||
}
|
||||
|
||||
public void Rename(string name) => Name = name.Trim();
|
||||
|
||||
public void SetFallbackGroup(Guid? groupId) => FallbackGroupId = groupId;
|
||||
|
||||
public void SetDefaultJunction(Guid? junctionId) => DefaultJunctionId = junctionId;
|
||||
|
||||
public void SetRules(string? rulesJson) =>
|
||||
RulesJson = string.IsNullOrWhiteSpace(rulesJson) ? null : rulesJson;
|
||||
|
||||
/// <summary>Отметить, что правила изменились — эфир пойдёт по старым до применения.</summary>
|
||||
public void MarkChanged() => Revision++;
|
||||
|
||||
/// <summary>Отметить, что хвост пересобран по текущей ревизии.</summary>
|
||||
public void MarkApplied() => AppliedRevision = Revision;
|
||||
|
||||
public GridLayer? FindLayer(Guid layerId) => _layers.FirstOrDefault(l => l.Id == layerId);
|
||||
|
||||
public GridLayer? Background => _layers.FirstOrDefault(l => l.IsBackground);
|
||||
|
||||
public GridLayer AddLayer(string name, int priority)
|
||||
{
|
||||
var layer = GridLayer.Create(Id, name, priority);
|
||||
_layers.Add(layer);
|
||||
return layer;
|
||||
}
|
||||
|
||||
/// <summary>Удаляет слой. Фоновый удалить нельзя — вернёт false.</summary>
|
||||
public bool RemoveLayer(Guid layerId)
|
||||
{
|
||||
var layer = _layers.FirstOrDefault(l => l.Id == layerId);
|
||||
if (layer is null || layer.IsBackground)
|
||||
return false;
|
||||
_layers.Remove(layer);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Слой, содержащий слот, или null.</summary>
|
||||
public GridLayer? FindLayerOfSlot(Guid slotId) =>
|
||||
_layers.FirstOrDefault(l => l.FindSlot(slotId) is not null);
|
||||
}
|
||||
|
||||
@@ -1,131 +1,131 @@
|
||||
namespace TeleWave.Domain.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Слот сетки: когда и чем заполнять эфир. Времена — цели, а не жёсткие границы: контент идёт встык,
|
||||
/// слот считается исчерпанным по бюджету, расхождение переносится на следующий. Опорные точки держат
|
||||
/// якоря (<see cref="IsAnchor"/>) и мягкое округление (<see cref="SnapToMinutes"/>).
|
||||
/// </summary>
|
||||
public class Slot
|
||||
{
|
||||
public Guid Id { get; private set; }
|
||||
public Guid LayerId { get; private set; }
|
||||
|
||||
/// <summary>День недели вещательных суток (0=Вс..6=Сб) или null — каждый день.</summary>
|
||||
public int? Weekday { get; private set; }
|
||||
|
||||
/// <summary>Целевое время старта в сутках канала.</summary>
|
||||
public TimeOnly TargetStart { get; private set; }
|
||||
|
||||
/// <summary>Бюджет слота в минутах.</summary>
|
||||
public int TargetDurationMinutes { get; private set; }
|
||||
|
||||
public string Title { get; private set; } = string.Empty;
|
||||
public Daypart Daypart { get; private set; }
|
||||
public SlotKind SlotKind { get; private set; }
|
||||
|
||||
/// <summary>Группа контента — для <see cref="SlotKind.Content"/>.</summary>
|
||||
public Guid? GroupId { get; private set; }
|
||||
|
||||
/// <summary>Стратегия выбора элемента (JSON). Домен её не интерпретирует — схема в Application.</summary>
|
||||
public string? StrategyJson { get; private set; }
|
||||
|
||||
/// <summary>Откуда брать повтор (JSON) — для <see cref="SlotKind.Repeat"/>.</summary>
|
||||
public string? RepeatSourceJson { get; private set; }
|
||||
|
||||
public SlotBlockMode BlockMode { get; private set; }
|
||||
|
||||
/// <summary>Единиц (<see cref="SlotBlockMode.Count"/>) или минут (<see cref="SlotBlockMode.Duration"/>).</summary>
|
||||
public int BlockValue { get; private set; }
|
||||
|
||||
public OverflowPolicy OverflowPolicy { get; private set; }
|
||||
|
||||
/// <summary>Старт жёсткий: генератор не начнёт единицу, которая через него перелезет.</summary>
|
||||
public bool IsAnchor { get; private set; }
|
||||
|
||||
/// <summary>Допуск отклонения фактического старта от целевого, минуты.</summary>
|
||||
public int MaxDriftMinutes { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Округлять старт до кратного N минут (5/10/15/30) или null. Мягкое, в отличие от якоря: если
|
||||
/// добирать пришлось бы дольше <see cref="MaxDriftMinutes"/>, округление пропускается.
|
||||
/// </summary>
|
||||
public int? SnapToMinutes { get; private set; }
|
||||
|
||||
/// <summary>Стык между единицами внутри блока (null — врезок внутри блока нет).</summary>
|
||||
public Guid? JunctionBetweenId { get; private set; }
|
||||
|
||||
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
|
||||
public Guid? JunctionAfterId { get; private set; }
|
||||
|
||||
public const int DefaultMaxDriftMinutes = 5;
|
||||
|
||||
private Slot() { }
|
||||
|
||||
public static Slot Create(
|
||||
Guid layerId,
|
||||
string title,
|
||||
TimeOnly targetStart,
|
||||
int targetDurationMinutes,
|
||||
Daypart daypart = Daypart.Day,
|
||||
SlotKind slotKind = SlotKind.Content,
|
||||
int? weekday = null
|
||||
) =>
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
LayerId = layerId,
|
||||
Title = title.Trim(),
|
||||
TargetStart = targetStart,
|
||||
TargetDurationMinutes = Math.Max(1, targetDurationMinutes),
|
||||
Daypart = daypart,
|
||||
SlotKind = slotKind,
|
||||
Weekday = weekday,
|
||||
BlockMode = SlotBlockMode.FillSlot,
|
||||
BlockValue = 1,
|
||||
OverflowPolicy = OverflowPolicy.ContinueNext,
|
||||
IsAnchor = false,
|
||||
MaxDriftMinutes = DefaultMaxDriftMinutes,
|
||||
};
|
||||
|
||||
/// <summary>Правит расписание слота: когда, сколько и как выравнивать.</summary>
|
||||
public void UpdateTiming(
|
||||
int? weekday,
|
||||
TimeOnly targetStart,
|
||||
int targetDurationMinutes,
|
||||
Daypart daypart,
|
||||
bool isAnchor,
|
||||
int maxDriftMinutes,
|
||||
int? snapToMinutes
|
||||
)
|
||||
{
|
||||
Weekday = weekday is >= 0 and <= 6 ? weekday : null;
|
||||
TargetStart = targetStart;
|
||||
TargetDurationMinutes = Math.Max(1, targetDurationMinutes);
|
||||
Daypart = daypart;
|
||||
IsAnchor = isAnchor;
|
||||
MaxDriftMinutes = Math.Max(0, maxDriftMinutes);
|
||||
SnapToMinutes = snapToMinutes is > 0 ? snapToMinutes : null;
|
||||
}
|
||||
|
||||
/// <summary>Правит наполнение слота: чем, в каком объёме и с какими врезками.</summary>
|
||||
public void UpdateContent(SlotContent content)
|
||||
{
|
||||
JunctionBetweenId = content.JunctionBetweenId;
|
||||
JunctionAfterId = content.JunctionAfterId;
|
||||
Title = content.Title.Trim();
|
||||
SlotKind = content.SlotKind;
|
||||
BlockMode = content.BlockMode;
|
||||
BlockValue = Math.Max(1, content.BlockValue);
|
||||
OverflowPolicy = content.OverflowPolicy;
|
||||
|
||||
// Поля, не относящиеся к типу слота, гасим: повтор и конец вещания стратегии не имеют,
|
||||
// и оставленный от прежнего типа мусор потом читался бы генератором как настройка.
|
||||
GroupId = content.SlotKind == SlotKind.Content ? content.GroupId : null;
|
||||
StrategyJson = content.SlotKind == SlotKind.Content ? content.StrategyJson : null;
|
||||
RepeatSourceJson = content.SlotKind == SlotKind.Repeat ? content.RepeatSourceJson : null;
|
||||
}
|
||||
|
||||
/// <summary>Конец слота в сутках канала. Может выйти за полночь — вещательные сутки длиннее суток.</summary>
|
||||
public TimeSpan TargetEndOffset =>
|
||||
TargetStart.ToTimeSpan() + TimeSpan.FromMinutes(TargetDurationMinutes);
|
||||
}
|
||||
namespace TeleWave.Domain.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Слот сетки: когда и чем заполнять эфир. Времена — цели, а не жёсткие границы: контент идёт встык,
|
||||
/// слот считается исчерпанным по бюджету, расхождение переносится на следующий. Опорные точки держат
|
||||
/// якоря (<see cref="IsAnchor"/>) и мягкое округление (<see cref="SnapToMinutes"/>).
|
||||
/// </summary>
|
||||
public class Slot
|
||||
{
|
||||
public Guid Id { get; private set; }
|
||||
public Guid LayerId { get; private set; }
|
||||
|
||||
/// <summary>День недели вещательных суток (0=Вс..6=Сб) или null — каждый день.</summary>
|
||||
public int? Weekday { get; private set; }
|
||||
|
||||
/// <summary>Целевое время старта в сутках канала.</summary>
|
||||
public TimeOnly TargetStart { get; private set; }
|
||||
|
||||
/// <summary>Бюджет слота в минутах.</summary>
|
||||
public int TargetDurationMinutes { get; private set; }
|
||||
|
||||
public string Title { get; private set; } = string.Empty;
|
||||
public Daypart Daypart { get; private set; }
|
||||
public SlotKind SlotKind { get; private set; }
|
||||
|
||||
/// <summary>Группа контента — для <see cref="SlotKind.Content"/>.</summary>
|
||||
public Guid? GroupId { get; private set; }
|
||||
|
||||
/// <summary>Стратегия выбора элемента (JSON). Домен её не интерпретирует — схема в Application.</summary>
|
||||
public string? StrategyJson { get; private set; }
|
||||
|
||||
/// <summary>Откуда брать повтор (JSON) — для <see cref="SlotKind.Repeat"/>.</summary>
|
||||
public string? RepeatSourceJson { get; private set; }
|
||||
|
||||
public SlotBlockMode BlockMode { get; private set; }
|
||||
|
||||
/// <summary>Единиц (<see cref="SlotBlockMode.Count"/>) или минут (<see cref="SlotBlockMode.Duration"/>).</summary>
|
||||
public int BlockValue { get; private set; }
|
||||
|
||||
public OverflowPolicy OverflowPolicy { get; private set; }
|
||||
|
||||
/// <summary>Старт жёсткий: генератор не начнёт единицу, которая через него перелезет.</summary>
|
||||
public bool IsAnchor { get; private set; }
|
||||
|
||||
/// <summary>Допуск отклонения фактического старта от целевого, минуты.</summary>
|
||||
public int MaxDriftMinutes { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Округлять старт до кратного N минут (5/10/15/30) или null. Мягкое, в отличие от якоря: если
|
||||
/// добирать пришлось бы дольше <see cref="MaxDriftMinutes"/>, округление пропускается.
|
||||
/// </summary>
|
||||
public int? SnapToMinutes { get; private set; }
|
||||
|
||||
/// <summary>Стык между единицами внутри блока (null — врезок внутри блока нет).</summary>
|
||||
public Guid? JunctionBetweenId { get; private set; }
|
||||
|
||||
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
|
||||
public Guid? JunctionAfterId { get; private set; }
|
||||
|
||||
public const int DefaultMaxDriftMinutes = 5;
|
||||
|
||||
private Slot() { }
|
||||
|
||||
public static Slot Create(
|
||||
Guid layerId,
|
||||
string title,
|
||||
TimeOnly targetStart,
|
||||
int targetDurationMinutes,
|
||||
Daypart daypart = Daypart.Day,
|
||||
SlotKind slotKind = SlotKind.Content,
|
||||
int? weekday = null
|
||||
) =>
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
LayerId = layerId,
|
||||
Title = title.Trim(),
|
||||
TargetStart = targetStart,
|
||||
TargetDurationMinutes = Math.Max(1, targetDurationMinutes),
|
||||
Daypart = daypart,
|
||||
SlotKind = slotKind,
|
||||
Weekday = weekday,
|
||||
BlockMode = SlotBlockMode.FillSlot,
|
||||
BlockValue = 1,
|
||||
OverflowPolicy = OverflowPolicy.ContinueNext,
|
||||
IsAnchor = false,
|
||||
MaxDriftMinutes = DefaultMaxDriftMinutes,
|
||||
};
|
||||
|
||||
/// <summary>Правит расписание слота: когда, сколько и как выравнивать.</summary>
|
||||
public void UpdateTiming(
|
||||
int? weekday,
|
||||
TimeOnly targetStart,
|
||||
int targetDurationMinutes,
|
||||
Daypart daypart,
|
||||
bool isAnchor,
|
||||
int maxDriftMinutes,
|
||||
int? snapToMinutes
|
||||
)
|
||||
{
|
||||
Weekday = weekday is >= 0 and <= 6 ? weekday : null;
|
||||
TargetStart = targetStart;
|
||||
TargetDurationMinutes = Math.Max(1, targetDurationMinutes);
|
||||
Daypart = daypart;
|
||||
IsAnchor = isAnchor;
|
||||
MaxDriftMinutes = Math.Max(0, maxDriftMinutes);
|
||||
SnapToMinutes = snapToMinutes is > 0 ? snapToMinutes : null;
|
||||
}
|
||||
|
||||
/// <summary>Правит наполнение слота: чем, в каком объёме и с какими врезками.</summary>
|
||||
public void UpdateContent(SlotContent content)
|
||||
{
|
||||
JunctionBetweenId = content.JunctionBetweenId;
|
||||
JunctionAfterId = content.JunctionAfterId;
|
||||
Title = content.Title.Trim();
|
||||
SlotKind = content.SlotKind;
|
||||
BlockMode = content.BlockMode;
|
||||
BlockValue = Math.Max(1, content.BlockValue);
|
||||
OverflowPolicy = content.OverflowPolicy;
|
||||
|
||||
// Поля, не относящиеся к типу слота, гасим: повтор и конец вещания стратегии не имеют,
|
||||
// и оставленный от прежнего типа мусор потом читался бы генератором как настройка.
|
||||
GroupId = content.SlotKind == SlotKind.Content ? content.GroupId : null;
|
||||
StrategyJson = content.SlotKind == SlotKind.Content ? content.StrategyJson : null;
|
||||
RepeatSourceJson = content.SlotKind == SlotKind.Repeat ? content.RepeatSourceJson : null;
|
||||
}
|
||||
|
||||
/// <summary>Конец слота в сутках канала. Может выйти за полночь — вещательные сутки длиннее суток.</summary>
|
||||
public TimeSpan TargetEndOffset =>
|
||||
TargetStart.ToTimeSpan() + TimeSpan.FromMinutes(TargetDurationMinutes);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user