diff --git a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs index ad33cd6..812dafc 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs @@ -379,10 +379,11 @@ public sealed class GridScheduleGenerator( ? groupElements : []; - var repeatUnits = - slot.SlotKind == SlotKind.Repeat - ? await LoadRepeatUnitsAsync(channel, slot, item, cancellationToken) - : null; + var repeatWindow = + slot.SlotKind == SlotKind.Repeat ? BuildRepeatWindow(channel, slot, item) : null; + var repeatUnits = repeatWindow is null + ? null + : await LoadRepeatUnitsAsync(channel.Id, repeatWindow, startUtc, cancellationToken); var strategy = ToPlanningStrategy(SlotStrategy.FromJson(slot.StrategyJson)); var cursor = states.TryGetValue(slot.Id, out var state) @@ -410,6 +411,7 @@ public sealed class GridScheduleGenerator( elements, cursor, repeatUnits, + repeatWindow, BuildJunction( slot.JunctionBetweenId, junctions, @@ -579,20 +581,16 @@ public sealed class GridScheduleGenerator( : null; } - /// - /// Что играло в точке, на которую ссылается слот-повтор. Читается уже записанная лента того же - /// канала — стратегий и состояния повтору не нужно. - /// - private async Task> LoadRepeatUnitsAsync( + /// Отрезок ленты, на который ссылается слот-повтор, в UTC. + private static PlanningRepeatWindow? BuildRepeatWindow( Channel channel, Slot slot, - ScheduledSlot scheduled, - CancellationToken cancellationToken + ScheduledSlot scheduled ) { var source = RepeatSource.FromJson(slot.RepeatSourceJson); if (source is null) - return []; + return null; var offset = TimeSpan.FromMinutes(channel.UtcOffsetMinutes); var sourceDate = scheduled.BroadcastDate.AddDays(-Math.Max(1, source.DaysAgo)); @@ -602,15 +600,36 @@ public sealed class GridScheduleGenerator( offset, channel.DayStartTime ); - var to = from.AddMinutes(Math.Max(1, source.DurationMinutes)); + + return new PlanningRepeatWindow(from, from.AddMinutes(Math.Max(1, source.DurationMinutes))); + } + + /// + /// Что играло в точке, на которую ссылается слот-повтор, по уже записанной ленте. Берётся только + /// прошлое до начала прогона: всё, что позже, планировщик найдёт в собственной ленте, и без этой + /// границы предпросмотр складывал бы реальный эфир с симулированным. + /// + /// Условие — пересечение с окном, а не попадание старта в него: старты эластичные, и фильм, + /// начавшийся на десять минут раньше объявленного времени, по старту не нашёлся бы. + /// + private async Task> LoadRepeatUnitsAsync( + Guid channelId, + PlanningRepeatWindow window, + DateTimeOffset startUtc, + CancellationToken cancellationToken + ) + { + var from = window.FromUtc; + var to = window.ToUtc; var entries = await dbContext .ScheduleEntries.AsNoTracking() .Where(e => - e.ChannelId == channel.Id + e.ChannelId == channelId && e.Kind == ScheduleEntryKind.Program - && e.StartsAtUtc >= from && e.StartsAtUtc < to + && e.EndsAtUtc > from + && e.StartsAtUtc < startUtc ) .OrderBy(e => e.StartsAtUtc) .Select(e => new diff --git a/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs b/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs index 0900092..1ab3ebb 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs @@ -70,8 +70,14 @@ public sealed record PlanningSlot( PlanningStrategy Strategy, IReadOnlyList Elements, PlanningCursor? Cursor, - /// Готовые записи для — что играло в источнике повтора. + /// + /// Готовые записи для — что играло в источнике повтора по уже + /// записанной ленте. Покрывает только прошлое до начала прогона; то, что попадает в окно уже + /// внутри прогона, планировщик добирает сам по . + /// IReadOnlyList? RepeatUnits = null, + /// Отрезок ленты, который повторяет слот (null — слот не повтор). + PlanningRepeatWindow? RepeatWindow = null, /// Врезки между единицами внутри блока. PlanningJunction? JunctionBetween = null, /// Врезки в конце блока. @@ -85,6 +91,13 @@ public sealed record PlanningSlot( public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes); } +/// +/// Отрезок ленты, который повторяет слот-повтор. Планировщику он нужен отдельно от готовых единиц: +/// первая генерация строит весь горизонт одним прогоном, и вечер понедельника, который повторяют во +/// вторник утром, в этот момент существует только внутри прогона — в базе его ещё нет. +/// +public sealed record PlanningRepeatWindow(DateTimeOffset FromUtc, DateTimeOffset ToUtc); + /// Окно времени суток в часах канала; допускает переход через полночь (22:00 → 06:00). public sealed record PlanningTimeWindow(TimeOnly From, TimeOnly To) { diff --git a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs index bac505b..7ce701f 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs @@ -190,7 +190,7 @@ public static class SchedulePlanner PlanningRun run ) { - var units = slot.RepeatUnits ?? []; + var units = CollectRepeatUnits(slot, run); if (units.Count == 0) { run.Warnings.Add( @@ -215,6 +215,40 @@ public static class SchedulePlanner return cursor; } + /// + /// Что повторять. Сначала уже записанная лента — её подобрал оркестратор, — затем то, что + /// спланировано в этом же прогоне: горизонт строится целиком, и источник повтора обычно + /// рождается на несколько слотов раньше самого повтора, ещё не дойдя до базы. + /// + /// Единицы берутся по пересечению с окном, а не по попаданию старта в него: сетка эластичная, + /// и вечерний фильм законно начинается на десять минут раньше объявленного времени — по старту + /// он бы в своё же окно не попал. + /// + private static List CollectRepeatUnits(PlanningSlot slot, PlanningRun run) + { + var units = new List(slot.RepeatUnits ?? []); + if (slot.RepeatWindow is not { } window) + return units; + + // Лента прогона хронологична, а записанное прошлое всё раньше её начала — порядок сохраняется + // сам собой, пересортировывать нечего. + units.AddRange( + run.Items.Where(item => + item.Kind == PlannedItemKind.Program + && item.StartsAtUtc < window.ToUtc + && item.EndsAtUtc > window.FromUtc + ) + .Select(item => new PlanningUnit( + item.MediaAssetId, + item.EndsAtUtc - item.StartsAtUtc, + item.ShowId ?? Guid.Empty, + item.UnitIndex ?? 0 + )) + ); + + return units; + } + private static DateTimeOffset FillContent( PlanningSlot slot, DateTimeOffset cursor, diff --git a/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs b/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs index b862122..6cb63aa 100644 --- a/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs @@ -341,6 +341,56 @@ public class SchedulePlannerTests Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.RepeatSourceEmpty); } + /// + /// Горизонт строится одним прогоном, поэтому источник повтора обычно ещё не в базе, а в этой же + /// ленте: вечерний фильм понедельника повторяют во вторник утром, и оба спланированы вместе. + /// + [Fact] + public void RepeatSlot_TakesSourceFromTheSameRun() + { + var evening = Slot(T0, 60, [Element(1, 60)]); + var morning = Slot(T0.AddHours(2), 60, []) with + { + SlotKind = SlotKind.Repeat, + RepeatWindow = new PlanningRepeatWindow(T0, T0.AddHours(1)), + }; + + var result = Run(Input([evening, morning], horizonHours: 4)); + + var source = result.Items.First(i => i.StartsAtUtc == T0); + Assert.Contains( + result.Items, + i => i.StartsAtUtc >= T0.AddHours(2) && i.MediaAssetId == source.MediaAssetId + ); + Assert.DoesNotContain( + result.Warnings, + w => w.Kind == PlanningWarningKind.RepeatSourceEmpty + ); + } + + /// + /// Старты эластичные: фильм законно начинается раньше объявленного времени, и окно повтора + /// обязано ловить его по пересечению, а не по попаданию старта внутрь. + /// + [Fact] + public void RepeatSlot_CatchesUnitStartedBeforeWindow() + { + var evening = Slot(T0, 60, [Element(1, 60)]); + var morning = Slot(T0.AddHours(2), 60, []) with + { + SlotKind = SlotKind.Repeat, + RepeatWindow = new PlanningRepeatWindow(T0.AddMinutes(10), T0.AddMinutes(70)), + }; + + var result = Run(Input([evening, morning], horizonHours: 4)); + + var source = result.Items.First(i => i.StartsAtUtc == T0); + Assert.Contains( + result.Items, + i => i.StartsAtUtc >= T0.AddHours(2) && i.MediaAssetId == source.MediaAssetId + ); + } + [Fact] public void Trace_IsWrittenWithSlotAndStrategy() { diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index 7bfd268..b85577d 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -319,6 +319,16 @@ Slot стратегии, ни курсора, ни остывания — самый дешёвый тип слота из всех. Если в источнике ничего не нашлось (эфир ещё не шёл, окно почищено ретеншном) — слот отдаётся фоновому слою с предупреждением. +Две тонкости, без которых повтор находит пустоту: + +- **источник ищется по пересечению с окном, а не по попаданию старта внутрь.** Сетка эластичная: + вечерний фильм законно начинается в 20:50 вместо 21:00, и по старту в собственное окно `21:00 + 2 ч` + он бы не попал; +- **повтор видит и текущий прогон, не только записанную ленту.** Горизонт строится целиком, и вечер + понедельника, который повторяют во вторник утром, в этот момент существует только внутри прогона — + в базе его ещё нет. Записанное прошлое берётся до начала прогона, остальное — из собираемой ленты; + границы не пересекаются, поэтому и в предпросмотре реальный эфир не складывается с симулированным. + **`slotKind = signOff`** — конец вещания. Каналы тогда не работали круглосуточно: в два часа ночи настроечная таблица, гимн или «не забудьте выключить телевизор», в шесть утра эфир возобновлялся. Слот заполняется зацикленным ассетом и помечается в программе как «эфир не ведётся».