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.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user