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: 'Каналы',