using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning;
/// Что дала генерация: сколько записей добавлено и с какими предупреждениями.
public sealed record GenerationReport(
int Added,
IReadOnlyList Warnings,
bool ChannelSkipped = false
);
///
/// Оркестратор генерации по сетке: собирает конфигурацию канала, вызывает чистый планировщик,
/// материализует ленту и двигает курсоры слотов.
///
/// Генерация одного канала сериализуется advisory-блокировкой: фоновый тик и ручное применение
/// не должны читать одну точку продолжения и оба дописывать хвост.
///
public sealed class GridScheduleGenerator(
IAppDbContext dbContext,
GroupExpander expander,
BumperResolver bumperResolver,
PostCheckRunner postChecks,
IRandomSource random,
IOptions options,
IOptions streamingOptions
)
{
private readonly SchedulerOptions _options = options.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
private static readonly JsonSerializerOptions TraceJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
///
/// Достраивает горизонт канала, а при — пересобирает будущий
/// хвост от текущего момента. Прошлое и идущая сейчас запись не трогаются никогда: зритель
/// не должен обнаружить, что у него из-под носа вырезали программу.
///
public async Task GenerateAsync(
Guid channelId,
DateTimeOffset now,
bool rebuildFuture,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels.FirstOrDefaultAsync(
c => c.Id == channelId,
cancellationToken
);
if (channel is null)
return new GenerationReport(0, [], ChannelSkipped: true);
if (!channel.IsEnabled || channel.TemplateId is null)
return await SilentChannelAsync(channelId, now, rebuildFuture, cancellationToken);
await using var transaction = await dbContext.BeginTransactionAsync(cancellationToken);
await dbContext.AcquireChannelLockAsync(channelId, cancellationToken);
var template = await dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Id == channel.TemplateId, cancellationToken);
if (template is null)
return new GenerationReport(0, [], ChannelSkipped: true);
await CleanupAsync(channelId, now, cancellationToken);
// Пересборка выбрасывает хвост, который курсоры уже «съели»: без отмотки каждое применение
// проматывало бы библиотеку на горизонт вперёд, теряя серии, которые так и не вышли.
var aired = new Dictionary();
if (rebuildFuture)
{
await DropFutureAsync(channelId, now, cancellationToken);
aired = await LoadAiredPositionsAsync(channelId, cancellationToken);
}
// Точка продолжения — конец последней сохранённой записи. При пересборке будущее уже удалено,
// поэтому она укажет на границу неизменяемого прошлого.
var lastEnd = await dbContext
.ScheduleEntries.Where(e => e.ChannelId == channelId)
.MaxAsync(e => (DateTimeOffset?)e.EndsAtUtc, cancellationToken);
var startUtc = lastEnd is { } end && end > now ? end : now;
var horizonEnd = now.AddDays(Math.Max(1, _options.HorizonDays));
if (startUtc >= horizonEnd)
{
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return new GenerationReport(0, []);
}
var input = await BuildInputAsync(
channel,
template,
startUtc,
horizonEnd,
aired,
cancellationToken
);
var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random);
// Пост-проверки считаются по готовой ленте и только предупреждают — переигрывать что-либо
// по их итогу мы намеренно не будем (см. 3.8).
var postWarnings = await postChecks.RunAsync(
result.Items,
PlanningRules.FromJson(template.RulesJson),
channel.UtcOffsetMinutes,
channel.DayStartTime,
cancellationToken
);
// Заставки резолвятся после сборки ленты: пара соседей известна только теперь.
var bumperAssets = await bumperResolver.ResolveAsync(
channel,
result.Items,
cancellationToken
);
var added = WriteEntries(channel.Id, result.Items, bumperAssets);
await SaveCursorsAsync(result.Cursors, cancellationToken);
// Снимок вместе с отметкой: применённое состояние — единственная корректная точка отката,
// и снимать его надо ровно тогда, когда по нему собрана лента.
template.MarkApplied(TemplateSnapshot.Capture(template).ToJson());
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return new GenerationReport(added, [.. result.Warnings, .. postWarnings]);
}
///
/// Сухой прогон: считает, каким получился бы эфир по текущим правилам, но ничего не пишет —
/// ни ленты, ни курсоров слотов, ни отметки о применении. Заставки остаются резервом известной
/// длины: рендер долгий и может упасть, поэтому он делается только при реальном применении.
///
public async Task PreviewAsync(
Guid channelId,
DateTimeOffset from,
int days,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
if (channel is null || channel.TemplateId is null)
return null;
var template = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Id == channel.TemplateId, cancellationToken);
if (template is null)
return null;
var horizonEnd = from.AddDays(Math.Clamp(days, 1, Math.Max(1, _options.HorizonDays)));
// Предпросмотр ничего не сносит, отматывать нечего: симуляция идёт от текущего состояния.
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(
result.Items,
PlanningRules.FromJson(template.RulesJson),
channel.UtcOffsetMinutes,
channel.DayStartTime,
cancellationToken
);
return result with
{
Warnings = [.. result.Warnings, .. postWarnings],
};
}
///
/// Канал выключен или без сетки — вещать нечем. По таймеру это просто пропуск, но по кнопке
/// «Применить» админ ждёт, что лента придёт в соответствие с настройками: раз эфира нет,
/// не должно остаться и будущих записей. Иначе они висят вечно — пересобрать их нечем,
/// а удалить шоу, которое в них стоит, невозможно.
///
private async Task SilentChannelAsync(
Guid channelId,
DateTimeOffset now,
bool rebuildFuture,
CancellationToken cancellationToken
)
{
if (!rebuildFuture)
return new GenerationReport(0, [], ChannelSkipped: true);
await DropFutureAsync(channelId, now, cancellationToken);
return new GenerationReport(0, []);
}
/// Снимает будущий хвост ленты. Прошлое и идущую запись не трогает никогда.
private Task DropFutureAsync(
Guid channelId,
DateTimeOffset now,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleEntries.Where(e => e.ChannelId == channelId && e.StartsAtUtc >= now)
.ExecuteDeleteAsync(cancellationToken);
///
/// Переносит собранную ленту в записи расписания. Заставка без отрендеренного ассета
/// пропускается: запись без файла стала бы дырой в эфире.
///
private int WriteEntries(
Guid channelId,
IReadOnlyList items,
IReadOnlyDictionary bumperAssets
)
{
var added = 0;
for (var index = 0; index < items.Count; index++)
{
var item = items[index];
var assetId = item.MediaAssetId;
if (
item.Kind == PlannedItemKind.Bumper
&& !bumperAssets.TryGetValue(index, out assetId)
)
continue;
dbContext.ScheduleEntries.Add(
ScheduleEntry.FromSlot(
channelId,
assetId,
ToEntryKind(item.Kind),
item.StartsAtUtc,
item.EndsAtUtc,
new ScheduleEntryOrigin(
item.ShowId,
item.UnitIndex,
item.SlotId,
item.Trace is null
? null
: JsonSerializer.Serialize(item.Trace, TraceJsonOptions),
item.CollectionId
)
)
);
added++;
}
return added;
}
private Task CleanupAsync(
Guid channelId,
DateTimeOffset now,
CancellationToken cancellationToken
)
{
var cutoff = now.AddDays(-Math.Max(1, _options.RetentionDays));
return dbContext
.ScheduleEntries.Where(e => e.ChannelId == channelId && e.EndsAtUtc < cutoff)
.ExecuteDeleteAsync(cancellationToken);
}
private async Task SaveCursorsAsync(
IReadOnlyList cursors,
CancellationToken cancellationToken
)
{
if (cursors.Count == 0)
return;
var slotIds = cursors.Select(c => c.SlotId).Distinct().ToList();
var states = await dbContext
.SlotStates.Where(s => slotIds.Contains(s.SlotId))
.ToDictionaryAsync(s => s.SlotId, cancellationToken);
foreach (var cursor in cursors)
{
if (!states.TryGetValue(cursor.SlotId, out var state))
{
state = SlotState.Create(cursor.SlotId);
dbContext.SlotStates.Add(state);
states[cursor.SlotId] = state;
}
if (cursor.ElementKind is { } kind && cursor.ElementId is { } elementId)
state.MoveTo(kind, elementId, cursor.NextUnitIndex);
else
state.Reset();
}
}
/// Куда слот доиграл в уцелевшей ленте — перебивает снимок состояния при
/// пересборке; пусто на обычном тике и в предпросмотре.
private async Task BuildInputAsync(
Channel channel,
ScheduleTemplate template,
DateTimeOffset startUtc,
DateTimeOffset horizonEnd,
IReadOnlyDictionary aired,
CancellationToken cancellationToken
)
{
var grid = EffectiveGridBuilder.Build(
template,
channel.UtcOffsetMinutes,
channel.DayStartTime,
startUtc,
horizonEnd
);
// Перекрытые слоты фонового слоя разворачиваются так же, как обычные: паузы закрываются
// их контентом, и им нужны и группа, и стратегия, и курсор.
var scheduled = grid.Slots.Concat(grid.Background).ToList();
// Стыки грузим целиком: их немного, а группы врезок надо развернуть тем же проходом,
// что и группы контента. Стыки общие, поэтому фильтра по каналу нет.
var junctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Include(j => j.Elements)
.ToDictionaryAsync(j => j.Id, cancellationToken);
// Блоки заставок нужны только длительностью — текст подставит резолвер после сборки ленты.
var bumperTemplates = await dbContext
.BumperTemplates.AsNoTracking()
.ToDictionaryAsync(t => t.Id, cancellationToken);
var groupIds = scheduled
.Select(s => s.Slot.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Concat(
junctions
.Values.SelectMany(j => j.Elements)
.Select(e => e.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
)
.Distinct()
.ToList();
// Правила канала: детское время резолвится на каждый слот по его времени, а под потолок
// повторов нужна история показов — её глубина и задаётся окном правила.
var rules = PlanningRules.FromJson(template.RulesJson);
var repeatLimit = rules?.MaxRepeatsInWindow is { WindowDays: > 0, Max: > 0 } limit
? new RepeatLimit(limit.WindowDays, limit.Max)
: null;
var elementsByGroup = await expander.ExpandAsync(
groupIds,
channel.Id,
cancellationToken,
repeatLimit is null ? null : startUtc.AddDays(-repeatLimit.WindowDays)
);
var slotIds = scheduled.Select(s => s.Slot.Id).Distinct().ToList();
var states = await dbContext
.SlotStates.AsNoTracking()
.Where(s => slotIds.Contains(s.SlotId))
.ToDictionaryAsync(s => s.SlotId, cancellationToken);
var planned = new Dictionary();
foreach (var item in scheduled)
{
var slot = item.Slot;
var elements =
slot.GroupId is { } groupId
&& elementsByGroup.TryGetValue(groupId, out var groupElements)
? groupElements
: [];
var repeatWindow =
slot.SlotKind == SlotKind.Repeat ? BuildRepeatWindow(channel, slot, item) : null;
var repeatUnits = repeatWindow is null
? null
: await LoadRepeatUnitsAsync(channel.Id, repeatWindow, startUtc, cancellationToken);
var strategy = ToPlanningStrategy(SlotStrategy.FromJson(slot.StrategyJson));
var cursor =
(
aired.TryGetValue(slot.Id, out var position)
? RewoundCursor(position, elements)
: null
)
?? (
states.TryGetValue(slot.Id, out var state)
? new PlanningCursor(
state.CurrentElementKind,
state.CurrentElementId,
state.NextUnitIndex
)
: null
);
planned.Add(
item,
new PlanningSlot(
slot.Id,
item.StartUtc,
slot.TargetDurationMinutes,
slot.SlotKind,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes,
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
strategy,
elements,
cursor,
repeatUnits,
repeatWindow,
BuildJunction(
slot.JunctionBetweenId,
junctions,
elementsByGroup,
bumperTemplates,
slot.Daypart
),
BuildJunction(
slot.JunctionAfterId ?? template.DefaultJunctionId,
junctions,
elementsByGroup,
bumperTemplates,
slot.Daypart
),
rules?.AudienceAt(
TimeOnly.FromDateTime(
item.StartUtc.ToOffset(
TimeSpan.FromMinutes(channel.UtcOffsetMinutes)
).DateTime
)
),
repeatLimit
)
);
}
var fallback = await LoadFallbackUnitsAsync(channel, template, cancellationToken);
// Чем добивается шов до круглой отметки: ролики и анонсы стыка канала по умолчанию.
var padJunction = BuildJunction(
template.DefaultJunctionId,
junctions,
elementsByGroup,
bumperTemplates,
Daypart.Day
);
return new PlanningInput(
channel.Id,
startUtc,
horizonEnd,
grid.Slots.Select(s => planned[s]).ToList(),
fallback,
_segmentSeconds,
channel.UtcOffsetMinutes,
grid.Background.Select(s => planned[s]).ToList(),
PadUnits(padJunction),
padJunction?.MaxTotal ?? PlanningInput.DefaultMaxPad
);
}
///
/// Ролики и анонсы стыка, распрямлённые в плоский список для добора шва. Заставки сюда
/// не попадают: их ассет рендерится под конкретную пару соседей, а шов — это ровно то место,
/// где пары ещё нет.
///
private static IReadOnlyList PadUnits(PlanningJunction? junction) =>
junction is null
? []
: junction
.Elements.Where(e => e.Kind != JunctionElementKind.Bumper && e.Units.Count > 0)
.SelectMany(e =>
e.Units.Select(unit => new PadUnit(
unit,
e.Kind == JunctionElementKind.Promo
? PlannedItemKind.Promo
: PlannedItemKind.Ad
))
)
.ToList();
/// Разворачивает шаблон стыка для планировщика, включая резерв под заставки.
private PlanningJunction? BuildJunction(
Guid? junctionId,
IReadOnlyDictionary junctions,
IReadOnlyDictionary> elementsByGroup,
IReadOnlyDictionary bumperTemplates,
Daypart daypart
)
{
if (junctionId is not { } id || !junctions.TryGetValue(id, out var template))
return null;
var elements = new List();
foreach (var element in template.Elements.OrderBy(e => e.Position))
{
var conditions =
JunctionConditions.FromJson(element.ConditionsJson) ?? new JunctionConditions();
// Дейпарт — свойство слота, а не момента: отсекаем здесь, чтобы домен не знал про сетку.
if (!conditions.AllowsDaypart(daypart))
continue;
var units = ResolveUnits(element, elementsByGroup, bumperTemplates, out var bumper);
if (units is null)
continue;
elements.Add(
new PlanningJunctionElement(
element.Id,
element.Kind,
units,
element.AmountMode,
element.AmountValue,
element.IsRequired,
conditions.OnlyOnElementChange,
conditions.MinMinutesBetween,
conditions.Chance,
conditions.TimeWindow is { } window
? new PlanningTimeWindow(window.From, window.To)
: null,
conditions.NearHourMinutes,
element.ChoiceKey,
element.ChoiceWeight,
element.BumperTemplateId,
element.BumperVariantId,
bumper
)
);
}
if (elements.Count == 0)
return null;
var cap = template.MaxTotalSeconds is { } seconds
? TimeSpan.FromSeconds(seconds)
: (TimeSpan?)null;
return new PlanningJunction(id, elements, cap);
}
///
/// Что играет во врезке: единицы группы либо резерв под заставку. null — врезка настроена
/// не до конца (нет группы или блока), и в эфир ей идти нечем.
///
private IReadOnlyList? ResolveUnits(
JunctionElement element,
IReadOnlyDictionary> elementsByGroup,
IReadOnlyDictionary bumperTemplates,
out TimeSpan bumperDuration
)
{
bumperDuration = TimeSpan.Zero;
if (element.Kind == JunctionElementKind.Bumper)
{
// Длительность задаётся блоком (по звуку) и выровнена на сегмент: планировщик
// резервирует именно её, ассет подставит резолвер после сборки ленты.
if (
element.BumperTemplateId is not { } templateId
|| !bumperTemplates.TryGetValue(templateId, out var bumperTemplate)
)
return null;
bumperDuration = TimeSpan.FromSeconds(
BumperDuration.Aligned(
BumperDuration.TemplateSeconds(bumperTemplate),
_segmentSeconds
)
);
return [];
}
return
element.GroupId is { } groupId
&& elementsByGroup.TryGetValue(groupId, out var groupElements)
? groupElements.SelectMany(e => e.Units).ToList()
: 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,
Slot slot,
ScheduledSlot scheduled
)
{
var source = RepeatSource.FromJson(slot.RepeatSourceJson);
if (source is null)
return null;
var offset = TimeSpan.FromMinutes(channel.UtcOffsetMinutes);
var sourceDate = scheduled.BroadcastDate.AddDays(-Math.Max(1, source.DaysAgo));
var from = EffectiveGridBuilder.ToUtc(
sourceDate,
source.Time,
offset,
channel.DayStartTime
);
return new PlanningRepeatWindow(from, from.AddMinutes(Math.Max(1, source.DurationMinutes)));
}
///
/// Что играло в точке, на которую ссылается слот-повтор, по уже записанной ленте. Берётся только
/// прошлое до начала прогона: всё, что позже, планировщик найдёт в собственной ленте, и без этой
/// границы предпросмотр складывал бы реальный эфир с симулированным.
///
/// Условие — пересечение с окном, а не попадание старта в него: старты эластичные, и фильм,
/// начавшийся на десять минут раньше объявленного времени, по старту не нашёлся бы.
///
private async Task> LoadRepeatUnitsAsync(
Guid channelId,
PlanningRepeatWindow window,
DateTimeOffset startUtc,
CancellationToken cancellationToken
)
{
var from = window.FromUtc;
var to = window.ToUtc;
var entries = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channelId
&& e.Kind == ScheduleEntryKind.Program
&& e.StartsAtUtc < to
&& e.EndsAtUtc > from
&& e.StartsAtUtc < startUtc
)
.OrderBy(e => e.StartsAtUtc)
.Select(e => new
{
e.MediaAssetId,
e.ShowId,
e.EpisodeIndex,
Duration = e.EndsAtUtc - e.StartsAtUtc,
})
.ToListAsync(cancellationToken);
return entries
.Select(e => new PlanningUnit(
e.MediaAssetId,
e.Duration,
e.ShowId ?? Guid.Empty,
e.EpisodeIndex ?? 0
))
.ToList();
}
///
/// Чем закрывать паузы. Сначала аварийная группа шаблона, затем филлер канала — последний
/// зациклен, поэтому короткая единица закрывает любой остаток лучше длинной.
///
private async Task> LoadFallbackUnitsAsync(
Channel channel,
ScheduleTemplate template,
CancellationToken cancellationToken
)
{
var units = new List();
if (template.FallbackGroupId is { } groupId)
{
var expanded = await expander.ExpandAsync([groupId], channel.Id, cancellationToken);
if (expanded.TryGetValue(groupId, out var elements))
units.AddRange(elements.SelectMany(e => e.Units));
}
if (channel.FillerAssetId is { } fillerId)
{
var filler = await dbContext
.MediaAssets.AsNoTracking()
.Where(a =>
a.Id == fillerId && a.Status == MediaAssetStatus.Ready && a.Duration != null
)
.Select(a => new { a.Id, a.Duration })
.FirstOrDefaultAsync(cancellationToken);
if (filler is not null)
units.Add(new PlanningUnit(filler.Id, filler.Duration!.Value, Guid.Empty, 0));
}
// Короткие вперёд: чем короче единица, тем меньше остаётся незакрытым остаток паузы.
return units.OrderBy(u => u.Duration).ToList();
}
private static PlanningStrategy ToPlanningStrategy(SlotStrategy? strategy)
{
if (strategy is null)
return new PlanningStrategy(SlotStrategyKind.Sequential);
var kind = strategy.Type switch
{
SlotStrategyType.RandomWithCooldown => SlotStrategyKind.RandomWithCooldown,
SlotStrategyType.Fixed => SlotStrategyKind.Fixed,
_ => SlotStrategyKind.Sequential,
};
return new PlanningStrategy(
kind,
strategy.RestartOnEnd,
strategy.CooldownDays,
strategy.Fallback == CooldownFallback.IgnoreCooldown,
strategy.FixedElementId
);
}
private static ScheduleEntryKind ToEntryKind(PlannedItemKind kind) =>
kind switch
{
PlannedItemKind.SignOff => ScheduleEntryKind.SignOff,
PlannedItemKind.Fallback => ScheduleEntryKind.Fallback,
PlannedItemKind.Ad or PlannedItemKind.Promo => ScheduleEntryKind.Ad,
PlannedItemKind.Bumper => ScheduleEntryKind.Bumper,
_ => ScheduleEntryKind.Program,
};
}