Refactor GridScheduleGenerator and related models for improved repeat slot handling
ci / build-backend (push) Successful in 1m17s
ci / build-frontend (push) Successful in 37s
ci / tests (push) Successful in 1m38s
ci / sonar (push) Successful in 3m44s

Updated the GridScheduleGenerator to utilize a new PlanningRepeatWindow structure for managing repeat slots, enhancing the logic for loading repeat units. The LoadRepeatUnitsAsync method was modified to accept a channel ID and repeat window, ensuring accurate retrieval of past entries. Additionally, the PlanningSlot record was updated to include the new RepeatWindow property, and the SchedulePlanner was adjusted to collect repeat units based on the updated logic. Enhanced unit tests to verify the correct behavior of repeat slot functionality, ensuring that overlaps and elastic start times are handled appropriately.
This commit is contained in:
Leonid Pershin
2026-07-29 08:34:03 +03:00
parent 46c0169dea
commit c67d1f7752
5 changed files with 143 additions and 17 deletions
@@ -379,10 +379,11 @@ public sealed class GridScheduleGenerator(
? groupElements ? groupElements
: []; : [];
var repeatUnits = var repeatWindow =
slot.SlotKind == SlotKind.Repeat slot.SlotKind == SlotKind.Repeat ? BuildRepeatWindow(channel, slot, item) : null;
? await LoadRepeatUnitsAsync(channel, slot, item, cancellationToken) var repeatUnits = repeatWindow is null
: null; ? null
: await LoadRepeatUnitsAsync(channel.Id, repeatWindow, startUtc, cancellationToken);
var strategy = ToPlanningStrategy(SlotStrategy.FromJson(slot.StrategyJson)); var strategy = ToPlanningStrategy(SlotStrategy.FromJson(slot.StrategyJson));
var cursor = states.TryGetValue(slot.Id, out var state) var cursor = states.TryGetValue(slot.Id, out var state)
@@ -410,6 +411,7 @@ public sealed class GridScheduleGenerator(
elements, elements,
cursor, cursor,
repeatUnits, repeatUnits,
repeatWindow,
BuildJunction( BuildJunction(
slot.JunctionBetweenId, slot.JunctionBetweenId,
junctions, junctions,
@@ -579,20 +581,16 @@ public sealed class GridScheduleGenerator(
: null; : null;
} }
/// <summary> /// <summary>Отрезок ленты, на который ссылается слот-повтор, в UTC.</summary>
/// Что играло в точке, на которую ссылается слот-повтор. Читается уже записанная лента того же private static PlanningRepeatWindow? BuildRepeatWindow(
/// канала — стратегий и состояния повтору не нужно.
/// </summary>
private async Task<IReadOnlyList<PlanningUnit>> LoadRepeatUnitsAsync(
Channel channel, Channel channel,
Slot slot, Slot slot,
ScheduledSlot scheduled, ScheduledSlot scheduled
CancellationToken cancellationToken
) )
{ {
var source = RepeatSource.FromJson(slot.RepeatSourceJson); var source = RepeatSource.FromJson(slot.RepeatSourceJson);
if (source is null) if (source is null)
return []; return null;
var offset = TimeSpan.FromMinutes(channel.UtcOffsetMinutes); var offset = TimeSpan.FromMinutes(channel.UtcOffsetMinutes);
var sourceDate = scheduled.BroadcastDate.AddDays(-Math.Max(1, source.DaysAgo)); var sourceDate = scheduled.BroadcastDate.AddDays(-Math.Max(1, source.DaysAgo));
@@ -602,15 +600,36 @@ public sealed class GridScheduleGenerator(
offset, offset,
channel.DayStartTime channel.DayStartTime
); );
var to = from.AddMinutes(Math.Max(1, source.DurationMinutes));
return new PlanningRepeatWindow(from, from.AddMinutes(Math.Max(1, source.DurationMinutes)));
}
/// <summary>
/// Что играло в точке, на которую ссылается слот-повтор, по уже записанной ленте. Берётся только
/// прошлое до начала прогона: всё, что позже, планировщик найдёт в собственной ленте, и без этой
/// границы предпросмотр складывал бы реальный эфир с симулированным.
///
/// Условие — пересечение с окном, а не попадание старта в него: старты эластичные, и фильм,
/// начавшийся на десять минут раньше объявленного времени, по старту не нашёлся бы.
/// </summary>
private async Task<IReadOnlyList<PlanningUnit>> LoadRepeatUnitsAsync(
Guid channelId,
PlanningRepeatWindow window,
DateTimeOffset startUtc,
CancellationToken cancellationToken
)
{
var from = window.FromUtc;
var to = window.ToUtc;
var entries = await dbContext var entries = await dbContext
.ScheduleEntries.AsNoTracking() .ScheduleEntries.AsNoTracking()
.Where(e => .Where(e =>
e.ChannelId == channel.Id e.ChannelId == channelId
&& e.Kind == ScheduleEntryKind.Program && e.Kind == ScheduleEntryKind.Program
&& e.StartsAtUtc >= from
&& e.StartsAtUtc < to && e.StartsAtUtc < to
&& e.EndsAtUtc > from
&& e.StartsAtUtc < startUtc
) )
.OrderBy(e => e.StartsAtUtc) .OrderBy(e => e.StartsAtUtc)
.Select(e => new .Select(e => new
@@ -70,8 +70,14 @@ public sealed record PlanningSlot(
PlanningStrategy Strategy, PlanningStrategy Strategy,
IReadOnlyList<PlanningElement> Elements, IReadOnlyList<PlanningElement> Elements,
PlanningCursor? Cursor, PlanningCursor? Cursor,
/// <summary>Готовые записи для <see cref="SlotKind.Repeat"/> — что играло в источнике повтора.</summary> /// <summary>
/// Готовые записи для <see cref="SlotKind.Repeat"/> — что играло в источнике повтора по уже
/// записанной ленте. Покрывает только прошлое до начала прогона; то, что попадает в окно уже
/// внутри прогона, планировщик добирает сам по <see cref="RepeatWindow"/>.
/// </summary>
IReadOnlyList<PlanningUnit>? RepeatUnits = null, IReadOnlyList<PlanningUnit>? RepeatUnits = null,
/// <summary>Отрезок ленты, который повторяет слот (null — слот не повтор).</summary>
PlanningRepeatWindow? RepeatWindow = null,
/// <summary>Врезки между единицами внутри блока.</summary> /// <summary>Врезки между единицами внутри блока.</summary>
PlanningJunction? JunctionBetween = null, PlanningJunction? JunctionBetween = null,
/// <summary>Врезки в конце блока.</summary> /// <summary>Врезки в конце блока.</summary>
@@ -85,6 +91,13 @@ public sealed record PlanningSlot(
public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes); public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
} }
/// <summary>
/// Отрезок ленты, который повторяет слот-повтор. Планировщику он нужен отдельно от готовых единиц:
/// первая генерация строит весь горизонт одним прогоном, и вечер понедельника, который повторяют во
/// вторник утром, в этот момент существует только внутри прогона — в базе его ещё нет.
/// </summary>
public sealed record PlanningRepeatWindow(DateTimeOffset FromUtc, DateTimeOffset ToUtc);
/// <summary>Окно времени суток в часах канала; допускает переход через полночь (22:00 → 06:00).</summary> /// <summary>Окно времени суток в часах канала; допускает переход через полночь (22:00 → 06:00).</summary>
public sealed record PlanningTimeWindow(TimeOnly From, TimeOnly To) public sealed record PlanningTimeWindow(TimeOnly From, TimeOnly To)
{ {
@@ -190,7 +190,7 @@ public static class SchedulePlanner
PlanningRun run PlanningRun run
) )
{ {
var units = slot.RepeatUnits ?? []; var units = CollectRepeatUnits(slot, run);
if (units.Count == 0) if (units.Count == 0)
{ {
run.Warnings.Add( run.Warnings.Add(
@@ -215,6 +215,40 @@ public static class SchedulePlanner
return cursor; return cursor;
} }
/// <summary>
/// Что повторять. Сначала уже записанная лента — её подобрал оркестратор, — затем то, что
/// спланировано в этом же прогоне: горизонт строится целиком, и источник повтора обычно
/// рождается на несколько слотов раньше самого повтора, ещё не дойдя до базы.
///
/// Единицы берутся по пересечению с окном, а не по попаданию старта в него: сетка эластичная,
/// и вечерний фильм законно начинается на десять минут раньше объявленного времени — по старту
/// он бы в своё же окно не попал.
/// </summary>
private static List<PlanningUnit> CollectRepeatUnits(PlanningSlot slot, PlanningRun run)
{
var units = new List<PlanningUnit>(slot.RepeatUnits ?? []);
if (slot.RepeatWindow is not { } window)
return units;
// Лента прогона хронологична, а записанное прошлое всё раньше её начала — порядок сохраняется
// сам собой, пересортировывать нечего.
units.AddRange(
run.Items.Where(item =>
item.Kind == PlannedItemKind.Program
&& item.StartsAtUtc < window.ToUtc
&& item.EndsAtUtc > window.FromUtc
)
.Select(item => new PlanningUnit(
item.MediaAssetId,
item.EndsAtUtc - item.StartsAtUtc,
item.ShowId ?? Guid.Empty,
item.UnitIndex ?? 0
))
);
return units;
}
private static DateTimeOffset FillContent( private static DateTimeOffset FillContent(
PlanningSlot slot, PlanningSlot slot,
DateTimeOffset cursor, DateTimeOffset cursor,
@@ -341,6 +341,56 @@ public class SchedulePlannerTests
Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.RepeatSourceEmpty); Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.RepeatSourceEmpty);
} }
/// <summary>
/// Горизонт строится одним прогоном, поэтому источник повтора обычно ещё не в базе, а в этой же
/// ленте: вечерний фильм понедельника повторяют во вторник утром, и оба спланированы вместе.
/// </summary>
[Fact]
public void RepeatSlot_TakesSourceFromTheSameRun()
{
var evening = Slot(T0, 60, [Element(1, 60)]);
var morning = Slot(T0.AddHours(2), 60, []) with
{
SlotKind = SlotKind.Repeat,
RepeatWindow = new PlanningRepeatWindow(T0, T0.AddHours(1)),
};
var result = Run(Input([evening, morning], horizonHours: 4));
var source = result.Items.First(i => i.StartsAtUtc == T0);
Assert.Contains(
result.Items,
i => i.StartsAtUtc >= T0.AddHours(2) && i.MediaAssetId == source.MediaAssetId
);
Assert.DoesNotContain(
result.Warnings,
w => w.Kind == PlanningWarningKind.RepeatSourceEmpty
);
}
/// <summary>
/// Старты эластичные: фильм законно начинается раньше объявленного времени, и окно повтора
/// обязано ловить его по пересечению, а не по попаданию старта внутрь.
/// </summary>
[Fact]
public void RepeatSlot_CatchesUnitStartedBeforeWindow()
{
var evening = Slot(T0, 60, [Element(1, 60)]);
var morning = Slot(T0.AddHours(2), 60, []) with
{
SlotKind = SlotKind.Repeat,
RepeatWindow = new PlanningRepeatWindow(T0.AddMinutes(10), T0.AddMinutes(70)),
};
var result = Run(Input([evening, morning], horizonHours: 4));
var source = result.Items.First(i => i.StartsAtUtc == T0);
Assert.Contains(
result.Items,
i => i.StartsAtUtc >= T0.AddHours(2) && i.MediaAssetId == source.MediaAssetId
);
}
[Fact] [Fact]
public void Trace_IsWrittenWithSlotAndStrategy() public void Trace_IsWrittenWithSlotAndStrategy()
{ {
+10
View File
@@ -319,6 +319,16 @@ Slot
стратегии, ни курсора, ни остывания — самый дешёвый тип слота из всех. Если в источнике ничего не стратегии, ни курсора, ни остывания — самый дешёвый тип слота из всех. Если в источнике ничего не
нашлось (эфир ещё не шёл, окно почищено ретеншном) — слот отдаётся фоновому слою с предупреждением. нашлось (эфир ещё не шёл, окно почищено ретеншном) — слот отдаётся фоновому слою с предупреждением.
Две тонкости, без которых повтор находит пустоту:
- **источник ищется по пересечению с окном, а не по попаданию старта внутрь.** Сетка эластичная:
вечерний фильм законно начинается в 20:50 вместо 21:00, и по старту в собственное окно `21:00 + 2 ч`
он бы не попал;
- **повтор видит и текущий прогон, не только записанную ленту.** Горизонт строится целиком, и вечер
понедельника, который повторяют во вторник утром, в этот момент существует только внутри прогона —
в базе его ещё нет. Записанное прошлое берётся до начала прогона, остальное — из собираемой ленты;
границы не пересекаются, поэтому и в предпросмотре реальный эфир не складывается с симулированным.
**`slotKind = signOff`** — конец вещания. Каналы тогда не работали круглосуточно: в два часа ночи **`slotKind = signOff`** — конец вещания. Каналы тогда не работали круглосуточно: в два часа ночи
настроечная таблица, гимн или «не забудьте выключить телевизор», в шесть утра эфир возобновлялся. настроечная таблица, гимн или «не забудьте выключить телевизор», в шесть утра эфир возобновлялся.
Слот заполняется зацикленным ассетом и помечается в программе как «эфир не ведётся». Слот заполняется зацикленным ассетом и помечается в программе как «эфир не ведётся».