Files
TeleWave/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs
T

601 lines
24 KiB
C#

using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Domain.Media;
namespace TeleWave.Application.Broadcast.Scheduling;
/// <summary>
/// Оркестратор планирования: загружает конфигурацию канала и готовые ассеты, вызывает чистый
/// <see cref="SchedulePlanner"/>, материализует записи и двигает курсоры. Используется фоновым
/// планировщиком (расширение горизонта) и командой перегенерации (правка конфигурации канала).
/// Заставки-переходы, отмеченные планировщиком, здесь рендерятся (или берутся из кэша) по выбранному
/// блоку и подставляются как обычные ассеты.
/// </summary>
public sealed class ScheduleGenerator(
IAppDbContext dbContext,
IRandomSource random,
IBumperRenderer bumperRenderer,
IBumperTemplateStorage bumperStorage,
IImageStore imageStore,
IOptions<SchedulerOptions> options,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions,
ILogger<ScheduleGenerator> logger
)
{
private readonly SchedulerOptions _options = options.Value;
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>Длительность заставки без загруженного звука (сек) — синтезированный джингл.</summary>
private const int DefaultBumperDurationSeconds = 8;
/// <summary>
/// Достраивает (или, при <paramref name="regenerate"/>, перестраивает будущий хвост) расписание
/// канала до горизонта. Возвращает число добавленных записей (-1 — канал не найден/выключен).
/// </summary>
public async Task<int> GenerateAsync(
Guid channelId,
DateTimeOffset now,
bool regenerate,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels
.Include(c => c.Shows)
.ThenInclude(s => s.PreferredHours)
.Include(c => c.Ads)
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.Include(c => c.Overrides)
.ThenInclude(o => o.Shows)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
if (channel is null || !channel.IsEnabled)
return -1;
var horizonEnd = now.AddDays(_options.HorizonDays);
// Чистим прошлое сверх окна ретеншна.
var retentionCutoff = now.AddHours(-_options.RetentionHours);
await dbContext.ScheduleEntries
.Where(e => e.ChannelId == channelId && e.EndsAtUtc < retentionCutoff)
.ExecuteDeleteAsync(cancellationToken);
// Точка продолжения: конец последней сохранённой записи (для regenerate — только уже стартовавшей).
var lastEnd = await dbContext.ScheduleEntries
.Where(e =>
e.ChannelId == channelId && (!regenerate || e.StartsAtUtc < now)
)
.MaxAsync(e => (DateTimeOffset?)e.EndsAtUtc, cancellationToken);
var startTime = lastEnd ?? now;
if (startTime < now)
startTime = now;
if (regenerate)
await dbContext.ScheduleEntries
.Where(e => e.ChannelId == channelId && e.StartsAtUtc >= now)
.ExecuteDeleteAsync(cancellationToken);
if (startTime >= horizonEnd)
{
await dbContext.SaveChangesAsync(cancellationToken);
return 0;
}
var showNames = await LoadShowNamesAsync(channel, cancellationToken);
var input = await BuildInputAsync(channel, startTime, horizonEnd, cancellationToken);
var result = SchedulePlanner.Plan(input, random);
// Рендерим/достаём из кэша ассеты заставок для всех переходов плана (по паре шоу + блоку).
var bumperAssets = await ResolveBumperAssetsAsync(
channel,
result.Entries,
showNames,
cancellationToken
);
var added = 0;
foreach (var entry in result.Entries)
{
ScheduleEntry? scheduleEntry = entry.Kind switch
{
ScheduleEntryKind.Program => ScheduleEntry.Program(
channel.Id,
entry.MediaAssetId,
entry.StartsAtUtc,
entry.EndsAtUtc,
entry.ShowId!.Value,
entry.EpisodeIndex!.Value
),
ScheduleEntryKind.Ad => ScheduleEntry.Ad(
channel.Id,
entry.MediaAssetId,
entry.StartsAtUtc,
entry.EndsAtUtc
),
ScheduleEntryKind.Bumper => BuildBumperEntry(channel.Id, entry, bumperAssets),
_ => null,
};
if (scheduleEntry is null)
continue;
dbContext.ScheduleEntries.Add(scheduleEntry);
added++;
}
foreach (var channelShow in channel.Shows)
if (result.NextEpisodeIndexByChannelShow.TryGetValue(channelShow.Id, out var idx))
channelShow.SetNextEpisodeIndex(idx);
channel.SetNextAdIndex(result.NextAdIndex);
channel.SetNextBumperIndex(result.NextBumperIndex);
await dbContext.SaveChangesAsync(cancellationToken);
return added;
}
private static ScheduleEntry? BuildBumperEntry(
Guid channelId,
PlannedEntry entry,
IReadOnlyDictionary<(Guid From, Guid To, Guid Variant), Guid> bumperAssets
)
{
// Заставка резолвится по паре шоу + выбранному подблоку (отрендерена/из кэша). Если рендер не
// удался — пропускаем запись (слот заполнит филлер/следующая программа; длину планировщик учёл).
if (
entry.FromShowId is not { } from
|| entry.ToShowId is not { } to
|| entry.BumperVariantId is not { } variant
|| !bumperAssets.TryGetValue((from, to, variant), out var assetId)
)
return null;
return ScheduleEntry.Bumper(
channelId,
assetId,
entry.StartsAtUtc,
entry.EndsAtUtc,
entry.ToShowId,
entry.BumperVariantId
);
}
/// <summary>
/// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id готового
/// ассета-заставки: из кэша (<see cref="BumperAsset"/>) либо свежесгенерированного.
/// </summary>
private async Task<Dictionary<(Guid From, Guid To, Guid Variant), Guid>> ResolveBumperAssetsAsync(
Channel channel,
IReadOnlyList<PlannedEntry> entries,
IReadOnlyDictionary<Guid, string> showNames,
CancellationToken cancellationToken
)
{
var result = new Dictionary<(Guid, Guid, Guid), Guid>();
var templatesById = channel.BumperTemplates.ToDictionary(t => t.Id);
var variantsById = channel.BumperTemplates
.SelectMany(t => t.Variants.Select(v => (Variant: v, Template: t)))
.ToDictionary(x => x.Variant.Id);
var combos = entries
.Where(e =>
e.Kind == ScheduleEntryKind.Bumper
&& e.FromShowId is not null
&& e.ToShowId is not null
&& e.BumperVariantId is not null
)
.Select(e => (
From: e.FromShowId!.Value,
To: e.ToShowId!.Value,
Variant: e.BumperVariantId!.Value
))
.Distinct()
.ToList();
if (combos.Count == 0)
return result;
var fromIds = combos.Select(c => c.From).Distinct().ToList();
var toIds = combos.Select(c => c.To).Distinct().ToList();
// Постеры шоу-получателей (из реестра изображений) — как фон заставки, если у блока нет
// своей фон-картинки. Резолвим id постера → расширение → абсолютный путь.
var showIds = fromIds.Concat(toIds).Distinct().ToList();
var posterShows = await dbContext.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id) && s.PosterImageId != null)
.Select(s => new { s.Id, ImageId = s.PosterImageId!.Value })
.ToListAsync(cancellationToken);
var posterImageIds = posterShows.Select(p => p.ImageId).Distinct().ToList();
var posterExtById = await dbContext.Images.AsNoTracking()
.Where(i => posterImageIds.Contains(i.Id))
.Select(i => new { i.Id, i.FileExtension })
.ToDictionaryAsync(i => i.Id, i => i.FileExtension, cancellationToken);
var posterByShow = new Dictionary<Guid, (Guid ImageId, string AbsPath)>();
foreach (var p in posterShows)
if (
posterExtById.TryGetValue(p.ImageId, out var ext)
&& imageStore.ResolvePath(p.ImageId, ext) is { } abs
)
posterByShow[p.Id] = (p.ImageId, abs);
// Фон-картинки блоков (из реестра) — абсолютные пути по id.
var bgImageIds = channel.BumperTemplates
.Where(t => t.BackgroundImageId != null)
.Select(t => t.BackgroundImageId!.Value)
.Distinct()
.ToList();
var bgExtById = await dbContext.Images.AsNoTracking()
.Where(i => bgImageIds.Contains(i.Id))
.Select(i => new { i.Id, i.FileExtension })
.ToDictionaryAsync(i => i.Id, i => i.FileExtension, cancellationToken);
var bgByTemplate = new Dictionary<Guid, string>();
foreach (var t in channel.BumperTemplates)
if (
t.BackgroundImageId is { } bgId
&& bgExtById.TryGetValue(bgId, out var bgExt)
&& imageStore.ResolvePath(bgId, bgExt) is { } bgAbs
)
bgByTemplate[t.Id] = bgAbs;
// Кандидаты из кэша + статусы их ассетов (годятся только Ready — файлы могли удалить).
var cached = await dbContext.BumperAssets.AsNoTracking()
.Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId))
.Select(b => new
{
b.FromShowId,
b.ToShowId,
b.Signature,
b.MediaAssetId,
})
.ToListAsync(cancellationToken);
var cachedAssetIds = cached.Select(c => c.MediaAssetId).Distinct().ToList();
var readyAssetIds = await dbContext.MediaAssets.AsNoTracking()
.Where(a => cachedAssetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready)
.Select(a => a.Id)
.ToListAsync(cancellationToken);
var readySet = readyAssetIds.ToHashSet();
foreach (var combo in combos)
{
if (!variantsById.TryGetValue(combo.Variant, out var pair))
continue;
var (variant, template) = pair;
var fromName = showNames.GetValueOrDefault(combo.From, "…");
var toName = showNames.GetValueOrDefault(combo.To, "…");
// Постер шоу-получателя как фон — только для «Сейчас/Далее» (свободный текст шоу не упоминает).
var usePoster = variant.Kind == BumperTextKind.NowNext;
var poster = usePoster && posterByShow.TryGetValue(combo.To, out var pr) ? pr : default;
var posterToken = poster.ImageId == Guid.Empty ? "-" : poster.ImageId.ToString();
var posterAbs = poster.ImageId == Guid.Empty ? null : poster.AbsPath;
var bgAbs = bgByTemplate.GetValueOrDefault(template.Id);
var aligned = AlignedDurationSeconds(TemplateDurationSeconds(template));
var signature = ComputeSignature(channel, template, variant, fromName, toName, aligned, posterToken);
var hit = cached.FirstOrDefault(c =>
c.FromShowId == combo.From
&& c.ToShowId == combo.To
&& c.Signature == signature
&& readySet.Contains(c.MediaAssetId)
);
if (hit is not null)
{
result[combo] = hit.MediaAssetId;
continue;
}
try
{
var assetId = await RenderBumperAsync(
channel,
template,
variant,
combo.From,
combo.To,
fromName,
toName,
aligned,
signature,
posterAbs,
bgAbs,
cancellationToken
);
result[combo] = assetId;
}
catch (Exception ex)
{
logger.LogError(
ex,
"Не удалось отрендерить заставку {From} → {To}",
fromName,
toName
);
}
}
return result;
}
private async Task<Guid> RenderBumperAsync(
Channel channel,
BumperTemplate template,
BumperTextVariant variant,
Guid fromShowId,
Guid toShowId,
string fromName,
string toName,
int alignedDurationSeconds,
string signature,
string? posterAbsolutePath,
string? backgroundAbsolutePath,
CancellationToken cancellationToken
)
{
var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
var render = await bumperRenderer.RenderAsync(
asset.Id,
BuildSpec(
channel,
template,
variant,
alignedDurationSeconds,
fromName,
toName,
posterAbsolutePath,
backgroundAbsolutePath
),
cancellationToken
);
asset.MarkReady(
render.Duration,
render.SegmentSeconds,
render.SegmentCount,
render.Width,
render.Height,
"h264",
"aac",
render.RelativePath
);
dbContext.MediaAssets.Add(asset);
dbContext.BumperAssets.Add(
BumperAsset.Create(fromShowId, toShowId, signature, asset.Id)
);
return asset.Id;
}
private BumperRenderSpec BuildSpec(
Channel channel,
BumperTemplate template,
BumperTextVariant variant,
int alignedDurationSeconds,
string fromName,
string toName,
string? posterAbsolutePath,
string? backgroundAbsolutePath
)
{
var free = variant.Kind == BumperTextKind.Free;
return new BumperRenderSpec(
alignedDurationSeconds,
_bumper.Width,
_bumper.Height,
template.BackgroundColor,
template.BackgroundColor2,
template.AccentColor,
template.TextColor,
FontPath(channel.BumperFont),
free ? "" : variant.NowLabel,
free ? "" : fromName,
free ? "" : variant.NextLabel,
free ? "" : toName,
backgroundAbsolutePath,
bumperStorage.AudioPath(template.Id, template.AudioExtension),
posterAbsolutePath,
free,
variant.Line1,
variant.Line2
);
}
private string FontPath(BumperFont font) =>
font == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
/// <summary>Длина заставки блока (сек): по загруженному звуку либо дефолт для синтеза.</summary>
private static double TemplateDurationSeconds(BumperTemplate template) =>
template.AudioDurationSeconds is { } d and > 0 ? d : DefaultBumperDurationSeconds;
/// <summary>Длительность, выровненная вверх до кратности длине сегмента (инвариант раздачи).</summary>
private int AlignedDurationSeconds(double seconds)
{
var requested = Math.Max(_segmentSeconds, seconds);
return (int)(Math.Ceiling(requested / _segmentSeconds) * _segmentSeconds);
}
/// <summary>
/// Сигнатура рендера = хэш всех входов заставки: общие настройки канала (шрифт/подписи/версия
/// шаблона), оформление и файлы блока (цвета/фон/звук/ревизия), названия шоу и постер. Меняется —
/// заставка пересобирается.
/// </summary>
private string ComputeSignature(
Channel channel,
BumperTemplate template,
BumperTextVariant variant,
string fromName,
string toName,
int alignedDurationSeconds,
string poster
)
{
var raw = string.Join(
'',
_bumper.TemplateVersion,
_bumper.Width,
_bumper.Height,
alignedDurationSeconds,
channel.BumperFont,
variant.Kind,
variant.NowLabel,
variant.NextLabel,
variant.Line1,
variant.Line2,
template.BackgroundColor,
template.BackgroundColor2,
template.AccentColor,
template.TextColor,
template.Revision,
template.BackgroundImageId?.ToString() ?? "-",
template.AudioExtension ?? "-",
fromName,
toName,
poster
);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
return Convert.ToHexString(hash);
}
private async Task<Dictionary<Guid, string>> LoadShowNamesAsync(
Channel channel,
CancellationToken cancellationToken
)
{
var showIds = channel.Shows.Select(s => s.ShowId).Distinct().ToList();
if (showIds.Count == 0)
return new Dictionary<Guid, string>();
return await dbContext.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new { s.Id, s.Name })
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
}
private async Task<PlannerInput> BuildInputAsync(
Channel channel,
DateTimeOffset startTime,
DateTimeOffset horizonEnd,
CancellationToken cancellationToken
)
{
var enabledShows = channel.Shows.Where(s => s.IsEnabled).ToList();
var showIds = enabledShows.Select(s => s.ShowId).Distinct().ToList();
var shows = await dbContext.Shows
.Include(s => s.Episodes)
.Where(s => showIds.Contains(s.Id))
.ToListAsync(cancellationToken);
var episodesByShow = shows.ToDictionary(
s => s.Id,
s => s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).ToList()
);
var candidateAssetIds = episodesByShow.Values
.SelectMany(x => x)
.Concat(channel.Ads.Select(a => a.MediaAssetId))
.Distinct()
.ToList();
var durations = await dbContext.MediaAssets
.Where(a =>
candidateAssetIds.Contains(a.Id)
&& a.Status == MediaAssetStatus.Ready
&& a.Duration != null
)
.Select(a => new { a.Id, a.Duration })
.ToDictionaryAsync(x => x.Id, x => x.Duration!.Value, cancellationToken);
var plannerShows = new List<PlannerShow>();
foreach (var channelShow in enabledShows)
{
if (!episodesByShow.TryGetValue(channelShow.ShowId, out var episodeIds))
continue;
var ready = episodeIds.Where(durations.ContainsKey).ToList();
if (ready.Count == 0)
continue;
plannerShows.Add(
new PlannerShow(
channelShow.Id,
channelShow.ShowId,
channelShow.Weight,
channelShow.BlockMode,
channelShow.BlockValue,
ready,
channelShow.NextEpisodeIndex,
channelShow.PreferredHours
.Select(h => new PlannerHourWindow(h.StartHour, h.EndHour))
.ToList(),
channelShow.PreferredWeightMultiplier
)
);
}
var adPool = channel.Ads
.OrderBy(a => a.Position)
.Select(a => a.MediaAssetId)
.Where(durations.ContainsKey)
.ToList();
// Подблоки заставок (плоский список): длительность слота — по звуку блока, выровнена на сегмент.
var bumperVariants = channel.BumperTemplates
.OrderBy(t => t.Position)
.SelectMany(t =>
{
var dur = TimeSpan.FromSeconds(AlignedDurationSeconds(TemplateDurationSeconds(t)));
return t.Variants
.OrderBy(v => v.Position)
.Select(v => new PlannerBumperVariant(v.Id, t.Id, dur, v.Trigger, v.Weight));
})
.ToList();
var overrides = channel.Overrides
.Select(o => new PlannerOverride(
o.Mode,
o.Shows.Select(s => new PlannerOverrideShow(s.ShowId, s.Weight)).ToList(),
o.Recurrence,
o.StartsAtUtc,
o.EndsAtUtc,
o.DayOfWeek,
o.StartMinute,
o.EndMinute
))
.ToList();
var bumpers = new PlannerBumperConfig(
channel.BumpersEnabled,
TimeSpan.FromMinutes(channel.BumperMinIntervalMinutes),
channel.BumperSelection,
bumperVariants,
channel.BumperShowChangeChance,
channel.BumperEpisodeChangeChance
);
return new PlannerInput(
channel.Id,
channel.AdInsertion,
channel.AdsPerBreak,
channel.NextAdIndex,
plannerShows,
adPool,
durations,
overrides,
startTime,
horizonEnd,
bumpers,
channel.NextBumperIndex
);
}
}