Enhance EffectiveGridBuilder to support background slots and improve scheduling logic
ci / build-backend (push) Successful in 2m37s
ci / build-frontend (push) Successful in 1m11s
ci / tests (push) Successful in 2m31s
ci / sonar (push) Successful in 6m52s

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:
Leonid Pershin
2026-07-28 01:06:29 +03:00
parent e0b85807df
commit 7bee84c548
12 changed files with 512 additions and 58 deletions
@@ -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}% — контента не хватает."
);
}