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
: [];
var repeatUnits =
slot.SlotKind == SlotKind.Repeat
? await LoadRepeatUnitsAsync(channel, slot, item, cancellationToken)
: null;
var repeatWindow =
slot.SlotKind == SlotKind.Repeat ? BuildRepeatWindow(channel, slot, item) : null;
var repeatUnits = repeatWindow is null
? null
: await LoadRepeatUnitsAsync(channel.Id, repeatWindow, startUtc, cancellationToken);
var strategy = ToPlanningStrategy(SlotStrategy.FromJson(slot.StrategyJson));
var cursor = states.TryGetValue(slot.Id, out var state)
@@ -410,6 +411,7 @@ public sealed class GridScheduleGenerator(
elements,
cursor,
repeatUnits,
repeatWindow,
BuildJunction(
slot.JunctionBetweenId,
junctions,
@@ -579,20 +581,16 @@ public sealed class GridScheduleGenerator(
: null;
}
/// <summary>
/// Что играло в точке, на которую ссылается слот-повтор. Читается уже записанная лента того же
/// канала — стратегий и состояния повтору не нужно.
/// </summary>
private async Task<IReadOnlyList<PlanningUnit>> LoadRepeatUnitsAsync(
/// <summary>Отрезок ленты, на который ссылается слот-повтор, в UTC.</summary>
private static PlanningRepeatWindow? BuildRepeatWindow(
Channel channel,
Slot slot,
ScheduledSlot scheduled,
CancellationToken cancellationToken
ScheduledSlot scheduled
)
{
var source = RepeatSource.FromJson(slot.RepeatSourceJson);
if (source is null)
return [];
return null;
var offset = TimeSpan.FromMinutes(channel.UtcOffsetMinutes);
var sourceDate = scheduled.BroadcastDate.AddDays(-Math.Max(1, source.DaysAgo));
@@ -602,15 +600,36 @@ public sealed class GridScheduleGenerator(
offset,
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
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channel.Id
e.ChannelId == channelId
&& e.Kind == ScheduleEntryKind.Program
&& e.StartsAtUtc >= from
&& e.StartsAtUtc < to
&& e.EndsAtUtc > from
&& e.StartsAtUtc < startUtc
)
.OrderBy(e => e.StartsAtUtc)
.Select(e => new
@@ -70,8 +70,14 @@ public sealed record PlanningSlot(
PlanningStrategy Strategy,
IReadOnlyList<PlanningElement> Elements,
PlanningCursor? Cursor,
/// <summary>Готовые записи для <see cref="SlotKind.Repeat"/> — что играло в источнике повтора.</summary>
/// <summary>
/// Готовые записи для <see cref="SlotKind.Repeat"/> — что играло в источнике повтора по уже
/// записанной ленте. Покрывает только прошлое до начала прогона; то, что попадает в окно уже
/// внутри прогона, планировщик добирает сам по <see cref="RepeatWindow"/>.
/// </summary>
IReadOnlyList<PlanningUnit>? RepeatUnits = null,
/// <summary>Отрезок ленты, который повторяет слот (null — слот не повтор).</summary>
PlanningRepeatWindow? RepeatWindow = null,
/// <summary>Врезки между единицами внутри блока.</summary>
PlanningJunction? JunctionBetween = null,
/// <summary>Врезки в конце блока.</summary>
@@ -85,6 +91,13 @@ public sealed record PlanningSlot(
public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
}
/// <summary>
/// Отрезок ленты, который повторяет слот-повтор. Планировщику он нужен отдельно от готовых единиц:
/// первая генерация строит весь горизонт одним прогоном, и вечер понедельника, который повторяют во
/// вторник утром, в этот момент существует только внутри прогона — в базе его ещё нет.
/// </summary>
public sealed record PlanningRepeatWindow(DateTimeOffset FromUtc, DateTimeOffset ToUtc);
/// <summary>Окно времени суток в часах канала; допускает переход через полночь (22:00 → 06:00).</summary>
public sealed record PlanningTimeWindow(TimeOnly From, TimeOnly To)
{
@@ -190,7 +190,7 @@ public static class SchedulePlanner
PlanningRun run
)
{
var units = slot.RepeatUnits ?? [];
var units = CollectRepeatUnits(slot, run);
if (units.Count == 0)
{
run.Warnings.Add(
@@ -215,6 +215,40 @@ public static class SchedulePlanner
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(
PlanningSlot slot,
DateTimeOffset cursor,