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}";
}
}
}
}