From fbfc6e677ec1bef9876030b288ae2876a1eba643 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 29 Jul 2026 08:57:46 +0300 Subject: [PATCH] Enhance GridScheduleGenerator and related components for improved slot handling and cursor management Updated the GridScheduleGenerator to support rebuilding future schedules while correctly managing aired positions. Introduced a new AiredPosition record to track the last aired state of slots, ensuring that the cursor rewinds to the correct position during rebuilds. Refactored the BuildInputAsync method to accept aired positions, and modified the LoadAiredPositionsAsync method for accurate retrieval of past entries. Enhanced the SchedulePlanner to utilize shared cursor states between main and background loops, preventing duplicate series plays. Updated documentation to reflect these changes and added integration tests to verify the correct behavior of the new functionality. --- .../Planning/GridScheduleGenerator.cs | 155 ++++++++++++++++-- .../Programming/Planning/PostCheckRunner.cs | 21 +++ .../Programming/Planning/BackgroundFiller.cs | 107 +++++------- .../Programming/Planning/ElementSelector.cs | 49 ++++-- .../Programming/Planning/RunPlayHistory.cs | 57 +++++++ .../Programming/Planning/SchedulePlanner.cs | 78 ++++++--- .../Programming/Planning/SlotCursors.cs | 36 ++++ .../Programming/SchedulePlannerTests.cs | 106 ++++++++++++ .../GridScheduleGeneratorIntegrationTests.cs | 48 ++++++ .../TransactionIntegrationTests.cs | 2 + docs/tv-scheduler-architecture.md | 26 ++- 11 files changed, 568 insertions(+), 117 deletions(-) create mode 100644 backend/src/TeleWave.Domain/Programming/Planning/RunPlayHistory.cs create mode 100644 backend/src/TeleWave.Domain/Programming/Planning/SlotCursors.cs diff --git a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs index 812dafc..6228378 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs @@ -80,8 +80,14 @@ public sealed class GridScheduleGenerator( await CleanupAsync(channelId, now, cancellationToken); + // Пересборка выбрасывает хвост, который курсоры уже «съели»: без отмотки каждое применение + // проматывало бы библиотеку на горизонт вперёд, теряя серии, которые так и не вышли. + var aired = new Dictionary(); if (rebuildFuture) + { await DropFutureAsync(channelId, now, cancellationToken); + aired = await LoadAiredPositionsAsync(channelId, cancellationToken); + } // Точка продолжения — конец последней сохранённой записи. При пересборке будущее уже удалено, // поэтому она укажет на границу неизменяемого прошлого. @@ -104,6 +110,7 @@ public sealed class GridScheduleGenerator( template, startUtc, horizonEnd, + aired, cancellationToken ); var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random); @@ -166,7 +173,15 @@ public sealed class GridScheduleGenerator( return null; var horizonEnd = from.AddDays(Math.Clamp(days, 1, Math.Max(1, _options.HorizonDays))); - var input = await BuildInputAsync(channel, template, from, horizonEnd, cancellationToken); + // Предпросмотр ничего не сносит, отматывать нечего: симуляция идёт от текущего состояния. + var input = await BuildInputAsync( + channel, + template, + from, + horizonEnd, + new Dictionary(), + cancellationToken + ); var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random); var postWarnings = await postChecks.RunAsync( @@ -183,10 +198,6 @@ public sealed class GridScheduleGenerator( }; } - /// - /// Чистит прошлое сверх окна хранения. Окно должно покрывать самое долгое остывание среди правил — - /// история показов берётся из самой ленты, отдельного журнала нет. - /// /// /// Канал выключен или без сетки — вещать нечем. По таймеру это просто пропуск, но по кнопке /// «Применить» админ ждёт, что лента придёт в соответствие с настройками: раз эфира нет, @@ -304,11 +315,14 @@ public sealed class GridScheduleGenerator( } } + /// Куда слот доиграл в уцелевшей ленте — перебивает снимок состояния при + /// пересборке; пусто на обычном тике и в предпросмотре. private async Task BuildInputAsync( Channel channel, ScheduleTemplate template, DateTimeOffset startUtc, DateTimeOffset horizonEnd, + IReadOnlyDictionary aired, CancellationToken cancellationToken ) { @@ -386,13 +400,21 @@ public sealed class GridScheduleGenerator( : await LoadRepeatUnitsAsync(channel.Id, repeatWindow, startUtc, cancellationToken); var strategy = ToPlanningStrategy(SlotStrategy.FromJson(slot.StrategyJson)); - var cursor = states.TryGetValue(slot.Id, out var state) - ? new PlanningCursor( - state.CurrentElementKind, - state.CurrentElementId, - state.NextUnitIndex + var cursor = + ( + aired.TryGetValue(slot.Id, out var position) + ? RewoundCursor(position, elements) + : null ) - : null; + ?? ( + states.TryGetValue(slot.Id, out var state) + ? new PlanningCursor( + state.CurrentElementKind, + state.CurrentElementId, + state.NextUnitIndex + ) + : null + ); planned.Add( item, @@ -581,6 +603,117 @@ public sealed class GridScheduleGenerator( : null; } + /// Последняя запись слота в уцелевшей ленте: элемент и единица, которой он доиграл. + private sealed record AiredPosition( + GroupElementKind ElementKind, + Guid ElementId, + Guid ShowId, + int UnitIndex + ); + + /// + /// Куда каждый слот доиграл к моменту пересборки. Элемент берётся из трейса: в самой записи его + /// нет, а по шоу его не восстановить — одно шоу играет и само по себе, и внутри коллекции. + /// + private async Task> LoadAiredPositionsAsync( + Guid channelId, + CancellationToken cancellationToken + ) + { + var maxima = await dbContext + .ScheduleEntries.AsNoTracking() + .Where(e => + e.ChannelId == channelId + && e.SlotId != null + && e.ShowId != null + && e.Kind == ScheduleEntryKind.Program + ) + .GroupBy(e => e.SlotId!.Value) + .Select(g => new { SlotId = g.Key, Last = g.Max(e => e.StartsAtUtc) }) + .ToListAsync(cancellationToken); + + var result = new Dictionary(); + if (maxima.Count == 0) + return result; + + // Вторым запросом, а не подзапросом на строку: слотов десятки, и «взять запись по (слот, + // старт)» на такой выборке дешевле, чем оконная функция по всей ленте канала. + var starts = maxima.Select(m => m.Last).Distinct().ToList(); + var rows = await dbContext + .ScheduleEntries.AsNoTracking() + .Where(e => + e.ChannelId == channelId && e.SlotId != null && starts.Contains(e.StartsAtUtc) + ) + .Select(e => new + { + SlotId = e.SlotId!.Value, + e.StartsAtUtc, + e.ShowId, + e.EpisodeIndex, + e.TraceJson, + }) + .ToListAsync(cancellationToken); + + var bySlotAndStart = rows.ToLookup(r => (r.SlotId, r.StartsAtUtc)); + foreach (var max in maxima) + { + var row = bySlotAndStart[(max.SlotId, max.Last)].FirstOrDefault(); + if (row?.ShowId is not { } showId || row.EpisodeIndex is not { } unitIndex) + continue; + + var trace = ParseTrace(row.TraceJson); + if (trace?.ElementKind is not { } kind || trace.ElementId is not { } elementId) + continue; + + result[max.SlotId] = new AiredPosition(kind, elementId, showId, unitIndex); + } + + return result; + } + + /// + /// Курсор, отмотанный к отыгранному. Позиция ищется в развёрнутом элементе, а не берётся из + /// EpisodeIndex напрямую: у коллекции серии нумеруются внутри каждой части, и как индекс + /// в общей последовательности элемента это число не годится. + /// + private static PlanningCursor? RewoundCursor( + AiredPosition aired, + IReadOnlyList elements + ) + { + var element = elements.FirstOrDefault(e => + e.Kind == aired.ElementKind && e.ElementId == aired.ElementId + ); + if (element is null) + return null; + + for (var i = 0; i < element.Units.Count; i++) + if ( + element.Units[i].ShowId == aired.ShowId + && element.Units[i].UnitIndex == aired.UnitIndex + ) + return new PlanningCursor(element.Kind, element.ElementId, i + 1); + + // Элемент есть, но той единицы в нём больше нет — серию удалили или ассет перестал быть + // готовым. Отматывать некуда, пусть решает сохранённое состояние. + return null; + } + + private static PlanTrace? ParseTrace(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + return JsonSerializer.Deserialize(json, TraceJsonOptions); + } + catch (JsonException) + { + return null; + } + } + /// Отрезок ленты, на который ссылается слот-повтор, в UTC. private static PlanningRepeatWindow? BuildRepeatWindow( Channel channel, diff --git a/backend/src/TeleWave.Application/Programming/Planning/PostCheckRunner.cs b/backend/src/TeleWave.Application/Programming/Planning/PostCheckRunner.cs index d7aaa83..e3857f1 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/PostCheckRunner.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/PostCheckRunner.cs @@ -137,8 +137,17 @@ public sealed class PostCheckRunner(IAppDbContext dbContext) .Where(i => i.Kind == PlannedItemKind.Program && i.ShowId is not null) .GroupBy(i => BroadcastDate(i.StartsAtUtc + offset, dayStartTime)); + // Края горизонта: прогон начинается и кончается посреди вещательных суток, и в этих + // огрызках один фильм честно занимает больше половины «дня». Доля от неполных суток + // ничего не значит, а предупреждение по ней приходит на каждое применение. + var from = items.Min(i => i.StartsAtUtc) + offset; + var until = items.Max(i => i.EndsAtUtc) + offset; + foreach (var day in days) { + if (!IsWholeDay(day.Key, dayStartTime, from, until)) + continue; + var total = day.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes); if (total <= 0) continue; @@ -172,6 +181,18 @@ public sealed class PostCheckRunner(IAppDbContext dbContext) private static DateTimeOffset Truncate(DateTimeOffset value, TimeSpan step) => value.AddTicks(-(value.Ticks % step.Ticks)); + /// Целиком ли вещательные сутки попали в прогон — только по таким считаются доли. + private static bool IsWholeDay( + DateOnly date, + TimeOnly dayStartTime, + DateTimeOffset from, + DateTimeOffset until + ) + { + var start = new DateTimeOffset(date.ToDateTime(dayStartTime), from.Offset); + return start >= from && start.AddDays(1) <= until; + } + /// Вещательные сутки момента: ночь до — вчерашний день. private static DateOnly BroadcastDate(DateTimeOffset channelTime, TimeOnly dayStartTime) => DateOnly.FromDateTime( diff --git a/backend/src/TeleWave.Domain/Programming/Planning/BackgroundFiller.cs b/backend/src/TeleWave.Domain/Programming/Planning/BackgroundFiller.cs index 197461d..69dd723 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/BackgroundFiller.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/BackgroundFiller.cs @@ -15,102 +15,71 @@ public sealed record BackgroundPick( /// всё это закрывал аварийный запас — зацикленный филлер канала, — и в эфире получались часы /// прочерков там, где по замыслу должен идти фон со своей группой и своим временем суток. /// -/// Состояние держится на прогон: фон зовут десятки раз за сутки, и он обязан продолжаться с той -/// серии, на которой остановился, а не начинаться заново в каждой паузе. +/// Позиция слота живёт не здесь, а в общем на прогон : тот же слот может +/// в одни сутки идти обычным (его ведёт основной цикл), а в другие быть перекрытым и играть только +/// в паузах. Со своим счётчиком фон переигрывал бы серии, уже поставленные основным циклом. /// -public sealed class BackgroundFiller(IReadOnlyList slots, IRandomSource random) +public sealed class BackgroundFiller( + IReadOnlyList slots, + IRandomSource random, + SlotCursors cursors, + RunPlayHistory plays +) { - private readonly Dictionary _states = []; - /// /// Единица фона под остаток ; null — фон на это время не задан, /// пуст или его серия длиннее остатка. Короткий хвост добирает аварийный запас. /// public BackgroundPick? Next(DateTimeOffset moment, TimeSpan available) { - if (SlotAt(moment) is not { } slot) + if (SlotAt(moment) is not { } scheduled) return null; - var state = State(slot); - if (!state.HasUnitsLeft && !Reselect(slot, state, moment)) + var slot = cursors.Apply(scheduled); + + // Начатый элемент доигрывается; исчерпан — берём следующий по стратегии слота. + if ((Position(slot) ?? Reselect(slot, moment)) is not { } position) return null; - var unit = state.Element!.Units[state.NextUnitIndex]; + var (element, nextUnitIndex) = position; + var unit = element.Units[nextUnitIndex]; if (unit.Duration <= TimeSpan.Zero || unit.Duration > available) return null; - state.NextUnitIndex++; - return new BackgroundPick(slot, state.Element, unit, slot.Strategy.Kind); + cursors.Advance(slot.SlotId, element, nextUnitIndex + 1); + return new BackgroundPick(slot, 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) + /// Где слот стоит сейчас, если начатый элемент ещё не доигран. + private static (PlanningElement Element, int NextUnitIndex)? Position(PlanningSlot slot) { - if (_states.TryGetValue(slot.SlotId, out var existing)) - return existing; + if (slot.Cursor?.ElementId is not { } elementId) + return null; - // Первый заход — стартуем с того места, где фон остановился в прошлом прогоне. - 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); - } + var element = slot.Elements.FirstOrDefault(e => + e.ElementId == elementId && e.Kind == slot.Cursor.ElementKind + ); + if (element is null) + return null; - _states[slot.SlotId] = state; - return state; + var index = Math.Clamp(slot.Cursor.NextUnitIndex, 0, element.Units.Count); + return index < element.Units.Count ? (element, index) : null; } - /// Берёт следующий элемент по стратегии слота; false — играть в фоне нечем. - private bool Reselect(PlanningSlot slot, BackgroundState state, DateTimeOffset moment) + /// Берёт следующий элемент по стратегии слота; null — играть в фоне нечем. + private (PlanningElement Element, int NextUnitIndex)? Reselect( + PlanningSlot slot, + 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(slot, moment, random, plays) is not { } pick) + return null; - 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; + var index = Math.Clamp(pick.StartUnitIndex, 0, pick.Element.Units.Count); + return index < pick.Element.Units.Count ? (pick.Element, index) : null; } } diff --git a/backend/src/TeleWave.Domain/Programming/Planning/ElementSelector.cs b/backend/src/TeleWave.Domain/Programming/Planning/ElementSelector.cs index e84f5f9..fedc7d4 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/ElementSelector.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/ElementSelector.cs @@ -16,10 +16,13 @@ public sealed record ElementPick( /// public static class ElementSelector { + /// Показы этого прогона; складывается со снимком ленты из базы — + /// без него остывание и потолок повторов не видели бы собственный горизонт. public static ElementPick? Select( PlanningSlot slot, DateTimeOffset moment, - IRandomSource random + IRandomSource random, + RunPlayHistory? history = null ) { // Возрастной потолок — жёсткое «нельзя», поэтому отсекает до любой стратегии и до курсора: @@ -41,7 +44,7 @@ public static class ElementSelector { SlotStrategyKind.Fixed => SelectFixed(slot, playable), SlotStrategyKind.Sequential => SelectSequential(slot, playable, current), - _ => SelectRandom(slot, playable, current, moment, random), + _ => SelectRandom(slot, playable, current, moment, random, history), }; } @@ -93,7 +96,8 @@ public static class ElementSelector List playable, PlanningElement? current, DateTimeOffset moment, - IRandomSource random + IRandomSource random, + RunPlayHistory? history ) { // Начатый элемент доигрывается до конца: иначе блок «4 серии подряд» рассыпался бы @@ -103,14 +107,16 @@ public static class ElementSelector // Потолок повторов — тоже жёсткий фильтр, но применяется только здесь: в последовательной // стратегии он выбрасывал бы очередную серию сериала и рвал порядок показа. - var withinLimit = ApplyRepeatLimit(slot, playable, moment); + var withinLimit = ApplyRepeatLimit(slot, playable, moment, history); var cooldown = TimeSpan.FromDays(Math.Max(0, slot.Strategy.CooldownDays)); var eligible = cooldown <= TimeSpan.Zero ? withinLimit : withinLimit - .Where(e => e.LastPlayedUtc is not { } last || moment - last >= cooldown) + .Where(e => + LastPlayedOf(e, history) is not { } last || moment - last >= cooldown + ) .ToList(); var exhausted = eligible.Count == 0; @@ -121,7 +127,12 @@ public static class ElementSelector // повторов пулу: иначе более слабое правило воскрешало бы отсечённое более строгим. eligible = slot.Strategy.IgnoreCooldownWhenExhausted ? withinLimit - : [withinLimit.OrderBy(e => e.LastPlayedUtc ?? DateTimeOffset.MinValue).First()]; + : + [ + withinLimit + .OrderBy(e => LastPlayedOf(e, history) ?? DateTimeOffset.MinValue) + .First(), + ]; } var picked = WeightedPick(eligible, random); @@ -143,21 +154,39 @@ public static class ElementSelector private static List ApplyRepeatLimit( PlanningSlot slot, List playable, - DateTimeOffset moment + DateTimeOffset moment, + RunPlayHistory? history ) { if (slot.RepeatLimit is not { } limit || limit.WindowDays <= 0 || limit.Max <= 0) return playable; var from = moment.AddDays(-limit.WindowDays); - var filtered = playable - .Where(e => (e.RecentPlaysUtc?.Count(p => p >= from) ?? 0) < limit.Max) - .ToList(); + var filtered = playable.Where(e => PlaysSince(e, from, history) < limit.Max).ToList(); // Все упёрлись в потолок — правило не должно оставлять слот пустым: пусть решает остывание. return filtered.Count == 0 ? playable : filtered; } + /// Последний показ элемента: позднее из записанной ленты и текущего прогона. + private static DateTimeOffset? LastPlayedOf(PlanningElement element, RunPlayHistory? history) + { + var inRun = history?.LastPlayed(element); + if (element.LastPlayedUtc is not { } stored) + return inRun; + + return inRun is { } fresh && fresh > stored ? fresh : stored; + } + + /// Показы элемента за период: записанные плюс поставленные этим прогоном. + private static int PlaysSince( + PlanningElement element, + DateTimeOffset from, + RunPlayHistory? history + ) => + (element.RecentPlaysUtc?.Count(p => p >= from) ?? 0) + + (history?.CountSince(element, from) ?? 0); + /// Продолжение текущего элемента с позиции курсора. private static ElementPick Continue(PlanningSlot slot, PlanningElement element) { diff --git a/backend/src/TeleWave.Domain/Programming/Planning/RunPlayHistory.cs b/backend/src/TeleWave.Domain/Programming/Planning/RunPlayHistory.cs new file mode 100644 index 0000000..00e58c4 --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/Planning/RunPlayHistory.cs @@ -0,0 +1,57 @@ +namespace TeleWave.Domain.Programming.Planning; + +/// +/// Показы, добавленные этим же прогоном. Остывание и потолок повторов считаются по ленте, а она на +/// момент сборки входа содержит только прошлое: горизонт в неделю строится одним прогоном, и без +/// этого счётчика оба правила не видели бы ничего из того, что сами же и поставили. +/// +/// Ключ — шоу, как и в снимке из базы: у коллекции остывание общее по всем частям, поэтому элемент +/// спрашивает историю сразу по всем своим шоу. +/// +public sealed class RunPlayHistory +{ + private readonly Dictionary> _byShow = []; + + public void Record(Guid showId, DateTimeOffset startsAtUtc) + { + if (!_byShow.TryGetValue(showId, out var plays)) + _byShow[showId] = plays = []; + + plays.Add(startsAtUtc); + } + + /// Когда элемент последний раз выходил в этом прогоне; null — ещё не выходил. + public DateTimeOffset? LastPlayed(PlanningElement element) + { + DateTimeOffset? last = null; + foreach (var showId in ShowsOf(element)) + { + if (!_byShow.TryGetValue(showId, out var plays)) + continue; + + foreach (var play in plays) + if (last is null || play > last) + last = play; + } + + return last; + } + + /// Сколько раз элемент выходил в этом прогоне начиная с момента. + public int CountSince(PlanningElement element, DateTimeOffset from) + { + var count = 0; + foreach (var showId in ShowsOf(element)) + if (_byShow.TryGetValue(showId, out var plays)) + count += plays.Count(play => play >= from); + + return count; + } + + /// + /// Шоу элемента берутся из его единиц: у коллекции их столько же, сколько частей, и это ровно + /// тот набор, по которому историю собирает и оркестратор. + /// + private static IEnumerable ShowsOf(PlanningElement element) => + element.Units.Select(unit => unit.ShowId).Distinct(); +} diff --git a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs index 7ce701f..c61f362 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs @@ -27,10 +27,26 @@ public static class SchedulePlanner public PlanningInput Input { get; } = input; public IRandomSource Random { get; } = random; public List Items { get; } = []; - public List Cursors { get; } = []; public List Warnings { get; } = []; public JunctionHistory Junctions { get; } = new(); + /// Позиция слотов: одна на слот, а не на каждые сутки его выхода. + public SlotCursors Cursors { get; } = new(); + + /// Что этот прогон уже поставил — по нему работают остывание и потолок повторов. + public RunPlayHistory Plays { get; } = new(); + + /// + /// Кладёт запись в ленту, попутно отмечая показ. Через этот метод идут все программы: правила + /// «не чаще раза в N дней» обязаны видеть и фоновые выходы, а они добавляются другим путём. + /// + public void Add(PlannedItem item) + { + Items.Add(item); + if (item.Kind == PlannedItemKind.Program && item.ShowId is { } showId) + Plays.Record(showId, item.StartsAtUtc); + } + private JunctionRun? _junctionRun; /// Накопители, которые нужны раскладке стыка: история, жребий и сама лента. @@ -49,8 +65,15 @@ public static class SchedulePlanner /// Ролики и анонсы для добора шва — тоже по кругу, чтобы не повторяться. public UnitRotation Pad { get; } = new(input.PadUnits, pad => pad.Unit.Duration); - /// Фоновый слой: чем закрываются паузы до того, как дело дойдёт до аварийного запаса. - public BackgroundFiller Background { get; } = new(input.BackgroundSlots, random); + private BackgroundFiller? _background; + + /// + /// Фоновый слой: чем закрываются паузы до того, как дело дойдёт до аварийного запаса. Ведёт + /// те же слоты, что и основной цикл, поэтому получает и общее хранилище позиций, и общую + /// историю показов — два независимых счётчика одного слота переигрывали бы серии. + /// + public BackgroundFiller Background => + _background ??= new(Input.BackgroundSlots, Random, Cursors, Plays); } public static PlanningResult Plan(PlanningInput input, IRandomSource random) @@ -96,12 +119,7 @@ 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); + return new PlanningResult(run.Items, run.Cursors.Updates, run.Warnings); } /// @@ -204,14 +222,27 @@ public static class SchedulePlanner } var slotEnd = Min(slot.TargetEndUtc, limit); + var placed = 0; foreach (var unit in units) { if (cursor + unit.Duration > slotEnd) break; - run.Items.Add(Program(unit, cursor, slot.SlotId, trace)); + run.Add(Program(unit, cursor, slot.SlotId, trace)); cursor += unit.Duration; + placed++; } + // Источник есть, но не влез ни одной единицей — слот короче того, что повторяет. Молчать + // здесь нельзя: снаружи это неотличимо от пустого источника, а чинится совсем другим. + if (placed == 0) + run.Warnings.Add( + new PlanningWarning( + PlanningWarningKind.RepeatSourceEmpty, + slot.SlotId, + "Источник повтора не помещается в слот целиком — слот отдан фоновому слою." + ) + ); + return cursor; } @@ -250,14 +281,19 @@ public static class SchedulePlanner } private static DateTimeOffset FillContent( - PlanningSlot slot, + PlanningSlot scheduled, DateTimeOffset cursor, DateTimeOffset limit, PlanTrace trace, PlanningRun run ) { - var pick = ElementSelector.Select(slot, cursor, run.Random); + // Позиция берётся из хранилища прогона, а не из снимка в слоте: слот приходит по экземпляру + // на каждые свои сутки, и все они несут один курсор из базы. Момент важен — брать её нужно + // именно здесь: паузу перед стартом слота только что закрывал фон, а он ведёт те же слоты. + var slot = run.Cursors.Apply(scheduled); + + var pick = ElementSelector.Select(slot, cursor, run.Random, run.Plays); if (pick is null) { run.Warnings.Add(NoCandidatesWarning(slot)); @@ -293,7 +329,7 @@ public static class SchedulePlanner && !FitsEntirely(element, unitIndex, cursor, budgetEnd) ) { - run.Cursors.Add(CursorUpdate(slot, element, unitIndex)); + run.Cursors.Advance(slot.SlotId, element, unitIndex); return FillGap(cursor, budgetEnd, run, slot.SlotId); } @@ -325,7 +361,7 @@ public static class SchedulePlanner if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd)) break; - run.Items.Add(Program(unit, cursor, slot.SlotId, slotTrace, CollectionOf(element))); + run.Add(Program(unit, cursor, slot.SlotId, slotTrace, CollectionOf(element))); cursor += unit.Duration; accumulated += unit.Duration; unitIndex++; @@ -350,7 +386,7 @@ public static class SchedulePlanner slotTrace ); - run.Cursors.Add(CursorUpdate(slot, element, unitIndex)); + run.Cursors.Advance(slot.SlotId, element, unitIndex); // Недобор до целевого конца закрываем фоном — только для слотов, чей бюджет привязан ко времени. if (slot.BlockMode == SlotBlockMode.FillSlot && cursor < budgetEnd) @@ -464,7 +500,7 @@ public static class SchedulePlanner if (run.Pad.Next(limit - cursor) is not { } pad) break; - run.Items.Add( + run.Add( new PlannedItem( pad.Unit.MediaAssetId, cursor, @@ -496,7 +532,7 @@ public static class SchedulePlanner if (run.Background.Next(cursor, until - cursor) is not { } pick) break; - run.Items.Add( + run.Add( new PlannedItem( pick.Unit.MediaAssetId, cursor, @@ -548,7 +584,7 @@ public static class SchedulePlanner if (run.Fallback.Next(until - cursor) is not { } unit) break; - run.Items.Add( + run.Add( new PlannedItem( unit.MediaAssetId, cursor, @@ -593,12 +629,6 @@ public static class SchedulePlanner CollectionId: collectionId ); - private static PlanningCursorUpdate CursorUpdate( - PlanningSlot slot, - PlanningElement element, - int nextUnitIndex - ) => new(slot.SlotId, element.Kind, element.ElementId, nextUnitIndex); - /// Ближайший якорь среди последующих слотов — до него нельзя перелезать контентом. private static DateTimeOffset? FindNextAnchor(IReadOnlyList slots, int fromIndex) { diff --git a/backend/src/TeleWave.Domain/Programming/Planning/SlotCursors.cs b/backend/src/TeleWave.Domain/Programming/Planning/SlotCursors.cs new file mode 100644 index 0000000..8eecdbc --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/Planning/SlotCursors.cs @@ -0,0 +1,36 @@ +namespace TeleWave.Domain.Programming.Planning; + +/// +/// Позиция каждого слота на время прогона. +/// +/// Слот приходит в планировщик по экземпляру на вещательные сутки, и курсор у всех этих экземпляров +/// один и тот же — снимок SlotState из базы на начало прогона. Без общего хранилища слот +/// начинал бы вторник ровно с того места, что и понедельник, а фоновый слот, перекрытый в одни сутки +/// и свободный в другие, вёл бы вторую позицию независимо от основного цикла и переигрывал уже +/// поставленные серии. +/// +public sealed class SlotCursors +{ + private readonly Dictionary _current = []; + + /// Где слот сейчас: позиция этого прогона, а до первого хода — снимок из базы. + public PlanningCursor? Of(PlanningSlot slot) => + _current.TryGetValue(slot.SlotId, out var cursor) ? cursor : slot.Cursor; + + /// Тот же слот с актуальной позицией — в таком виде его и получают заполнители. + public PlanningSlot Apply(PlanningSlot slot) => slot with { Cursor = Of(slot) }; + + public void Advance(Guid slotId, PlanningElement element, int nextUnitIndex) => + _current[slotId] = new PlanningCursor(element.Kind, element.ElementId, nextUnitIndex); + + /// Что записать в состояние слотов: только те, что в этом прогоне действительно играли. + public IReadOnlyList Updates => + [ + .. _current.Select(pair => new PlanningCursorUpdate( + pair.Key, + pair.Value.ElementKind, + pair.Value.ElementId, + pair.Value.NextUnitIndex + )), + ]; +} diff --git a/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs b/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs index 6cb63aa..9c1cb58 100644 --- a/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs @@ -368,6 +368,22 @@ public class SchedulePlannerTests ); } + /// Источник нашёлся, но слот короче — это другая беда, и молчать о ней нельзя. + [Fact] + public void RepeatSlot_WithSourceTooLongForSlot_Warns() + { + var evening = Slot(T0, 60, [Element(1, 60)]); + var morning = Slot(T0.AddHours(2), 30, []) with + { + SlotKind = SlotKind.Repeat, + RepeatWindow = new PlanningRepeatWindow(T0, T0.AddHours(1)), + }; + + var result = Run(Input([evening, morning], horizonHours: 4)); + + Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.RepeatSourceEmpty); + } + /// /// Старты эластичные: фильм законно начинается раньше объявленного времени, и окно повтора /// обязано ловить его по пересечению, а не по попаданию старта внутрь. @@ -391,6 +407,96 @@ public class SchedulePlannerTests ); } + /// + /// Слот приходит по экземпляру на каждые свои сутки, и курсор у всех этих экземпляров один — + /// снимок из базы. Без общей позиции вторник переигрывал бы понедельник. + /// + [Fact] + public void SameSlot_ContinuesAcrossItsDays_WithinOneRun() + { + var element = Element(4, 30); + var monday = Slot(T0, 60, [element]); + var tuesday = monday with { TargetStartUtc = T0.AddHours(3) }; + + var result = Run(Input([monday, tuesday], horizonHours: 6)); + + var played = result + .Items.Where(i => i.Kind == PlannedItemKind.Program && i.SlotId == monday.SlotId) + .Select(i => i.MediaAssetId) + .ToList(); + Assert.Equal(4, played.Count); + Assert.Equal(4, played.Distinct().Count()); + } + + /// + /// Тот же слот бывает перекрыт в одни сутки и свободен в другие: фон и основной цикл ведут его + /// вместе, и второй счётчик позиции переигрывал бы уже поставленные серии. + /// + [Fact] + public void Background_AndMainLoop_ShareOneSlotPosition() + { + var element = Element(8, 30); + var background = Slot(T0, 360, [element]); + var main = background with { TargetStartUtc = T0.AddHours(2), TargetDurationMinutes = 60 }; + + var result = Run(Input([main], horizonHours: 4, fallback: [], background: [background])); + + var played = result + .Items.Where(i => i.Kind == PlannedItemKind.Program) + .Select(i => i.MediaAssetId) + .ToList(); + Assert.Equal(8, played.Count); + Assert.Equal(8, played.Distinct().Count()); + } + + /// Остывание обязано видеть то, что прогон поставил сам: горизонт строится разом. + [Fact] + public void Cooldown_CountsPlaysFromTheSameRun() + { + var first = Slot( + T0, + 60, + [Element(1, 60, position: 0), Element(1, 60, position: 1)], + strategy: SlotStrategyKind.RandomWithCooldown, + cooldownDays: 14 + ); + var second = first with { SlotId = Guid.NewGuid(), TargetStartUtc = T0.AddHours(2) }; + + var result = Run(Input([first, second], horizonHours: 4)); + + var shows = result + .Items.Where(i => i.Kind == PlannedItemKind.Program) + .Select(i => i.ShowId) + .ToList(); + Assert.Equal(2, shows.Count); + Assert.Equal(2, shows.Distinct().Count()); + } + + /// Потолок повторов — тот же случай: показы прогона считаются наравне с записанными. + [Fact] + public void RepeatLimit_CountsPlaysFromTheSameRun() + { + var first = Slot( + T0, + 60, + [Element(1, 60, position: 0), Element(1, 60, position: 1)], + strategy: SlotStrategyKind.RandomWithCooldown + ) with + { + RepeatLimit = new RepeatLimit(WindowDays: 7, Max: 1), + }; + var second = first with { SlotId = Guid.NewGuid(), TargetStartUtc = T0.AddHours(2) }; + + var result = Run(Input([first, second], horizonHours: 4)); + + var shows = result + .Items.Where(i => i.Kind == PlannedItemKind.Program) + .Select(i => i.ShowId) + .ToList(); + Assert.Equal(2, shows.Count); + Assert.Equal(2, shows.Distinct().Count()); + } + [Fact] public void Trace_IsWrittenWithSlotAndStrategy() { diff --git a/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs index c2e110c..a5e57eb 100644 --- a/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs +++ b/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs @@ -115,6 +115,54 @@ public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixtur Assert.Equal(past.Count, survived.Count); } + /// + /// Пересборка выбрасывает хвост, который курсор уже прошёл. Без отмотки к отыгранному каждое + /// применение проматывало бы библиотеку на горизонт вперёд, теряя невышедшие серии. + /// + [SkippableFact] + public async Task Generate_Rebuild_RewindsCursorToWhatActuallyAired() + { + Skip.IfNot(fixture.Available, "Docker недоступен"); + + await using var seedDb = fixture.CreateContext(); + // Серий заведомо больше, чем влезет в горизонт: иначе сериал успеет пойти по кругу, + // и «продолжает с той же серии» проверять станет нечем. + var world = await SeedAsync(seedDb, episodes: 100); + + // Первый прогон строит сутки вперёд от момента двухчасовой давности. + await using var first = fixture.CreateContext(); + await Generator(first).GenerateAsync(world.ChannelId, Now.AddHours(-2), false, default); + + await using var beforeDb = fixture.CreateContext(); + var aired = beforeDb + .ScheduleEntries.Where(e => + e.ChannelId == world.ChannelId + && e.Kind == ScheduleEntryKind.Program + && e.StartsAtUtc < Now + ) + .OrderByDescending(e => e.StartsAtUtc) + .First(); + var beforeRebuild = beforeDb.SlotStates.Single(s => s.SlotId == world.SlotId); + // Курсор ушёл за эфир: он прошёл весь построенный хвост, а не только вышедшее. + Assert.True(beforeRebuild.NextUnitIndex > aired.EpisodeIndex + 1); + + await using var rebuild = fixture.CreateContext(); + await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default); + + await using var verify = fixture.CreateContext(); + var firstAfterRebuild = verify + .ScheduleEntries.Where(e => + e.ChannelId == world.ChannelId + && e.Kind == ScheduleEntryKind.Program + && e.StartsAtUtc >= Now + ) + .OrderBy(e => e.StartsAtUtc) + .First(); + + // Пересобранный эфир продолжает ровно с той серии, что шла следующей по отыгранному. + Assert.Equal(aired.EpisodeIndex + 1, firstAfterRebuild.EpisodeIndex); + } + [SkippableFact] public async Task Generate_CollectionInGroup_StampsCollectionOnEntries() { diff --git a/backend/tests/TeleWave.Integration.Tests/TransactionIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/TransactionIntegrationTests.cs index 90aac92..6e1644e 100644 --- a/backend/tests/TeleWave.Integration.Tests/TransactionIntegrationTests.cs +++ b/backend/tests/TeleWave.Integration.Tests/TransactionIntegrationTests.cs @@ -121,6 +121,7 @@ public sealed class TransactionIntegrationTests(PostgresFixture fixture) [SkippableFact] public async Task DeleteShow_WithSchedule_CutsFutureEntries() { + Skip.IfNot(fixture.Available, "Docker недоступен"); var channel = Channel.Create("Канал", $"cut-{Guid.NewGuid():N}", DateTimeOffset.UnixEpoch); var show = Show.Create("Шоу", ShowKind.Single); var asset = MediaAsset.Register("live.mkv", ".mkv", MediaSource.Upload); @@ -181,6 +182,7 @@ public sealed class TransactionIntegrationTests(PostgresFixture fixture) [SkippableFact] public async Task DeleteShow_WithMedia_RemovesEverything() { + Skip.IfNot(fixture.Available, "Docker недоступен"); var channel = Channel.Create("Канал", $"del-{Guid.NewGuid():N}", DateTimeOffset.UnixEpoch); var show = Show.Create("Шоу", ShowKind.Single); var asset = MediaAsset.Register("movie.mkv", ".mkv", MediaSource.Upload); diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index b85577d..1e63d8a 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -409,9 +409,27 @@ SlotState Курсор хранит **ссылку на элемент**, а не числовой индекс в группе: при удалении позиции из группы курсор корректно переезжает на следующую, а не сдвигает всё. +**Курсор один на слот в пределах прогона.** `SlotState` — это снимок на начало прогона, а слот +попадает в планировщик по экземпляру на каждые свои вещательные сутки; горизонт в неделю строится +целиком. Позиция поэтому живёт в накопителе прогона, а снимок только задаёт её начальное значение. +Иначе вторник начинался бы ровно с того места, что и понедельник. По той же причине позиция общая +с фоновым слоем: слот, перекрытый в одни сутки и свободный в другие, идёт то основным циклом, то +в паузах, и второй счётчик переигрывал бы уже поставленные серии. Берётся позиция в момент выбора +элемента, а не при входе в слот: паузу перед стартом слота закрывает фон, и он мог сдвинуть её +только что. + **История показов для остывания** берётся из материализованного расписания (`ScheduleEntry` с `Kind = Program`, индекс по `channelId + showId + startsAtUtc`), отдельного журнала не заводим — одна -правда, и при пересборке хвоста будущие показы удаляются вместе с записями. +правда, и при пересборке хвоста будущие показы удаляются вместе с записями. К ней **прибавляются +показы текущего прогона**: лента на момент сборки входа содержит только прошлое, и без этого +остывание с потолком повторов не видели бы собственный горизонт — на свежем канале оба правила +не срабатывали бы ни разу. + +**Пересборка отматывает курсор к отыгранному.** Снос будущего хвоста выбрасывает выходы, которые +курсор уже прошёл; без отмотки каждое применение проматывало бы библиотеку на горизонт вперёд, +теряя серии, которые так и не вышли. Точка берётся по последней уцелевшей записи каждого слота: +элемент — из её трейса, позиция — поиском единицы в развёрнутом элементе (у коллекции серии +нумеруются внутри каждой части, и `episodeIndex` как индекс в общей последовательности не годится). Из этого следует: `SchedulerOptions.RetentionHours` (сейчас 24 часа) заменяется на `RetentionDays`, дефолт 90. Значение обязано покрывать максимальный `cooldownDays` среди правил канала и максимальный @@ -829,8 +847,10 @@ seed = hash(channelId, date, slotId, occurrenceInDay) ### 5.2. После генерации -- элементы, отданные фону (не нашлось контента); -- пост-проверки из 3.8; +- элементы, отданные фону (не нашлось контента, либо источник повтора не поместился в слот целиком — + снаружи это неотличимо от пустого источника, а чинится совсем другим); +- пост-проверки из 3.8 — доли считаются только по вещательным суткам, попавшим в прогон целиком: + на краях горизонта сутки обрезаны, и один фильм честно занимает в таком огрызке больше половины; - тепловая карта повторов: матрица «элемент × день», яркость = число показов — сразу видно, что один фильм крутится четыре раза за неделю; - фактическая доля врезок по часам против лимита.