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);
}
@@ -1,10 +1,12 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Кэш отрендеренной ТВ-заставки перехода. Один сгенерированный <see cref="MediaAssetId"/> на
/// уникальную комбинацию (<see cref="FromShowId"/> → <see cref="ToShowId"/>) при данной
/// <see cref="Signature"/> (хэш названий шоу и версии шаблона). Переиспользуется между днями и
/// каналами; при смене названий/шаблона <see cref="Signature"/> меняется и рендерится новый ассет.
/// Кэш ТВ-заставки перехода. Один сгенерированный <see cref="MediaAssetId"/> на уникальную комбинацию
/// (<see cref="FromShowId"/> → <see cref="ToShowId"/>) при данной <see cref="Signature"/> (хэш названий
/// шоу и версии шаблона). Ассет создаётся в статусе Pending и рендерится ffmpeg'ом асинхронно фоновым
/// сервисом — поэтому здесь же храним, ЧЕМ его рендерить (<see cref="ChannelId"/>/<see cref="TemplateId"/>/
/// <see cref="VariantId"/>), чтобы фоновый рендерер восстановил спецификацию без участия планировщика.
/// Переиспользуется между днями и каналами; при смене названий/шаблона сигнатура меняется — новый ассет.
/// </summary>
public class BumperAsset
{
@@ -12,10 +14,15 @@ public class BumperAsset
public Guid FromShowId { get; private set; }
public Guid ToShowId { get; private set; }
/// <summary>Канал/блок/подблок, по которым фоновый рендерер восстановит спецификацию заставки.</summary>
public Guid ChannelId { get; private set; }
public Guid TemplateId { get; private set; }
public Guid VariantId { get; private set; }
/// <summary>Хэш входных данных рендера (названия «из/в» + версия шаблона).</summary>
public string Signature { get; private set; } = string.Empty;
/// <summary>Сгенерированный ассет-заставка (нарезан в assets/{id}, статус Ready).</summary>
/// <summary>Сгенерированный ассет-заставка (нарезается в assets/{id} фоновым рендерером).</summary>
public Guid MediaAssetId { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
@@ -23,6 +30,9 @@ public class BumperAsset
private BumperAsset() { }
public static BumperAsset Create(
Guid channelId,
Guid templateId,
Guid variantId,
Guid fromShowId,
Guid toShowId,
string signature,
@@ -31,6 +41,9 @@ public class BumperAsset
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
TemplateId = templateId,
VariantId = variantId,
FromShowId = fromShowId,
ToShowId = toShowId,
Signature = signature,
@@ -56,8 +56,9 @@ public class MediaAsset
}
/// <summary>
/// Ассет, сгенерированный системой (ТВ-заставка). Нарезку делает не общий пайплайн обработки, а
/// специализированный рендерер, поэтому сразу помечаем <see cref="MarkReady"/> после создания.
/// Ассет, сгенерированный системой (ТВ-заставка). Создаётся в <see cref="MediaAssetStatus.Pending"/>
/// и нарезается не общим пайплайном обработки, а специализированным фоновым рендерером
/// (BumperRenderBackgroundService), который по готовности вызывает <see cref="MarkReady"/>.
/// </summary>
public static MediaAsset RegisterGenerated(string displayName)
{
@@ -158,8 +158,10 @@ public static class DependencyInjection
services.AddSingleton<IAudioProbe, FfprobeAudioProbe>();
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>();
services.AddSingleton<IBumperRenderQueue, BumperRenderQueue>();
services.AddHostedService<MediaProcessingBackgroundService>();
services.AddHostedService<InboxScannerBackgroundService>();
services.AddHostedService<BumperRenderBackgroundService>();
}
}
@@ -0,0 +1,273 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Media;
namespace TeleWave.Infrastructure.Media;
/// <summary>
/// Асинхронно рендерит ТВ-заставки расписания: планировщик лишь создаёт ассет (Source=Generated) в
/// статусе Pending и кэш-строку <see cref="Domain.Broadcast.BumperAsset"/>, а сам ffmpeg крутится здесь,
/// вне тика планировщика и его транзакции. Источник истины — статус в БД (последовательно берём
/// следующий Pending c Source=Generated, помечаем Processing), поэтому рестарт/краш ничего не теряет
/// (прерванные Processing сбрасываются в Pending на старте). До готовности ассета плейлист отдаёт филлер.
/// </summary>
public sealed class BumperRenderBackgroundService(
IBumperRenderQueue queue,
IServiceScopeFactory scopeFactory,
IBumperRenderer renderer,
IBumperTemplateStorage bumperStorage,
IImageStore imageStore,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions,
ILogger<BumperRenderBackgroundService> logger
) : BackgroundService
{
private static readonly TimeSpan IdlePoll = TimeSpan.FromSeconds(30);
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await ResetInterruptedAsync(stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Разбираем всю накопившуюся работу из БД.
while (!stoppingToken.IsCancellationRequested)
{
var assetId = await ClaimNextAsync(stoppingToken);
if (assetId is not { } id)
break;
await RenderClaimedAsync(id, stoppingToken);
}
using var wake = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
wake.CancelAfter(IdlePoll);
try
{
await queue.WaitAsync(wake.Token);
}
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
{
// Тайм-аут опроса — просто перепроверяем БД.
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка цикла рендера заставок");
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}
/// <summary>Сброс прерванных рестартом заставок (Generated Processing → Pending) на старте.</summary>
private async Task ResetInterruptedAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var interrupted = await db
.MediaAssets.Where(x =>
x.Status == MediaAssetStatus.Processing && x.Source == MediaSource.Generated
)
.ToListAsync(cancellationToken);
if (interrupted.Count == 0)
return;
foreach (var asset in interrupted)
asset.ResetToPending();
await db.SaveChangesAsync(cancellationToken);
}
/// <summary>Атомарно захватывает самую раннюю Pending-заставку (Generated): Pending → Processing.</summary>
private async Task<Guid?> ClaimNextAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var asset = await db
.MediaAssets.Where(x =>
x.Status == MediaAssetStatus.Pending && x.Source == MediaSource.Generated
)
.OrderBy(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (asset is null)
return null;
asset.MarkProcessing();
await db.SaveChangesAsync(cancellationToken);
return asset.Id;
}
private async Task RenderClaimedAsync(Guid assetId, CancellationToken cancellationToken)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var spec = await BuildSpecAsync(db, assetId, cancellationToken);
if (spec is null)
{
await FailAsync(assetId, "Не удалось восстановить спецификацию заставки", cancellationToken);
return;
}
var render = await renderer.RenderAsync(assetId, spec, cancellationToken);
var asset = await db.MediaAssets.FirstOrDefaultAsync(
a => a.Id == assetId,
cancellationToken
);
if (asset is null)
return;
asset.MarkReady(
render.Duration,
render.SegmentSeconds,
render.SegmentCount,
render.Width,
render.Height,
"h264",
"aac",
render.RelativePath
);
await db.SaveChangesAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
logger.LogError(ex, "Рендер заставки {AssetId} провалился", assetId);
await FailAsync(assetId, ex.Message, CancellationToken.None);
}
}
/// <summary>Восстанавливает <see cref="BumperRenderSpec"/> по кэш-строке заставки (канал/блок/подблок).</summary>
private async Task<BumperRenderSpec?> BuildSpecAsync(
IAppDbContext db,
Guid assetId,
CancellationToken cancellationToken
)
{
var cache = await db
.BumperAssets.AsNoTracking()
.Where(b => b.MediaAssetId == assetId)
.OrderByDescending(b => b.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (cache is null)
return null;
var channel = await db
.Channels.AsNoTracking()
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.FirstOrDefaultAsync(c => c.Id == cache.ChannelId, cancellationToken);
var template = channel?.BumperTemplates.FirstOrDefault(t => t.Id == cache.TemplateId);
var variant = template?.Variants.FirstOrDefault(v => v.Id == cache.VariantId);
if (channel is null || template is null || variant is null)
return null;
var names = await db
.Shows.AsNoTracking()
.Where(s => s.Id == cache.FromShowId || s.Id == cache.ToShowId)
.Select(s => new { s.Id, s.Name })
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
var fromName = names.GetValueOrDefault(cache.FromShowId, "…");
var toName = names.GetValueOrDefault(cache.ToShowId, "…");
// Постер шоу-получателя как фон — только для «Сейчас/Далее».
string? posterPath = null;
if (variant.Kind == Domain.Broadcast.BumperTextKind.NowNext)
posterPath = await ResolveShowPosterAsync(db, cache.ToShowId, cancellationToken);
var bgPath = await ResolveTemplateBackgroundAsync(db, template.BackgroundImageId, cancellationToken);
var aligned = BumperDuration.Aligned(
BumperDuration.TemplateSeconds(template),
_segmentSeconds
);
var audioPath = bumperStorage.AudioPath(template.Id, template.AudioExtension);
return BumperSpecFactory.Build(
_bumper,
channel.BumperFont,
template,
variant,
aligned,
fromName,
toName,
audioPath,
posterPath,
bgPath
);
}
private async Task<string?> ResolveShowPosterAsync(
IAppDbContext db,
Guid showId,
CancellationToken cancellationToken
)
{
var posterImageId = await db
.Shows.AsNoTracking()
.Where(s => s.Id == showId && s.PosterImageId != null)
.Select(s => s.PosterImageId!.Value)
.FirstOrDefaultAsync(cancellationToken);
if (posterImageId == Guid.Empty)
return null;
return await ResolveImagePathAsync(db, posterImageId, cancellationToken);
}
private async Task<string?> ResolveTemplateBackgroundAsync(
IAppDbContext db,
Guid? backgroundImageId,
CancellationToken cancellationToken
)
{
if (backgroundImageId is not { } bgId)
return null;
return await ResolveImagePathAsync(db, bgId, cancellationToken);
}
private async Task<string?> ResolveImagePathAsync(
IAppDbContext db,
Guid imageId,
CancellationToken cancellationToken
)
{
var ext = await db
.Images.AsNoTracking()
.Where(i => i.Id == imageId)
.Select(i => i.FileExtension)
.FirstOrDefaultAsync(cancellationToken);
return ext is null ? null : imageStore.ResolvePath(imageId, ext);
}
private async Task FailAsync(Guid assetId, string error, CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var asset = await db.MediaAssets.FirstOrDefaultAsync(
a => a.Id == assetId,
cancellationToken
);
if (asset is null)
return;
asset.MarkFailed(error);
await db.SaveChangesAsync(cancellationToken);
}
}
@@ -0,0 +1,20 @@
using System.Threading.Channels;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Media;
/// <summary>Сигнальная очередь-будильник поверх Channel (id ассета — лишь сигнал; работу берём из БД).</summary>
public sealed class BumperRenderQueue : IBumperRenderQueue
{
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
new UnboundedChannelOptions { SingleReader = true }
);
public void Enqueue(Guid assetId) => _channel.Writer.TryWrite(assetId);
public async ValueTask WaitAsync(CancellationToken cancellationToken)
{
await _channel.Reader.ReadAsync(cancellationToken);
while (_channel.Reader.TryRead(out _)) { }
}
}
@@ -130,7 +130,9 @@ public sealed class MediaProcessingBackgroundService(
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var interrupted = await db
.MediaAssets.Where(x => x.Status == MediaAssetStatus.Processing)
.MediaAssets.Where(x =>
x.Status == MediaAssetStatus.Processing && x.Source != MediaSource.Generated
)
.ToListAsync(cancellationToken);
if (interrupted.Count == 0)
return;
@@ -152,8 +154,11 @@ public sealed class MediaProcessingBackgroundService(
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
// Generated-ассеты (ТВ-заставки) обслуживает BumperRenderBackgroundService — их не берём.
var asset = await db
.MediaAssets.Where(x => x.Status == MediaAssetStatus.Pending)
.MediaAssets.Where(x =>
x.Status == MediaAssetStatus.Pending && x.Source != MediaSource.Generated
)
.OrderBy(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (asset is null)
@@ -0,0 +1,52 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class BumperAssetRenderContext : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ChannelId",
table: "BumperAssets",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.AddColumn<Guid>(
name: "TemplateId",
table: "BumperAssets",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.AddColumn<Guid>(
name: "VariantId",
table: "BumperAssets",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ChannelId",
table: "BumperAssets");
migrationBuilder.DropColumn(
name: "TemplateId",
table: "BumperAssets");
migrationBuilder.DropColumn(
name: "VariantId",
table: "BumperAssets");
}
}
}
@@ -164,6 +164,9 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -178,9 +181,15 @@ namespace TeleWave.Infrastructure.Migrations
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("TemplateId")
.HasColumnType("uuid");
b.Property<Guid>("ToShowId")
.HasColumnType("uuid");
b.Property<Guid>("VariantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("FromShowId", "ToShowId", "Signature");