using System.Globalization; using System.Security.Cryptography; using System.Text; using Microsoft.EntityFrameworkCore; using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Common.Interfaces; using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast.Scheduling; using TeleWave.Domain.Media; using TeleWave.Domain.Programming.Planning; namespace TeleWave.Application.Programming.Planning; /// /// Подставляет ассеты заставкам, которые планировщик зарезервировал. Резерв и рендер разделены /// намеренно: текст заставки зависит от пары соседей и времени показа, а они известны только после /// того, как слоты наполнены. /// /// Готовый ассет переиспользуется по сигнатуре содержимого (оформление блока + подставленный текст), /// недостающий регистрируется в и уходит фоновому рендереру. /// Запись при этом ставится в ленту сразу: горизонт — неделя, к эфиру рендер давно закончится. /// public sealed class BumperResolver( IAppDbContext dbContext, IBumperRenderQueue renderQueue, IRandomSource random ) { /// Ассеты заставок по индексу записи в ленте: одна и та же пара шоу может дать разный текст. public async Task> ResolveAsync( Channel channel, IReadOnlyList items, CancellationToken cancellationToken ) { var reserved = items .Select((item, index) => (item, index)) .Where(pair => pair.item.Kind == PlannedItemKind.Bumper) .ToList(); if (reserved.Count == 0) return new Dictionary(); var templates = await LoadTemplatesAsync(reserved, cancellationToken); if (templates.Count == 0) return new Dictionary(); var tokens = BumperPlaceholders.TokensIn( templates .Values.SelectMany(t => t.Variants) .SelectMany(v => v.Lines) .Select(l => l.Text) ); var facts = await BumperFacts.LoadAsync( dbContext, channel, items, tokens, cancellationToken ); var requests = new List<(int Index, BumperRequest Request)>(); foreach (var (item, index) in reserved) { if ( item.BumperTemplateId is not { } templateId || !templates.TryGetValue(templateId, out var template) ) continue; var fromShowId = item.FromShowId ?? Guid.Empty; var toShowId = item.ToShowId ?? Guid.Empty; var variant = PickVariant(template, item.BumperVariantId, fromShowId != toShowId); if (variant is null) continue; var context = facts.Context(item, index); var lines = variant .Lines.OrderBy(l => l.Position) .Select(l => new BumperRenderLine( l.Style, l.Color, BumperPlaceholders.Resolve(l.Text, context) )) .Where(l => !string.IsNullOrWhiteSpace(l.Text)) .ToList(); var posterShowId = variant.Background switch { BumperBackground.NextPoster when toShowId != Guid.Empty => toShowId, BumperBackground.NowPoster when fromShowId != Guid.Empty => fromShowId, _ => (Guid?)null, }; var linesJson = BumperRenderedText.ToJson(lines); requests.Add( ( index, new BumperRequest( template, variant.Id, linesJson, posterShowId, ComputeSignature(template, variant.Id, linesJson, posterShowId) ) ) ); } return await MaterializeAsync(requests, cancellationToken); } /// Заводит недостающие ассеты и раздаёт готовые по записям ленты. private async Task> MaterializeAsync( IReadOnlyList<(int Index, BumperRequest Request)> requests, CancellationToken cancellationToken ) { var signatures = requests.Select(r => r.Request.Signature).Distinct().ToList(); var existing = await dbContext .BumperAssets.Where(b => signatures.Contains(b.Signature)) .ToDictionaryAsync(b => b.Signature, b => b.MediaAssetId, cancellationToken); var result = new Dictionary(); foreach (var (index, request) in requests) { if (existing.TryGetValue(request.Signature, out var assetId)) { result[index] = assetId; continue; } var asset = MediaAsset.RegisterGenerated(request.Caption); dbContext.MediaAssets.Add(asset); dbContext.BumperAssets.Add( BumperAsset.Create( request.Template.Id, request.VariantId, request.Signature, request.Lines, request.PosterShowId, asset.Id ) ); existing[request.Signature] = asset.Id; result[index] = asset.Id; renderQueue.Enqueue(asset.Id); } return result; } private async Task> LoadTemplatesAsync( IReadOnlyList<(PlannedItem Item, int Index)> reserved, CancellationToken cancellationToken ) { var ids = reserved .Select(r => r.Item.BumperTemplateId) .Where(id => id is not null) .Select(id => id!.Value) .Distinct() .ToList(); return await dbContext .BumperTemplates.AsNoTracking() .Include(t => t.Variants) .Where(t => ids.Contains(t.Id)) .ToDictionaryAsync(t => t.Id, cancellationToken); } /// /// Подблок: либо жёстко заданный врезкой, либо подходящий под контекст перехода — на смене шоу /// и между сериями одного играют разные тексты. Среди подходящих выбор по весам. /// private BumperTextVariant? PickVariant( BumperTemplate template, Guid? fixedVariantId, bool isShowChange ) { if (fixedVariantId is { } id) return template.FindVariant(id); var eligible = template .Variants.Where(v => v.Matches(isShowChange)) .OrderBy(v => v.Position) .ToList(); if (eligible.Count == 0) return null; var total = eligible.Sum(v => Math.Max(0, v.Weight)); if (total <= 0) return eligible[random.Next(eligible.Count)]; var roll = random.Next(total); var accumulated = 0; foreach (var variant in eligible) { accumulated += Math.Max(0, variant.Weight); if (roll < accumulated) return variant; } return eligible[^1]; } /// /// Сигнатура — хэш содержимого: оформление блока с его ревизией плюс подставленный текст. Канала /// и пары шоу в ней нет намеренно: одинаковая заставка на трёх каналах рендерится один раз, а /// {channel} в тексте разводит их сам собой. /// private static string ComputeSignature( BumperTemplate template, Guid variantId, string linesJson, Guid? posterShowId ) { var raw = string.Create( CultureInfo.InvariantCulture, $"{template.Id}|{template.Revision}|{variantId}|{posterShowId}|{linesJson}" ); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..32]; } /// Что нужно отрендерить для одной записи ленты. private sealed record BumperRequest( BumperTemplate Template, Guid VariantId, string Lines, Guid? PosterShowId, string Signature ) { /// Имя ассета для админки: блок и первые строки заставки. public string Caption { get { var text = string.Join( " / ", BumperRenderedText.FromJson(Lines).Select(l => l.Text).Take(2) ); return text.Length == 0 ? Template.Name : $"{Template.Name}: {text}"; } } } }