Enhance GridScheduleGenerator to prevent reopening aired slots during rebuilds
ci / build-backend (push) Successful in 1m31s
ci / build-frontend (push) Successful in 39s
ci / tests (push) Successful in 1m26s
ci / sonar (push) Successful in 3m57s

Updated the GridScheduleGenerator to include logic that checks if a slot has already aired before planning it again, preventing duplicate scheduling of programs. Introduced a new method, LoadAiredSlotWindowsAsync, to retrieve aired time windows and updated the planning logic accordingly. Added integration tests to verify that slots that have already aired are not reopened during rebuilds, ensuring accurate scheduling behavior. Updated documentation to reflect these changes.
This commit is contained in:
Leonid Pershin
2026-07-30 23:09:39 +03:00
parent e983374dec
commit b9d96c915c
3 changed files with 168 additions and 1 deletions
@@ -482,11 +482,21 @@ public sealed class GridScheduleGenerator(
Daypart.Day
);
// Выход слота, который в уцелевшей ленте уже состоялся, второй раз не планируется. Иначе
// пересборка посреди длинного фильма открывала бы его слот заново: кино кончилось в 23:14,
// окно слота идёт до 23:15 — и планировщик честно ставил вторую полнометражку подряд.
var airedWindows = await LoadAiredSlotWindowsAsync(
channel.Id,
grid.Slots.Count > 0 ? grid.Slots.Min(s => s.StartUtc) : startUtc,
startUtc,
cancellationToken
);
return new PlanningInput(
channel.Id,
startUtc,
horizonEnd,
grid.Slots.Select(s => planned[s]).ToList(),
grid.Slots.Where(s => !AlreadyAired(s, airedWindows)).Select(s => planned[s]).ToList(),
fallback,
_segmentSeconds,
channel.UtcOffsetMinutes,
@@ -496,6 +506,58 @@ public sealed class GridScheduleGenerator(
);
}
/// <summary>
/// Занятые отрезки ленты по слотам: что каждый слот уже успел записать. Берётся только прошлое
/// до начала прогона — будущее либо ещё не построено, либо только что снесено пересборкой.
///
/// Записи не фильтруются по виду: слот, чьё время закрыл фон или аварийный запас, свой выход
/// тоже отработал, и открывать его заново — значит выдать программу дважды.
/// </summary>
private async Task<
ILookup<Guid, (DateTimeOffset From, DateTimeOffset To)>
> LoadAiredSlotWindowsAsync(
Guid channelId,
DateTimeOffset from,
DateTimeOffset until,
CancellationToken cancellationToken
)
{
var rows = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channelId
&& e.SlotId != null
&& e.StartsAtUtc < until
&& e.EndsAtUtc > from
)
.Select(e => new
{
SlotId = e.SlotId!.Value,
e.StartsAtUtc,
e.EndsAtUtc,
})
.ToListAsync(cancellationToken);
return rows.ToLookup(r => r.SlotId, r => (r.StartsAtUtc, r.EndsAtUtc));
}
/// <summary>
/// Состоялся ли этот выход слота. Сравниваются отрезки, а не старты: сетка эластичная, и кино
/// объявленных 21:00 законно начинается в 20:55 — по старту его окно не нашлось бы.
///
/// Фоновых слотов это не касается: они играют не по своему окну, а в чужих паузах, и «выход»
/// у них не определён.
/// </summary>
private static bool AlreadyAired(
ScheduledSlot scheduled,
ILookup<Guid, (DateTimeOffset From, DateTimeOffset To)> airedWindows
)
{
var end = scheduled.StartUtc.AddMinutes(scheduled.Slot.TargetDurationMinutes);
return airedWindows[scheduled.Slot.Id]
.Any(window => window.From < end && window.To > scheduled.StartUtc);
}
/// <summary>
/// Ролики и анонсы стыка, распрямлённые в плоский список для добора шва. Заставки сюда
/// не попадают: их ассет рендерится под конкретную пару соседей, а шов — это ровно то место,