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
+2
View File
@@ -118,6 +118,8 @@ Backend (из `backend/`):
```bash ```bash
dotnet build dotnet build
dotnet test tests/TeleWave.Domain.Tests tests/TeleWave.Application.Tests dotnet test tests/TeleWave.Domain.Tests tests/TeleWave.Application.Tests
# Интеграционные тесты (Testcontainers-Postgres) — нужен запущенный Docker; без него пропускаются:
dotnet test tests/TeleWave.Integration.Tests
dotnet run --project src/TeleWave.Api dotnet run --project src/TeleWave.Api
dotnet ef migrations add <Name> --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api dotnet ef migrations add <Name> --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api
dotnet ef database update --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api dotnet ef database update --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api
+41 -11
View File
@@ -42,7 +42,7 @@
### Планировщик / медиа ### Планировщик / медиа
- [x] **M8. Нет таймаута на ffmpeg/ffprobe**`ProcessRunner.cs` + `MediaOptions` (ffprobe 120с, транскод/рендер 1800с, конфигурируемо). - [x] **M8. Нет таймаута на ffmpeg/ffprobe**`ProcessRunner.cs` + `MediaOptions` (ffprobe 120с, транскод/рендер 1800с, конфигурируемо).
- [x] **M9. Гонка при конкурентной генерации расписания канала**`ScheduleGenerator.cs` (транзакция + `pg_advisory_xact_lock` по каналу). - [x] **M9. Гонка при конкурентной генерации расписания канала**`ScheduleGenerator.cs` (транзакция + `pg_advisory_xact_lock` по каналу).
- [~] **M10. Рендер заставок синхронно внутри тика планировщика**`ScheduleBumperResolver.cs:174`. _(частично: M8 ограничивает худший случай, кэш делает рендеры редкими, M9 сериализует канал. Полный вынос рендера из тика — отдельная архитектурная задача, флажок ниже.)_ - [x] **M10. Рендер заставок синхронно внутри тика планировщика**вынесен в асинхронную очередь: планировщик создаёт ассет заставки в Pending + кэш-строку и ставит в очередь, ffmpeg крутит новый `BumperRenderBackgroundService` вне тика/транзакции. До готовности плейлист отдаёт филлер.
- [x] **M11. Override через полночь не работает**`SchedulePlannerModels.cs:40` (`EndMinute <= StartMinute` → пустое окно). - [x] **M11. Override через полночь не работает**`SchedulePlannerModels.cs:40` (`EndMinute <= StartMinute` → пустое окно).
### Фронтенд ### Фронтенд
@@ -66,7 +66,7 @@
- [x] **L9. `AllowedHosts: "*"` и dev-креды БД в appsettings.json** — осознанные dev-дефолты, переопределяются env в проде; C2 теперь форсит реальный JWT-ключ. Оставлено как задокументированное. - [x] **L9. `AllowedHosts: "*"` и dev-креды БД в appsettings.json** — осознанные dev-дефолты, переопределяются env в проде; C2 теперь форсит реальный JWT-ключ. Оставлено как задокументированное.
- [x] **L10. SSRF-поверхность в ImageDownloader** (без allowlist схемы/хоста) — `ImageDownloader.cs:16` (allowlist http/https). - [x] **L10. SSRF-поверхность в ImageDownloader** (без allowlist схемы/хоста) — `ImageDownloader.cs:16` (allowlist http/https).
- [x] **L11. Документация (CLAUDE.md/README) отстала** — обновлены разделы «текущее состояние» и рабочий принцип под реальный объём. - [x] **L11. Документация (CLAUDE.md/README) отстала** — обновлены разделы «текущее состояние» и рабочий принцип под реальный объём.
- [~] **L12. Пробелы в тестах**частично: добавлены тесты на M11 (override через полночь), L3 (Single). Полное покрытие фоновых сервисов/оркестрации/эндпоинтов — отдельная задача. - [x] **L12. Пробелы в тестах**добавлен проект `TeleWave.Integration.Tests` на Testcontainers-Postgres: генерация расписания, M10 (Pending-заставки + очередь), M9 (advisory-lock против гонки), M7 (откат Register), M5 (транзакция DeleteShowMedia). При отсутствии Docker тесты пропускаются (SkippableFact). Плюс unit-тесты на M11/L3.
--- ---
@@ -166,13 +166,43 @@ Api:EnableOpenApi неявно). Тесты: +2 domain (override через по
--- ---
## Осталось (флажки на будущее — требуют отдельного решения) ## Журнал — M10 (async-рендер заставок) и L12 (интеграционные тесты)
- **M10 (полностью)** — вынести рендер ТВ-заставок из тика планировщика: генерировать расписание с ### 2026-07-25 — M10: асинхронная очередь рендера заставок
плейсхолдерами и рендерить ассеты асинхронно, чтобы один канал не задерживал достройку остальных и
чтобы не держать транзакцию/advisory-lock во время ffmpeg. - **Домен**: `BumperAsset` получил `ChannelId`/`TemplateId`/`VariantId` (чтобы фоновый рендерер
- **L12 (полностью)** — интеграционные тесты фоновых сервисов (media-конвейер, планировщик), восстановил спецификацию), фабрика обновлена; миграция `BumperAssetRenderContext` (Guid-колонки с
оркестрации `ScheduleGenerator`, эндпоинтов (нужен реальный/контейнерный Postgres — InMemory не дефолтом для существующих строк — самовосстановление при перерендере). `MediaAsset.RegisterGenerated`
тянет транзакции/advisory-lock/raw SQL). теперь честно Pending до готовности.
- **M15 (доменно)** — если нужны разные файлы с одинаковым именем: пересмотреть уникальность - **Очередь**: `IBumperRenderQueue` + `BumperRenderQueue` (сигнал поверх Channel, как у медиа).
`MediaAsset` (сейчас по `OriginalFileName`). - **`ScheduleBumperResolver`**: больше не рендерит — создаёт Pending-ассет (Source=Generated) + кэш-строку
и ставит в очередь; переиспользует Ready/Pending/Processing, перерендерит только Failed (по новейшей
кэш-строке). Тик планировщика и его транзакция больше не держатся во время ffmpeg (снимает и остаток M9).
- **`BumperRenderBackgroundService`**: последовательно берёт Pending Generated → восстанавливает спеку
(`BumperSpecFactory`) → ffmpeg → MarkReady/MarkFailed; сброс прерванных на старте; idle-poll 30с.
- **`MediaProcessingBackgroundService`**: Generated-ассеты исключены из claim/reset (их обслуживает
рендерер заставок).
- **Стриминг менять не пришлось**: `GetLivePlaylistQueryHandler` уже фильтрует по `Status==Ready`
Pending-заставка автоматически деградирует в филлер, пока рендерится.
- **Поведение**: заставка перехода впервые появляется на тик позже (до этого — филлер); дальше берётся
из кэша. Осознанный компромисс (выбран владельцем).
### 2026-07-25 — L12: интеграционные тесты на Testcontainers-Postgres
Новый проект `tests/TeleWave.Integration.Tests``.slnx`): поднимает `postgres:16-alpine`, применяет
реальные миграции, гоняет то, что InMemory не умеет (транзакции, advisory-lock, ExecuteDelete). 5 тестов:
генерация Program-расписания; создание Pending-заставок (Generated) + постановка в очередь (M10);
сериализация конкурентной генерации advisory-lock'ом без перекрытий (M9); откат Register при сбое
переноса файла (M7); атомарное удаление в DeleteShowMedia (M5). При отсутствии Docker — `SkippableFact`
пропускает (CI на раннере без Docker не падает; `dotnet test TeleWave.slnx` в CI прогонит их при наличии
Docker на ubuntu-latest).
Проверка: сборка решения 0/0; **98 domain + 49 application + 5 integration** зелёные; фронт `tsc`/`oxlint`
чистые.
---
## Осталось (доменное решение владельца, не дефект)
- **M15 (доменно)** — если понадобятся разные файлы с одинаковым именем: пересмотреть уникальность
`MediaAsset` (сейчас по `OriginalFileName` — намеренно, клиент это зеркалит).
+2
View File
@@ -43,5 +43,7 @@
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" /> <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="NSubstitute" Version="6.0.0" /> <PackageVersion Include="NSubstitute" Version="6.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.6.0" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+1
View File
@@ -8,5 +8,6 @@
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/TeleWave.Domain.Tests/TeleWave.Domain.Tests.csproj" /> <Project Path="tests/TeleWave.Domain.Tests/TeleWave.Domain.Tests.csproj" />
<Project Path="tests/TeleWave.Application.Tests/TeleWave.Application.Tests.csproj" /> <Project Path="tests/TeleWave.Application.Tests/TeleWave.Application.Tests.csproj" />
<Project Path="tests/TeleWave.Integration.Tests/TeleWave.Integration.Tests.csproj" />
</Folder> </Folder>
</Solution> </Solution>
@@ -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> /// </summary>
internal static class BumperDuration public static class BumperDuration
{ {
/// <summary>Длительность заставки без загруженного звука (сек) — синтезированный джингл.</summary> /// <summary>Длительность заставки без загруженного звука (сек) — синтезированный джингл.</summary>
private const int DefaultSeconds = 8; private const int DefaultSeconds = 8;
@@ -1,39 +1,37 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming; using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Broadcast.Scheduling; using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
using Microsoft.Extensions.Options;
namespace TeleWave.Application.Broadcast.Scheduling; namespace TeleWave.Application.Broadcast.Scheduling;
/// <summary> /// <summary>
/// Резолвит ассеты ТВ-заставок для запланированных переходов: для каждой уникальной тройки /// Резолвит ассеты ТВ-заставок для запланированных переходов: для каждой уникальной тройки
/// «из→в→подблок» возвращает id готового ассета — из кэша (<see cref="BumperAsset"/>) либо /// «из→в→подблок» возвращает id ассета-заставки. Сам ffmpeg НЕ запускает — если готового (или уже
/// свежесгенерированного ffmpeg-рендером. Работает через тот же scoped <see cref="IAppDbContext"/>, /// рендерящегося) ассета в кэше нет, создаёт <see cref="MediaAsset"/> в статусе Pending, кэш-строку
/// что и <see cref="ScheduleGenerator"/>: добавленные ассеты сохраняются его общим SaveChanges. /// <see cref="BumperAsset"/> (с id канала/блока/подблока для восстановления спеки) и ставит в очередь
/// фонового рендерера. Так тик планировщика не блокируется на ffmpeg, а его транзакция не держится во
/// время рендера. До готовности ассета плейлист отдаёт филлер (см. GetLivePlaylistQueryHandler).
/// </summary> /// </summary>
public sealed class ScheduleBumperResolver( public sealed class ScheduleBumperResolver(
IAppDbContext dbContext, IAppDbContext dbContext,
IBumperRenderer bumperRenderer, IBumperRenderQueue renderQueue,
IBumperTemplateStorage bumperStorage,
IImageStore imageStore,
IOptions<BumperOptions> bumperOptions, IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions, IOptions<StreamingOptions> streamingOptions
ILogger<ScheduleBumperResolver> logger
) )
{ {
private readonly BumperOptions _bumper = bumperOptions.Value; private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds); private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary> /// <summary>
/// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id готового /// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id ассета:
/// ассета-заставки: из кэша либо свежесгенерированного. /// готового/рендерящегося из кэша либо только что созданного Pending (поставлен в очередь рендера).
/// </summary> /// </summary>
public async Task<Dictionary<(Guid From, Guid To, Guid Variant), Guid>> ResolveAsync( public async Task<Dictionary<(Guid From, Guid To, Guid Variant), Guid>> ResolveAsync(
Channel channel, Channel channel,
@@ -68,52 +66,21 @@ public sealed class ScheduleBumperResolver(
var fromIds = combos.Select(c => c.From).Distinct().ToList(); var fromIds = combos.Select(c => c.From).Distinct().ToList();
var toIds = combos.Select(c => c.To).Distinct().ToList(); var toIds = combos.Select(c => c.To).Distinct().ToList();
// Постеры шоу-получателей (из реестра изображений) — как фон заставки, если у блока нет // Постер шоу-получателя участвует в сигнатуре (как токен id) — грузим только id картинки;
// своей фон-картинки. Резолвим id постера → расширение → абсолютный путь. // абсолютный путь для рендера резолвит уже фоновый рендерер.
var showIds = fromIds.Concat(toIds).Distinct().ToList(); var showIds = fromIds.Concat(toIds).Distinct().ToList();
var posterShows = await dbContext var posterImageByShow = await dbContext
.Shows.AsNoTracking() .Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id) && s.PosterImageId != null) .Where(s => showIds.Contains(s.Id) && s.PosterImageId != null)
.Select(s => new { s.Id, ImageId = s.PosterImageId!.Value }) .Select(s => new { s.Id, ImageId = s.PosterImageId!.Value })
.ToListAsync(cancellationToken); .ToDictionaryAsync(s => s.Id, s => s.ImageId, 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);
// Фон-картинки блоков (из реестра) — абсолютные пути по id. // Кэш заставок (новейшие первыми) + статусы их ассетов: Ready/Pending/Processing переиспользуем,
var bgImageIds = channel // Failed — рендерим заново.
.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 — файлы могли удалить).
var cached = await dbContext var cached = await dbContext
.BumperAssets.AsNoTracking() .BumperAssets.AsNoTracking()
.Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId)) .Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId))
.OrderByDescending(b => b.CreatedAt)
.Select(b => new .Select(b => new
{ {
b.FromShowId, b.FromShowId,
@@ -122,29 +89,28 @@ public sealed class ScheduleBumperResolver(
b.MediaAssetId, b.MediaAssetId,
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var cachedAssetIds = cached.Select(c => c.MediaAssetId).Distinct().ToList(); var cachedAssetIds = cached.Select(c => c.MediaAssetId).Distinct().ToList();
var readyAssetIds = await dbContext var statusById = await dbContext
.MediaAssets.AsNoTracking() .MediaAssets.AsNoTracking()
.Where(a => cachedAssetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready) .Where(a => cachedAssetIds.Contains(a.Id))
.Select(a => a.Id) .Select(a => new { a.Id, a.Status })
.ToListAsync(cancellationToken); .ToDictionaryAsync(a => a.Id, a => a.Status, cancellationToken);
var readySet = readyAssetIds.ToHashSet();
foreach (var combo in combos) foreach (var combo in combos)
{ {
if (result.ContainsKey(combo))
continue;
if (!variantsById.TryGetValue(combo.Variant, out var pair)) if (!variantsById.TryGetValue(combo.Variant, out var pair))
continue; continue;
var (variant, template) = pair; var (variant, template) = pair;
var fromName = showNames.GetValueOrDefault(combo.From, "…"); var fromName = showNames.GetValueOrDefault(combo.From, "…");
var toName = showNames.GetValueOrDefault(combo.To, "…"); var toName = showNames.GetValueOrDefault(combo.To, "…");
// Постер шоу-получателя как фон — только для «Сейчас/Далее» (свободный текст шоу не упоминает).
var usePoster = variant.Kind == BumperTextKind.NowNext; var usePoster = variant.Kind == BumperTextKind.NowNext;
var poster = usePoster && posterByShow.TryGetValue(combo.To, out var pr) ? pr : default; var posterToken =
var posterToken = poster.ImageId == Guid.Empty ? "-" : poster.ImageId.ToString(); usePoster && posterImageByShow.TryGetValue(combo.To, out var pid)
var posterAbs = poster.ImageId == Guid.Empty ? null : poster.AbsPath; ? pid.ToString()
var bgAbs = bgByTemplate.GetValueOrDefault(template.Id); : "-";
var aligned = BumperDuration.Aligned( var aligned = BumperDuration.Aligned(
BumperDuration.TemplateSeconds(template), BumperDuration.TemplateSeconds(template),
_segmentSeconds _segmentSeconds
@@ -159,134 +125,43 @@ public sealed class ScheduleBumperResolver(
posterToken posterToken
); );
// Новейшая кэш-строка с этой сигнатурой (список упорядочен по CreatedAt DESC).
var hit = cached.FirstOrDefault(c => var hit = cached.FirstOrDefault(c =>
c.FromShowId == combo.From c.FromShowId == combo.From && c.ToShowId == combo.To && c.Signature == signature
&& c.ToShowId == combo.To
&& c.Signature == signature
&& readySet.Contains(c.MediaAssetId)
); );
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; continue;
} }
try // Нет кэша либо прошлый рендер провалился — создаём Pending-ассет + кэш-строку, ставим в очередь.
{ var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
var assetId = await RenderAsync( dbContext.MediaAssets.Add(asset);
channel, dbContext.BumperAssets.Add(
template, BumperAsset.Create(
variant, channel.Id,
template.Id,
variant.Id,
combo.From, combo.From,
combo.To, combo.To,
fromName,
toName,
aligned,
signature, signature,
posterAbs, asset.Id
bgAbs, )
cancellationToken );
); // Сигнал — лучший случай (строка станет видимой после коммита транзакции планировщика);
result[combo] = assetId; // гарантия подхвата — периодический опрос БД фоновым рендерером.
} renderQueue.Enqueue(asset.Id);
catch (Exception ex) result[combo] = asset.Id;
{
logger.LogError(
ex,
"Не удалось отрендерить заставку {From} → {To}",
fromName,
toName
);
}
} }
return result; 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> /// <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; namespace TeleWave.Domain.Broadcast;
/// <summary> /// <summary>
/// Кэш отрендеренной ТВ-заставки перехода. Один сгенерированный <see cref="MediaAssetId"/> на /// Кэш ТВ-заставки перехода. Один сгенерированный <see cref="MediaAssetId"/> на уникальную комбинацию
/// уникальную комбинацию (<see cref="FromShowId"/> → <see cref="ToShowId"/>) при данной /// (<see cref="FromShowId"/> → <see cref="ToShowId"/>) при данной <see cref="Signature"/> (хэш названий
/// <see cref="Signature"/> (хэш названий шоу и версии шаблона). Переиспользуется между днями и /// шоу и версии шаблона). Ассет создаётся в статусе Pending и рендерится ffmpeg'ом асинхронно фоновым
/// каналами; при смене названий/шаблона <see cref="Signature"/> меняется и рендерится новый ассет. /// сервисом — поэтому здесь же храним, ЧЕМ его рендерить (<see cref="ChannelId"/>/<see cref="TemplateId"/>/
/// <see cref="VariantId"/>), чтобы фоновый рендерер восстановил спецификацию без участия планировщика.
/// Переиспользуется между днями и каналами; при смене названий/шаблона сигнатура меняется — новый ассет.
/// </summary> /// </summary>
public class BumperAsset public class BumperAsset
{ {
@@ -12,10 +14,15 @@ public class BumperAsset
public Guid FromShowId { get; private set; } public Guid FromShowId { get; private set; }
public Guid ToShowId { 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> /// <summary>Хэш входных данных рендера (названия «из/в» + версия шаблона).</summary>
public string Signature { get; private set; } = string.Empty; public string Signature { get; private set; } = string.Empty;
/// <summary>Сгенерированный ассет-заставка (нарезан в assets/{id}, статус Ready).</summary> /// <summary>Сгенерированный ассет-заставка (нарезается в assets/{id} фоновым рендерером).</summary>
public Guid MediaAssetId { get; private set; } public Guid MediaAssetId { get; private set; }
public DateTimeOffset CreatedAt { get; private set; } public DateTimeOffset CreatedAt { get; private set; }
@@ -23,6 +30,9 @@ public class BumperAsset
private BumperAsset() { } private BumperAsset() { }
public static BumperAsset Create( public static BumperAsset Create(
Guid channelId,
Guid templateId,
Guid variantId,
Guid fromShowId, Guid fromShowId,
Guid toShowId, Guid toShowId,
string signature, string signature,
@@ -31,6 +41,9 @@ public class BumperAsset
new() new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
ChannelId = channelId,
TemplateId = templateId,
VariantId = variantId,
FromShowId = fromShowId, FromShowId = fromShowId,
ToShowId = toShowId, ToShowId = toShowId,
Signature = signature, Signature = signature,
@@ -56,8 +56,9 @@ public class MediaAsset
} }
/// <summary> /// <summary>
/// Ассет, сгенерированный системой (ТВ-заставка). Нарезку делает не общий пайплайн обработки, а /// Ассет, сгенерированный системой (ТВ-заставка). Создаётся в <see cref="MediaAssetStatus.Pending"/>
/// специализированный рендерер, поэтому сразу помечаем <see cref="MarkReady"/> после создания. /// и нарезается не общим пайплайном обработки, а специализированным фоновым рендерером
/// (BumperRenderBackgroundService), который по готовности вызывает <see cref="MarkReady"/>.
/// </summary> /// </summary>
public static MediaAsset RegisterGenerated(string displayName) public static MediaAsset RegisterGenerated(string displayName)
{ {
@@ -158,8 +158,10 @@ public static class DependencyInjection
services.AddSingleton<IAudioProbe, FfprobeAudioProbe>(); services.AddSingleton<IAudioProbe, FfprobeAudioProbe>();
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>(); services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>(); services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>();
services.AddSingleton<IBumperRenderQueue, BumperRenderQueue>();
services.AddHostedService<MediaProcessingBackgroundService>(); services.AddHostedService<MediaProcessingBackgroundService>();
services.AddHostedService<InboxScannerBackgroundService>(); 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 db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var interrupted = await db var interrupted = await db
.MediaAssets.Where(x => x.Status == MediaAssetStatus.Processing) .MediaAssets.Where(x =>
x.Status == MediaAssetStatus.Processing && x.Source != MediaSource.Generated
)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (interrupted.Count == 0) if (interrupted.Count == 0)
return; return;
@@ -152,8 +154,11 @@ public sealed class MediaProcessingBackgroundService(
await using var scope = scopeFactory.CreateAsyncScope(); await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
// Generated-ассеты (ТВ-заставки) обслуживает BumperRenderBackgroundService — их не берём.
var asset = await db 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) .OrderBy(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
if (asset is null) 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") b.Property<Guid>("Id")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt") b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -178,9 +181,15 @@ namespace TeleWave.Infrastructure.Migrations
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("character varying(128)"); .HasColumnType("character varying(128)");
b.Property<Guid>("TemplateId")
.HasColumnType("uuid");
b.Property<Guid>("ToShowId") b.Property<Guid>("ToShowId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<Guid>("VariantId")
.HasColumnType("uuid");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("FromShowId", "ToShowId", "Signature"); b.HasIndex("FromShowId", "ToShowId", "Signature");
@@ -25,12 +25,18 @@ public class DomainRecordFactoryTests
[Fact] [Fact]
public void BumperAsset_Create_StoresPair() public void BumperAsset_Create_StoresPair()
{ {
var channel = Guid.NewGuid();
var template = Guid.NewGuid();
var variant = Guid.NewGuid();
var from = Guid.NewGuid(); var from = Guid.NewGuid();
var to = Guid.NewGuid(); var to = Guid.NewGuid();
var asset = Guid.NewGuid(); var asset = Guid.NewGuid();
var b = BumperAsset.Create(from, to, "sig", asset); var b = BumperAsset.Create(channel, template, variant, from, to, "sig", asset);
Assert.Equal(channel, b.ChannelId);
Assert.Equal(template, b.TemplateId);
Assert.Equal(variant, b.VariantId);
Assert.Equal(from, b.FromShowId); Assert.Equal(from, b.FromShowId);
Assert.Equal(to, b.ToShowId); Assert.Equal(to, b.ToShowId);
Assert.Equal("sig", b.Signature); Assert.Equal("sig", b.Signature);
@@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Infrastructure.Persistence;
using Testcontainers.PostgreSql;
using Xunit;
namespace TeleWave.Integration.Tests;
/// <summary>
/// Поднимает одноразовый Postgres в контейнере и применяет к нему реальные миграции. Даёт свежие
/// экземпляры <see cref="AppDbContext"/> (каждый — своё соединение), чтобы тестировать то, что InMemory
/// не умеет: транзакции, advisory-lock, ExecuteDelete, raw SQL. Требует запущенного Docker.
/// </summary>
public sealed class PostgresFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
/// <summary>false, если Docker недоступен (напр. CI-раннер без Docker) — тогда тесты пропускаются.</summary>
public bool Available { get; private set; }
public string ConnectionString => _container.GetConnectionString();
public async Task InitializeAsync()
{
try
{
await _container.StartAsync();
await using var db = CreateContext();
await db.Database.MigrateAsync();
Available = true;
}
catch (Exception)
{
// Docker не запущен/недоступен — интеграционные тесты будут пропущены (Skip), а не упадут.
Available = false;
}
}
public AppDbContext CreateContext() =>
new(new DbContextOptionsBuilder<AppDbContext>().UseNpgsql(ConnectionString).Options);
public async Task DisposeAsync() => await _container.DisposeAsync();
}
[CollectionDefinition("postgres")]
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>;
/// <summary>Детерминированный источник случайности для планировщика в тестах.</summary>
internal sealed class SequenceRandom(params int[] sequence) : Domain.Broadcast.Scheduling.IRandomSource
{
private readonly int[] _sequence = sequence.Length == 0 ? [0] : sequence;
private int _i;
public int Next(int maxExclusive)
{
if (maxExclusive <= 0)
return 0;
var value = _sequence[_i++ % _sequence.Length];
return ((value % maxExclusive) + maxExclusive) % maxExclusive;
}
}
@@ -0,0 +1,207 @@
using Microsoft.EntityFrameworkCore;
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.Broadcast;
using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Persistence;
using Xunit;
namespace TeleWave.Integration.Tests;
[Collection("postgres")]
public sealed class ScheduleGeneratorIntegrationTests(PostgresFixture fixture)
{
private static readonly DateTimeOffset Now = new(2026, 3, 1, 0, 0, 0, TimeSpan.Zero);
private const int Seg = 2;
private static MediaAsset ReadyAsset(string name, int seconds)
{
var a = MediaAsset.Register(name, ".mkv", MediaSource.Upload);
a.MarkReady(
TimeSpan.FromSeconds(seconds),
Seg,
seconds / Seg,
1920,
1080,
"h264",
"aac",
$"assets/{a.Id}"
);
return a;
}
private static ScheduleGenerator NewGenerator(
AppDbContext db,
IRandomSource random,
IBumperRenderQueue queue
)
{
var streaming = Options.Create(new StreamingOptions { SegmentSeconds = Seg });
var resolver = new ScheduleBumperResolver(
db,
queue,
Options.Create(new BumperOptions()),
streaming
);
return new ScheduleGenerator(
db,
random,
resolver,
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionHours = 24 }),
streaming
);
}
private sealed class RecordingQueue : IBumperRenderQueue
{
public List<Guid> Enqueued { get; } = [];
public void Enqueue(Guid assetId) => Enqueued.Add(assetId);
public ValueTask WaitAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
}
[SkippableFact]
public async Task GenerateAsync_MaterializesProgramSchedule()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
var channel = Channel.Create("Prog", $"prog-{Guid.NewGuid():N}", Now);
var show = Show.Create("Show A", ShowKind.Series);
var a1 = ReadyAsset("a1.mkv", 1200);
var a2 = ReadyAsset("a2.mkv", 1200);
show.AddEpisode(a1.Id);
show.AddEpisode(a2.Id);
channel.AddShow(show.Id, 1, BlockMode.Count, 1);
await using (var seed = fixture.CreateContext())
{
seed.Shows.Add(show);
seed.MediaAssets.AddRange(a1, a2);
seed.Channels.Add(channel);
await seed.SaveChangesAsync(CancellationToken.None);
}
int added;
await using (var db = fixture.CreateContext())
{
added = await NewGenerator(db, new SequenceRandom(0), new RecordingQueue())
.GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None);
}
Assert.True(added > 0);
await using var verify = fixture.CreateContext();
var entries = await verify
.ScheduleEntries.AsNoTracking()
.Where(e => e.ChannelId == channel.Id)
.ToListAsync();
Assert.NotEmpty(entries);
Assert.Contains(entries, e => e.Kind == ScheduleEntryKind.Program);
Assert.All(entries, e => Assert.True(e.EndsAtUtc <= Now.AddDays(1).AddSeconds(Seg)));
}
[SkippableFact]
public async Task GenerateAsync_CreatesPendingBumperAssets_ForShowTransitions()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
// Канал с двумя шоу и включёнными заставками — на смене шоу планировщик ставит заставку;
// её ассет должен создаться в Pending (Generated) и попасть в очередь фонового рендера.
var channel = Channel.Create("Bump", $"bump-{Guid.NewGuid():N}", Now);
channel.UpdateSettings(channel.Name, true, AdInsertion.BetweenBlocks, 0, true, null);
var showA = Show.Create("A", ShowKind.Series);
var showB = Show.Create("B", ShowKind.Series);
var a1 = ReadyAsset("a1.mkv", 1200);
var b1 = ReadyAsset("b1.mkv", 1200);
showA.AddEpisode(a1.Id);
showB.AddEpisode(b1.Id);
channel.AddShow(showA.Id, 1, BlockMode.Count, 1);
channel.AddShow(showB.Id, 1, BlockMode.Count, 1);
await using (var seed = fixture.CreateContext())
{
seed.Shows.AddRange(showA, showB);
seed.MediaAssets.AddRange(a1, b1);
seed.Channels.Add(channel);
await seed.SaveChangesAsync(CancellationToken.None);
}
var queue = new RecordingQueue();
await using (var db = fixture.CreateContext())
{
// Чередуем выбор шоу (0,1,0,1…), чтобы гарантированно были переходы «A→B».
await NewGenerator(db, new SequenceRandom(0, 1), queue)
.GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None);
}
await using var verify = fixture.CreateContext();
var bumperCache = await verify
.BumperAssets.AsNoTracking()
.Where(b => b.ChannelId == channel.Id)
.ToListAsync();
Assert.NotEmpty(bumperCache);
var bumperAssetIds = bumperCache.Select(b => b.MediaAssetId).ToList();
var bumperAssets = await verify
.MediaAssets.AsNoTracking()
.Where(a => bumperAssetIds.Contains(a.Id))
.ToListAsync();
Assert.All(
bumperAssets,
a =>
{
Assert.Equal(MediaSource.Generated, a.Source);
Assert.Equal(MediaAssetStatus.Pending, a.Status);
}
);
Assert.NotEmpty(queue.Enqueued);
}
[SkippableFact]
public async Task ConcurrentGenerate_SerializedByAdvisoryLock_NoOverlappingEntries()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
var channel = Channel.Create("Race", $"race-{Guid.NewGuid():N}", Now);
var show = Show.Create("Show", ShowKind.Series);
var a1 = ReadyAsset("r1.mkv", 1200);
var a2 = ReadyAsset("r2.mkv", 1200);
show.AddEpisode(a1.Id);
show.AddEpisode(a2.Id);
channel.AddShow(show.Id, 1, BlockMode.Count, 1);
await using (var seed = fixture.CreateContext())
{
seed.Shows.Add(show);
seed.MediaAssets.AddRange(a1, a2);
seed.Channels.Add(channel);
await seed.SaveChangesAsync(CancellationToken.None);
}
// Два параллельных прогона на РАЗНЫХ соединениях — advisory-lock должен их сериализовать,
// иначе оба дописали бы хвост от одной точки продолжения и записи бы перекрылись.
await using var db1 = fixture.CreateContext();
await using var db2 = fixture.CreateContext();
var g1 = NewGenerator(db1, new SequenceRandom(0), new RecordingQueue())
.GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None);
var g2 = NewGenerator(db2, new SequenceRandom(0), new RecordingQueue())
.GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None);
await Task.WhenAll(g1, g2);
await using var verify = fixture.CreateContext();
var entries = await verify
.ScheduleEntries.AsNoTracking()
.Where(e => e.ChannelId == channel.Id)
.OrderBy(e => e.StartsAtUtc)
.ToListAsync();
Assert.NotEmpty(entries);
for (var i = 1; i < entries.Count; i++)
Assert.True(
entries[i].StartsAtUtc >= entries[i - 1].EndsAtUtc,
$"Записи перекрываются: [{entries[i - 1].StartsAtUtc:o}..{entries[i - 1].EndsAtUtc:o}] и [{entries[i].StartsAtUtc:o}..{entries[i].EndsAtUtc:o}]"
);
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<EnforceCodeStyleInBuild>false</EnforceCodeStyleInBuild>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="NSubstitute" />
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="Xunit.SkippableFact" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\TeleWave.Application\TeleWave.Application.csproj" />
<ProjectReference Include="..\..\src\TeleWave.Infrastructure\TeleWave.Infrastructure.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,100 @@
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Maintenance.DeleteShowMedia;
using TeleWave.Application.Media.Register;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using Xunit;
namespace TeleWave.Integration.Tests;
[Collection("postgres")]
public sealed class TransactionIntegrationTests(PostgresFixture fixture)
{
[SkippableFact]
public async Task RegisterMediaAsset_RollsBack_WhenFilePromoteFails()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
var fileName = $"clip-{Guid.NewGuid():N}.mkv";
var storage = Substitute.For<IMediaStorage>();
storage
.PromoteToOriginalAsync(
Arg.Any<MediaSource>(),
Arg.Any<string>(),
Arg.Any<Guid>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>()
)
.Returns(Task.FromException(new IOException("disk full")));
await using var db = fixture.CreateContext();
var handler = new RegisterMediaAssetCommandHandler(db, storage);
await Assert.ThrowsAsync<IOException>(() =>
handler.Handle(
new RegisterMediaAssetCommand(fileName, MediaSource.Upload, fileName),
CancellationToken.None
)
);
// Перенос файла упал — регистрация должна откатиться, чтобы не осталось orphan-строки.
await using var verify = fixture.CreateContext();
var exists = await verify.MediaAssets.AnyAsync(a => a.OriginalFileName == fileName);
Assert.False(exists);
}
[SkippableFact]
public async Task DeleteShowMedia_AtomicallyRemovesEntriesAssetsAndEpisodes()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
var channel = Channel.Create("c", $"c-{Guid.NewGuid():N}", DateTimeOffset.UnixEpoch);
var show = Show.Create("Show", ShowKind.Series);
var asset = MediaAsset.Register("ep.mkv", ".mkv", MediaSource.Upload);
asset.MarkReady(TimeSpan.FromMinutes(20), 2, 600, 1920, 1080, "h264", "aac", "assets/x");
show.AddEpisode(asset.Id);
var entry = ScheduleEntry.Program(
channel.Id,
asset.Id,
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch.AddMinutes(20),
show.Id,
0
);
await using (var seed = fixture.CreateContext())
{
seed.Channels.Add(channel);
seed.Shows.Add(show);
seed.MediaAssets.Add(asset);
seed.ScheduleEntries.Add(entry);
await seed.SaveChangesAsync(CancellationToken.None);
}
var storage = Substitute.For<IMediaStorage>();
storage
.DeleteAssetArtifactsAsync(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
await using (var db = fixture.CreateContext())
{
var result = await new DeleteShowMediaCommandHandler(db, storage).Handle(
new DeleteShowMediaCommand(show.Id),
CancellationToken.None
);
Assert.True(result.IsSuccess);
}
await using var verify = fixture.CreateContext();
Assert.False(await verify.MediaAssets.AnyAsync(a => a.Id == asset.Id));
Assert.False(await verify.ScheduleEntries.AnyAsync(e => e.MediaAssetId == asset.Id));
var reloaded = await verify
.Shows.Include(s => s.Episodes)
.FirstAsync(s => s.Id == show.Id);
Assert.Empty(reloaded.Episodes);
await storage
.Received()
.DeleteAssetArtifactsAsync(asset.Id, ".mkv", Arg.Any<CancellationToken>());
}
}