Implement BumperEndpoints and remove deprecated bumper-related functionality
Added new BumperEndpoints to the API for managing bumper templates and variants, enhancing the channel management capabilities. Removed outdated bumper-related commands and handlers from the application, streamlining the codebase and improving maintainability. Updated ChannelEndpoints to reflect these changes and ensure proper routing for the new endpoints.
This commit is contained in:
@@ -2,6 +2,7 @@ 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;
|
||||
@@ -10,21 +11,14 @@ using TeleWave.Domain.Programming.Planning;
|
||||
|
||||
namespace TeleWave.Application.Programming.Planning;
|
||||
|
||||
/// <summary>Ключ отрендеренной заставки: блок, подблок и пара шоу, между которыми она стоит.</summary>
|
||||
public readonly record struct BumperKey(
|
||||
Guid TemplateId,
|
||||
Guid VariantId,
|
||||
Guid FromShowId,
|
||||
Guid ToShowId
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Подставляет ассеты заставкам, которые планировщик зарезервировал. Резерв и рендер разделены
|
||||
/// намеренно: ассет зависит от пары соседей, а пара известна только после того, как слоты наполнены.
|
||||
/// намеренно: текст заставки зависит от пары соседей и времени показа, а они известны только после
|
||||
/// того, как слоты наполнены.
|
||||
///
|
||||
/// Готовый ассет переиспользуется по сигнатуре (пара названий + версия блока), недостающий
|
||||
/// регистрируется в <see cref="MediaAssetStatus.Pending"/> и уходит фоновому рендереру. Запись при
|
||||
/// этом ставится в ленту сразу: горизонт — неделя, к эфиру рендер давно закончится.
|
||||
/// Готовый ассет переиспользуется по сигнатуре содержимого (оформление блока + подставленный текст),
|
||||
/// недостающий регистрируется в <see cref="MediaAssetStatus.Pending"/> и уходит фоновому рендереру.
|
||||
/// Запись при этом ставится в ленту сразу: горизонт — неделя, к эфиру рендер давно закончится.
|
||||
/// </summary>
|
||||
public sealed class BumperResolver(
|
||||
IAppDbContext dbContext,
|
||||
@@ -32,120 +26,175 @@ public sealed class BumperResolver(
|
||||
IRandomSource random
|
||||
)
|
||||
{
|
||||
public async Task<IReadOnlyDictionary<BumperKey, Guid>> ResolveAsync(
|
||||
/// <summary>Ассеты заставок по индексу записи в ленте: одна и та же пара шоу может дать разный текст.</summary>
|
||||
public async Task<IReadOnlyDictionary<int, Guid>> ResolveAsync(
|
||||
Channel channel,
|
||||
IReadOnlyList<PlannedItem> items,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var reserved = items.Where(i => i.Kind == PlannedItemKind.Bumper).ToList();
|
||||
var reserved = items
|
||||
.Select((item, index) => (item, index))
|
||||
.Where(pair => pair.item.Kind == PlannedItemKind.Bumper)
|
||||
.ToList();
|
||||
if (reserved.Count == 0)
|
||||
return new Dictionary<BumperKey, Guid>();
|
||||
return new Dictionary<int, Guid>();
|
||||
|
||||
var showNames = await LoadShowNamesAsync(reserved, cancellationToken);
|
||||
var result = new Dictionary<BumperKey, Guid>();
|
||||
var templates = await LoadTemplatesAsync(reserved, cancellationToken);
|
||||
if (templates.Count == 0)
|
||||
return new Dictionary<int, Guid>();
|
||||
|
||||
// Кэш существующих заставок канала: одна пара шоу встречается в горизонте многократно.
|
||||
var existing = await dbContext
|
||||
.BumperAssets.Where(b => b.ChannelId == channel.Id)
|
||||
.ToDictionaryAsync(b => b.Signature, b => b.MediaAssetId, cancellationToken);
|
||||
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 in reserved)
|
||||
foreach (var (item, index) in reserved)
|
||||
{
|
||||
if (
|
||||
item.BumperTemplateId is not { } templateId
|
||||
|| channel.FindBumperTemplate(templateId) is not { } template
|
||||
|| !templates.TryGetValue(templateId, out var template)
|
||||
)
|
||||
continue;
|
||||
|
||||
var fromShowId = item.FromShowId ?? Guid.Empty;
|
||||
var toShowId = item.ToShowId ?? Guid.Empty;
|
||||
var variant = PickVariant(template, fromShowId != toShowId, channel, random);
|
||||
var variant = PickVariant(template, item.BumperVariantId, fromShowId != toShowId);
|
||||
if (variant is null)
|
||||
continue;
|
||||
|
||||
var key = new BumperKey(templateId, variant.Id, fromShowId, toShowId);
|
||||
if (result.ContainsKey(key))
|
||||
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 fromName = showNames.GetValueOrDefault(fromShowId, "—");
|
||||
var toName = showNames.GetValueOrDefault(toShowId, "—");
|
||||
var signature = Signature(template, variant.Id, fromName, toName);
|
||||
|
||||
if (existing.TryGetValue(signature, out var assetId))
|
||||
var posterShowId = variant.Background switch
|
||||
{
|
||||
result[key] = assetId;
|
||||
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,
|
||||
Signature(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($"{template.Name}: {fromName} → {toName}");
|
||||
var asset = MediaAsset.RegisterGenerated(request.Caption);
|
||||
dbContext.MediaAssets.Add(asset);
|
||||
dbContext.BumperAssets.Add(
|
||||
BumperAsset.Create(
|
||||
channel.Id,
|
||||
templateId,
|
||||
variant.Id,
|
||||
fromShowId,
|
||||
toShowId,
|
||||
signature,
|
||||
request.Template.Id,
|
||||
request.VariantId,
|
||||
request.Signature,
|
||||
request.Lines,
|
||||
request.PosterShowId,
|
||||
asset.Id
|
||||
)
|
||||
);
|
||||
|
||||
existing[signature] = asset.Id;
|
||||
result[key] = asset.Id;
|
||||
existing[request.Signature] = asset.Id;
|
||||
result[index] = asset.Id;
|
||||
renderQueue.Enqueue(asset.Id);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Подблок, подходящий под контекст перехода: на смене шоу и между сериями одного играют разные
|
||||
/// тексты. Стратегия выбора — общая настройка канала.
|
||||
/// </summary>
|
||||
private static BumperTextVariant? PickVariant(
|
||||
BumperTemplate template,
|
||||
bool isShowChange,
|
||||
Channel channel,
|
||||
IRandomSource random
|
||||
private async Task<Dictionary<Guid, BumperTemplate>> LoadTemplatesAsync(
|
||||
IReadOnlyList<(PlannedItem Item, int Index)> reserved,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var eligible = template
|
||||
.Variants.Where(v =>
|
||||
v.Trigger switch
|
||||
{
|
||||
BumperTrigger.OnShowChange => isShowChange,
|
||||
BumperTrigger.BetweenEpisodes => !isShowChange,
|
||||
_ => true,
|
||||
}
|
||||
)
|
||||
.OrderBy(v => v.Position)
|
||||
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;
|
||||
|
||||
return channel.BumperSelection switch
|
||||
{
|
||||
BumperSelection.AlwaysFirst => eligible[0],
|
||||
BumperSelection.Random => eligible[random.Next(eligible.Count)],
|
||||
BumperSelection.WeightedRandom => WeightedPick(eligible, random),
|
||||
_ => eligible[random.Next(eligible.Count)],
|
||||
};
|
||||
}
|
||||
|
||||
private static BumperTextVariant WeightedPick(
|
||||
IReadOnlyList<BumperTextVariant> eligible,
|
||||
IRandomSource random
|
||||
)
|
||||
{
|
||||
var total = eligible.Sum(v => (long)Math.Max(0, v.Weight));
|
||||
var total = eligible.Sum(v => Math.Max(0, v.Weight));
|
||||
if (total <= 0)
|
||||
return eligible[random.Next(eligible.Count)];
|
||||
|
||||
var roll = random.Next((int)Math.Min(total, int.MaxValue));
|
||||
long accumulated = 0;
|
||||
var roll = random.Next(total);
|
||||
var accumulated = 0;
|
||||
foreach (var variant in eligible)
|
||||
{
|
||||
accumulated += Math.Max(0, variant.Weight);
|
||||
@@ -157,41 +206,44 @@ public sealed class BumperResolver(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сигнатура включает версию блока: замена звука или фона обязана пересобрать заставки, иначе
|
||||
/// в эфире осталась бы старая картинка с новым оформлением рядом.
|
||||
/// Сигнатура — хэш содержимого: оформление блока с его ревизией плюс подставленный текст. Канала
|
||||
/// и пары шоу в ней нет намеренно: одинаковая заставка на трёх каналах рендерится один раз, а
|
||||
/// <c>{channel}</c> в тексте разводит их сам собой.
|
||||
/// </summary>
|
||||
private static string Signature(
|
||||
BumperTemplate template,
|
||||
Guid variantId,
|
||||
string fromName,
|
||||
string toName
|
||||
string linesJson,
|
||||
Guid? posterShowId
|
||||
)
|
||||
{
|
||||
var raw = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{template.Id}|{variantId}|{template.Revision}|{fromName}|{toName}"
|
||||
$"{template.Id}|{template.Revision}|{variantId}|{posterShowId}|{linesJson}"
|
||||
);
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..32];
|
||||
}
|
||||
|
||||
private async Task<Dictionary<Guid, string>> LoadShowNamesAsync(
|
||||
IReadOnlyList<PlannedItem> reserved,
|
||||
CancellationToken cancellationToken
|
||||
/// <summary>Что нужно отрендерить для одной записи ленты.</summary>
|
||||
private sealed record BumperRequest(
|
||||
BumperTemplate Template,
|
||||
Guid VariantId,
|
||||
string Lines,
|
||||
Guid? PosterShowId,
|
||||
string Signature
|
||||
)
|
||||
{
|
||||
var showIds = reserved
|
||||
.SelectMany(i => new[] { i.FromShowId, i.ToShowId })
|
||||
.Where(id => id is not null && id != Guid.Empty)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (showIds.Count == 0)
|
||||
return [];
|
||||
|
||||
return await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
||||
/// <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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user