Enhance GridScheduleGenerator to prevent reopening aired slots during rebuilds
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:
@@ -482,11 +482,21 @@ public sealed class GridScheduleGenerator(
|
|||||||
Daypart.Day
|
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(
|
return new PlanningInput(
|
||||||
channel.Id,
|
channel.Id,
|
||||||
startUtc,
|
startUtc,
|
||||||
horizonEnd,
|
horizonEnd,
|
||||||
grid.Slots.Select(s => planned[s]).ToList(),
|
grid.Slots.Where(s => !AlreadyAired(s, airedWindows)).Select(s => planned[s]).ToList(),
|
||||||
fallback,
|
fallback,
|
||||||
_segmentSeconds,
|
_segmentSeconds,
|
||||||
channel.UtcOffsetMinutes,
|
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>
|
/// <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)
|
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);
|
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>
|
/// <summary>Ассет, доведённый до Ready: в эфир попадают только такие.</summary>
|
||||||
private static MediaAsset ReadyAsset(AppDbContext db, string fileName, TimeSpan duration)
|
private static MediaAsset ReadyAsset(AppDbContext db, string fileName, TimeSpan duration)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -453,6 +453,15 @@ SlotState
|
|||||||
готовым) — остаётся сохранённое состояние: слот в эфире уже был, и откат в начало группы виден
|
готовым) — остаётся сохранённое состояние: слот в эфире уже был, и откат в начало группы виден
|
||||||
зрителю.
|
зрителю.
|
||||||
|
|
||||||
|
**Выход, который уже состоялся, не переигрывается.** Курсора для этого мало: он говорит, на чём
|
||||||
|
слот остановился, но не говорит, что этот его выход уже был. Пересборка посреди длинного фильма
|
||||||
|
попадала ровно в эту щель — кино объявленных 21:00 идёт до 23:14, окно слота 21:00–23:15, точка
|
||||||
|
продолжения 23:14 внутри окна, и планировщик открывал слот заново, ставя вторую полнометражку
|
||||||
|
подряд. Поэтому экземпляры слотов, чьи отрезки уже заняты записями в уцелевшей ленте, из плана
|
||||||
|
выбрасываются: сравниваются отрезки, а не старты (сетка эластичная, и кино законно начинается
|
||||||
|
в 20:55), а вид записи не важен — слот, чьё время закрыл фон, свой выход тоже отработал.
|
||||||
|
Фоновых слотов это не касается: они играют не по своему окну, а в чужих паузах.
|
||||||
|
|
||||||
Из этого следует: `SchedulerOptions.RetentionHours` (сейчас 24 часа) заменяется на `RetentionDays`,
|
Из этого следует: `SchedulerOptions.RetentionHours` (сейчас 24 часа) заменяется на `RetentionDays`,
|
||||||
дефолт 90. Значение обязано покрывать максимальный `cooldownDays` среди правил канала и максимальный
|
дефолт 90. Значение обязано покрывать максимальный `cooldownDays` среди правил канала и максимальный
|
||||||
`daysAgo` среди слотов повтора — иначе остывание начнёт врать, а повторы находить нечего. Валидация
|
`daysAgo` среди слотов повтора — иначе остывание начнёт врать, а повторы находить нечего. Валидация
|
||||||
|
|||||||
Reference in New Issue
Block a user