From 7bee84c548181a88fd0b4a3337a5cf1442c9a23e Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Tue, 28 Jul 2026 01:06:29 +0300 Subject: [PATCH] Enhance EffectiveGridBuilder to support background slots and improve scheduling logic Updated the EffectiveGridBuilder to return an EffectiveGrid containing both scheduled and background slots, allowing for better management of overlapping content. Refactored the ResolveDay method to handle background slots separately, ensuring they fill gaps in the schedule appropriately. Adjusted related components, including GridScheduleGenerator and SchedulePlanner, to accommodate the new structure and improve overall scheduling accuracy. Enhanced documentation and comments for clarity. --- .../Planning/EffectiveGridBuilder.cs | 60 ++++-- .../Planning/GridScheduleGenerator.cs | 15 +- .../Programming/Planning/PostCheckRunner.cs | 6 +- .../ListPublicChannelsQueryHandler.cs | 5 +- .../Programming/Planning/BackgroundFiller.cs | 116 +++++++++++ .../Programming/Planning/PlanningModels.cs | 18 +- .../Programming/Planning/SchedulePlanner.cs | 128 ++++++++++-- .../Programming/LayerApplicabilityTests.cs | 10 +- .../Programming/SchedulePlannerTests.cs | 185 +++++++++++++++++- docs/tv-scheduler-architecture.md | 23 ++- frontend/src/shared/lib/locales/en.ts | 2 +- frontend/src/shared/lib/locales/ru.ts | 2 +- 12 files changed, 512 insertions(+), 58 deletions(-) create mode 100644 backend/src/TeleWave.Domain/Programming/Planning/BackgroundFiller.cs 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: 'Каналы',