Files
TeleWave/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs
T
Leonid Pershin 1afe01aed8
ci / build-backend (push) Successful in 2m15s
ci / build-frontend (push) Successful in 1m7s
ci / tests (push) Successful in 2m33s
ci / sonar (push) Successful in 10m26s
Refactor BumperPlaceholders and BumperFacts for improved logic and readability
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.
2026-07-27 23:04:15 +03:00

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