Enhance EffectiveGridBuilder to support background slots and improve scheduling logic
Updated the EffectiveGridBuilder to return an EffectiveGrid containing both scheduled and background slots, allowing for better management of overlapping content. Refactored the ResolveDay method to handle background slots separately, ensuring they fill gaps in the schedule appropriately. Adjusted related components, including GridScheduleGenerator and SchedulePlanner, to accommodate the new structure and improve overall scheduling accuracy. Enhanced documentation and comments for clarity.
This commit is contained in:
@@ -6,6 +6,16 @@ namespace TeleWave.Application.Programming.Planning;
|
||||
/// <summary>Слот, привязанный к конкретным вещательным суткам и переведённый в UTC.</summary>
|
||||
public sealed record ScheduledSlot(Slot Slot, DateOnly BroadcastDate, DateTimeOffset StartUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Эффективная сетка: что реально действует на диапазон. <paramref name="Background"/> — слоты
|
||||
/// фонового слоя, перекрытые слоями выше: в эфир они сами не идут, но ими закрываются паузы
|
||||
/// (остаток слота, добор до якоря, пустой повтор), и без них там играл бы аварийный филлер.
|
||||
/// </summary>
|
||||
public sealed record EffectiveGrid(
|
||||
IReadOnlyList<ScheduledSlot> Slots,
|
||||
IReadOnlyList<ScheduledSlot> Background
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Разворачивает шаблон в эффективную сетку на диапазон дат: какие слоты и в какие моменты реально
|
||||
/// действуют. Применимость слоёв разрешается здесь, потому что её схема (JSON) принадлежит
|
||||
@@ -13,7 +23,7 @@ public sealed record ScheduledSlot(Slot Slot, DateOnly BroadcastDate, DateTimeOf
|
||||
/// </summary>
|
||||
public static class EffectiveGridBuilder
|
||||
{
|
||||
public static IReadOnlyList<ScheduledSlot> Build(
|
||||
public static EffectiveGrid Build(
|
||||
ScheduleTemplate template,
|
||||
int utcOffsetMinutes,
|
||||
TimeOnly dayStartTime,
|
||||
@@ -23,6 +33,7 @@ public static class EffectiveGridBuilder
|
||||
{
|
||||
var offset = TimeSpan.FromMinutes(utcOffsetMinutes);
|
||||
var result = new List<ScheduledSlot>();
|
||||
var background = new List<ScheduledSlot>();
|
||||
|
||||
// Захватываем сутки с запасом с обеих сторон: слот вещательных суток может начаться
|
||||
// до fromUtc (ночной блок предыдущего дня) либо после полуночи следующих.
|
||||
@@ -43,14 +54,24 @@ public static class EffectiveGridBuilder
|
||||
)
|
||||
.ToList();
|
||||
|
||||
foreach (var scheduled in ResolveDay(applicable, date, offset, dayStartTime))
|
||||
var (day, skipped) = ResolveDay(applicable, date, offset, dayStartTime);
|
||||
foreach (var scheduled in day)
|
||||
{
|
||||
if (scheduled.StartUtc >= fromUtc.AddDays(-1) && scheduled.StartUtc < toUtc)
|
||||
result.Add(scheduled);
|
||||
}
|
||||
|
||||
foreach (var scheduled in skipped)
|
||||
{
|
||||
if (scheduled.StartUtc >= fromUtc.AddDays(-1) && scheduled.StartUtc < toUtc)
|
||||
background.Add(scheduled);
|
||||
}
|
||||
}
|
||||
|
||||
return result.OrderBy(s => s.StartUtc).ToList();
|
||||
return new EffectiveGrid(
|
||||
result.OrderBy(s => s.StartUtc).ToList(),
|
||||
background.OrderBy(s => s.StartUtc).ToList()
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -59,8 +80,14 @@ public static class EffectiveGridBuilder
|
||||
///
|
||||
/// Именно пропускается, а не обрезается: слот — атомарная единица со своей группой и стратегией,
|
||||
/// и половина слота означала бы половину настройки.
|
||||
///
|
||||
/// Пропущенные слоты фонового слоя возвращаются отдельно: в эфир они не идут, но именно ими
|
||||
/// закрываются паузы внутри чужих слотов.
|
||||
/// </summary>
|
||||
private static IEnumerable<ScheduledSlot> ResolveDay(
|
||||
private static (
|
||||
IReadOnlyList<ScheduledSlot> Slots,
|
||||
IReadOnlyList<ScheduledSlot> Background
|
||||
) ResolveDay(
|
||||
IReadOnlyList<GridLayer> layers,
|
||||
DateOnly date,
|
||||
TimeSpan offset,
|
||||
@@ -69,6 +96,7 @@ public static class EffectiveGridBuilder
|
||||
{
|
||||
var taken = new List<(TimeSpan From, TimeSpan To)>();
|
||||
var result = new List<ScheduledSlot>();
|
||||
var background = new List<ScheduledSlot>();
|
||||
|
||||
foreach (var layer in layers)
|
||||
{
|
||||
@@ -86,23 +114,27 @@ public static class EffectiveGridBuilder
|
||||
var from = OffsetInBroadcastDay(slot.TargetStart, dayStartTime);
|
||||
var to = from + TimeSpan.FromMinutes(slot.TargetDurationMinutes);
|
||||
|
||||
if (taken.Any(t => from < t.To && t.From < to))
|
||||
continue;
|
||||
|
||||
result.Add(
|
||||
new ScheduledSlot(
|
||||
slot,
|
||||
date,
|
||||
ToUtc(date, slot.TargetStart, offset, dayStartTime)
|
||||
)
|
||||
var scheduled = new ScheduledSlot(
|
||||
slot,
|
||||
date,
|
||||
ToUtc(date, slot.TargetStart, offset, dayStartTime)
|
||||
);
|
||||
|
||||
if (taken.Any(t => from < t.To && t.From < to))
|
||||
{
|
||||
if (layer.IsBackground)
|
||||
background.Add(scheduled);
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(scheduled);
|
||||
added.Add((from, to));
|
||||
}
|
||||
|
||||
taken.AddRange(added);
|
||||
}
|
||||
|
||||
return result;
|
||||
return (result, background);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -265,13 +265,16 @@ public sealed class GridScheduleGenerator(
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var scheduled = EffectiveGridBuilder.Build(
|
||||
var grid = EffectiveGridBuilder.Build(
|
||||
template,
|
||||
channel.UtcOffsetMinutes,
|
||||
channel.DayStartTime,
|
||||
startUtc,
|
||||
horizonEnd
|
||||
);
|
||||
// Перекрытые слоты фонового слоя разворачиваются так же, как обычные: паузы закрываются
|
||||
// их контентом, и им нужны и группа, и стратегия, и курсор.
|
||||
var scheduled = grid.Slots.Concat(grid.Background).ToList();
|
||||
|
||||
// Стыки грузим целиком: их немного, а группы врезок надо развернуть тем же проходом,
|
||||
// что и группы контента. Стыки общие, поэтому фильтра по каналу нет.
|
||||
@@ -319,7 +322,7 @@ public sealed class GridScheduleGenerator(
|
||||
.Where(s => slotIds.Contains(s.SlotId))
|
||||
.ToDictionaryAsync(s => s.SlotId, cancellationToken);
|
||||
|
||||
var slots = new List<PlanningSlot>();
|
||||
var planned = new Dictionary<ScheduledSlot, PlanningSlot>();
|
||||
foreach (var item in scheduled)
|
||||
{
|
||||
var slot = item.Slot;
|
||||
@@ -343,7 +346,8 @@ public sealed class GridScheduleGenerator(
|
||||
)
|
||||
: null;
|
||||
|
||||
slots.Add(
|
||||
planned.Add(
|
||||
item,
|
||||
new PlanningSlot(
|
||||
slot.Id,
|
||||
item.StartUtc,
|
||||
@@ -391,10 +395,11 @@ public sealed class GridScheduleGenerator(
|
||||
channel.Id,
|
||||
startUtc,
|
||||
horizonEnd,
|
||||
slots,
|
||||
grid.Slots.Select(s => planned[s]).ToList(),
|
||||
fallback,
|
||||
_segmentSeconds,
|
||||
channel.UtcOffsetMinutes
|
||||
channel.UtcOffsetMinutes,
|
||||
grid.Background.Select(s => planned[s]).ToList()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,9 @@ public sealed class PostCheckRunner(IAppDbContext dbContext)
|
||||
if (total <= 0)
|
||||
yield break;
|
||||
|
||||
// Конец вещания в долю фона не входит: это осознанная настройка, а не нехватка контента.
|
||||
// Считается аварийный запас, а не фоновый слой: слоты фона — это программы со своей группой,
|
||||
// и их доля ничего не говорит о нехватке. Конец вещания сюда тоже не входит: это осознанная
|
||||
// настройка, а не дыра.
|
||||
var fallback = items
|
||||
.Where(i => i.Kind == PlannedItemKind.Fallback)
|
||||
.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes);
|
||||
@@ -84,7 +86,7 @@ public sealed class PostCheckRunner(IAppDbContext dbContext)
|
||||
yield return new PlanningWarning(
|
||||
PlanningWarningKind.FallbackShareExceeded,
|
||||
null,
|
||||
$"Фон занял {share:0}% эфира при норме {capPercent}% — контента не хватает."
|
||||
$"Аварийный запас занял {share:0}% эфира при норме {capPercent}% — контента не хватает."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -40,12 +40,13 @@ public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var channelIds = channels.Select(c => c.Id).ToList();
|
||||
|
||||
// Что идёт прямо сейчас на каждом канале (программа) — для постера-обложки плитки.
|
||||
// Что идёт прямо сейчас на каждом канале — для постера-обложки плитки. Тип записи не важен:
|
||||
// фон — это те же программы из группы, и подписаны они так же. Важно лишь, что у записи
|
||||
// есть шоу: у заставок, рекламы и аварийного филлера его нет, и обложке взяться неоткуда.
|
||||
var currentByChannel = await dbContext
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.Where(e =>
|
||||
channelIds.Contains(e.ChannelId)
|
||||
&& e.Kind == ScheduleEntryKind.Program
|
||||
&& e.StartsAtUtc <= now
|
||||
&& e.EndsAtUtc > now
|
||||
&& e.ShowId != null
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
|
||||
namespace TeleWave.Domain.Programming.Planning;
|
||||
|
||||
/// <summary>Что фоновый слой отдал в паузу: единица, слот-источник и выбранный элемент (для трейса).</summary>
|
||||
public sealed record BackgroundPick(
|
||||
PlanningSlot Slot,
|
||||
PlanningElement Element,
|
||||
PlanningUnit Unit,
|
||||
SlotStrategyKind Strategy
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Фоновый слой в паузах: остаток слота короче серии, добор до якоря, пустой слот-повтор. Раньше
|
||||
/// всё это закрывал аварийный запас — зацикленный филлер канала, — и в эфире получались часы
|
||||
/// прочерков там, где по замыслу должен идти фон со своей группой и своим временем суток.
|
||||
///
|
||||
/// Состояние держится на прогон: фон зовут десятки раз за сутки, и он обязан продолжаться с той
|
||||
/// серии, на которой остановился, а не начинаться заново в каждой паузе.
|
||||
/// </summary>
|
||||
public sealed class BackgroundFiller(IReadOnlyList<PlanningSlot> slots, IRandomSource random)
|
||||
{
|
||||
private readonly Dictionary<Guid, BackgroundState> _states = [];
|
||||
|
||||
/// <summary>
|
||||
/// Единица фона под остаток <paramref name="available"/>; null — фон на это время не задан,
|
||||
/// пуст или его серия длиннее остатка. Короткий хвост добирает аварийный запас.
|
||||
/// </summary>
|
||||
public BackgroundPick? Next(DateTimeOffset moment, TimeSpan available)
|
||||
{
|
||||
if (SlotAt(moment) is not { } slot)
|
||||
return null;
|
||||
|
||||
var state = State(slot);
|
||||
if (!state.HasUnitsLeft && !Reselect(slot, state, moment))
|
||||
return null;
|
||||
|
||||
var unit = state.Element!.Units[state.NextUnitIndex];
|
||||
if (unit.Duration <= TimeSpan.Zero || unit.Duration > available)
|
||||
return null;
|
||||
|
||||
state.NextUnitIndex++;
|
||||
return new BackgroundPick(slot, state.Element, unit, slot.Strategy.Kind);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Куда доиграл фон. Слоты, которые в этом прогоне шли ещё и обычными (не были перекрыты),
|
||||
/// пропускаются: их курсор ведёт основной цикл, и два независимых счётчика одной позиции
|
||||
/// перетирали бы друг друга.
|
||||
/// </summary>
|
||||
public IEnumerable<PlanningCursorUpdate> Cursors(IReadOnlySet<Guid> ownedByMainLoop) =>
|
||||
_states
|
||||
.Where(pair => !ownedByMainLoop.Contains(pair.Key) && pair.Value.Element is not null)
|
||||
.Select(pair => new PlanningCursorUpdate(
|
||||
pair.Key,
|
||||
pair.Value.Element!.Kind,
|
||||
pair.Value.Element.ElementId,
|
||||
pair.Value.NextUnitIndex
|
||||
));
|
||||
|
||||
/// <summary>Активный фоновый слот на момент; сутки фон покрывает целиком, но дыры возможны.</summary>
|
||||
private PlanningSlot? SlotAt(DateTimeOffset moment) =>
|
||||
slots.FirstOrDefault(s => s.TargetStartUtc <= moment && moment < s.TargetEndUtc);
|
||||
|
||||
private BackgroundState State(PlanningSlot slot)
|
||||
{
|
||||
if (_states.TryGetValue(slot.SlotId, out var existing))
|
||||
return existing;
|
||||
|
||||
// Первый заход — стартуем с того места, где фон остановился в прошлом прогоне.
|
||||
var state = new BackgroundState();
|
||||
if (slot.Cursor?.ElementId is { } elementId)
|
||||
{
|
||||
state.Element = slot.Elements.FirstOrDefault(e =>
|
||||
e.ElementId == elementId && e.Kind == slot.Cursor.ElementKind
|
||||
);
|
||||
state.NextUnitIndex = state.Element is null
|
||||
? 0
|
||||
: Math.Clamp(slot.Cursor.NextUnitIndex, 0, state.Element.Units.Count);
|
||||
}
|
||||
|
||||
_states[slot.SlotId] = state;
|
||||
return state;
|
||||
}
|
||||
|
||||
/// <summary>Берёт следующий элемент по стратегии слота; false — играть в фоне нечем.</summary>
|
||||
private bool Reselect(PlanningSlot slot, BackgroundState state, DateTimeOffset moment)
|
||||
{
|
||||
var withCursor = slot with
|
||||
{
|
||||
Cursor = state.Element is null
|
||||
? null
|
||||
: new PlanningCursor(
|
||||
state.Element.Kind,
|
||||
state.Element.ElementId,
|
||||
state.NextUnitIndex
|
||||
),
|
||||
};
|
||||
|
||||
if (ElementSelector.Select(withCursor, moment, random) is not { } pick)
|
||||
return false;
|
||||
|
||||
state.Element = pick.Element;
|
||||
state.NextUnitIndex = pick.StartUnitIndex;
|
||||
return state.HasUnitsLeft;
|
||||
}
|
||||
|
||||
/// <summary>Где фоновый слот остановился: элемент и следующая единица внутри него.</summary>
|
||||
private sealed class BackgroundState
|
||||
{
|
||||
public PlanningElement? Element { get; set; }
|
||||
public int NextUnitIndex { get; set; }
|
||||
|
||||
public bool HasUnitsLeft => Element is not null && NextUnitIndex < Element.Units.Count;
|
||||
}
|
||||
}
|
||||
@@ -136,12 +136,24 @@ public sealed record PlanningInput(
|
||||
DateTimeOffset StartUtc,
|
||||
DateTimeOffset HorizonEndUtc,
|
||||
IReadOnlyList<PlanningSlot> Slots,
|
||||
/// <summary>Чем закрывать место, не покрытое слотами и не заполненное контентом.</summary>
|
||||
/// <summary>
|
||||
/// Аварийный запас: зацикленный филлер канала и группа-подстраховка шаблона. Последняя ступень,
|
||||
/// когда даже фоновый слой не дал контента.
|
||||
/// </summary>
|
||||
IReadOnlyList<PlanningUnit> FallbackUnits,
|
||||
int SegmentSeconds,
|
||||
/// <summary>Смещение времени канала от UTC — по нему считаются окна суток у врезок стыка.</summary>
|
||||
int UtcOffsetMinutes = 0
|
||||
);
|
||||
int UtcOffsetMinutes = 0,
|
||||
/// <summary>
|
||||
/// Слоты фонового слоя, включая перекрытые слоями выше. Ими закрываются паузы: остаток слота
|
||||
/// короче одной серии, добор до якоря, пустой слот-повтор. Отдельным списком, потому что
|
||||
/// в основном перекрытых слотов нет — там побеждает слой с большим приоритетом.
|
||||
/// </summary>
|
||||
IReadOnlyList<PlanningSlot> BackgroundSlots = null!
|
||||
)
|
||||
{
|
||||
public IReadOnlyList<PlanningSlot> BackgroundSlots { get; init; } = BackgroundSlots ?? [];
|
||||
}
|
||||
|
||||
/// <summary>Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно.</summary>
|
||||
public sealed record PlannedItem(
|
||||
|
||||
@@ -41,6 +41,12 @@ public static class SchedulePlanner
|
||||
|
||||
/// <summary>Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент.</summary>
|
||||
public Guid? PreviousShowId { get; set; }
|
||||
|
||||
/// <summary>С какой единицы аварийного запаса продолжать — общий на прогон, см. NextFallbackUnit.</summary>
|
||||
public int FallbackCursor { get; set; }
|
||||
|
||||
/// <summary>Фоновый слой: чем закрываются паузы до того, как дело дойдёт до аварийного запаса.</summary>
|
||||
public BackgroundFiller Background { get; } = new(input.BackgroundSlots, random);
|
||||
}
|
||||
|
||||
public static PlanningResult Plan(PlanningInput input, IRandomSource random)
|
||||
@@ -75,7 +81,7 @@ public static class SchedulePlanner
|
||||
// Хвост до горизонта закрывает фон: лента обязана быть непрерывной, иначе живой край
|
||||
// упрётся в дыру.
|
||||
if (cursor < input.HorizonEndUtc)
|
||||
cursor = FillWithFallback(cursor, input.HorizonEndUtc, run, null);
|
||||
cursor = FillGap(cursor, input.HorizonEndUtc, run, null);
|
||||
|
||||
if (cursor < input.HorizonEndUtc && input.FallbackUnits.Count == 0)
|
||||
run.Warnings.Add(
|
||||
@@ -86,6 +92,11 @@ public static class SchedulePlanner
|
||||
)
|
||||
);
|
||||
|
||||
// Курсоры фона дописываются последними: основной цикл ведёт свои слоты сам, а фон —
|
||||
// только те, что были перекрыты и обычными слотами в этом прогоне не играли.
|
||||
var ownedByMainLoop = input.Slots.Select(s => s.SlotId).ToHashSet();
|
||||
run.Cursors.AddRange(run.Background.Cursors(ownedByMainLoop));
|
||||
|
||||
return new PlanningResult(run.Items, run.Cursors, run.Warnings);
|
||||
}
|
||||
|
||||
@@ -106,7 +117,7 @@ public static class SchedulePlanner
|
||||
{
|
||||
// До целевого времени ещё есть место — закрываем его фоном. Для якоря это обязательно,
|
||||
// для обычного слота тоже: иначе он начнётся раньше объявленного в программе времени.
|
||||
cursor = FillWithFallback(cursor, slot.TargetStartUtc, run, slot.SlotId);
|
||||
cursor = FillGap(cursor, slot.TargetStartUtc, run, slot.SlotId);
|
||||
}
|
||||
else if (slot.SnapToMinutes is { } snap && snap > 0)
|
||||
{
|
||||
@@ -115,7 +126,7 @@ public static class SchedulePlanner
|
||||
var target = RoundUp(cursor, TimeSpan.FromMinutes(snap));
|
||||
if (target - cursor <= TimeSpan.FromMinutes(slot.MaxDriftMinutes))
|
||||
{
|
||||
var afterFill = FillWithFallback(cursor, target, run, slot.SlotId);
|
||||
var afterFill = FillGap(cursor, target, run, slot.SlotId);
|
||||
snapped = afterFill > cursor;
|
||||
cursor = afterFill;
|
||||
}
|
||||
@@ -182,10 +193,10 @@ public static class SchedulePlanner
|
||||
new PlanningWarning(
|
||||
PlanningWarningKind.RepeatSourceEmpty,
|
||||
slot.SlotId,
|
||||
"В источнике повтора ничего не нашлось — слот закрыт фоном."
|
||||
"В источнике повтора ничего не нашлось — слот отдан фоновому слою."
|
||||
)
|
||||
);
|
||||
return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
|
||||
return FillGap(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
|
||||
}
|
||||
|
||||
var slotEnd = Min(slot.TargetEndUtc, limit);
|
||||
@@ -212,7 +223,7 @@ public static class SchedulePlanner
|
||||
if (pick is null)
|
||||
{
|
||||
run.Warnings.Add(NoCandidatesWarning(slot));
|
||||
return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
|
||||
return FillGap(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
|
||||
}
|
||||
|
||||
if (pick.CooldownExhausted)
|
||||
@@ -245,7 +256,7 @@ public static class SchedulePlanner
|
||||
)
|
||||
{
|
||||
run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
|
||||
return FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
|
||||
return FillGap(cursor, budgetEnd, run, slot.SlotId);
|
||||
}
|
||||
|
||||
while (unitIndex < element.Units.Count && cursor < limit)
|
||||
@@ -305,7 +316,7 @@ public static class SchedulePlanner
|
||||
|
||||
// Недобор до целевого конца закрываем фоном — только для слотов, чей бюджет привязан ко времени.
|
||||
if (slot.BlockMode == SlotBlockMode.FillSlot && cursor < budgetEnd)
|
||||
cursor = FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
|
||||
cursor = FillGap(cursor, budgetEnd, run, slot.SlotId);
|
||||
|
||||
return cursor;
|
||||
}
|
||||
@@ -375,9 +386,60 @@ public static class SchedulePlanner
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Закрывает интервал зацикленными единицами фона. Ставит только те, что влезают целиком:
|
||||
/// обрезать нельзя, а перехлёст сдвинул бы следующий якорь. Остаток короче одной единицы
|
||||
/// остаётся незакрытым — раздача покажет там аварийный филлер канала.
|
||||
/// Закрывает паузу: сначала фоновым слоем, и только потом аварийным запасом. Порядок здесь —
|
||||
/// это и есть замысел фонового слоя: остаток слота, добор до якоря и пустой повтор должны
|
||||
/// играть настоящую программу своего времени суток, а зацикленный филлер в эфире выглядит
|
||||
/// поломкой и остаётся последней ступенью.
|
||||
/// </summary>
|
||||
private static DateTimeOffset FillGap(
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset until,
|
||||
PlanningRun run,
|
||||
Guid? slotId
|
||||
)
|
||||
{
|
||||
var cursor = from;
|
||||
var guard = 0;
|
||||
|
||||
while (cursor < until && guard++ < IterationBackstop)
|
||||
{
|
||||
if (run.Background.Next(cursor, until - cursor) is not { } pick)
|
||||
break;
|
||||
|
||||
run.Items.Add(
|
||||
new PlannedItem(
|
||||
pick.Unit.MediaAssetId,
|
||||
cursor,
|
||||
cursor + pick.Unit.Duration,
|
||||
pick.Unit.ShowId,
|
||||
pick.Unit.UnitIndex,
|
||||
// Слот в записи — фоновый: «почему это здесь» должно показывать его, а не тот,
|
||||
// чей остаток закрывали.
|
||||
pick.Slot.SlotId,
|
||||
PlannedItemKind.Program,
|
||||
new PlanTrace(
|
||||
pick.Slot.SlotId,
|
||||
pick.Slot.SlotKind,
|
||||
pick.Element.Kind,
|
||||
pick.Element.ElementId,
|
||||
pick.Strategy,
|
||||
null,
|
||||
0,
|
||||
false
|
||||
)
|
||||
)
|
||||
);
|
||||
cursor += pick.Unit.Duration;
|
||||
run.PreviousShowId = pick.Unit.ShowId;
|
||||
}
|
||||
|
||||
return FillWithFallback(cursor, until, run, slotId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Закрывает интервал единицами аварийного запаса. Ставит только те, что влезают целиком:
|
||||
/// обрезать нельзя, а перехлёст сдвинул бы следующий якорь. Остаток короче самой короткой
|
||||
/// единицы остаётся незакрытым — раздача покажет там аварийный филлер канала.
|
||||
/// </summary>
|
||||
private static DateTimeOffset FillWithFallback(
|
||||
DateTimeOffset from,
|
||||
@@ -388,20 +450,15 @@ public static class SchedulePlanner
|
||||
PlanTrace? trace = null
|
||||
)
|
||||
{
|
||||
var fallback = run.Input.FallbackUnits;
|
||||
if (fallback.Count == 0 || until <= from)
|
||||
if (run.Input.FallbackUnits.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)
|
||||
if (NextFallbackUnit(run, until - cursor) is not { } unit)
|
||||
break;
|
||||
|
||||
run.Items.Add(
|
||||
@@ -409,8 +466,12 @@ public static class SchedulePlanner
|
||||
unit.MediaAssetId,
|
||||
cursor,
|
||||
cursor + unit.Duration,
|
||||
null,
|
||||
null,
|
||||
// Фон — это тоже программы из группы, и в эфирной ленте они обязаны быть
|
||||
// подписаны: без шоу зритель и админ видят на их месте прочерк.
|
||||
unit.ShowId == Guid.Empty
|
||||
? null
|
||||
: unit.ShowId,
|
||||
unit.ShowId == Guid.Empty ? null : unit.UnitIndex,
|
||||
slotId,
|
||||
kind,
|
||||
trace
|
||||
@@ -422,6 +483,33 @@ public static class SchedulePlanner
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Следующая единица фона под остаток <paramref name="available"/>. Перебор продолжается с того
|
||||
/// места, где остановился прошлый добор: иначе каждая пауза начиналась бы с одного и того же
|
||||
/// ролика, и ночь выглядела бы одним зациклённым куском.
|
||||
///
|
||||
/// Если очередная единица не влезает, ищется первая подходящая среди остальных, а не бросается
|
||||
/// весь добор: список отсортирован по возрастанию длительности, и «не влезла эта» не значит
|
||||
/// «не влезет ничего» — иначе в ленте оставалась бы дыра при живом коротком контенте.
|
||||
/// </summary>
|
||||
private static PlanningUnit? NextFallbackUnit(PlanningRun run, TimeSpan available)
|
||||
{
|
||||
var units = run.Input.FallbackUnits;
|
||||
|
||||
for (var offset = 0; offset < units.Count; offset++)
|
||||
{
|
||||
var index = (run.FallbackCursor + offset) % units.Count;
|
||||
var unit = units[index];
|
||||
if (unit.Duration <= TimeSpan.Zero || unit.Duration > available)
|
||||
continue;
|
||||
|
||||
run.FallbackCursor = (index + 1) % units.Count;
|
||||
return unit;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Коллекция элемента или null, если в эфир шло отдельное шоу.</summary>
|
||||
private static Guid? CollectionOf(PlanningElement element) =>
|
||||
element.Kind == GroupElementKind.Collection ? element.ElementId : null;
|
||||
|
||||
Reference in New Issue
Block a user