Implement asynchronous bumper rendering and add integration tests: refactor BumperAsset to support async rendering, introduce BumperRenderBackgroundService for processing, and create TeleWave.Integration.Tests with Testcontainers-Postgres for comprehensive testing of scheduling and bumper generation scenarios. Update documentation and ensure tests skip if Docker is not available.

This commit is contained in:
Leonid Pershin
2026-07-25 23:42:40 +03:00
parent 6c18a9da79
commit d6ad8e11a4
22 changed files with 1967 additions and 197 deletions
@@ -0,0 +1,47 @@
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Чистая сборка <see cref="BumperRenderSpec"/> из уже разрешённых входов (пути к постеру/фону/звуку,
/// названия шоу). Общая точка для фонового рендерера заставок расписания и превью в админке.
/// </summary>
public static class BumperSpecFactory
{
public static BumperRenderSpec Build(
BumperOptions bumper,
BumperFont font,
BumperTemplate template,
BumperTextVariant variant,
int alignedDurationSeconds,
string fromName,
string toName,
string? audioPath,
string? posterAbsolutePath,
string? backgroundAbsolutePath
)
{
var free = variant.Kind == BumperTextKind.Free;
return new BumperRenderSpec(
alignedDurationSeconds,
bumper.Width,
bumper.Height,
template.BackgroundColor,
template.BackgroundColor2,
template.AccentColor,
template.TextColor,
font == BumperFont.Serif ? bumper.FontFileSerif : bumper.FontFileSans,
free ? "" : variant.NowLabel,
free ? "" : fromName,
free ? "" : variant.NextLabel,
free ? "" : toName,
backgroundAbsolutePath,
audioPath,
posterAbsolutePath,
free,
variant.Line1,
variant.Line2
);
}
}
@@ -7,7 +7,7 @@ namespace TeleWave.Application.Broadcast.Scheduling;
/// вверх до кратности длине сегмента — ключевой инвариант эфирной математики. Общая для генератора
/// (слоты в плане) и резолвера заставок (рендер).
/// </summary>
internal static class BumperDuration
public static class BumperDuration
{
/// <summary>Длительность заставки без загруженного звука (сек) — синтезированный джингл.</summary>
private const int DefaultSeconds = 8;
@@ -1,39 +1,37 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Domain.Media;
using Microsoft.Extensions.Options;
namespace TeleWave.Application.Broadcast.Scheduling;
/// <summary>
/// Резолвит ассеты ТВ-заставок для запланированных переходов: для каждой уникальной тройки
/// «из→в→подблок» возвращает id готового ассета — из кэша (<see cref="BumperAsset"/>) либо
/// свежесгенерированного ffmpeg-рендером. Работает через тот же scoped <see cref="IAppDbContext"/>,
/// что и <see cref="ScheduleGenerator"/>: добавленные ассеты сохраняются его общим SaveChanges.
/// «из→в→подблок» возвращает id ассета-заставки. Сам ffmpeg НЕ запускает — если готового (или уже
/// рендерящегося) ассета в кэше нет, создаёт <see cref="MediaAsset"/> в статусе Pending, кэш-строку
/// <see cref="BumperAsset"/> (с id канала/блока/подблока для восстановления спеки) и ставит в очередь
/// фонового рендерера. Так тик планировщика не блокируется на ffmpeg, а его транзакция не держится во
/// время рендера. До готовности ассета плейлист отдаёт филлер (см. GetLivePlaylistQueryHandler).
/// </summary>
public sealed class ScheduleBumperResolver(
IAppDbContext dbContext,
IBumperRenderer bumperRenderer,
IBumperTemplateStorage bumperStorage,
IImageStore imageStore,
IBumperRenderQueue renderQueue,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions,
ILogger<ScheduleBumperResolver> logger
IOptions<StreamingOptions> streamingOptions
)
{
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>
/// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id готового
/// ассета-заставки: из кэша либо свежесгенерированного.
/// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id ассета:
/// готового/рендерящегося из кэша либо только что созданного Pending (поставлен в очередь рендера).
/// </summary>
public async Task<Dictionary<(Guid From, Guid To, Guid Variant), Guid>> ResolveAsync(
Channel channel,
@@ -68,52 +66,21 @@ public sealed class ScheduleBumperResolver(
var fromIds = combos.Select(c => c.From).Distinct().ToList();
var toIds = combos.Select(c => c.To).Distinct().ToList();
// Постеры шоу-получателей (из реестра изображений) — как фон заставки, если у блока нет
// своей фон-картинки. Резолвим id постера → расширение → абсолютный путь.
// Постер шоу-получателя участвует в сигнатуре (как токен id) — грузим только id картинки;
// абсолютный путь для рендера резолвит уже фоновый рендерер.
var showIds = fromIds.Concat(toIds).Distinct().ToList();
var posterShows = await dbContext
var posterImageByShow = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id) && s.PosterImageId != null)
.Select(s => new { s.Id, ImageId = s.PosterImageId!.Value })
.ToListAsync(cancellationToken);
var posterImageIds = posterShows.Select(p => p.ImageId).Distinct().ToList();
var posterExtById = await dbContext
.Images.AsNoTracking()
.Where(i => posterImageIds.Contains(i.Id))
.Select(i => new { i.Id, i.FileExtension })
.ToDictionaryAsync(i => i.Id, i => i.FileExtension, cancellationToken);
var posterByShow = new Dictionary<Guid, (Guid ImageId, string AbsPath)>();
foreach (var p in posterShows)
if (
posterExtById.TryGetValue(p.ImageId, out var ext)
&& imageStore.ResolvePath(p.ImageId, ext) is { } abs
)
posterByShow[p.Id] = (p.ImageId, abs);
.ToDictionaryAsync(s => s.Id, s => s.ImageId, cancellationToken);
// Фон-картинки блоков (из реестра) — абсолютные пути по id.
var bgImageIds = channel
.BumperTemplates.Where(t => t.BackgroundImageId != null)
.Select(t => t.BackgroundImageId!.Value)
.Distinct()
.ToList();
var bgExtById = await dbContext
.Images.AsNoTracking()
.Where(i => bgImageIds.Contains(i.Id))
.Select(i => new { i.Id, i.FileExtension })
.ToDictionaryAsync(i => i.Id, i => i.FileExtension, cancellationToken);
var bgByTemplate = new Dictionary<Guid, string>();
foreach (var t in channel.BumperTemplates)
if (
t.BackgroundImageId is { } bgId
&& bgExtById.TryGetValue(bgId, out var bgExt)
&& imageStore.ResolvePath(bgId, bgExt) is { } bgAbs
)
bgByTemplate[t.Id] = bgAbs;
// Кандидаты из кэша + статусы их ассетов (годятся только Ready — файлы могли удалить).
// Кэш заставок (новейшие первыми) + статусы их ассетов: Ready/Pending/Processing переиспользуем,
// Failed — рендерим заново.
var cached = await dbContext
.BumperAssets.AsNoTracking()
.Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId))
.OrderByDescending(b => b.CreatedAt)
.Select(b => new
{
b.FromShowId,
@@ -122,29 +89,28 @@ public sealed class ScheduleBumperResolver(
b.MediaAssetId,
})
.ToListAsync(cancellationToken);
var cachedAssetIds = cached.Select(c => c.MediaAssetId).Distinct().ToList();
var readyAssetIds = await dbContext
var statusById = await dbContext
.MediaAssets.AsNoTracking()
.Where(a => cachedAssetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready)
.Select(a => a.Id)
.ToListAsync(cancellationToken);
var readySet = readyAssetIds.ToHashSet();
.Where(a => cachedAssetIds.Contains(a.Id))
.Select(a => new { a.Id, a.Status })
.ToDictionaryAsync(a => a.Id, a => a.Status, cancellationToken);
foreach (var combo in combos)
{
if (result.ContainsKey(combo))
continue;
if (!variantsById.TryGetValue(combo.Variant, out var pair))
continue;
var (variant, template) = pair;
var fromName = showNames.GetValueOrDefault(combo.From, "…");
var toName = showNames.GetValueOrDefault(combo.To, "…");
// Постер шоу-получателя как фон — только для «Сейчас/Далее» (свободный текст шоу не упоминает).
var usePoster = variant.Kind == BumperTextKind.NowNext;
var poster = usePoster && posterByShow.TryGetValue(combo.To, out var pr) ? pr : default;
var posterToken = poster.ImageId == Guid.Empty ? "-" : poster.ImageId.ToString();
var posterAbs = poster.ImageId == Guid.Empty ? null : poster.AbsPath;
var bgAbs = bgByTemplate.GetValueOrDefault(template.Id);
var posterToken =
usePoster && posterImageByShow.TryGetValue(combo.To, out var pid)
? pid.ToString()
: "-";
var aligned = BumperDuration.Aligned(
BumperDuration.TemplateSeconds(template),
_segmentSeconds
@@ -159,134 +125,43 @@ public sealed class ScheduleBumperResolver(
posterToken
);
// Новейшая кэш-строка с этой сигнатурой (список упорядочен по CreatedAt DESC).
var hit = cached.FirstOrDefault(c =>
c.FromShowId == combo.From
&& c.ToShowId == combo.To
&& c.Signature == signature
&& readySet.Contains(c.MediaAssetId)
c.FromShowId == combo.From && c.ToShowId == combo.To && c.Signature == signature
);
if (hit is not null)
if (
hit is not null
&& statusById.TryGetValue(hit.MediaAssetId, out var status)
&& status != MediaAssetStatus.Failed
)
{
result[combo] = hit.MediaAssetId;
result[combo] = hit.MediaAssetId; // Ready / Pending / Processing — переиспользуем
continue;
}
try
{
var assetId = await RenderAsync(
channel,
template,
variant,
// Нет кэша либо прошлый рендер провалился — создаём Pending-ассет + кэш-строку, ставим в очередь.
var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
dbContext.MediaAssets.Add(asset);
dbContext.BumperAssets.Add(
BumperAsset.Create(
channel.Id,
template.Id,
variant.Id,
combo.From,
combo.To,
fromName,
toName,
aligned,
signature,
posterAbs,
bgAbs,
cancellationToken
);
result[combo] = assetId;
}
catch (Exception ex)
{
logger.LogError(
ex,
"Не удалось отрендерить заставку {From} → {To}",
fromName,
toName
);
}
asset.Id
)
);
// Сигнал — лучший случай (строка станет видимой после коммита транзакции планировщика);
// гарантия подхвата — периодический опрос БД фоновым рендерером.
renderQueue.Enqueue(asset.Id);
result[combo] = asset.Id;
}
return result;
}
private async Task<Guid> RenderAsync(
Channel channel,
BumperTemplate template,
BumperTextVariant variant,
Guid fromShowId,
Guid toShowId,
string fromName,
string toName,
int alignedDurationSeconds,
string signature,
string? posterAbsolutePath,
string? backgroundAbsolutePath,
CancellationToken cancellationToken
)
{
var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
var render = await bumperRenderer.RenderAsync(
asset.Id,
BuildSpec(
channel,
template,
variant,
alignedDurationSeconds,
fromName,
toName,
posterAbsolutePath,
backgroundAbsolutePath
),
cancellationToken
);
asset.MarkReady(
render.Duration,
render.SegmentSeconds,
render.SegmentCount,
render.Width,
render.Height,
"h264",
"aac",
render.RelativePath
);
dbContext.MediaAssets.Add(asset);
dbContext.BumperAssets.Add(BumperAsset.Create(fromShowId, toShowId, signature, asset.Id));
return asset.Id;
}
private BumperRenderSpec BuildSpec(
Channel channel,
BumperTemplate template,
BumperTextVariant variant,
int alignedDurationSeconds,
string fromName,
string toName,
string? posterAbsolutePath,
string? backgroundAbsolutePath
)
{
var free = variant.Kind == BumperTextKind.Free;
return new BumperRenderSpec(
alignedDurationSeconds,
_bumper.Width,
_bumper.Height,
template.BackgroundColor,
template.BackgroundColor2,
template.AccentColor,
template.TextColor,
FontPath(channel.BumperFont),
free ? "" : variant.NowLabel,
free ? "" : fromName,
free ? "" : variant.NextLabel,
free ? "" : toName,
backgroundAbsolutePath,
bumperStorage.AudioPath(template.Id, template.AudioExtension),
posterAbsolutePath,
free,
variant.Line1,
variant.Line2
);
}
private string FontPath(BumperFont font) =>
font == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
/// <summary>
/// Сигнатура рендера = хэш всех входов заставки: общие настройки канала (шрифт/подписи/версия
/// шаблона), оформление и файлы блока (цвета/фон/звук/ревизия), названия шоу и постер. Меняется —
@@ -0,0 +1,15 @@
namespace TeleWave.Application.Common.Interfaces;
/// <summary>
/// Сигнал «появилась заставка на рендер» для фонового рендерера. Как и у медиа-конвейера, источник
/// истины — статус ассета в БД (рендерер берёт следующий Pending c Source=Generated), а очередь лишь
/// будит его без задержки; потеря сигнала при рестарте задачи не теряет — они подхватываются из БД.
/// </summary>
public interface IBumperRenderQueue
{
/// <summary>Разбудить рендерер: появился ассет-заставка в статусе Pending.</summary>
void Enqueue(Guid assetId);
/// <summary>Ждать сигнала о новой работе (с дренажом накопленных).</summary>
ValueTask WaitAsync(CancellationToken cancellationToken);
}