Enhance GridScheduleGenerator and related components for improved slot handling and cursor management
ci / build-backend (push) Successful in 1m21s
ci / build-frontend (push) Successful in 51s
ci / tests (push) Successful in 1m40s
ci / sonar (push) Successful in 3m46s

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.
This commit is contained in:
Leonid Pershin
2026-07-29 08:57:46 +03:00
parent c67d1f7752
commit fbfc6e677e
11 changed files with 568 additions and 117 deletions
@@ -80,8 +80,14 @@ public sealed class GridScheduleGenerator(
await CleanupAsync(channelId, now, cancellationToken);
// Пересборка выбрасывает хвост, который курсоры уже «съели»: без отмотки каждое применение
// проматывало бы библиотеку на горизонт вперёд, теряя серии, которые так и не вышли.
var aired = new Dictionary<Guid, AiredPosition>();
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<Guid, AiredPosition>(),
cancellationToken
);
var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random);
var postWarnings = await postChecks.RunAsync(
@@ -183,10 +198,6 @@ public sealed class GridScheduleGenerator(
};
}
/// <summary>
/// Чистит прошлое сверх окна хранения. Окно должно покрывать самое долгое остывание среди правил —
/// история показов берётся из самой ленты, отдельного журнала нет.
/// </summary>
/// <summary>
/// Канал выключен или без сетки — вещать нечем. По таймеру это просто пропуск, но по кнопке
/// «Применить» админ ждёт, что лента придёт в соответствие с настройками: раз эфира нет,
@@ -304,11 +315,14 @@ public sealed class GridScheduleGenerator(
}
}
/// <param name="aired">Куда слот доиграл в уцелевшей ленте — перебивает снимок состояния при
/// пересборке; пусто на обычном тике и в предпросмотре.</param>
private async Task<PlanningInput> BuildInputAsync(
Channel channel,
ScheduleTemplate template,
DateTimeOffset startUtc,
DateTimeOffset horizonEnd,
IReadOnlyDictionary<Guid, AiredPosition> 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;
}
/// <summary>Последняя запись слота в уцелевшей ленте: элемент и единица, которой он доиграл.</summary>
private sealed record AiredPosition(
GroupElementKind ElementKind,
Guid ElementId,
Guid ShowId,
int UnitIndex
);
/// <summary>
/// Куда каждый слот доиграл к моменту пересборки. Элемент берётся из трейса: в самой записи его
/// нет, а по шоу его не восстановить — одно шоу играет и само по себе, и внутри коллекции.
/// </summary>
private async Task<Dictionary<Guid, AiredPosition>> 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<Guid, AiredPosition>();
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;
}
/// <summary>
/// Курсор, отмотанный к отыгранному. Позиция ищется в развёрнутом элементе, а не берётся из
/// <c>EpisodeIndex</c> напрямую: у коллекции серии нумеруются внутри каждой части, и как индекс
/// в общей последовательности элемента это число не годится.
/// </summary>
private static PlanningCursor? RewoundCursor(
AiredPosition aired,
IReadOnlyList<PlanningElement> 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<PlanTrace>(json, TraceJsonOptions);
}
catch (JsonException)
{
return null;
}
}
/// <summary>Отрезок ленты, на который ссылается слот-повтор, в UTC.</summary>
private static PlanningRepeatWindow? BuildRepeatWindow(
Channel channel,
@@ -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));
/// <summary>Целиком ли вещательные сутки попали в прогон — только по таким считаются доли.</summary>
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;
}
/// <summary>Вещательные сутки момента: ночь до <paramref name="dayStartTime"/> — вчерашний день.</summary>
private static DateOnly BroadcastDate(DateTimeOffset channelTime, TimeOnly dayStartTime) =>
DateOnly.FromDateTime(
@@ -15,102 +15,71 @@ public sealed record BackgroundPick(
/// всё это закрывал аварийный запас — зацикленный филлер канала, — и в эфире получались часы
/// прочерков там, где по замыслу должен идти фон со своей группой и своим временем суток.
///
/// Состояние держится на прогон: фон зовут десятки раз за сутки, и он обязан продолжаться с той
/// серии, на которой остановился, а не начинаться заново в каждой паузе.
/// Позиция слота живёт не здесь, а в общем на прогон <see cref="SlotCursors"/>: тот же слот может
/// в одни сутки идти обычным (его ведёт основной цикл), а в другие быть перекрытым и играть только
/// в паузах. Со своим счётчиком фон переигрывал бы серии, уже поставленные основным циклом.
/// </summary>
public sealed class BackgroundFiller(IReadOnlyList<PlanningSlot> slots, IRandomSource random)
public sealed class BackgroundFiller(
IReadOnlyList<PlanningSlot> slots,
IRandomSource random,
SlotCursors cursors,
RunPlayHistory plays
)
{
private readonly Dictionary<Guid, BackgroundState> _states = [];
/// <summary>
/// Единица фона под остаток <paramref name="available"/>; null — фон на это время не задан,
/// пуст или его серия длиннее остатка. Короткий хвост добирает аварийный запас.
/// </summary>
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);
}
/// <summary>
/// Куда доиграл фон. Слоты, которые в этом прогоне шли ещё и обычными (не были перекрыты),
/// пропускаются: их курсор ведёт основной цикл, и два независимых счётчика одной позиции
/// перетирали бы друг друга.
/// </summary>
public IEnumerable<PlanningCursorUpdate> Cursors(IReadOnlySet<Guid> 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
));
/// <summary>Активный фоновый слот на момент; сутки фон покрывает целиком, но дыры возможны.</summary>
private PlanningSlot? SlotAt(DateTimeOffset moment) =>
slots.FirstOrDefault(s => s.TargetStartUtc <= moment && moment < s.TargetEndUtc);
private BackgroundState State(PlanningSlot slot)
/// <summary>Где слот стоит сейчас, если начатый элемент ещё не доигран.</summary>
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;
}
/// <summary>Берёт следующий элемент по стратегии слота; false — играть в фоне нечем.</summary>
private bool Reselect(PlanningSlot slot, BackgroundState state, DateTimeOffset moment)
/// <summary>Берёт следующий элемент по стратегии слота; null — играть в фоне нечем.</summary>
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;
}
/// <summary>Где фоновый слот остановился: элемент и следующая единица внутри него.</summary>
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;
}
}
@@ -16,10 +16,13 @@ public sealed record ElementPick(
/// </summary>
public static class ElementSelector
{
/// <param name="history">Показы этого прогона; складывается со снимком ленты из базы —
/// без него остывание и потолок повторов не видели бы собственный горизонт.</param>
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<PlanningElement> 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<PlanningElement> ApplyRepeatLimit(
PlanningSlot slot,
List<PlanningElement> 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;
}
/// <summary>Последний показ элемента: позднее из записанной ленты и текущего прогона.</summary>
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;
}
/// <summary>Показы элемента за период: записанные плюс поставленные этим прогоном.</summary>
private static int PlaysSince(
PlanningElement element,
DateTimeOffset from,
RunPlayHistory? history
) =>
(element.RecentPlaysUtc?.Count(p => p >= from) ?? 0)
+ (history?.CountSince(element, from) ?? 0);
/// <summary>Продолжение текущего элемента с позиции курсора.</summary>
private static ElementPick Continue(PlanningSlot slot, PlanningElement element)
{
@@ -0,0 +1,57 @@
namespace TeleWave.Domain.Programming.Planning;
/// <summary>
/// Показы, добавленные этим же прогоном. Остывание и потолок повторов считаются по ленте, а она на
/// момент сборки входа содержит только прошлое: горизонт в неделю строится одним прогоном, и без
/// этого счётчика оба правила не видели бы ничего из того, что сами же и поставили.
///
/// Ключ — шоу, как и в снимке из базы: у коллекции остывание общее по всем частям, поэтому элемент
/// спрашивает историю сразу по всем своим шоу.
/// </summary>
public sealed class RunPlayHistory
{
private readonly Dictionary<Guid, List<DateTimeOffset>> _byShow = [];
public void Record(Guid showId, DateTimeOffset startsAtUtc)
{
if (!_byShow.TryGetValue(showId, out var plays))
_byShow[showId] = plays = [];
plays.Add(startsAtUtc);
}
/// <summary>Когда элемент последний раз выходил в этом прогоне; null — ещё не выходил.</summary>
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;
}
/// <summary>Сколько раз элемент выходил в этом прогоне начиная с момента.</summary>
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;
}
/// <summary>
/// Шоу элемента берутся из его единиц: у коллекции их столько же, сколько частей, и это ровно
/// тот набор, по которому историю собирает и оркестратор.
/// </summary>
private static IEnumerable<Guid> ShowsOf(PlanningElement element) =>
element.Units.Select(unit => unit.ShowId).Distinct();
}
@@ -27,10 +27,26 @@ public static class SchedulePlanner
public PlanningInput Input { get; } = input;
public IRandomSource Random { get; } = random;
public List<PlannedItem> Items { get; } = [];
public List<PlanningCursorUpdate> Cursors { get; } = [];
public List<PlanningWarning> Warnings { get; } = [];
public JunctionHistory Junctions { get; } = new();
/// <summary>Позиция слотов: одна на слот, а не на каждые сутки его выхода.</summary>
public SlotCursors Cursors { get; } = new();
/// <summary>Что этот прогон уже поставил — по нему работают остывание и потолок повторов.</summary>
public RunPlayHistory Plays { get; } = new();
/// <summary>
/// Кладёт запись в ленту, попутно отмечая показ. Через этот метод идут все программы: правила
/// «не чаще раза в N дней» обязаны видеть и фоновые выходы, а они добавляются другим путём.
/// </summary>
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;
/// <summary>Накопители, которые нужны раскладке стыка: история, жребий и сама лента.</summary>
@@ -49,8 +65,15 @@ public static class SchedulePlanner
/// <summary>Ролики и анонсы для добора шва — тоже по кругу, чтобы не повторяться.</summary>
public UnitRotation<PadUnit> Pad { get; } = new(input.PadUnits, pad => pad.Unit.Duration);
/// <summary>Фоновый слой: чем закрываются паузы до того, как дело дойдёт до аварийного запаса.</summary>
public BackgroundFiller Background { get; } = new(input.BackgroundSlots, random);
private BackgroundFiller? _background;
/// <summary>
/// Фоновый слой: чем закрываются паузы до того, как дело дойдёт до аварийного запаса. Ведёт
/// те же слоты, что и основной цикл, поэтому получает и общее хранилище позиций, и общую
/// историю показов — два независимых счётчика одного слота переигрывали бы серии.
/// </summary>
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);
}
/// <summary>
@@ -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);
/// <summary>Ближайший якорь среди последующих слотов — до него нельзя перелезать контентом.</summary>
private static DateTimeOffset? FindNextAnchor(IReadOnlyList<PlanningSlot> slots, int fromIndex)
{
@@ -0,0 +1,36 @@
namespace TeleWave.Domain.Programming.Planning;
/// <summary>
/// Позиция каждого слота на время прогона.
///
/// Слот приходит в планировщик по экземпляру на вещательные сутки, и курсор у всех этих экземпляров
/// один и тот же — снимок <c>SlotState</c> из базы на начало прогона. Без общего хранилища слот
/// начинал бы вторник ровно с того места, что и понедельник, а фоновый слот, перекрытый в одни сутки
/// и свободный в другие, вёл бы вторую позицию независимо от основного цикла и переигрывал уже
/// поставленные серии.
/// </summary>
public sealed class SlotCursors
{
private readonly Dictionary<Guid, PlanningCursor> _current = [];
/// <summary>Где слот сейчас: позиция этого прогона, а до первого хода — снимок из базы.</summary>
public PlanningCursor? Of(PlanningSlot slot) =>
_current.TryGetValue(slot.SlotId, out var cursor) ? cursor : slot.Cursor;
/// <summary>Тот же слот с актуальной позицией — в таком виде его и получают заполнители.</summary>
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);
/// <summary>Что записать в состояние слотов: только те, что в этом прогоне действительно играли.</summary>
public IReadOnlyList<PlanningCursorUpdate> Updates =>
[
.. _current.Select(pair => new PlanningCursorUpdate(
pair.Key,
pair.Value.ElementKind,
pair.Value.ElementId,
pair.Value.NextUnitIndex
)),
];
}