Files
TeleWave/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs
T
Leonid Pershin 1afe01aed8
ci / build-backend (push) Successful in 2m15s
ci / build-frontend (push) Successful in 1m7s
ci / tests (push) Successful in 2m33s
ci / sonar (push) Successful in 10m26s
Refactor BumperPlaceholders and BumperFacts for improved logic and readability
Updated the BumperPlaceholders class to streamline token extraction from texts, enhancing performance and clarity. Modified BumperFacts to initialize slot titles with an empty array instead of a dictionary for better consistency. Renamed methods in BumperResolver for clarity, and refactored GridScheduleGenerator to simplify return logic. Additionally, improved the BumperLinesEditor component by implementing a keyed list for better state management and user experience.
2026-07-27 23:04:15 +03:00

619 lines
25 KiB
C#

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;
/// <summary>Что дала генерация: сколько записей добавлено и с какими предупреждениями.</summary>
public sealed record GenerationReport(
int Added,
IReadOnlyList<PlanningWarning> Warnings,
bool ChannelSkipped = false
);
/// <summary>
/// Оркестратор генерации по сетке: собирает конфигурацию канала, вызывает чистый планировщик,
/// материализует ленту и двигает курсоры слотов.
///
/// Генерация одного канала сериализуется advisory-блокировкой: фоновый тик и ручное применение
/// не должны читать одну точку продолжения и оба дописывать хвост.
/// </summary>
public sealed class GridScheduleGenerator(
IAppDbContext dbContext,
GroupExpander expander,
BumperResolver bumperResolver,
PostCheckRunner postChecks,
IRandomSource random,
IOptions<SchedulerOptions> options,
IOptions<StreamingOptions> 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,
};
/// <summary>
/// Достраивает горизонт канала, а при <paramref name="rebuildFuture"/> — пересобирает будущий
/// хвост от текущего момента. Прошлое и идущая сейчас запись не трогаются никогда: зритель
/// не должен обнаружить, что у него из-под носа вырезали программу.
/// </summary>
public async Task<GenerationReport> GenerateAsync(
Guid channelId,
DateTimeOffset now,
bool rebuildFuture,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels.FirstOrDefaultAsync(
c => c.Id == channelId,
cancellationToken
);
if (channel is null || !channel.IsEnabled || channel.TemplateId is null)
return new GenerationReport(0, [], ChannelSkipped: true);
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);
if (rebuildFuture)
await dbContext
.ScheduleEntries.Where(e => e.ChannelId == channelId && e.StartsAtUtc >= now)
.ExecuteDeleteAsync(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,
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 = 0;
for (var index = 0; index < result.Items.Count; index++)
{
var item = result.Items[index];
var assetId = item.MediaAssetId;
if (
item.Kind == PlannedItemKind.Bumper
&& !bumperAssets.TryGetValue(index, out assetId)
)
continue; // Без ассета запись стала бы дырой в ленте.
dbContext.ScheduleEntries.Add(
ScheduleEntry.FromSlot(
channel.Id,
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++;
}
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]);
}
/// <summary>
/// Сухой прогон: считает, каким получился бы эфир по текущим правилам, но ничего не пишет —
/// ни ленты, ни курсоров слотов, ни отметки о применении. Заставки остаются резервом известной
/// длины: рендер долгий и может упасть, поэтому он делается только при реальном применении.
/// </summary>
public async Task<PlanningResult?> 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, 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],
};
}
/// <summary>
/// Чистит прошлое сверх окна хранения. Окно должно покрывать самое долгое остывание среди правил —
/// история показов берётся из самой ленты, отдельного журнала нет.
/// </summary>
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<PlanningCursorUpdate> 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<PlanningInput> BuildInputAsync(
Channel channel,
ScheduleTemplate template,
DateTimeOffset startUtc,
DateTimeOffset horizonEnd,
CancellationToken cancellationToken
)
{
var scheduled = EffectiveGridBuilder.Build(
template,
channel.UtcOffsetMinutes,
channel.DayStartTime,
startUtc,
horizonEnd
);
// Стыки грузим целиком: их немного, а группы врезок надо развернуть тем же проходом,
// что и группы контента. Стыки общие, поэтому фильтра по каналу нет.
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 slots = new List<PlanningSlot>();
foreach (var item in scheduled)
{
var slot = item.Slot;
var elements =
slot.GroupId is { } groupId
&& elementsByGroup.TryGetValue(groupId, out var groupElements)
? groupElements
: [];
var repeatUnits =
slot.SlotKind == SlotKind.Repeat
? await LoadRepeatUnitsAsync(channel, slot, item, cancellationToken)
: null;
var strategy = ToPlanningStrategy(SlotStrategy.FromJson(slot.StrategyJson));
var cursor = states.TryGetValue(slot.Id, out var state)
? new PlanningCursor(
state.CurrentElementKind,
state.CurrentElementId,
state.NextUnitIndex
)
: null;
slots.Add(
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,
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);
return new PlanningInput(
channel.Id,
startUtc,
horizonEnd,
slots,
fallback,
_segmentSeconds,
channel.UtcOffsetMinutes
);
}
/// <summary>Разворачивает шаблон стыка для планировщика, включая резерв под заставки.</summary>
private PlanningJunction? BuildJunction(
Guid? junctionId,
IReadOnlyDictionary<Guid, JunctionTemplate> junctions,
IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>> elementsByGroup,
IReadOnlyDictionary<Guid, BumperTemplate> bumperTemplates,
Daypart daypart
)
{
if (junctionId is not { } id || !junctions.TryGetValue(id, out var template))
return null;
var elements = new List<PlanningJunctionElement>();
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,
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);
}
/// <summary>
/// Что играет во врезке: единицы группы либо резерв под заставку. null — врезка настроена
/// не до конца (нет группы или блока), и в эфир ей идти нечем.
/// </summary>
private IReadOnlyList<PlanningUnit>? ResolveUnits(
JunctionElement element,
IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>> elementsByGroup,
IReadOnlyDictionary<Guid, BumperTemplate> 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;
}
/// <summary>
/// Что играло в точке, на которую ссылается слот-повтор. Читается уже записанная лента того же
/// канала — стратегий и состояния повтору не нужно.
/// </summary>
private async Task<IReadOnlyList<PlanningUnit>> LoadRepeatUnitsAsync(
Channel channel,
Slot slot,
ScheduledSlot scheduled,
CancellationToken cancellationToken
)
{
var source = RepeatSource.FromJson(slot.RepeatSourceJson);
if (source is null)
return [];
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
);
var to = from.AddMinutes(Math.Max(1, source.DurationMinutes));
var entries = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channel.Id
&& e.Kind == ScheduleEntryKind.Program
&& e.StartsAtUtc >= from
&& e.StartsAtUtc < to
)
.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();
}
/// <summary>
/// Чем закрывать паузы. Сначала аварийная группа шаблона, затем филлер канала — последний
/// зациклен, поэтому короткая единица закрывает любой остаток лучше длинной.
/// </summary>
private async Task<IReadOnlyList<PlanningUnit>> LoadFallbackUnitsAsync(
Channel channel,
ScheduleTemplate template,
CancellationToken cancellationToken
)
{
var units = new List<PlanningUnit>();
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,
};
}