Update scheduling parameters and refactor channel endpoints: extend HorizonDays to 7 and RetentionDays to 90 in appsettings.json. Consolidate channel-related endpoint logic by removing obsolete files and enhancing the ShowEndpoints with audience and genre management capabilities. Improve error handling and streamline command handlers for channel operations.
build / backend (push) Successful in 7m40s
build / frontend (push) Failing after 39s
tests / backend-tests (push) Successful in 6m9s

This commit is contained in:
Leonid Pershin
2026-07-26 13:32:13 +03:00
parent c4ef954dea
commit 66040a8841
272 changed files with 27944 additions and 8699 deletions
@@ -0,0 +1,151 @@
using TeleWave.Domain.Broadcast.Scheduling;
namespace TeleWave.Domain.Programming.Planning;
/// <summary>Что выбрано и почему — второе нужно для трейса «почему это здесь».</summary>
public sealed record ElementPick(
PlanningElement Element,
int StartUnitIndex,
int? CandidatesAfterCooldown,
bool CooldownExhausted
);
/// <summary>
/// Выбор элемента группы по стратегии слота. Отвечает только за выбор элемента: единицы внутри
/// элемента всегда идут по порядку от курсора — сериал не должен прыгать по сериям.
/// </summary>
public static class ElementSelector
{
public static ElementPick? Select(
PlanningSlot slot,
DateTimeOffset moment,
IRandomSource random
)
{
var playable = slot.Elements.Where(e => e.Units.Count > 0).ToList();
if (playable.Count == 0)
return null;
// Курсор указывает на элемент, а не на индекс в группе: удаление позиции не сдвигает всё.
var current = slot.Cursor?.ElementId is { } currentId
? playable.FirstOrDefault(e =>
e.ElementId == currentId && e.Kind == slot.Cursor.ElementKind
)
: null;
return slot.Strategy.Kind switch
{
SlotStrategyKind.Fixed => SelectFixed(slot, playable),
SlotStrategyKind.Sequential => SelectSequential(slot, playable, current),
_ => SelectRandom(slot, playable, current, moment, random),
};
}
private static ElementPick? SelectFixed(PlanningSlot slot, List<PlanningElement> playable)
{
var fixedElement = playable.FirstOrDefault(e =>
e.ElementId == slot.Strategy.FixedElementId
);
return fixedElement is null ? null : Continue(slot, fixedElement);
}
/// <summary>
/// Последовательная стратегия: доигрываем текущий элемент, а исчерпав его — берём следующий
/// по позиции. Дойдя до конца группы, начинаем сначала либо останавливаемся.
/// </summary>
private static ElementPick? SelectSequential(
PlanningSlot slot,
List<PlanningElement> playable,
PlanningElement? current
)
{
var ordered = playable.OrderBy(e => e.Position).ToList();
if (current is not null && HasUnitsLeft(slot, current))
return Continue(slot, current);
var currentIndex = current is null ? -1 : ordered.FindIndex(e => e.ElementId == current.ElementId);
var nextIndex = currentIndex + 1;
if (nextIndex >= ordered.Count)
{
if (!slot.Strategy.RestartOnEnd)
return null;
nextIndex = 0;
}
return new ElementPick(ordered[nextIndex], 0, null, false);
}
/// <summary>
/// Случайный выбор с остыванием. Порядок принципиален: сначала остывание отсекает недавно
/// показанное (жёсткий фильтр «нельзя»), затем взвешенный выбор работает среди оставшихся.
/// В обратном порядке вес постоянно упирался бы в кулдаун и не работал.
/// </summary>
private static ElementPick? SelectRandom(
PlanningSlot slot,
List<PlanningElement> playable,
PlanningElement? current,
DateTimeOffset moment,
IRandomSource random
)
{
// Начатый элемент доигрывается до конца: иначе блок «4 серии подряд» рассыпался бы
// на серии из разных сериалов.
if (current is not null && HasUnitsLeft(slot, current))
return Continue(slot, current);
var cooldown = TimeSpan.FromDays(Math.Max(0, slot.Strategy.CooldownDays));
var eligible = cooldown <= TimeSpan.Zero
? playable
: playable
.Where(e => e.LastPlayedUtc is not { } last || moment - last >= cooldown)
.ToList();
var exhausted = eligible.Count == 0;
if (exhausted)
{
// Остывание отсекло всех. Либо игнорируем его на этот выход, либо берём самый давний —
// пустой эфир хуже раннего повтора в обоих случаях.
eligible = slot.Strategy.IgnoreCooldownWhenExhausted
? playable
: [playable.OrderBy(e => e.LastPlayedUtc ?? DateTimeOffset.MinValue).First()];
}
var picked = WeightedPick(eligible, random);
return new ElementPick(picked, 0, eligible.Count, exhausted);
}
/// <summary>Продолжение текущего элемента с позиции курсора.</summary>
private static ElementPick Continue(PlanningSlot slot, PlanningElement element)
{
var index = slot.Cursor?.ElementId == element.ElementId ? slot.Cursor.NextUnitIndex : 0;
return new ElementPick(element, Math.Clamp(index, 0, element.Units.Count), null, false);
}
private static bool HasUnitsLeft(PlanningSlot slot, PlanningElement element) =>
slot.Cursor is { } cursor
&& cursor.ElementId == element.ElementId
&& cursor.NextUnitIndex < element.Units.Count;
private static PlanningElement WeightedPick(
IReadOnlyList<PlanningElement> candidates,
IRandomSource random
)
{
var total = candidates.Sum(c => (long)Math.Max(0, c.Weight));
if (total <= 0)
return candidates[random.Next(candidates.Count)];
var roll = random.Next((int)Math.Min(total, int.MaxValue));
long accumulated = 0;
foreach (var candidate in candidates)
{
accumulated += Math.Max(0, candidate.Weight);
if (roll < accumulated)
return candidate;
}
return candidates[^1];
}
}
@@ -0,0 +1,172 @@
namespace TeleWave.Domain.Programming.Planning;
/// <summary>Состояние стыков в рамках прогона: когда какая врезка ставилась последний раз.</summary>
public sealed class JunctionHistory
{
private readonly Dictionary<JunctionElementKind, DateTimeOffset> _lastPlaced = new();
public bool Allows(PlanningJunctionElement element, DateTimeOffset moment)
{
if (element.MinMinutesBetween <= 0)
return true;
if (!_lastPlaced.TryGetValue(element.Kind, out var last))
return true;
return moment - last >= TimeSpan.FromMinutes(element.MinMinutesBetween);
}
public void Record(PlanningJunctionElement element, DateTimeOffset moment) =>
_lastPlaced[element.Kind] = moment;
}
/// <summary>
/// Раскладка врезок стыка: реклама, промо, заставка, заполнитель. Ставит только то, что влезает
/// целиком до предела (якорь или горизонт) — обрезать врезку нельзя, а перехлёст сдвинул бы якорь.
///
/// Обязательные врезки (<see cref="PlanningJunctionElement.IsRequired"/>) идут первыми: при нехватке
/// времени выбрасываются необязательные, а не то, ради чего стык и заведён.
/// </summary>
public static class JunctionFiller
{
/// <summary>
/// Раскладывает стык от <paramref name="cursor"/>. <paramref name="fromShowId"/>/<paramref name="toShowId"/>
/// нужны заставке: её ассет зависит от пары соседей и рендерится после сборки ленты.
/// Возвращает курсор после стыка.
/// </summary>
public static DateTimeOffset Fill(
PlanningJunction? junction,
DateTimeOffset cursor,
DateTimeOffset limit,
Guid slotId,
Guid? fromShowId,
Guid? toShowId,
bool elementChanged,
JunctionHistory history,
List<PlannedItem> items,
PlanTrace? trace
)
{
if (junction is null || junction.Elements.Count == 0)
return cursor;
var ordered = junction
.Elements.OrderByDescending(e => e.IsRequired)
.ThenBy(e => junction.Elements.ToList().IndexOf(e))
.ToList();
foreach (var element in ordered)
{
if (element.OnlyOnElementChange && !elementChanged)
continue;
if (!history.Allows(element, cursor))
continue;
var placedAt = cursor;
cursor =
element.Kind == JunctionElementKind.Bumper
? PlaceBumper(element, cursor, limit, slotId, fromShowId, toShowId, items, trace)
: PlaceUnits(element, cursor, limit, slotId, items, trace);
if (cursor > placedAt)
history.Record(element, placedAt);
}
return cursor;
}
/// <summary>
/// Резервирует место под заставку. Ассет пуст: он рендерится под конкретную пару «из/в» уже после
/// того, как лента собрана, — до наполнения слотов пара попросту неизвестна.
/// </summary>
private static DateTimeOffset PlaceBumper(
PlanningJunctionElement element,
DateTimeOffset cursor,
DateTimeOffset limit,
Guid slotId,
Guid? fromShowId,
Guid? toShowId,
List<PlannedItem> items,
PlanTrace? trace
)
{
if (element.BumperDuration <= TimeSpan.Zero || cursor + element.BumperDuration > limit)
return cursor;
var end = cursor + element.BumperDuration;
items.Add(
new PlannedItem(
Guid.Empty,
cursor,
end,
toShowId,
null,
slotId,
PlannedItemKind.Bumper,
trace,
element.BumperTemplateId,
fromShowId,
toShowId
)
);
return end;
}
/// <summary>Ставит единицы врезки по её бюджету: N штук либо пока не наберётся M минут.</summary>
private static DateTimeOffset PlaceUnits(
PlanningJunctionElement element,
DateTimeOffset cursor,
DateTimeOffset limit,
Guid slotId,
List<PlannedItem> items,
PlanTrace? trace
)
{
if (element.Units.Count == 0)
return cursor;
var kind = element.Kind switch
{
JunctionElementKind.Ad => PlannedItemKind.Ad,
JunctionElementKind.Promo => PlannedItemKind.Promo,
_ => PlannedItemKind.Fallback,
};
var placed = 0;
var accumulated = TimeSpan.Zero;
var index = 0;
while (index < element.Units.Count)
{
var unit = element.Units[index];
index++;
if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > limit)
break;
var enough =
element.AmountMode == JunctionAmountMode.Count
? placed >= Math.Max(1, element.AmountValue)
// Последняя единица входит целиком: рекламный блок не разрезается.
: accumulated >= TimeSpan.FromMinutes(Math.Max(1, element.AmountValue));
if (enough)
break;
items.Add(
new PlannedItem(
unit.MediaAssetId,
cursor,
cursor + unit.Duration,
null,
null,
slotId,
kind,
trace
)
);
cursor += unit.Duration;
accumulated += unit.Duration;
placed++;
}
return cursor;
}
}
@@ -0,0 +1,192 @@
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>
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
)
{
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,
}
@@ -0,0 +1,487 @@
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(
new PlanningWarning(
PlanningWarningKind.SlotEmpty,
slot.SlotId,
"Слот не дал контента — место закрыл фон."
)
);
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));
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>
/// Влезает ли ещё одна единица в бюджет слота. <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;
}
private static PlannedItem Program(
PlanningUnit unit,
DateTimeOffset start,
Guid slotId,
PlanTrace trace
) =>
new(
unit.MediaAssetId,
start,
start + unit.Duration,
unit.ShowId,
unit.UnitIndex,
slotId,
PlannedItemKind.Program,
trace
);
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);
}
}