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.
This commit is contained in:
@@ -80,8 +80,14 @@ public sealed class GridScheduleGenerator(
|
|||||||
|
|
||||||
await CleanupAsync(channelId, now, cancellationToken);
|
await CleanupAsync(channelId, now, cancellationToken);
|
||||||
|
|
||||||
|
// Пересборка выбрасывает хвост, который курсоры уже «съели»: без отмотки каждое применение
|
||||||
|
// проматывало бы библиотеку на горизонт вперёд, теряя серии, которые так и не вышли.
|
||||||
|
var aired = new Dictionary<Guid, AiredPosition>();
|
||||||
if (rebuildFuture)
|
if (rebuildFuture)
|
||||||
|
{
|
||||||
await DropFutureAsync(channelId, now, cancellationToken);
|
await DropFutureAsync(channelId, now, cancellationToken);
|
||||||
|
aired = await LoadAiredPositionsAsync(channelId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
// Точка продолжения — конец последней сохранённой записи. При пересборке будущее уже удалено,
|
// Точка продолжения — конец последней сохранённой записи. При пересборке будущее уже удалено,
|
||||||
// поэтому она укажет на границу неизменяемого прошлого.
|
// поэтому она укажет на границу неизменяемого прошлого.
|
||||||
@@ -104,6 +110,7 @@ public sealed class GridScheduleGenerator(
|
|||||||
template,
|
template,
|
||||||
startUtc,
|
startUtc,
|
||||||
horizonEnd,
|
horizonEnd,
|
||||||
|
aired,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random);
|
var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random);
|
||||||
@@ -166,7 +173,15 @@ public sealed class GridScheduleGenerator(
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
var horizonEnd = from.AddDays(Math.Clamp(days, 1, Math.Max(1, _options.HorizonDays)));
|
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 result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random);
|
||||||
|
|
||||||
var postWarnings = await postChecks.RunAsync(
|
var postWarnings = await postChecks.RunAsync(
|
||||||
@@ -183,10 +198,6 @@ public sealed class GridScheduleGenerator(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Чистит прошлое сверх окна хранения. Окно должно покрывать самое долгое остывание среди правил —
|
|
||||||
/// история показов берётся из самой ленты, отдельного журнала нет.
|
|
||||||
/// </summary>
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Канал выключен или без сетки — вещать нечем. По таймеру это просто пропуск, но по кнопке
|
/// Канал выключен или без сетки — вещать нечем. По таймеру это просто пропуск, но по кнопке
|
||||||
/// «Применить» админ ждёт, что лента придёт в соответствие с настройками: раз эфира нет,
|
/// «Применить» админ ждёт, что лента придёт в соответствие с настройками: раз эфира нет,
|
||||||
@@ -304,11 +315,14 @@ public sealed class GridScheduleGenerator(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <param name="aired">Куда слот доиграл в уцелевшей ленте — перебивает снимок состояния при
|
||||||
|
/// пересборке; пусто на обычном тике и в предпросмотре.</param>
|
||||||
private async Task<PlanningInput> BuildInputAsync(
|
private async Task<PlanningInput> BuildInputAsync(
|
||||||
Channel channel,
|
Channel channel,
|
||||||
ScheduleTemplate template,
|
ScheduleTemplate template,
|
||||||
DateTimeOffset startUtc,
|
DateTimeOffset startUtc,
|
||||||
DateTimeOffset horizonEnd,
|
DateTimeOffset horizonEnd,
|
||||||
|
IReadOnlyDictionary<Guid, AiredPosition> aired,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -386,13 +400,21 @@ public sealed class GridScheduleGenerator(
|
|||||||
: await LoadRepeatUnitsAsync(channel.Id, repeatWindow, startUtc, cancellationToken);
|
: await LoadRepeatUnitsAsync(channel.Id, repeatWindow, startUtc, cancellationToken);
|
||||||
|
|
||||||
var strategy = ToPlanningStrategy(SlotStrategy.FromJson(slot.StrategyJson));
|
var strategy = ToPlanningStrategy(SlotStrategy.FromJson(slot.StrategyJson));
|
||||||
var cursor = states.TryGetValue(slot.Id, out var state)
|
var cursor =
|
||||||
? new PlanningCursor(
|
(
|
||||||
state.CurrentElementKind,
|
aired.TryGetValue(slot.Id, out var position)
|
||||||
state.CurrentElementId,
|
? RewoundCursor(position, elements)
|
||||||
state.NextUnitIndex
|
: null
|
||||||
)
|
)
|
||||||
: null;
|
?? (
|
||||||
|
states.TryGetValue(slot.Id, out var state)
|
||||||
|
? new PlanningCursor(
|
||||||
|
state.CurrentElementKind,
|
||||||
|
state.CurrentElementId,
|
||||||
|
state.NextUnitIndex
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
planned.Add(
|
planned.Add(
|
||||||
item,
|
item,
|
||||||
@@ -581,6 +603,117 @@ public sealed class GridScheduleGenerator(
|
|||||||
: null;
|
: 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>
|
/// <summary>Отрезок ленты, на который ссылается слот-повтор, в UTC.</summary>
|
||||||
private static PlanningRepeatWindow? BuildRepeatWindow(
|
private static PlanningRepeatWindow? BuildRepeatWindow(
|
||||||
Channel channel,
|
Channel channel,
|
||||||
|
|||||||
@@ -137,8 +137,17 @@ public sealed class PostCheckRunner(IAppDbContext dbContext)
|
|||||||
.Where(i => i.Kind == PlannedItemKind.Program && i.ShowId is not null)
|
.Where(i => i.Kind == PlannedItemKind.Program && i.ShowId is not null)
|
||||||
.GroupBy(i => BroadcastDate(i.StartsAtUtc + offset, dayStartTime));
|
.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)
|
foreach (var day in days)
|
||||||
{
|
{
|
||||||
|
if (!IsWholeDay(day.Key, dayStartTime, from, until))
|
||||||
|
continue;
|
||||||
|
|
||||||
var total = day.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes);
|
var total = day.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes);
|
||||||
if (total <= 0)
|
if (total <= 0)
|
||||||
continue;
|
continue;
|
||||||
@@ -172,6 +181,18 @@ public sealed class PostCheckRunner(IAppDbContext dbContext)
|
|||||||
private static DateTimeOffset Truncate(DateTimeOffset value, TimeSpan step) =>
|
private static DateTimeOffset Truncate(DateTimeOffset value, TimeSpan step) =>
|
||||||
value.AddTicks(-(value.Ticks % step.Ticks));
|
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>
|
/// <summary>Вещательные сутки момента: ночь до <paramref name="dayStartTime"/> — вчерашний день.</summary>
|
||||||
private static DateOnly BroadcastDate(DateTimeOffset channelTime, TimeOnly dayStartTime) =>
|
private static DateOnly BroadcastDate(DateTimeOffset channelTime, TimeOnly dayStartTime) =>
|
||||||
DateOnly.FromDateTime(
|
DateOnly.FromDateTime(
|
||||||
|
|||||||
@@ -15,102 +15,71 @@ public sealed record BackgroundPick(
|
|||||||
/// всё это закрывал аварийный запас — зацикленный филлер канала, — и в эфире получались часы
|
/// всё это закрывал аварийный запас — зацикленный филлер канала, — и в эфире получались часы
|
||||||
/// прочерков там, где по замыслу должен идти фон со своей группой и своим временем суток.
|
/// прочерков там, где по замыслу должен идти фон со своей группой и своим временем суток.
|
||||||
///
|
///
|
||||||
/// Состояние держится на прогон: фон зовут десятки раз за сутки, и он обязан продолжаться с той
|
/// Позиция слота живёт не здесь, а в общем на прогон <see cref="SlotCursors"/>: тот же слот может
|
||||||
/// серии, на которой остановился, а не начинаться заново в каждой паузе.
|
/// в одни сутки идти обычным (его ведёт основной цикл), а в другие быть перекрытым и играть только
|
||||||
|
/// в паузах. Со своим счётчиком фон переигрывал бы серии, уже поставленные основным циклом.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Единица фона под остаток <paramref name="available"/>; null — фон на это время не задан,
|
/// Единица фона под остаток <paramref name="available"/>; null — фон на это время не задан,
|
||||||
/// пуст или его серия длиннее остатка. Короткий хвост добирает аварийный запас.
|
/// пуст или его серия длиннее остатка. Короткий хвост добирает аварийный запас.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public BackgroundPick? Next(DateTimeOffset moment, TimeSpan available)
|
public BackgroundPick? Next(DateTimeOffset moment, TimeSpan available)
|
||||||
{
|
{
|
||||||
if (SlotAt(moment) is not { } slot)
|
if (SlotAt(moment) is not { } scheduled)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var state = State(slot);
|
var slot = cursors.Apply(scheduled);
|
||||||
if (!state.HasUnitsLeft && !Reselect(slot, state, moment))
|
|
||||||
|
// Начатый элемент доигрывается; исчерпан — берём следующий по стратегии слота.
|
||||||
|
if ((Position(slot) ?? Reselect(slot, moment)) is not { } position)
|
||||||
return null;
|
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)
|
if (unit.Duration <= TimeSpan.Zero || unit.Duration > available)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
state.NextUnitIndex++;
|
cursors.Advance(slot.SlotId, element, nextUnitIndex + 1);
|
||||||
return new BackgroundPick(slot, state.Element, unit, slot.Strategy.Kind);
|
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>
|
/// <summary>Активный фоновый слот на момент; сутки фон покрывает целиком, но дыры возможны.</summary>
|
||||||
private PlanningSlot? SlotAt(DateTimeOffset moment) =>
|
private PlanningSlot? SlotAt(DateTimeOffset moment) =>
|
||||||
slots.FirstOrDefault(s => s.TargetStartUtc <= moment && moment < s.TargetEndUtc);
|
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))
|
if (slot.Cursor?.ElementId is not { } elementId)
|
||||||
return existing;
|
return null;
|
||||||
|
|
||||||
// Первый заход — стартуем с того места, где фон остановился в прошлом прогоне.
|
var element = slot.Elements.FirstOrDefault(e =>
|
||||||
var state = new BackgroundState();
|
e.ElementId == elementId && e.Kind == slot.Cursor.ElementKind
|
||||||
if (slot.Cursor?.ElementId is { } elementId)
|
);
|
||||||
{
|
if (element is null)
|
||||||
state.Element = slot.Elements.FirstOrDefault(e =>
|
return null;
|
||||||
e.ElementId == elementId && e.Kind == slot.Cursor.ElementKind
|
|
||||||
);
|
|
||||||
state.NextUnitIndex = state.Element is null
|
|
||||||
? 0
|
|
||||||
: Math.Clamp(slot.Cursor.NextUnitIndex, 0, state.Element.Units.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
_states[slot.SlotId] = state;
|
var index = Math.Clamp(slot.Cursor.NextUnitIndex, 0, element.Units.Count);
|
||||||
return state;
|
return index < element.Units.Count ? (element, index) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Берёт следующий элемент по стратегии слота; false — играть в фоне нечем.</summary>
|
/// <summary>Берёт следующий элемент по стратегии слота; null — играть в фоне нечем.</summary>
|
||||||
private bool Reselect(PlanningSlot slot, BackgroundState state, DateTimeOffset moment)
|
private (PlanningElement Element, int NextUnitIndex)? Reselect(
|
||||||
|
PlanningSlot slot,
|
||||||
|
DateTimeOffset moment
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var withCursor = slot with
|
if (ElementSelector.Select(slot, moment, random, plays) is not { } pick)
|
||||||
{
|
return null;
|
||||||
Cursor = state.Element is null
|
|
||||||
? null
|
|
||||||
: new PlanningCursor(
|
|
||||||
state.Element.Kind,
|
|
||||||
state.Element.ElementId,
|
|
||||||
state.NextUnitIndex
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (ElementSelector.Select(withCursor, moment, random) is not { } pick)
|
var index = Math.Clamp(pick.StartUnitIndex, 0, pick.Element.Units.Count);
|
||||||
return false;
|
return index < pick.Element.Units.Count ? (pick.Element, index) : null;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ public sealed record ElementPick(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ElementSelector
|
public static class ElementSelector
|
||||||
{
|
{
|
||||||
|
/// <param name="history">Показы этого прогона; складывается со снимком ленты из базы —
|
||||||
|
/// без него остывание и потолок повторов не видели бы собственный горизонт.</param>
|
||||||
public static ElementPick? Select(
|
public static ElementPick? Select(
|
||||||
PlanningSlot slot,
|
PlanningSlot slot,
|
||||||
DateTimeOffset moment,
|
DateTimeOffset moment,
|
||||||
IRandomSource random
|
IRandomSource random,
|
||||||
|
RunPlayHistory? history = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// Возрастной потолок — жёсткое «нельзя», поэтому отсекает до любой стратегии и до курсора:
|
// Возрастной потолок — жёсткое «нельзя», поэтому отсекает до любой стратегии и до курсора:
|
||||||
@@ -41,7 +44,7 @@ public static class ElementSelector
|
|||||||
{
|
{
|
||||||
SlotStrategyKind.Fixed => SelectFixed(slot, playable),
|
SlotStrategyKind.Fixed => SelectFixed(slot, playable),
|
||||||
SlotStrategyKind.Sequential => SelectSequential(slot, playable, current),
|
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,
|
List<PlanningElement> playable,
|
||||||
PlanningElement? current,
|
PlanningElement? current,
|
||||||
DateTimeOffset moment,
|
DateTimeOffset moment,
|
||||||
IRandomSource random
|
IRandomSource random,
|
||||||
|
RunPlayHistory? history
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// Начатый элемент доигрывается до конца: иначе блок «4 серии подряд» рассыпался бы
|
// Начатый элемент доигрывается до конца: иначе блок «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 cooldown = TimeSpan.FromDays(Math.Max(0, slot.Strategy.CooldownDays));
|
||||||
var eligible =
|
var eligible =
|
||||||
cooldown <= TimeSpan.Zero
|
cooldown <= TimeSpan.Zero
|
||||||
? withinLimit
|
? withinLimit
|
||||||
: withinLimit
|
: withinLimit
|
||||||
.Where(e => e.LastPlayedUtc is not { } last || moment - last >= cooldown)
|
.Where(e =>
|
||||||
|
LastPlayedOf(e, history) is not { } last || moment - last >= cooldown
|
||||||
|
)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var exhausted = eligible.Count == 0;
|
var exhausted = eligible.Count == 0;
|
||||||
@@ -121,7 +127,12 @@ public static class ElementSelector
|
|||||||
// повторов пулу: иначе более слабое правило воскрешало бы отсечённое более строгим.
|
// повторов пулу: иначе более слабое правило воскрешало бы отсечённое более строгим.
|
||||||
eligible = slot.Strategy.IgnoreCooldownWhenExhausted
|
eligible = slot.Strategy.IgnoreCooldownWhenExhausted
|
||||||
? withinLimit
|
? withinLimit
|
||||||
: [withinLimit.OrderBy(e => e.LastPlayedUtc ?? DateTimeOffset.MinValue).First()];
|
:
|
||||||
|
[
|
||||||
|
withinLimit
|
||||||
|
.OrderBy(e => LastPlayedOf(e, history) ?? DateTimeOffset.MinValue)
|
||||||
|
.First(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
var picked = WeightedPick(eligible, random);
|
var picked = WeightedPick(eligible, random);
|
||||||
@@ -143,21 +154,39 @@ public static class ElementSelector
|
|||||||
private static List<PlanningElement> ApplyRepeatLimit(
|
private static List<PlanningElement> ApplyRepeatLimit(
|
||||||
PlanningSlot slot,
|
PlanningSlot slot,
|
||||||
List<PlanningElement> playable,
|
List<PlanningElement> playable,
|
||||||
DateTimeOffset moment
|
DateTimeOffset moment,
|
||||||
|
RunPlayHistory? history
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (slot.RepeatLimit is not { } limit || limit.WindowDays <= 0 || limit.Max <= 0)
|
if (slot.RepeatLimit is not { } limit || limit.WindowDays <= 0 || limit.Max <= 0)
|
||||||
return playable;
|
return playable;
|
||||||
|
|
||||||
var from = moment.AddDays(-limit.WindowDays);
|
var from = moment.AddDays(-limit.WindowDays);
|
||||||
var filtered = playable
|
var filtered = playable.Where(e => PlaysSince(e, from, history) < limit.Max).ToList();
|
||||||
.Where(e => (e.RecentPlaysUtc?.Count(p => p >= from) ?? 0) < limit.Max)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// Все упёрлись в потолок — правило не должно оставлять слот пустым: пусть решает остывание.
|
// Все упёрлись в потолок — правило не должно оставлять слот пустым: пусть решает остывание.
|
||||||
return filtered.Count == 0 ? playable : filtered;
|
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>
|
/// <summary>Продолжение текущего элемента с позиции курсора.</summary>
|
||||||
private static ElementPick Continue(PlanningSlot slot, PlanningElement element)
|
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 PlanningInput Input { get; } = input;
|
||||||
public IRandomSource Random { get; } = random;
|
public IRandomSource Random { get; } = random;
|
||||||
public List<PlannedItem> Items { get; } = [];
|
public List<PlannedItem> Items { get; } = [];
|
||||||
public List<PlanningCursorUpdate> Cursors { get; } = [];
|
|
||||||
public List<PlanningWarning> Warnings { get; } = [];
|
public List<PlanningWarning> Warnings { get; } = [];
|
||||||
public JunctionHistory Junctions { get; } = new();
|
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;
|
private JunctionRun? _junctionRun;
|
||||||
|
|
||||||
/// <summary>Накопители, которые нужны раскладке стыка: история, жребий и сама лента.</summary>
|
/// <summary>Накопители, которые нужны раскладке стыка: история, жребий и сама лента.</summary>
|
||||||
@@ -49,8 +65,15 @@ public static class SchedulePlanner
|
|||||||
/// <summary>Ролики и анонсы для добора шва — тоже по кругу, чтобы не повторяться.</summary>
|
/// <summary>Ролики и анонсы для добора шва — тоже по кругу, чтобы не повторяться.</summary>
|
||||||
public UnitRotation<PadUnit> Pad { get; } = new(input.PadUnits, pad => pad.Unit.Duration);
|
public UnitRotation<PadUnit> Pad { get; } = new(input.PadUnits, pad => pad.Unit.Duration);
|
||||||
|
|
||||||
/// <summary>Фоновый слой: чем закрываются паузы до того, как дело дойдёт до аварийного запаса.</summary>
|
private BackgroundFiller? _background;
|
||||||
public BackgroundFiller Background { get; } = new(input.BackgroundSlots, random);
|
|
||||||
|
/// <summary>
|
||||||
|
/// Фоновый слой: чем закрываются паузы до того, как дело дойдёт до аварийного запаса. Ведёт
|
||||||
|
/// те же слоты, что и основной цикл, поэтому получает и общее хранилище позиций, и общую
|
||||||
|
/// историю показов — два независимых счётчика одного слота переигрывали бы серии.
|
||||||
|
/// </summary>
|
||||||
|
public BackgroundFiller Background =>
|
||||||
|
_background ??= new(Input.BackgroundSlots, Random, Cursors, Plays);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static PlanningResult Plan(PlanningInput input, IRandomSource random)
|
public static PlanningResult Plan(PlanningInput input, IRandomSource random)
|
||||||
@@ -96,12 +119,7 @@ public static class SchedulePlanner
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Курсоры фона дописываются последними: основной цикл ведёт свои слоты сам, а фон —
|
return new PlanningResult(run.Items, run.Cursors.Updates, run.Warnings);
|
||||||
// только те, что были перекрыты и обычными слотами в этом прогоне не играли.
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -204,14 +222,27 @@ public static class SchedulePlanner
|
|||||||
}
|
}
|
||||||
|
|
||||||
var slotEnd = Min(slot.TargetEndUtc, limit);
|
var slotEnd = Min(slot.TargetEndUtc, limit);
|
||||||
|
var placed = 0;
|
||||||
foreach (var unit in units)
|
foreach (var unit in units)
|
||||||
{
|
{
|
||||||
if (cursor + unit.Duration > slotEnd)
|
if (cursor + unit.Duration > slotEnd)
|
||||||
break;
|
break;
|
||||||
run.Items.Add(Program(unit, cursor, slot.SlotId, trace));
|
run.Add(Program(unit, cursor, slot.SlotId, trace));
|
||||||
cursor += unit.Duration;
|
cursor += unit.Duration;
|
||||||
|
placed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Источник есть, но не влез ни одной единицей — слот короче того, что повторяет. Молчать
|
||||||
|
// здесь нельзя: снаружи это неотличимо от пустого источника, а чинится совсем другим.
|
||||||
|
if (placed == 0)
|
||||||
|
run.Warnings.Add(
|
||||||
|
new PlanningWarning(
|
||||||
|
PlanningWarningKind.RepeatSourceEmpty,
|
||||||
|
slot.SlotId,
|
||||||
|
"Источник повтора не помещается в слот целиком — слот отдан фоновому слою."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return cursor;
|
return cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,14 +281,19 @@ public static class SchedulePlanner
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static DateTimeOffset FillContent(
|
private static DateTimeOffset FillContent(
|
||||||
PlanningSlot slot,
|
PlanningSlot scheduled,
|
||||||
DateTimeOffset cursor,
|
DateTimeOffset cursor,
|
||||||
DateTimeOffset limit,
|
DateTimeOffset limit,
|
||||||
PlanTrace trace,
|
PlanTrace trace,
|
||||||
PlanningRun run
|
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)
|
if (pick is null)
|
||||||
{
|
{
|
||||||
run.Warnings.Add(NoCandidatesWarning(slot));
|
run.Warnings.Add(NoCandidatesWarning(slot));
|
||||||
@@ -293,7 +329,7 @@ public static class SchedulePlanner
|
|||||||
&& !FitsEntirely(element, unitIndex, cursor, budgetEnd)
|
&& !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);
|
return FillGap(cursor, budgetEnd, run, slot.SlotId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,7 +361,7 @@ public static class SchedulePlanner
|
|||||||
if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd))
|
if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd))
|
||||||
break;
|
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;
|
cursor += unit.Duration;
|
||||||
accumulated += unit.Duration;
|
accumulated += unit.Duration;
|
||||||
unitIndex++;
|
unitIndex++;
|
||||||
@@ -350,7 +386,7 @@ public static class SchedulePlanner
|
|||||||
slotTrace
|
slotTrace
|
||||||
);
|
);
|
||||||
|
|
||||||
run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
|
run.Cursors.Advance(slot.SlotId, element, unitIndex);
|
||||||
|
|
||||||
// Недобор до целевого конца закрываем фоном — только для слотов, чей бюджет привязан ко времени.
|
// Недобор до целевого конца закрываем фоном — только для слотов, чей бюджет привязан ко времени.
|
||||||
if (slot.BlockMode == SlotBlockMode.FillSlot && cursor < budgetEnd)
|
if (slot.BlockMode == SlotBlockMode.FillSlot && cursor < budgetEnd)
|
||||||
@@ -464,7 +500,7 @@ public static class SchedulePlanner
|
|||||||
if (run.Pad.Next(limit - cursor) is not { } pad)
|
if (run.Pad.Next(limit - cursor) is not { } pad)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
run.Items.Add(
|
run.Add(
|
||||||
new PlannedItem(
|
new PlannedItem(
|
||||||
pad.Unit.MediaAssetId,
|
pad.Unit.MediaAssetId,
|
||||||
cursor,
|
cursor,
|
||||||
@@ -496,7 +532,7 @@ public static class SchedulePlanner
|
|||||||
if (run.Background.Next(cursor, until - cursor) is not { } pick)
|
if (run.Background.Next(cursor, until - cursor) is not { } pick)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
run.Items.Add(
|
run.Add(
|
||||||
new PlannedItem(
|
new PlannedItem(
|
||||||
pick.Unit.MediaAssetId,
|
pick.Unit.MediaAssetId,
|
||||||
cursor,
|
cursor,
|
||||||
@@ -548,7 +584,7 @@ public static class SchedulePlanner
|
|||||||
if (run.Fallback.Next(until - cursor) is not { } unit)
|
if (run.Fallback.Next(until - cursor) is not { } unit)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
run.Items.Add(
|
run.Add(
|
||||||
new PlannedItem(
|
new PlannedItem(
|
||||||
unit.MediaAssetId,
|
unit.MediaAssetId,
|
||||||
cursor,
|
cursor,
|
||||||
@@ -593,12 +629,6 @@ public static class SchedulePlanner
|
|||||||
CollectionId: collectionId
|
CollectionId: collectionId
|
||||||
);
|
);
|
||||||
|
|
||||||
private static PlanningCursorUpdate CursorUpdate(
|
|
||||||
PlanningSlot slot,
|
|
||||||
PlanningElement element,
|
|
||||||
int nextUnitIndex
|
|
||||||
) => new(slot.SlotId, element.Kind, element.ElementId, nextUnitIndex);
|
|
||||||
|
|
||||||
/// <summary>Ближайший якорь среди последующих слотов — до него нельзя перелезать контентом.</summary>
|
/// <summary>Ближайший якорь среди последующих слотов — до него нельзя перелезать контентом.</summary>
|
||||||
private static DateTimeOffset? FindNextAnchor(IReadOnlyList<PlanningSlot> slots, int fromIndex)
|
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
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -368,6 +368,22 @@ public class SchedulePlannerTests
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Источник нашёлся, но слот короче — это другая беда, и молчать о ней нельзя.</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Старты эластичные: фильм законно начинается раньше объявленного времени, и окно повтора
|
/// Старты эластичные: фильм законно начинается раньше объявленного времени, и окно повтора
|
||||||
/// обязано ловить его по пересечению, а не по попаданию старта внутрь.
|
/// обязано ловить его по пересечению, а не по попаданию старта внутрь.
|
||||||
@@ -391,6 +407,96 @@ public class SchedulePlannerTests
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Слот приходит по экземпляру на каждые свои сутки, и курсор у всех этих экземпляров один —
|
||||||
|
/// снимок из базы. Без общей позиции вторник переигрывал бы понедельник.
|
||||||
|
/// </summary>
|
||||||
|
[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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Тот же слот бывает перекрыт в одни сутки и свободен в другие: фон и основной цикл ведут его
|
||||||
|
/// вместе, и второй счётчик позиции переигрывал бы уже поставленные серии.
|
||||||
|
/// </summary>
|
||||||
|
[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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Остывание обязано видеть то, что прогон поставил сам: горизонт строится разом.</summary>
|
||||||
|
[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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Потолок повторов — тот же случай: показы прогона считаются наравне с записанными.</summary>
|
||||||
|
[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]
|
[Fact]
|
||||||
public void Trace_IsWrittenWithSlotAndStrategy()
|
public void Trace_IsWrittenWithSlotAndStrategy()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -115,6 +115,54 @@ public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixtur
|
|||||||
Assert.Equal(past.Count, survived.Count);
|
Assert.Equal(past.Count, survived.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Пересборка выбрасывает хвост, который курсор уже прошёл. Без отмотки к отыгранному каждое
|
||||||
|
/// применение проматывало бы библиотеку на горизонт вперёд, теряя невышедшие серии.
|
||||||
|
/// </summary>
|
||||||
|
[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]
|
[SkippableFact]
|
||||||
public async Task Generate_CollectionInGroup_StampsCollectionOnEntries()
|
public async Task Generate_CollectionInGroup_StampsCollectionOnEntries()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ public sealed class TransactionIntegrationTests(PostgresFixture fixture)
|
|||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task DeleteShow_WithSchedule_CutsFutureEntries()
|
public async Task DeleteShow_WithSchedule_CutsFutureEntries()
|
||||||
{
|
{
|
||||||
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||||
var channel = Channel.Create("Канал", $"cut-{Guid.NewGuid():N}", DateTimeOffset.UnixEpoch);
|
var channel = Channel.Create("Канал", $"cut-{Guid.NewGuid():N}", DateTimeOffset.UnixEpoch);
|
||||||
var show = Show.Create("Шоу", ShowKind.Single);
|
var show = Show.Create("Шоу", ShowKind.Single);
|
||||||
var asset = MediaAsset.Register("live.mkv", ".mkv", MediaSource.Upload);
|
var asset = MediaAsset.Register("live.mkv", ".mkv", MediaSource.Upload);
|
||||||
@@ -181,6 +182,7 @@ public sealed class TransactionIntegrationTests(PostgresFixture fixture)
|
|||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task DeleteShow_WithMedia_RemovesEverything()
|
public async Task DeleteShow_WithMedia_RemovesEverything()
|
||||||
{
|
{
|
||||||
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||||
var channel = Channel.Create("Канал", $"del-{Guid.NewGuid():N}", DateTimeOffset.UnixEpoch);
|
var channel = Channel.Create("Канал", $"del-{Guid.NewGuid():N}", DateTimeOffset.UnixEpoch);
|
||||||
var show = Show.Create("Шоу", ShowKind.Single);
|
var show = Show.Create("Шоу", ShowKind.Single);
|
||||||
var asset = MediaAsset.Register("movie.mkv", ".mkv", MediaSource.Upload);
|
var asset = MediaAsset.Register("movie.mkv", ".mkv", MediaSource.Upload);
|
||||||
|
|||||||
@@ -409,9 +409,27 @@ SlotState
|
|||||||
Курсор хранит **ссылку на элемент**, а не числовой индекс в группе: при удалении позиции из группы
|
Курсор хранит **ссылку на элемент**, а не числовой индекс в группе: при удалении позиции из группы
|
||||||
курсор корректно переезжает на следующую, а не сдвигает всё.
|
курсор корректно переезжает на следующую, а не сдвигает всё.
|
||||||
|
|
||||||
|
**Курсор один на слот в пределах прогона.** `SlotState` — это снимок на начало прогона, а слот
|
||||||
|
попадает в планировщик по экземпляру на каждые свои вещательные сутки; горизонт в неделю строится
|
||||||
|
целиком. Позиция поэтому живёт в накопителе прогона, а снимок только задаёт её начальное значение.
|
||||||
|
Иначе вторник начинался бы ровно с того места, что и понедельник. По той же причине позиция общая
|
||||||
|
с фоновым слоем: слот, перекрытый в одни сутки и свободный в другие, идёт то основным циклом, то
|
||||||
|
в паузах, и второй счётчик переигрывал бы уже поставленные серии. Берётся позиция в момент выбора
|
||||||
|
элемента, а не при входе в слот: паузу перед стартом слота закрывает фон, и он мог сдвинуть её
|
||||||
|
только что.
|
||||||
|
|
||||||
**История показов для остывания** берётся из материализованного расписания (`ScheduleEntry` с
|
**История показов для остывания** берётся из материализованного расписания (`ScheduleEntry` с
|
||||||
`Kind = Program`, индекс по `channelId + showId + startsAtUtc`), отдельного журнала не заводим — одна
|
`Kind = Program`, индекс по `channelId + showId + startsAtUtc`), отдельного журнала не заводим — одна
|
||||||
правда, и при пересборке хвоста будущие показы удаляются вместе с записями.
|
правда, и при пересборке хвоста будущие показы удаляются вместе с записями. К ней **прибавляются
|
||||||
|
показы текущего прогона**: лента на момент сборки входа содержит только прошлое, и без этого
|
||||||
|
остывание с потолком повторов не видели бы собственный горизонт — на свежем канале оба правила
|
||||||
|
не срабатывали бы ни разу.
|
||||||
|
|
||||||
|
**Пересборка отматывает курсор к отыгранному.** Снос будущего хвоста выбрасывает выходы, которые
|
||||||
|
курсор уже прошёл; без отмотки каждое применение проматывало бы библиотеку на горизонт вперёд,
|
||||||
|
теряя серии, которые так и не вышли. Точка берётся по последней уцелевшей записи каждого слота:
|
||||||
|
элемент — из её трейса, позиция — поиском единицы в развёрнутом элементе (у коллекции серии
|
||||||
|
нумеруются внутри каждой части, и `episodeIndex` как индекс в общей последовательности не годится).
|
||||||
|
|
||||||
Из этого следует: `SchedulerOptions.RetentionHours` (сейчас 24 часа) заменяется на `RetentionDays`,
|
Из этого следует: `SchedulerOptions.RetentionHours` (сейчас 24 часа) заменяется на `RetentionDays`,
|
||||||
дефолт 90. Значение обязано покрывать максимальный `cooldownDays` среди правил канала и максимальный
|
дефолт 90. Значение обязано покрывать максимальный `cooldownDays` среди правил канала и максимальный
|
||||||
@@ -829,8 +847,10 @@ seed = hash(channelId, date, slotId, occurrenceInDay)
|
|||||||
|
|
||||||
### 5.2. После генерации
|
### 5.2. После генерации
|
||||||
|
|
||||||
- элементы, отданные фону (не нашлось контента);
|
- элементы, отданные фону (не нашлось контента, либо источник повтора не поместился в слот целиком —
|
||||||
- пост-проверки из 3.8;
|
снаружи это неотличимо от пустого источника, а чинится совсем другим);
|
||||||
|
- пост-проверки из 3.8 — доли считаются только по вещательным суткам, попавшим в прогон целиком:
|
||||||
|
на краях горизонта сутки обрезаны, и один фильм честно занимает в таком огрызке больше половины;
|
||||||
- тепловая карта повторов: матрица «элемент × день», яркость = число показов — сразу видно, что один
|
- тепловая карта повторов: матрица «элемент × день», яркость = число показов — сразу видно, что один
|
||||||
фильм крутится четыре раза за неделю;
|
фильм крутится четыре раза за неделю;
|
||||||
- фактическая доля врезок по часам против лимита.
|
- фактическая доля врезок по часам против лимита.
|
||||||
|
|||||||
Reference in New Issue
Block a user