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>
/// Ролики и анонсы стыка, распрямлённые в плоский список для добора шва. Заставки сюда
/// не попадают: их ассет рендерится под конкретную пару соседей, а шов — это ровно то место,
@@ -274,6 +274,53 @@ public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixtur
);
}
/// <summary>
/// Пересборка посреди длинного фильма не открывает его слот заново. Кино кончается в 23:14,
/// окно слота идёт до 23:15 — и без проверки «выход уже состоялся» планировщик честно ставил
/// вторую полнометражку подряд: слот-то по времени ещё не истёк.
/// </summary>
[SkippableFact]
public async Task Generate_Rebuild_DoesNotReopenSlotThatAlreadyAired()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedFilmsAsync(seedDb);
await using (var first = fixture.CreateContext())
await Generator(first).GenerateAsync(world.ChannelId, Now, false, default);
await using var read = fixture.CreateContext();
var film = read
.ScheduleEntries.Where(e =>
e.ChannelId == world.ChannelId
&& e.SlotId == world.SlotId
&& e.Kind == ScheduleEntryKind.Program
)
.OrderBy(e => e.StartsAtUtc)
.First();
var during = film.StartsAtUtc.AddMinutes(30);
await using (var rebuild = fixture.CreateContext())
await Generator(rebuild).GenerateAsync(world.ChannelId, during, true, default);
await using var verify = fixture.CreateContext();
// Второго кино из того же слота быть не должно; следующее окно этого слота — только завтра.
var extra = verify
.ScheduleEntries.Where(e =>
e.ChannelId == world.ChannelId
&& e.SlotId == world.SlotId
&& e.Kind == ScheduleEntryKind.Program
&& e.StartsAtUtc >= during
&& e.StartsAtUtc < film.StartsAtUtc.AddHours(4)
)
.ToList();
Assert.Empty(extra);
// Само кино при этом не тронуто: прошлое и идущая запись неприкосновенны.
Assert.NotNull(verify.ScheduleEntries.FirstOrDefault(e => e.Id == film.Id));
}
// ── Обвязка ─────────────────────────────────────────────────────────────
private static GridScheduleGenerator Generator(AppDbContext db)
@@ -367,6 +414,55 @@ public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixtur
return new World(channel.Id, template.Id, slot.Id, show.Id, collection?.Id);
}
/// <summary>
/// Канал с одним вечерним слотом кино: фильм длиннее слота и доигрывает целиком
/// (<see cref="OverflowPolicy.ExtendSlot"/>) — на этом и ловится повторное открытие слота.
/// </summary>
private static async Task<World> SeedFilmsAsync(AppDbContext db)
{
var suffix = Guid.NewGuid().ToString("N")[..8];
var group = Group.Create($"Полнометражки {suffix}");
for (var i = 0; i < 3; i++)
{
var film = Show.Create($"Фильм {i} {suffix}", ShowKind.Single);
film.AddEpisode(ReadyAsset(db, $"film{i}-{suffix}.mkv", TimeSpan.FromMinutes(139)).Id);
db.Shows.Add(film);
group.AddElement(GroupElementKind.Show, film.Id);
}
db.Groups.Add(group);
// Филлер подлиннее: паузу до вечера он закрывает по кругу, и минутный дал бы сотни записей.
var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(30));
var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
channel.UpdateSettings(channel.Name, isEnabled: true, filler.Id);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var layer = template.AddLayer("Базовый", 10);
var slot = layer.AddSlot("Кино в 21:00", new TimeOnly(21, 0), 135);
slot.UpdateContent(
new SlotContent(
slot.Title,
SlotKind.Content,
group.Id,
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
null,
SlotBlockMode.Count,
1,
OverflowPolicy.ExtendSlot
)
);
channel.SetTemplate(template.Id);
template.MarkChanged();
db.Channels.Add(channel);
db.ScheduleTemplates.Add(template);
await db.SaveChangesAsync();
return new World(channel.Id, template.Id, slot.Id, Guid.Empty, null);
}
/// <summary>Ассет, доведённый до Ready: в эфир попадают только такие.</summary>
private static MediaAsset ReadyAsset(AppDbContext db, string fileName, TimeSpan duration)
{