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; /// /// Оркестратор планирования: загружает конфигурацию канала и готовые ассеты, вызывает чистый /// , материализует записи и двигает курсоры. Используется фоновым /// планировщиком (расширение горизонта) и командой перегенерации (правка конфигурации канала). /// Заставки-переходы, отмеченные планировщиком, здесь рендерятся (или берутся из кэша) по выбранному /// блоку и подставляются как обычные ассеты. /// public sealed class ScheduleGenerator( IAppDbContext dbContext, IRandomSource random, IBumperRenderer bumperRenderer, IBumperTemplateStorage bumperStorage, IImageStore imageStore, IOptions options, IOptions bumperOptions, IOptions streamingOptions, ILogger logger ) { private readonly SchedulerOptions _options = options.Value; private readonly BumperOptions _bumper = bumperOptions.Value; private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds); /// Длительность заставки без загруженного звука (сек) — синтезированный джингл. private const int DefaultBumperDurationSeconds = 8; /// /// Достраивает (или, при , перестраивает будущий хвост) расписание /// канала до горизонта. Возвращает число добавленных записей (-1 — канал не найден/выключен). /// public async Task 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 ); } /// /// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id готового /// ассета-заставки: из кэша () либо свежесгенерированного. /// private async Task> ResolveBumperAssetsAsync( Channel channel, IReadOnlyList entries, IReadOnlyDictionary 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(); 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(); 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 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; /// Длина заставки блока (сек): по загруженному звуку либо дефолт для синтеза. private static double TemplateDurationSeconds(BumperTemplate template) => template.AudioDurationSeconds is { } d and > 0 ? d : DefaultBumperDurationSeconds; /// Длительность, выровненная вверх до кратности длине сегмента (инвариант раздачи). private int AlignedDurationSeconds(double seconds) { var requested = Math.Max(_segmentSeconds, seconds); return (int)(Math.Ceiling(requested / _segmentSeconds) * _segmentSeconds); } /// /// Сигнатура рендера = хэш всех входов заставки: общие настройки канала (шрифт/подписи/версия /// шаблона), оформление и файлы блока (цвета/фон/звук/ревизия), названия шоу и постер. Меняется — /// заставка пересобирается. /// 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> LoadShowNamesAsync( Channel channel, CancellationToken cancellationToken ) { var showIds = channel.Shows.Select(s => s.ShowId).Distinct().ToList(); if (showIds.Count == 0) return new Dictionary(); 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 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(); 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 ); } }