Update scheduling parameters and refactor channel endpoints: extend HorizonDays to 7 and RetentionDays to 90 in appsettings.json. Consolidate channel-related endpoint logic by removing obsolete files and enhancing the ShowEndpoints with audience and genre management capabilities. Improve error handling and streamline command handlers for channel operations.
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
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>Ключ отрендеренной заставки: блок, подблок и пара шоу, между которыми она стоит.</summary>
|
||||
public readonly record struct BumperKey(Guid TemplateId, Guid VariantId, Guid FromShowId, Guid ToShowId);
|
||||
|
||||
/// <summary>
|
||||
/// Подставляет ассеты заставкам, которые планировщик зарезервировал. Резерв и рендер разделены
|
||||
/// намеренно: ассет зависит от пары соседей, а пара известна только после того, как слоты наполнены.
|
||||
///
|
||||
/// Готовый ассет переиспользуется по сигнатуре (пара названий + версия блока), недостающий
|
||||
/// регистрируется в <see cref="MediaAssetStatus.Pending"/> и уходит фоновому рендереру. Запись при
|
||||
/// этом ставится в ленту сразу: горизонт — неделя, к эфиру рендер давно закончится.
|
||||
/// </summary>
|
||||
public sealed class BumperResolver(
|
||||
IAppDbContext dbContext,
|
||||
IBumperRenderQueue renderQueue,
|
||||
IRandomSource random
|
||||
)
|
||||
{
|
||||
public async Task<IReadOnlyDictionary<BumperKey, Guid>> ResolveAsync(
|
||||
Channel channel,
|
||||
IReadOnlyList<PlannedItem> items,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var reserved = items.Where(i => i.Kind == PlannedItemKind.Bumper).ToList();
|
||||
if (reserved.Count == 0)
|
||||
return new Dictionary<BumperKey, Guid>();
|
||||
|
||||
var showNames = await LoadShowNamesAsync(reserved, cancellationToken);
|
||||
var result = new Dictionary<BumperKey, Guid>();
|
||||
|
||||
// Кэш существующих заставок канала: одна пара шоу встречается в горизонте многократно.
|
||||
var existing = await dbContext
|
||||
.BumperAssets.Where(b => b.ChannelId == channel.Id)
|
||||
.ToDictionaryAsync(b => b.Signature, b => b.MediaAssetId, cancellationToken);
|
||||
|
||||
foreach (var item in reserved)
|
||||
{
|
||||
if (
|
||||
item.BumperTemplateId is not { } templateId
|
||||
|| channel.FindBumperTemplate(templateId) is not { } template
|
||||
)
|
||||
continue;
|
||||
|
||||
var fromShowId = item.FromShowId ?? Guid.Empty;
|
||||
var toShowId = item.ToShowId ?? Guid.Empty;
|
||||
var variant = PickVariant(template, fromShowId != toShowId, channel, random);
|
||||
if (variant is null)
|
||||
continue;
|
||||
|
||||
var key = new BumperKey(templateId, variant.Id, fromShowId, toShowId);
|
||||
if (result.ContainsKey(key))
|
||||
continue;
|
||||
|
||||
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))
|
||||
{
|
||||
result[key] = assetId;
|
||||
continue;
|
||||
}
|
||||
|
||||
var asset = MediaAsset.RegisterGenerated($"{template.Name}: {fromName} → {toName}");
|
||||
dbContext.MediaAssets.Add(asset);
|
||||
dbContext.BumperAssets.Add(
|
||||
BumperAsset.Create(
|
||||
channel.Id,
|
||||
templateId,
|
||||
variant.Id,
|
||||
fromShowId,
|
||||
toShowId,
|
||||
signature,
|
||||
asset.Id
|
||||
)
|
||||
);
|
||||
|
||||
existing[signature] = asset.Id;
|
||||
result[key] = asset.Id;
|
||||
renderQueue.Enqueue(asset.Id);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Подблок, подходящий под контекст перехода: на смене шоу и между сериями одного играют разные
|
||||
/// тексты. Стратегия выбора — общая настройка канала.
|
||||
/// </summary>
|
||||
private static BumperTextVariant? PickVariant(
|
||||
BumperTemplate template,
|
||||
bool isShowChange,
|
||||
Channel channel,
|
||||
IRandomSource random
|
||||
)
|
||||
{
|
||||
var eligible = template
|
||||
.Variants.Where(v =>
|
||||
v.Trigger switch
|
||||
{
|
||||
BumperTrigger.OnShowChange => isShowChange,
|
||||
BumperTrigger.BetweenEpisodes => !isShowChange,
|
||||
_ => true,
|
||||
}
|
||||
)
|
||||
.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));
|
||||
if (total <= 0)
|
||||
return eligible[random.Next(eligible.Count)];
|
||||
|
||||
var roll = random.Next((int)Math.Min(total, int.MaxValue));
|
||||
long accumulated = 0;
|
||||
foreach (var variant in eligible)
|
||||
{
|
||||
accumulated += Math.Max(0, variant.Weight);
|
||||
if (roll < accumulated)
|
||||
return variant;
|
||||
}
|
||||
|
||||
return eligible[^1];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сигнатура включает версию блока: замена звука или фона обязана пересобрать заставки, иначе
|
||||
/// в эфире осталась бы старая картинка с новым оформлением рядом.
|
||||
/// </summary>
|
||||
private static string Signature(
|
||||
BumperTemplate template,
|
||||
Guid variantId,
|
||||
string fromName,
|
||||
string toName
|
||||
)
|
||||
{
|
||||
var raw = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{template.Id}|{variantId}|{template.Revision}|{fromName}|{toName}"
|
||||
);
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..32];
|
||||
}
|
||||
|
||||
private async Task<Dictionary<Guid, string>> LoadShowNamesAsync(
|
||||
IReadOnlyList<PlannedItem> reserved,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user