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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user