diff --git a/backend/src/TeleWave.Application/Programming/Planning/EffectiveGridBuilder.cs b/backend/src/TeleWave.Application/Programming/Planning/EffectiveGridBuilder.cs
index 9b8d229..b898a74 100644
--- a/backend/src/TeleWave.Application/Programming/Planning/EffectiveGridBuilder.cs
+++ b/backend/src/TeleWave.Application/Programming/Planning/EffectiveGridBuilder.cs
@@ -6,6 +6,16 @@ namespace TeleWave.Application.Programming.Planning;
/// Слот, привязанный к конкретным вещательным суткам и переведённый в UTC.
public sealed record ScheduledSlot(Slot Slot, DateOnly BroadcastDate, DateTimeOffset StartUtc);
+///
+/// Эффективная сетка: что реально действует на диапазон. — слоты
+/// фонового слоя, перекрытые слоями выше: в эфир они сами не идут, но ими закрываются паузы
+/// (остаток слота, добор до якоря, пустой повтор), и без них там играл бы аварийный филлер.
+///
+public sealed record EffectiveGrid(
+ IReadOnlyList Slots,
+ IReadOnlyList Background
+);
+
///
/// Разворачивает шаблон в эффективную сетку на диапазон дат: какие слоты и в какие моменты реально
/// действуют. Применимость слоёв разрешается здесь, потому что её схема (JSON) принадлежит
@@ -13,7 +23,7 @@ public sealed record ScheduledSlot(Slot Slot, DateOnly BroadcastDate, DateTimeOf
///
public static class EffectiveGridBuilder
{
- public static IReadOnlyList Build(
+ public static EffectiveGrid Build(
ScheduleTemplate template,
int utcOffsetMinutes,
TimeOnly dayStartTime,
@@ -23,6 +33,7 @@ public static class EffectiveGridBuilder
{
var offset = TimeSpan.FromMinutes(utcOffsetMinutes);
var result = new List();
+ var background = new List();
// Захватываем сутки с запасом с обеих сторон: слот вещательных суток может начаться
// до fromUtc (ночной блок предыдущего дня) либо после полуночи следующих.
@@ -43,14 +54,24 @@ public static class EffectiveGridBuilder
)
.ToList();
- foreach (var scheduled in ResolveDay(applicable, date, offset, dayStartTime))
+ var (day, skipped) = ResolveDay(applicable, date, offset, dayStartTime);
+ foreach (var scheduled in day)
{
if (scheduled.StartUtc >= fromUtc.AddDays(-1) && scheduled.StartUtc < toUtc)
result.Add(scheduled);
}
+
+ foreach (var scheduled in skipped)
+ {
+ if (scheduled.StartUtc >= fromUtc.AddDays(-1) && scheduled.StartUtc < toUtc)
+ background.Add(scheduled);
+ }
}
- return result.OrderBy(s => s.StartUtc).ToList();
+ return new EffectiveGrid(
+ result.OrderBy(s => s.StartUtc).ToList(),
+ background.OrderBy(s => s.StartUtc).ToList()
+ );
}
///
@@ -59,8 +80,14 @@ public static class EffectiveGridBuilder
///
/// Именно пропускается, а не обрезается: слот — атомарная единица со своей группой и стратегией,
/// и половина слота означала бы половину настройки.
+ ///
+ /// Пропущенные слоты фонового слоя возвращаются отдельно: в эфир они не идут, но именно ими
+ /// закрываются паузы внутри чужих слотов.
///
- private static IEnumerable ResolveDay(
+ private static (
+ IReadOnlyList Slots,
+ IReadOnlyList Background
+ ) ResolveDay(
IReadOnlyList layers,
DateOnly date,
TimeSpan offset,
@@ -69,6 +96,7 @@ public static class EffectiveGridBuilder
{
var taken = new List<(TimeSpan From, TimeSpan To)>();
var result = new List();
+ var background = new List();
foreach (var layer in layers)
{
@@ -86,23 +114,27 @@ public static class EffectiveGridBuilder
var from = OffsetInBroadcastDay(slot.TargetStart, dayStartTime);
var to = from + TimeSpan.FromMinutes(slot.TargetDurationMinutes);
- if (taken.Any(t => from < t.To && t.From < to))
- continue;
-
- result.Add(
- new ScheduledSlot(
- slot,
- date,
- ToUtc(date, slot.TargetStart, offset, dayStartTime)
- )
+ var scheduled = new ScheduledSlot(
+ slot,
+ date,
+ ToUtc(date, slot.TargetStart, offset, dayStartTime)
);
+
+ if (taken.Any(t => from < t.To && t.From < to))
+ {
+ if (layer.IsBackground)
+ background.Add(scheduled);
+ continue;
+ }
+
+ result.Add(scheduled);
added.Add((from, to));
}
taken.AddRange(added);
}
- return result;
+ return (result, background);
}
///
diff --git a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs
index 9324c45..78f42d3 100644
--- a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs
+++ b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs
@@ -265,13 +265,16 @@ public sealed class GridScheduleGenerator(
CancellationToken cancellationToken
)
{
- var scheduled = EffectiveGridBuilder.Build(
+ var grid = EffectiveGridBuilder.Build(
template,
channel.UtcOffsetMinutes,
channel.DayStartTime,
startUtc,
horizonEnd
);
+ // Перекрытые слоты фонового слоя разворачиваются так же, как обычные: паузы закрываются
+ // их контентом, и им нужны и группа, и стратегия, и курсор.
+ var scheduled = grid.Slots.Concat(grid.Background).ToList();
// Стыки грузим целиком: их немного, а группы врезок надо развернуть тем же проходом,
// что и группы контента. Стыки общие, поэтому фильтра по каналу нет.
@@ -319,7 +322,7 @@ public sealed class GridScheduleGenerator(
.Where(s => slotIds.Contains(s.SlotId))
.ToDictionaryAsync(s => s.SlotId, cancellationToken);
- var slots = new List();
+ var planned = new Dictionary();
foreach (var item in scheduled)
{
var slot = item.Slot;
@@ -343,7 +346,8 @@ public sealed class GridScheduleGenerator(
)
: null;
- slots.Add(
+ planned.Add(
+ item,
new PlanningSlot(
slot.Id,
item.StartUtc,
@@ -391,10 +395,11 @@ public sealed class GridScheduleGenerator(
channel.Id,
startUtc,
horizonEnd,
- slots,
+ grid.Slots.Select(s => planned[s]).ToList(),
fallback,
_segmentSeconds,
- channel.UtcOffsetMinutes
+ channel.UtcOffsetMinutes,
+ grid.Background.Select(s => planned[s]).ToList()
);
}
diff --git a/backend/src/TeleWave.Application/Programming/Planning/PostCheckRunner.cs b/backend/src/TeleWave.Application/Programming/Planning/PostCheckRunner.cs
index 70acff2..d7aaa83 100644
--- a/backend/src/TeleWave.Application/Programming/Planning/PostCheckRunner.cs
+++ b/backend/src/TeleWave.Application/Programming/Planning/PostCheckRunner.cs
@@ -74,7 +74,9 @@ public sealed class PostCheckRunner(IAppDbContext dbContext)
if (total <= 0)
yield break;
- // Конец вещания в долю фона не входит: это осознанная настройка, а не нехватка контента.
+ // Считается аварийный запас, а не фоновый слой: слоты фона — это программы со своей группой,
+ // и их доля ничего не говорит о нехватке. Конец вещания сюда тоже не входит: это осознанная
+ // настройка, а не дыра.
var fallback = items
.Where(i => i.Kind == PlannedItemKind.Fallback)
.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes);
@@ -84,7 +86,7 @@ public sealed class PostCheckRunner(IAppDbContext dbContext)
yield return new PlanningWarning(
PlanningWarningKind.FallbackShareExceeded,
null,
- $"Фон занял {share:0}% эфира при норме {capPercent}% — контента не хватает."
+ $"Аварийный запас занял {share:0}% эфира при норме {capPercent}% — контента не хватает."
);
}
diff --git a/backend/src/TeleWave.Application/Streaming/ListPublicChannels/ListPublicChannelsQueryHandler.cs b/backend/src/TeleWave.Application/Streaming/ListPublicChannels/ListPublicChannelsQueryHandler.cs
index e3fb539..1d441e9 100644
--- a/backend/src/TeleWave.Application/Streaming/ListPublicChannels/ListPublicChannelsQueryHandler.cs
+++ b/backend/src/TeleWave.Application/Streaming/ListPublicChannels/ListPublicChannelsQueryHandler.cs
@@ -40,12 +40,13 @@ public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
var now = DateTimeOffset.UtcNow;
var channelIds = channels.Select(c => c.Id).ToList();
- // Что идёт прямо сейчас на каждом канале (программа) — для постера-обложки плитки.
+ // Что идёт прямо сейчас на каждом канале — для постера-обложки плитки. Тип записи не важен:
+ // фон — это те же программы из группы, и подписаны они так же. Важно лишь, что у записи
+ // есть шоу: у заставок, рекламы и аварийного филлера его нет, и обложке взяться неоткуда.
var currentByChannel = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
channelIds.Contains(e.ChannelId)
- && e.Kind == ScheduleEntryKind.Program
&& e.StartsAtUtc <= now
&& e.EndsAtUtc > now
&& e.ShowId != null
diff --git a/backend/src/TeleWave.Domain/Programming/Planning/BackgroundFiller.cs b/backend/src/TeleWave.Domain/Programming/Planning/BackgroundFiller.cs
new file mode 100644
index 0000000..197461d
--- /dev/null
+++ b/backend/src/TeleWave.Domain/Programming/Planning/BackgroundFiller.cs
@@ -0,0 +1,116 @@
+using TeleWave.Domain.Broadcast.Scheduling;
+
+namespace TeleWave.Domain.Programming.Planning;
+
+/// Что фоновый слой отдал в паузу: единица, слот-источник и выбранный элемент (для трейса).
+public sealed record BackgroundPick(
+ PlanningSlot Slot,
+ PlanningElement Element,
+ PlanningUnit Unit,
+ SlotStrategyKind Strategy
+);
+
+///
+/// Фоновый слой в паузах: остаток слота короче серии, добор до якоря, пустой слот-повтор. Раньше
+/// всё это закрывал аварийный запас — зацикленный филлер канала, — и в эфире получались часы
+/// прочерков там, где по замыслу должен идти фон со своей группой и своим временем суток.
+///
+/// Состояние держится на прогон: фон зовут десятки раз за сутки, и он обязан продолжаться с той
+/// серии, на которой остановился, а не начинаться заново в каждой паузе.
+///
+public sealed class BackgroundFiller(IReadOnlyList slots, IRandomSource random)
+{
+ private readonly Dictionary _states = [];
+
+ ///
+ /// Единица фона под остаток ; null — фон на это время не задан,
+ /// пуст или его серия длиннее остатка. Короткий хвост добирает аварийный запас.
+ ///
+ public BackgroundPick? Next(DateTimeOffset moment, TimeSpan available)
+ {
+ if (SlotAt(moment) is not { } slot)
+ return null;
+
+ var state = State(slot);
+ if (!state.HasUnitsLeft && !Reselect(slot, state, moment))
+ return null;
+
+ var unit = state.Element!.Units[state.NextUnitIndex];
+ if (unit.Duration <= TimeSpan.Zero || unit.Duration > available)
+ return null;
+
+ state.NextUnitIndex++;
+ return new BackgroundPick(slot, state.Element, unit, slot.Strategy.Kind);
+ }
+
+ ///
+ /// Куда доиграл фон. Слоты, которые в этом прогоне шли ещё и обычными (не были перекрыты),
+ /// пропускаются: их курсор ведёт основной цикл, и два независимых счётчика одной позиции
+ /// перетирали бы друг друга.
+ ///
+ public IEnumerable Cursors(IReadOnlySet ownedByMainLoop) =>
+ _states
+ .Where(pair => !ownedByMainLoop.Contains(pair.Key) && pair.Value.Element is not null)
+ .Select(pair => new PlanningCursorUpdate(
+ pair.Key,
+ pair.Value.Element!.Kind,
+ pair.Value.Element.ElementId,
+ pair.Value.NextUnitIndex
+ ));
+
+ /// Активный фоновый слот на момент; сутки фон покрывает целиком, но дыры возможны.
+ private PlanningSlot? SlotAt(DateTimeOffset moment) =>
+ slots.FirstOrDefault(s => s.TargetStartUtc <= moment && moment < s.TargetEndUtc);
+
+ private BackgroundState State(PlanningSlot slot)
+ {
+ if (_states.TryGetValue(slot.SlotId, out var existing))
+ return existing;
+
+ // Первый заход — стартуем с того места, где фон остановился в прошлом прогоне.
+ var state = new BackgroundState();
+ if (slot.Cursor?.ElementId is { } elementId)
+ {
+ state.Element = slot.Elements.FirstOrDefault(e =>
+ e.ElementId == elementId && e.Kind == slot.Cursor.ElementKind
+ );
+ state.NextUnitIndex = state.Element is null
+ ? 0
+ : Math.Clamp(slot.Cursor.NextUnitIndex, 0, state.Element.Units.Count);
+ }
+
+ _states[slot.SlotId] = state;
+ return state;
+ }
+
+ /// Берёт следующий элемент по стратегии слота; false — играть в фоне нечем.
+ private bool Reselect(PlanningSlot slot, BackgroundState state, DateTimeOffset moment)
+ {
+ var withCursor = slot with
+ {
+ Cursor = state.Element is null
+ ? null
+ : new PlanningCursor(
+ state.Element.Kind,
+ state.Element.ElementId,
+ state.NextUnitIndex
+ ),
+ };
+
+ if (ElementSelector.Select(withCursor, moment, random) is not { } pick)
+ return false;
+
+ state.Element = pick.Element;
+ state.NextUnitIndex = pick.StartUnitIndex;
+ return state.HasUnitsLeft;
+ }
+
+ /// Где фоновый слот остановился: элемент и следующая единица внутри него.
+ private sealed class BackgroundState
+ {
+ public PlanningElement? Element { get; set; }
+ public int NextUnitIndex { get; set; }
+
+ public bool HasUnitsLeft => Element is not null && NextUnitIndex < Element.Units.Count;
+ }
+}
diff --git a/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs b/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs
index 2390c37..752c7f1 100644
--- a/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs
+++ b/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs
@@ -136,12 +136,24 @@ public sealed record PlanningInput(
DateTimeOffset StartUtc,
DateTimeOffset HorizonEndUtc,
IReadOnlyList Slots,
- /// Чем закрывать место, не покрытое слотами и не заполненное контентом.
+ ///
+ /// Аварийный запас: зацикленный филлер канала и группа-подстраховка шаблона. Последняя ступень,
+ /// когда даже фоновый слой не дал контента.
+ ///
IReadOnlyList FallbackUnits,
int SegmentSeconds,
/// Смещение времени канала от UTC — по нему считаются окна суток у врезок стыка.
- int UtcOffsetMinutes = 0
-);
+ int UtcOffsetMinutes = 0,
+ ///
+ /// Слоты фонового слоя, включая перекрытые слоями выше. Ими закрываются паузы: остаток слота
+ /// короче одной серии, добор до якоря, пустой слот-повтор. Отдельным списком, потому что
+ /// в основном перекрытых слотов нет — там побеждает слой с большим приоритетом.
+ ///
+ IReadOnlyList BackgroundSlots = null!
+)
+{
+ public IReadOnlyList BackgroundSlots { get; init; } = BackgroundSlots ?? [];
+}
/// Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно.
public sealed record PlannedItem(
diff --git a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs
index ab2c865..5461aab 100644
--- a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs
+++ b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs
@@ -41,6 +41,12 @@ public static class SchedulePlanner
/// Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент.
public Guid? PreviousShowId { get; set; }
+
+ /// С какой единицы аварийного запаса продолжать — общий на прогон, см. NextFallbackUnit.
+ public int FallbackCursor { get; set; }
+
+ /// Фоновый слой: чем закрываются паузы до того, как дело дойдёт до аварийного запаса.
+ public BackgroundFiller Background { get; } = new(input.BackgroundSlots, random);
}
public static PlanningResult Plan(PlanningInput input, IRandomSource random)
@@ -75,7 +81,7 @@ public static class SchedulePlanner
// Хвост до горизонта закрывает фон: лента обязана быть непрерывной, иначе живой край
// упрётся в дыру.
if (cursor < input.HorizonEndUtc)
- cursor = FillWithFallback(cursor, input.HorizonEndUtc, run, null);
+ cursor = FillGap(cursor, input.HorizonEndUtc, run, null);
if (cursor < input.HorizonEndUtc && input.FallbackUnits.Count == 0)
run.Warnings.Add(
@@ -86,6 +92,11 @@ public static class SchedulePlanner
)
);
+ // Курсоры фона дописываются последними: основной цикл ведёт свои слоты сам, а фон —
+ // только те, что были перекрыты и обычными слотами в этом прогоне не играли.
+ var ownedByMainLoop = input.Slots.Select(s => s.SlotId).ToHashSet();
+ run.Cursors.AddRange(run.Background.Cursors(ownedByMainLoop));
+
return new PlanningResult(run.Items, run.Cursors, run.Warnings);
}
@@ -106,7 +117,7 @@ public static class SchedulePlanner
{
// До целевого времени ещё есть место — закрываем его фоном. Для якоря это обязательно,
// для обычного слота тоже: иначе он начнётся раньше объявленного в программе времени.
- cursor = FillWithFallback(cursor, slot.TargetStartUtc, run, slot.SlotId);
+ cursor = FillGap(cursor, slot.TargetStartUtc, run, slot.SlotId);
}
else if (slot.SnapToMinutes is { } snap && snap > 0)
{
@@ -115,7 +126,7 @@ public static class SchedulePlanner
var target = RoundUp(cursor, TimeSpan.FromMinutes(snap));
if (target - cursor <= TimeSpan.FromMinutes(slot.MaxDriftMinutes))
{
- var afterFill = FillWithFallback(cursor, target, run, slot.SlotId);
+ var afterFill = FillGap(cursor, target, run, slot.SlotId);
snapped = afterFill > cursor;
cursor = afterFill;
}
@@ -182,10 +193,10 @@ public static class SchedulePlanner
new PlanningWarning(
PlanningWarningKind.RepeatSourceEmpty,
slot.SlotId,
- "В источнике повтора ничего не нашлось — слот закрыт фоном."
+ "В источнике повтора ничего не нашлось — слот отдан фоновому слою."
)
);
- return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
+ return FillGap(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
}
var slotEnd = Min(slot.TargetEndUtc, limit);
@@ -212,7 +223,7 @@ public static class SchedulePlanner
if (pick is null)
{
run.Warnings.Add(NoCandidatesWarning(slot));
- return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
+ return FillGap(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
}
if (pick.CooldownExhausted)
@@ -245,7 +256,7 @@ public static class SchedulePlanner
)
{
run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
- return FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
+ return FillGap(cursor, budgetEnd, run, slot.SlotId);
}
while (unitIndex < element.Units.Count && cursor < limit)
@@ -305,7 +316,7 @@ public static class SchedulePlanner
// Недобор до целевого конца закрываем фоном — только для слотов, чей бюджет привязан ко времени.
if (slot.BlockMode == SlotBlockMode.FillSlot && cursor < budgetEnd)
- cursor = FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
+ cursor = FillGap(cursor, budgetEnd, run, slot.SlotId);
return cursor;
}
@@ -375,9 +386,60 @@ public static class SchedulePlanner
}
///
- /// Закрывает интервал зацикленными единицами фона. Ставит только те, что влезают целиком:
- /// обрезать нельзя, а перехлёст сдвинул бы следующий якорь. Остаток короче одной единицы
- /// остаётся незакрытым — раздача покажет там аварийный филлер канала.
+ /// Закрывает паузу: сначала фоновым слоем, и только потом аварийным запасом. Порядок здесь —
+ /// это и есть замысел фонового слоя: остаток слота, добор до якоря и пустой повтор должны
+ /// играть настоящую программу своего времени суток, а зацикленный филлер в эфире выглядит
+ /// поломкой и остаётся последней ступенью.
+ ///
+ private static DateTimeOffset FillGap(
+ DateTimeOffset from,
+ DateTimeOffset until,
+ PlanningRun run,
+ Guid? slotId
+ )
+ {
+ var cursor = from;
+ var guard = 0;
+
+ while (cursor < until && guard++ < IterationBackstop)
+ {
+ if (run.Background.Next(cursor, until - cursor) is not { } pick)
+ break;
+
+ run.Items.Add(
+ new PlannedItem(
+ pick.Unit.MediaAssetId,
+ cursor,
+ cursor + pick.Unit.Duration,
+ pick.Unit.ShowId,
+ pick.Unit.UnitIndex,
+ // Слот в записи — фоновый: «почему это здесь» должно показывать его, а не тот,
+ // чей остаток закрывали.
+ pick.Slot.SlotId,
+ PlannedItemKind.Program,
+ new PlanTrace(
+ pick.Slot.SlotId,
+ pick.Slot.SlotKind,
+ pick.Element.Kind,
+ pick.Element.ElementId,
+ pick.Strategy,
+ null,
+ 0,
+ false
+ )
+ )
+ );
+ cursor += pick.Unit.Duration;
+ run.PreviousShowId = pick.Unit.ShowId;
+ }
+
+ return FillWithFallback(cursor, until, run, slotId);
+ }
+
+ ///
+ /// Закрывает интервал единицами аварийного запаса. Ставит только те, что влезают целиком:
+ /// обрезать нельзя, а перехлёст сдвинул бы следующий якорь. Остаток короче самой короткой
+ /// единицы остаётся незакрытым — раздача покажет там аварийный филлер канала.
///
private static DateTimeOffset FillWithFallback(
DateTimeOffset from,
@@ -388,20 +450,15 @@ public static class SchedulePlanner
PlanTrace? trace = null
)
{
- var fallback = run.Input.FallbackUnits;
- if (fallback.Count == 0 || until <= from)
+ if (run.Input.FallbackUnits.Count == 0 || until <= from)
return from;
var cursor = from;
- var index = 0;
var guard = 0;
while (cursor < until && guard++ < IterationBackstop)
{
- var unit = fallback[index % fallback.Count];
- index++;
-
- if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > until)
+ if (NextFallbackUnit(run, until - cursor) is not { } unit)
break;
run.Items.Add(
@@ -409,8 +466,12 @@ public static class SchedulePlanner
unit.MediaAssetId,
cursor,
cursor + unit.Duration,
- null,
- null,
+ // Фон — это тоже программы из группы, и в эфирной ленте они обязаны быть
+ // подписаны: без шоу зритель и админ видят на их месте прочерк.
+ unit.ShowId == Guid.Empty
+ ? null
+ : unit.ShowId,
+ unit.ShowId == Guid.Empty ? null : unit.UnitIndex,
slotId,
kind,
trace
@@ -422,6 +483,33 @@ public static class SchedulePlanner
return cursor;
}
+ ///
+ /// Следующая единица фона под остаток . Перебор продолжается с того
+ /// места, где остановился прошлый добор: иначе каждая пауза начиналась бы с одного и того же
+ /// ролика, и ночь выглядела бы одним зациклённым куском.
+ ///
+ /// Если очередная единица не влезает, ищется первая подходящая среди остальных, а не бросается
+ /// весь добор: список отсортирован по возрастанию длительности, и «не влезла эта» не значит
+ /// «не влезет ничего» — иначе в ленте оставалась бы дыра при живом коротком контенте.
+ ///
+ private static PlanningUnit? NextFallbackUnit(PlanningRun run, TimeSpan available)
+ {
+ var units = run.Input.FallbackUnits;
+
+ for (var offset = 0; offset < units.Count; offset++)
+ {
+ var index = (run.FallbackCursor + offset) % units.Count;
+ var unit = units[index];
+ if (unit.Duration <= TimeSpan.Zero || unit.Duration > available)
+ continue;
+
+ run.FallbackCursor = (index + 1) % units.Count;
+ return unit;
+ }
+
+ return null;
+ }
+
/// Коллекция элемента или null, если в эфир шло отдельное шоу.
private static Guid? CollectionOf(PlanningElement element) =>
element.Kind == GroupElementKind.Collection ? element.ElementId : null;
diff --git a/backend/tests/TeleWave.Application.Tests/Programming/LayerApplicabilityTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/LayerApplicabilityTests.cs
index d3aa384..dcc54fd 100644
--- a/backend/tests/TeleWave.Application.Tests/Programming/LayerApplicabilityTests.cs
+++ b/backend/tests/TeleWave.Application.Tests/Programming/LayerApplicabilityTests.cs
@@ -139,12 +139,8 @@ public class LayerApplicabilityTests
)
{
var start = from ?? new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
- return EffectiveGridBuilder.Build(
- template,
- utcOffsetMinutes: 180,
- DayStart,
- start,
- start.AddHours(18)
- );
+ return EffectiveGridBuilder
+ .Build(template, utcOffsetMinutes: 180, DayStart, start, start.AddHours(18))
+ .Slots;
}
}
diff --git a/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs b/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs
index 9f9bfb1..98e7156 100644
--- a/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs
+++ b/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs
@@ -74,7 +74,8 @@ public class SchedulePlannerTests
IReadOnlyList slots,
DateTimeOffset? start = null,
int horizonHours = 6,
- IReadOnlyList? fallback = null
+ IReadOnlyList? fallback = null,
+ IReadOnlyList? background = null
) =>
new(
Guid.NewGuid(),
@@ -82,7 +83,9 @@ public class SchedulePlannerTests
(start ?? T0).AddHours(horizonHours),
slots,
fallback ?? [Unit(1, Guid.NewGuid(), 0)],
- SegmentSeconds: 2
+ SegmentSeconds: 2,
+ UtcOffsetMinutes: 0,
+ background ?? []
);
private static PlanningResult Run(PlanningInput input) =>
@@ -347,6 +350,184 @@ public class SchedulePlannerTests
Assert.Equal(element.ElementId, program.Trace.ElementId);
}
+ [Fact]
+ public void Background_PlaysInLeftoverInsteadOfEmergencyFiller()
+ {
+ // Слот на полчаса при часовом горизонте: остаток по замыслу принадлежит фоновому слою.
+ var backgroundElement = Element(4, 10);
+ var background = Slot(T0, 24 * 60, [backgroundElement]);
+
+ var result = Run(
+ Input([Slot(T0, 30, [Element(1, 30)])], horizonHours: 1, background: [background])
+ );
+
+ var filled = result
+ .Items.Where(i => i.SlotId == background.SlotId)
+ .OrderBy(i => i.StartsAtUtc)
+ .ToList();
+ Assert.Equal(3, filled.Count);
+ // Фон — это программа своего слота, а не аварийный запас.
+ Assert.All(filled, item => Assert.Equal(PlannedItemKind.Program, item.Kind));
+ Assert.All(
+ filled,
+ item => Assert.Equal(backgroundElement.ElementId, item.Trace!.ElementId)
+ );
+ Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Fallback);
+ }
+
+ [Fact]
+ public void Background_ContinuesAcrossGaps()
+ {
+ var backgroundElement = Element(6, 10);
+ var background = Slot(T0, 24 * 60, [backgroundElement]);
+
+ var result = Run(
+ Input(
+ [Slot(T0, 20, [Element(1, 20)]), Slot(T0.AddMinutes(40), 20, [Element(1, 20)])],
+ horizonHours: 2,
+ background: [background]
+ )
+ );
+
+ var filled = result
+ .Items.Where(i => i.SlotId == background.SlotId)
+ .OrderBy(i => i.StartsAtUtc)
+ .Select(i => i.UnitIndex)
+ .ToList();
+ // Вторая пауза продолжает сериал с той серии, на которой остановилась первая; исчерпав
+ // элемент, последовательная стратегия начинает его заново — это её штатное поведение.
+ Assert.Equal([0, 1, 2, 3, 4, 5], filled.Take(6));
+ }
+
+ [Fact]
+ public void Background_FallsBackToEmergencyWhenEpisodeDoesNotFit()
+ {
+ var background = Slot(T0, 24 * 60, [Element(4, 30)]);
+
+ var result = Run(
+ Input(
+ [Slot(T0, 50, [Element(1, 50)])],
+ horizonHours: 1,
+ fallback: [Unit(2, Guid.Empty, 0)],
+ background: [background]
+ )
+ );
+
+ // Остаток — 10 минут, серия фона длиннее: хвост добирает аварийный запас, а не дыра.
+ Assert.DoesNotContain(result.Items, i => i.SlotId == background.SlotId);
+ Assert.Equal(5, result.Items.Count(i => i.Kind == PlannedItemKind.Fallback));
+ }
+
+ [Fact]
+ public void Background_FillsEmptyRepeatSlot()
+ {
+ var backgroundElement = Element(6, 10);
+ var background = Slot(T0, 24 * 60, [backgroundElement]);
+ var repeat = Slot(T0, 60, []) with { SlotKind = SlotKind.Repeat, RepeatUnits = [] };
+
+ var result = Run(Input([repeat], horizonHours: 1, background: [background]));
+
+ // Свежему каналу повторять нечего — по замыслу это место отдаётся фоновому слою.
+ Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.RepeatSourceEmpty);
+ Assert.Equal(6, result.Items.Count(i => i.SlotId == background.SlotId));
+ Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Fallback);
+ }
+
+ [Fact]
+ public void Background_CursorIsSavedOnlyForOverlappedSlots()
+ {
+ var backgroundElement = Element(6, 10);
+ var background = Slot(T0, 24 * 60, [backgroundElement]);
+
+ var result = Run(
+ Input([Slot(T0, 30, [Element(1, 30)])], horizonHours: 1, background: [background])
+ );
+
+ var cursor = Assert.Single(result.Cursors, c => c.SlotId == background.SlotId);
+ Assert.Equal(backgroundElement.ElementId, cursor.ElementId);
+ Assert.Equal(3, cursor.NextUnitIndex);
+ }
+
+ [Fact]
+ public void Fallback_KeepsShowOfItsUnits()
+ {
+ var showId = Guid.NewGuid();
+ // Слот на 30 минут при часовом горизонте: остаток закрывает фон.
+ var result = Run(
+ Input([Slot(T0, 30, [Element(1, 30)])], horizonHours: 1, fallback: [Unit(5, showId, 3)])
+ );
+
+ var fallback = result.Items.Where(i => i.Kind == PlannedItemKind.Fallback).ToList();
+ Assert.NotEmpty(fallback);
+ // Фон — это программы из группы: без шоу в ленте на их месте прочерк.
+ Assert.All(fallback, item => Assert.Equal(showId, item.ShowId));
+ Assert.All(fallback, item => Assert.Equal(3, item.UnitIndex));
+ }
+
+ [Fact]
+ public void Fallback_AnonymousFillerHasNoShow()
+ {
+ // Аварийный филлер канала приходит без шоу — подписывать его нечем и не нужно.
+ var result = Run(
+ Input(
+ [Slot(T0, 30, [Element(1, 30)])],
+ horizonHours: 1,
+ fallback: [Unit(5, Guid.Empty, 0)]
+ )
+ );
+
+ Assert.All(
+ result.Items.Where(i => i.Kind == PlannedItemKind.Fallback),
+ item => Assert.Null(item.ShowId)
+ );
+ }
+
+ [Fact]
+ public void Fallback_RotatesUnitsBetweenGaps()
+ {
+ var first = Unit(5, Guid.NewGuid(), 0);
+ var second = Unit(5, Guid.NewGuid(), 0);
+
+ var result = Run(
+ Input(
+ [Slot(T0, 30, [Element(1, 30)]), Slot(T0.AddMinutes(40), 30, [Element(1, 30)])],
+ horizonHours: 2,
+ fallback: [first, second]
+ )
+ );
+
+ var fallback = result
+ .Items.Where(i => i.Kind == PlannedItemKind.Fallback)
+ .OrderBy(i => i.StartsAtUtc)
+ .ToList();
+ // Пауза продолжает перебор с того места, где остановилась прошлая: иначе ночь выглядит
+ // одним зациклённым куском.
+ Assert.Equal(first.MediaAssetId, fallback[0].MediaAssetId);
+ Assert.Equal(second.MediaAssetId, fallback[1].MediaAssetId);
+ }
+
+ [Fact]
+ public void Fallback_TakesShorterUnitWhenNextDoesNotFit()
+ {
+ var showId = Guid.NewGuid();
+ // Список отсортирован по возрастанию, как его готовит оркестратор.
+ var result = Run(
+ Input(
+ [Slot(T0, 50, [Element(1, 50)])],
+ horizonHours: 1,
+ fallback: [Unit(2, showId, 0), Unit(30, showId, 1)]
+ )
+ );
+
+ // Остаток — 10 минут: длинная единица не влезает, и вместо дыры в ленте идут короткие.
+ var fallback = result.Items.Where(i => i.Kind == PlannedItemKind.Fallback).ToList();
+ Assert.Equal(5, fallback.Count);
+ Assert.All(
+ fallback,
+ item => Assert.Equal(TimeSpan.FromMinutes(2), item.EndsAtUtc - item.StartsAtUtc)
+ );
+ }
+
[Fact]
public void NoFallbackUnits_ProducesWarning()
{
diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md
index 0aa2b57..abe7afe 100644
--- a/docs/tv-scheduler-architecture.md
+++ b/docs/tv-scheduler-architecture.md
@@ -338,8 +338,18 @@ Slot
третья. Любая дыра в расписании — просто место, где сверху ничего не легло, и играет фон. Тонкость
настройки получается тем же механизмом, без второго UI и без отдельной ветки в генераторе.
+**Дыра — это не только непокрытый интервал.** Фон играет и там, где слот сверху есть, но не заполнил
+своё время: остаток короче одной серии, добор до якоря или до круглой отметки, слот-повтор, которому
+нечего повторять. Поэтому генератор получает слоты фонового слоя **и перекрытые тоже** — в эфир
+сами они не идут, но их контент закрывает паузы, продолжая свой сериал от паузы к паузе (курсор
+у фонового слота свой, как у обычного).
+
+Записи фона — обычные программы (`kind = program`) со своим слотом в трейсе: в ленте это настоящий
+контент, а не затычка, и «почему это здесь» показывает фоновый слот.
+
На самом краю остаётся `ScheduleTemplate.fallbackGroupId` и зацикленный аварийный ассет канала — на
-случай, когда пуст и фон.
+случай, когда пуст и фон. Только это и считается пост-проверкой «доля фона»: зацикленный филлер
+в эфире выглядит поломкой, а фоновый слот — ночным блоком.
### 3.5. Стратегии
@@ -666,6 +676,17 @@ ScheduleEntry — существующая сущность,
Шаги 1–4 — чистая функция без БД и ФС, юнит-тестируемая, по образцу текущего `SchedulePlanner`.
Шаги 5–7 — оркестратор, по образцу текущего `ScheduleGenerator`.
+Любая пауза на шаге 4 закрывается в одном и том же порядке, и порядок этот значим:
+
+```
+фоновый слой (слот этого времени, свой курсор)
+ → аварийная группа шаблона
+ → зацикленный филлер канала
+```
+
+Конец вещания (`slotKind = signOff`) — исключение: там играет только зацикленный ассет. Канал
+не вещает, и подставлять туда программы фона было бы прямым враньём в программе передач.
+
### 4.2. Наполнение слота
```
diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts
index 1a883bd..cd40e3a 100644
--- a/frontend/src/shared/lib/locales/en.ts
+++ b/frontend/src/shared/lib/locales/en.ts
@@ -644,7 +644,7 @@ export const en = {
CandidatesFiltered: 'The age cap ruled out every candidate',
BreakLimitExceeded: 'Breaks in an hour exceed the cap',
GenreShareExceeded: 'Genre share above the norm',
- FallbackShareExceeded: 'Background share above the norm',
+ FallbackShareExceeded: 'Emergency filler share above the norm',
},
title: 'Channels',
diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts
index 1cfa41a..251e9a9 100644
--- a/frontend/src/shared/lib/locales/ru.ts
+++ b/frontend/src/shared/lib/locales/ru.ts
@@ -644,7 +644,7 @@ export const ru = {
CandidatesFiltered: 'Возрастной потолок отсёк всех',
BreakLimitExceeded: 'Врезок в часе больше потолка',
GenreShareExceeded: 'Доля жанра выше нормы',
- FallbackShareExceeded: 'Фона в эфире больше нормы',
+ FallbackShareExceeded: 'Аварийного запаса в эфире больше нормы',
},
title: 'Каналы',