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`** — конец вещания. Каналы тогда не работали круглосуточно: в два часа ночи
настроечная таблица, гимн или «не забудьте выключить телевизор», в шесть утра эфир возобновлялся.
Слот заполняется зацикленным ассетом и помечается в программе как «эфир не ведётся».