513 lines
20 KiB
C#
513 lines
20 KiB
C#
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;
|
|
|
|
public static PlanningResult Plan(PlanningInput input, IRandomSource random)
|
|
{
|
|
var items = new List<PlannedItem>();
|
|
var cursors = new List<PlanningCursorUpdate>();
|
|
var warnings = new List<PlanningWarning>();
|
|
|
|
var slots = input.Slots.OrderBy(s => s.TargetStartUtc).ToList();
|
|
var cursor = input.StartUtc;
|
|
var iterations = 0;
|
|
|
|
// История врезок общая на прогон: «не чаще раза в полчаса» должно работать и через границу слота.
|
|
var junctions = new JunctionHistory();
|
|
Guid? previousShowId = null;
|
|
|
|
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, input, items, warnings, out var trace);
|
|
if (cursor >= input.HorizonEndUtc)
|
|
break;
|
|
|
|
cursor = FillSlot(
|
|
slot,
|
|
cursor,
|
|
nextAnchor,
|
|
input,
|
|
random,
|
|
items,
|
|
cursors,
|
|
warnings,
|
|
trace,
|
|
junctions,
|
|
ref previousShowId
|
|
);
|
|
}
|
|
|
|
// Хвост до горизонта закрывает фон: лента обязана быть непрерывной, иначе живой край
|
|
// упрётся в дыру.
|
|
if (cursor < input.HorizonEndUtc)
|
|
cursor = FillWithFallback(cursor, input.HorizonEndUtc, input, items, null);
|
|
|
|
if (cursor < input.HorizonEndUtc && input.FallbackUnits.Count == 0)
|
|
warnings.Add(
|
|
new PlanningWarning(
|
|
PlanningWarningKind.FallbackEmpty,
|
|
null,
|
|
"Нет ни одной единицы для заполнения пауз — в ленте останутся дыры."
|
|
)
|
|
);
|
|
|
|
return new PlanningResult(items, cursors, warnings);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Подводит курсор к старту слота: добирает фоном до якоря либо до круглой отметки. Возвращает
|
|
/// фактический старт и заполняет трейс сведениями о дрейфе.
|
|
/// </summary>
|
|
private static DateTimeOffset OpenSlot(
|
|
PlanningSlot slot,
|
|
DateTimeOffset cursor,
|
|
PlanningInput input,
|
|
List<PlannedItem> items,
|
|
List<PlanningWarning> warnings,
|
|
out PlanTrace trace
|
|
)
|
|
{
|
|
var snapped = false;
|
|
|
|
if (cursor < slot.TargetStartUtc)
|
|
{
|
|
// До целевого времени ещё есть место — закрываем его фоном. Для якоря это обязательно,
|
|
// для обычного слота тоже: иначе он начнётся раньше объявленного в программе времени.
|
|
cursor = FillWithFallback(cursor, slot.TargetStartUtc, input, items, 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, input, items, slot.SlotId);
|
|
snapped = afterFill > cursor;
|
|
cursor = afterFill;
|
|
}
|
|
}
|
|
|
|
var drift = (int)Math.Round((cursor - slot.TargetStartUtc).TotalMinutes);
|
|
if (Math.Abs(drift) > slot.MaxDriftMinutes)
|
|
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,
|
|
PlanningInput input,
|
|
IRandomSource random,
|
|
List<PlannedItem> items,
|
|
List<PlanningCursorUpdate> cursors,
|
|
List<PlanningWarning> warnings,
|
|
PlanTrace trace,
|
|
JunctionHistory junctions,
|
|
ref Guid? previousShowId
|
|
)
|
|
{
|
|
var limit = Min(input.HorizonEndUtc, nextAnchor);
|
|
|
|
switch (slot.SlotKind)
|
|
{
|
|
case SlotKind.SignOff:
|
|
// Конец вещания: место занимает зацикленный фон, но в программе это помечено особо.
|
|
return FillWithFallback(
|
|
cursor,
|
|
Min(slot.TargetEndUtc, limit),
|
|
input,
|
|
items,
|
|
slot.SlotId,
|
|
PlannedItemKind.SignOff,
|
|
trace
|
|
);
|
|
|
|
case SlotKind.Repeat:
|
|
return FillRepeat(slot, cursor, limit, input, items, warnings, trace);
|
|
|
|
default:
|
|
return FillContent(
|
|
slot,
|
|
cursor,
|
|
limit,
|
|
input,
|
|
random,
|
|
items,
|
|
cursors,
|
|
warnings,
|
|
trace,
|
|
junctions,
|
|
ref previousShowId
|
|
);
|
|
}
|
|
}
|
|
|
|
private static DateTimeOffset FillRepeat(
|
|
PlanningSlot slot,
|
|
DateTimeOffset cursor,
|
|
DateTimeOffset limit,
|
|
PlanningInput input,
|
|
List<PlannedItem> items,
|
|
List<PlanningWarning> warnings,
|
|
PlanTrace trace
|
|
)
|
|
{
|
|
var units = slot.RepeatUnits ?? [];
|
|
if (units.Count == 0)
|
|
{
|
|
warnings.Add(
|
|
new PlanningWarning(
|
|
PlanningWarningKind.RepeatSourceEmpty,
|
|
slot.SlotId,
|
|
"В источнике повтора ничего не нашлось — слот закрыт фоном."
|
|
)
|
|
);
|
|
return FillWithFallback(
|
|
cursor,
|
|
Min(slot.TargetEndUtc, limit),
|
|
input,
|
|
items,
|
|
slot.SlotId
|
|
);
|
|
}
|
|
|
|
var slotEnd = Min(slot.TargetEndUtc, limit);
|
|
foreach (var unit in units)
|
|
{
|
|
if (cursor + unit.Duration > slotEnd)
|
|
break;
|
|
items.Add(Program(unit, cursor, slot.SlotId, trace));
|
|
cursor += unit.Duration;
|
|
}
|
|
|
|
return cursor;
|
|
}
|
|
|
|
private static DateTimeOffset FillContent(
|
|
PlanningSlot slot,
|
|
DateTimeOffset cursor,
|
|
DateTimeOffset limit,
|
|
PlanningInput input,
|
|
IRandomSource random,
|
|
List<PlannedItem> items,
|
|
List<PlanningCursorUpdate> cursors,
|
|
List<PlanningWarning> warnings,
|
|
PlanTrace trace,
|
|
JunctionHistory junctions,
|
|
ref Guid? previousShowId
|
|
)
|
|
{
|
|
var pick = ElementSelector.Select(slot, cursor, random);
|
|
if (pick is null)
|
|
{
|
|
warnings.Add(NoCandidatesWarning(slot));
|
|
return FillWithFallback(
|
|
cursor,
|
|
Min(slot.TargetEndUtc, limit),
|
|
input,
|
|
items,
|
|
slot.SlotId
|
|
);
|
|
}
|
|
|
|
if (pick.CooldownExhausted)
|
|
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)
|
|
)
|
|
{
|
|
cursors.Add(CursorUpdate(slot, element, unitIndex));
|
|
return FillWithFallback(cursor, budgetEnd, input, items, slot.SlotId);
|
|
}
|
|
|
|
while (unitIndex < element.Units.Count && cursor < limit)
|
|
{
|
|
var unit = element.Units[unitIndex];
|
|
|
|
// Врезки между единицами: перед каждой, кроме первой в блоке.
|
|
if (placed > 0)
|
|
cursor = JunctionFiller.Fill(
|
|
slot.JunctionBetween,
|
|
cursor,
|
|
limit,
|
|
slot.SlotId,
|
|
previousShowId,
|
|
unit.ShowId,
|
|
elementChanged: previousShowId != unit.ShowId,
|
|
junctions,
|
|
items,
|
|
slotTrace
|
|
);
|
|
|
|
// Через якорь не перелезаем: то, что не влезает до него, не начинают вовсе.
|
|
if (cursor + unit.Duration > limit)
|
|
break;
|
|
|
|
if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd))
|
|
break;
|
|
|
|
items.Add(Program(unit, cursor, slot.SlotId, slotTrace, CollectionOf(element)));
|
|
cursor += unit.Duration;
|
|
accumulated += unit.Duration;
|
|
unitIndex++;
|
|
placed++;
|
|
previousShowId = unit.ShowId;
|
|
}
|
|
|
|
// Врезки в конце блока ставятся до добора фоном: иначе реклама оказалась бы после заполнителя.
|
|
if (placed > 0)
|
|
cursor = JunctionFiller.Fill(
|
|
slot.JunctionAfter,
|
|
cursor,
|
|
limit,
|
|
slot.SlotId,
|
|
previousShowId,
|
|
null,
|
|
elementChanged: true,
|
|
junctions,
|
|
items,
|
|
slotTrace
|
|
);
|
|
|
|
cursors.Add(CursorUpdate(slot, element, unitIndex));
|
|
|
|
// Недобор до целевого конца закрываем фоном — только для слотов, чей бюджет привязан ко времени.
|
|
if (slot.BlockMode == SlotBlockMode.FillSlot && cursor < budgetEnd)
|
|
cursor = FillWithFallback(cursor, budgetEnd, input, items, 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,
|
|
PlanningInput input,
|
|
List<PlannedItem> items,
|
|
Guid? slotId,
|
|
PlannedItemKind kind = PlannedItemKind.Fallback,
|
|
PlanTrace? trace = null
|
|
)
|
|
{
|
|
if (input.FallbackUnits.Count == 0 || until <= from)
|
|
return from;
|
|
|
|
var cursor = from;
|
|
var index = 0;
|
|
var guard = 0;
|
|
|
|
while (cursor < until && guard++ < IterationBackstop)
|
|
{
|
|
var unit = input.FallbackUnits[index % input.FallbackUnits.Count];
|
|
index++;
|
|
|
|
if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > until)
|
|
break;
|
|
|
|
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);
|
|
}
|
|
}
|