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