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` среди слотов повтора — иначе остывание начнёт врать, а повторы находить нечего. Валидация