diff --git a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs
index 4060301..aac4747 100644
--- a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs
+++ b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs
@@ -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(
);
}
+ ///
+ /// Занятые отрезки ленты по слотам: что каждый слот уже успел записать. Берётся только прошлое
+ /// до начала прогона — будущее либо ещё не построено, либо только что снесено пересборкой.
+ ///
+ /// Записи не фильтруются по виду: слот, чьё время закрыл фон или аварийный запас, свой выход
+ /// тоже отработал, и открывать его заново — значит выдать программу дважды.
+ ///
+ private async Task<
+ ILookup
+ > 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));
+ }
+
+ ///
+ /// Состоялся ли этот выход слота. Сравниваются отрезки, а не старты: сетка эластичная, и кино
+ /// объявленных 21:00 законно начинается в 20:55 — по старту его окно не нашлось бы.
+ ///
+ /// Фоновых слотов это не касается: они играют не по своему окну, а в чужих паузах, и «выход»
+ /// у них не определён.
+ ///
+ private static bool AlreadyAired(
+ ScheduledSlot scheduled,
+ ILookup airedWindows
+ )
+ {
+ var end = scheduled.StartUtc.AddMinutes(scheduled.Slot.TargetDurationMinutes);
+ return airedWindows[scheduled.Slot.Id]
+ .Any(window => window.From < end && window.To > scheduled.StartUtc);
+ }
+
///
/// Ролики и анонсы стыка, распрямлённые в плоский список для добора шва. Заставки сюда
/// не попадают: их ассет рендерится под конкретную пару соседей, а шов — это ровно то место,
diff --git a/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs
index 56ca3b2..da22bc4 100644
--- a/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs
+++ b/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs
@@ -274,6 +274,53 @@ public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixtur
);
}
+ ///
+ /// Пересборка посреди длинного фильма не открывает его слот заново. Кино кончается в 23:14,
+ /// окно слота идёт до 23:15 — и без проверки «выход уже состоялся» планировщик честно ставил
+ /// вторую полнометражку подряд: слот-то по времени ещё не истёк.
+ ///
+ [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);
}
+ ///
+ /// Канал с одним вечерним слотом кино: фильм длиннее слота и доигрывает целиком
+ /// () — на этом и ловится повторное открытие слота.
+ ///
+ private static async Task 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);
+ }
+
/// Ассет, доведённый до Ready: в эфир попадают только такие.
private static MediaAsset ReadyAsset(AppDbContext db, string fileName, TimeSpan duration)
{
diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md
index 7df2d61..31be9f8 100644
--- a/docs/tv-scheduler-architecture.md
+++ b/docs/tv-scheduler-architecture.md
@@ -453,6 +453,15 @@ SlotState
готовым) — остаётся сохранённое состояние: слот в эфире уже был, и откат в начало группы виден
зрителю.
+**Выход, который уже состоялся, не переигрывается.** Курсора для этого мало: он говорит, на чём
+слот остановился, но не говорит, что этот его выход уже был. Пересборка посреди длинного фильма
+попадала ровно в эту щель — кино объявленных 21:00 идёт до 23:14, окно слота 21:00–23:15, точка
+продолжения 23:14 внутри окна, и планировщик открывал слот заново, ставя вторую полнометражку
+подряд. Поэтому экземпляры слотов, чьи отрезки уже заняты записями в уцелевшей ленте, из плана
+выбрасываются: сравниваются отрезки, а не старты (сетка эластичная, и кино законно начинается
+в 20:55), а вид записи не важен — слот, чьё время закрыл фон, свой выход тоже отработал.
+Фоновых слотов это не касается: они играют не по своему окну, а в чужих паузах.
+
Из этого следует: `SchedulerOptions.RetentionHours` (сейчас 24 часа) заменяется на `RetentionDays`,
дефолт 90. Значение обязано покрывать максимальный `cooldownDays` среди правил канала и максимальный
`daysAgo` среди слотов повтора — иначе остывание начнёт врать, а повторы находить нечего. Валидация