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.
build / backend (push) Successful in 7m40s
build / frontend (push) Failing after 39s
tests / backend-tests (push) Successful in 6m9s

This commit is contained in:
Leonid Pershin
2026-07-26 13:32:13 +03:00
parent c4ef954dea
commit 66040a8841
272 changed files with 27944 additions and 8699 deletions
@@ -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);
}
}