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.
250 lines
9.4 KiB
C#
250 lines
9.4 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Подставляет ассеты заставкам, которые планировщик зарезервировал. Резерв и рендер разделены
|
|
/// намеренно: текст заставки зависит от пары соседей и времени показа, а они известны только после
|
|
/// того, как слоты наполнены.
|
|
///
|
|
/// Готовый ассет переиспользуется по сигнатуре содержимого (оформление блока + подставленный текст),
|
|
/// недостающий регистрируется в <see cref="MediaAssetStatus.Pending"/> и уходит фоновому рендереру.
|
|
/// Запись при этом ставится в ленту сразу: горизонт — неделя, к эфиру рендер давно закончится.
|
|
/// </summary>
|
|
public sealed class BumperResolver(
|
|
IAppDbContext dbContext,
|
|
IBumperRenderQueue renderQueue,
|
|
IRandomSource random
|
|
)
|
|
{
|
|
/// <summary>Ассеты заставок по индексу записи в ленте: одна и та же пара шоу может дать разный текст.</summary>
|
|
public async Task<IReadOnlyDictionary<int, Guid>> ResolveAsync(
|
|
Channel channel,
|
|
IReadOnlyList<PlannedItem> 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<int, Guid>();
|
|
|
|
var templates = await LoadTemplatesAsync(reserved, cancellationToken);
|
|
if (templates.Count == 0)
|
|
return new Dictionary<int, Guid>();
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>Заводит недостающие ассеты и раздаёт готовые по записям ленты.</summary>
|
|
private async Task<IReadOnlyDictionary<int, Guid>> 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<int, Guid>();
|
|
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<Dictionary<Guid, BumperTemplate>> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Подблок: либо жёстко заданный врезкой, либо подходящий под контекст перехода — на смене шоу
|
|
/// и между сериями одного играют разные тексты. Среди подходящих выбор по весам.
|
|
/// </summary>
|
|
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];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Сигнатура — хэш содержимого: оформление блока с его ревизией плюс подставленный текст. Канала
|
|
/// и пары шоу в ней нет намеренно: одинаковая заставка на трёх каналах рендерится один раз, а
|
|
/// <c>{channel}</c> в тексте разводит их сам собой.
|
|
/// </summary>
|
|
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];
|
|
}
|
|
|
|
/// <summary>Что нужно отрендерить для одной записи ленты.</summary>
|
|
private sealed record BumperRequest(
|
|
BumperTemplate Template,
|
|
Guid VariantId,
|
|
string Lines,
|
|
Guid? PosterShowId,
|
|
string Signature
|
|
)
|
|
{
|
|
/// <summary>Имя ассета для админки: блок и первые строки заставки.</summary>
|
|
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}";
|
|
}
|
|
}
|
|
}
|
|
}
|