diff --git a/CLAUDE.md b/CLAUDE.md index e6dec8c..f83697f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,6 +118,8 @@ Backend (из `backend/`): ```bash dotnet build 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 ef migrations add --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api dotnet ef database update --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api diff --git a/REVIEW.md b/REVIEW.md index 8f3159a..3971bdf 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -42,7 +42,7 @@ ### Планировщик / медиа - [x] **M8. Нет таймаута на ffmpeg/ffprobe** — `ProcessRunner.cs` + `MediaOptions` (ffprobe 120с, транскод/рендер 1800с, конфигурируемо). - [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` → пустое окно). ### Фронтенд @@ -66,7 +66,7 @@ - [x] **L9. `AllowedHosts: "*"` и dev-креды БД в appsettings.json** — осознанные dev-дефолты, переопределяются env в проде; C2 теперь форсит реальный JWT-ключ. Оставлено как задокументированное. - [x] **L10. SSRF-поверхность в ImageDownloader** (без allowlist схемы/хоста) — `ImageDownloader.cs:16` (allowlist http/https). - [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 (полностью)** — вынести рендер ТВ-заставок из тика планировщика: генерировать расписание с - плейсхолдерами и рендерить ассеты асинхронно, чтобы один канал не задерживал достройку остальных и - чтобы не держать транзакцию/advisory-lock во время ffmpeg. -- **L12 (полностью)** — интеграционные тесты фоновых сервисов (media-конвейер, планировщик), - оркестрации `ScheduleGenerator`, эндпоинтов (нужен реальный/контейнерный Postgres — InMemory не - тянет транзакции/advisory-lock/raw SQL). -- **M15 (доменно)** — если нужны разные файлы с одинаковым именем: пересмотреть уникальность - `MediaAsset` (сейчас по `OriginalFileName`). +### 2026-07-25 — M10: асинхронная очередь рендера заставок + +- **Домен**: `BumperAsset` получил `ChannelId`/`TemplateId`/`VariantId` (чтобы фоновый рендерер + восстановил спецификацию), фабрика обновлена; миграция `BumperAssetRenderContext` (Guid-колонки с + дефолтом для существующих строк — самовосстановление при перерендере). `MediaAsset.RegisterGenerated` + теперь честно Pending до готовности. +- **Очередь**: `IBumperRenderQueue` + `BumperRenderQueue` (сигнал поверх Channel, как у медиа). +- **`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` — намеренно, клиент это зеркалит). diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 2b05f41..1f3adbe 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -43,5 +43,7 @@ + + diff --git a/backend/TeleWave.slnx b/backend/TeleWave.slnx index eacaea4..7daeda9 100644 --- a/backend/TeleWave.slnx +++ b/backend/TeleWave.slnx @@ -8,5 +8,6 @@ + diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs new file mode 100644 index 0000000..8a229f8 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs @@ -0,0 +1,47 @@ +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Broadcast; + +namespace TeleWave.Application.Broadcast.Bumpers; + +/// +/// Чистая сборка из уже разрешённых входов (пути к постеру/фону/звуку, +/// названия шоу). Общая точка для фонового рендерера заставок расписания и превью в админке. +/// +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 + ); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/BumperDuration.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/BumperDuration.cs index cfd7ba4..b0502ab 100644 --- a/backend/src/TeleWave.Application/Broadcast/Scheduling/BumperDuration.cs +++ b/backend/src/TeleWave.Application/Broadcast/Scheduling/BumperDuration.cs @@ -7,7 +7,7 @@ namespace TeleWave.Application.Broadcast.Scheduling; /// вверх до кратности длине сегмента — ключевой инвариант эфирной математики. Общая для генератора /// (слоты в плане) и резолвера заставок (рендер). /// -internal static class BumperDuration +public static class BumperDuration { /// Длительность заставки без загруженного звука (сек) — синтезированный джингл. private const int DefaultSeconds = 8; diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleBumperResolver.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleBumperResolver.cs index 6497028..2c09321 100644 --- a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleBumperResolver.cs +++ b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleBumperResolver.cs @@ -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; /// /// Резолвит ассеты ТВ-заставок для запланированных переходов: для каждой уникальной тройки -/// «из→в→подблок» возвращает id готового ассета — из кэша () либо -/// свежесгенерированного ffmpeg-рендером. Работает через тот же scoped , -/// что и : добавленные ассеты сохраняются его общим SaveChanges. +/// «из→в→подблок» возвращает id ассета-заставки. Сам ffmpeg НЕ запускает — если готового (или уже +/// рендерящегося) ассета в кэше нет, создаёт в статусе Pending, кэш-строку +/// (с id канала/блока/подблока для восстановления спеки) и ставит в очередь +/// фонового рендерера. Так тик планировщика не блокируется на ffmpeg, а его транзакция не держится во +/// время рендера. До готовности ассета плейлист отдаёт филлер (см. GetLivePlaylistQueryHandler). /// public sealed class ScheduleBumperResolver( IAppDbContext dbContext, - IBumperRenderer bumperRenderer, - IBumperTemplateStorage bumperStorage, - IImageStore imageStore, + IBumperRenderQueue renderQueue, IOptions bumperOptions, - IOptions streamingOptions, - ILogger logger + IOptions streamingOptions ) { private readonly BumperOptions _bumper = bumperOptions.Value; private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds); /// - /// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id готового - /// ассета-заставки: из кэша либо свежесгенерированного. + /// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id ассета: + /// готового/рендерящегося из кэша либо только что созданного Pending (поставлен в очередь рендера). /// public async Task> 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(); - 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(); - 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 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; - /// /// Сигнатура рендера = хэш всех входов заставки: общие настройки канала (шрифт/подписи/версия /// шаблона), оформление и файлы блока (цвета/фон/звук/ревизия), названия шоу и постер. Меняется — diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderQueue.cs b/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderQueue.cs new file mode 100644 index 0000000..f0480ed --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderQueue.cs @@ -0,0 +1,15 @@ +namespace TeleWave.Application.Common.Interfaces; + +/// +/// Сигнал «появилась заставка на рендер» для фонового рендерера. Как и у медиа-конвейера, источник +/// истины — статус ассета в БД (рендерер берёт следующий Pending c Source=Generated), а очередь лишь +/// будит его без задержки; потеря сигнала при рестарте задачи не теряет — они подхватываются из БД. +/// +public interface IBumperRenderQueue +{ + /// Разбудить рендерер: появился ассет-заставка в статусе Pending. + void Enqueue(Guid assetId); + + /// Ждать сигнала о новой работе (с дренажом накопленных). + ValueTask WaitAsync(CancellationToken cancellationToken); +} diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs b/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs index a10565e..658488f 100644 --- a/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs +++ b/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs @@ -1,10 +1,12 @@ namespace TeleWave.Domain.Broadcast; /// -/// Кэш отрендеренной ТВ-заставки перехода. Один сгенерированный на -/// уникальную комбинацию () при данной -/// (хэш названий шоу и версии шаблона). Переиспользуется между днями и -/// каналами; при смене названий/шаблона меняется и рендерится новый ассет. +/// Кэш ТВ-заставки перехода. Один сгенерированный на уникальную комбинацию +/// () при данной (хэш названий +/// шоу и версии шаблона). Ассет создаётся в статусе Pending и рендерится ffmpeg'ом асинхронно фоновым +/// сервисом — поэтому здесь же храним, ЧЕМ его рендерить (// +/// ), чтобы фоновый рендерер восстановил спецификацию без участия планировщика. +/// Переиспользуется между днями и каналами; при смене названий/шаблона сигнатура меняется — новый ассет. /// public class BumperAsset { @@ -12,10 +14,15 @@ public class BumperAsset public Guid FromShowId { get; private set; } public Guid ToShowId { get; private set; } + /// Канал/блок/подблок, по которым фоновый рендерер восстановит спецификацию заставки. + public Guid ChannelId { get; private set; } + public Guid TemplateId { get; private set; } + public Guid VariantId { get; private set; } + /// Хэш входных данных рендера (названия «из/в» + версия шаблона). public string Signature { get; private set; } = string.Empty; - /// Сгенерированный ассет-заставка (нарезан в assets/{id}, статус Ready). + /// Сгенерированный ассет-заставка (нарезается в assets/{id} фоновым рендерером). 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, diff --git a/backend/src/TeleWave.Domain/Media/MediaAsset.cs b/backend/src/TeleWave.Domain/Media/MediaAsset.cs index 46bb92c..51731b3 100644 --- a/backend/src/TeleWave.Domain/Media/MediaAsset.cs +++ b/backend/src/TeleWave.Domain/Media/MediaAsset.cs @@ -56,8 +56,9 @@ public class MediaAsset } /// - /// Ассет, сгенерированный системой (ТВ-заставка). Нарезку делает не общий пайплайн обработки, а - /// специализированный рендерер, поэтому сразу помечаем после создания. + /// Ассет, сгенерированный системой (ТВ-заставка). Создаётся в + /// и нарезается не общим пайплайном обработки, а специализированным фоновым рендерером + /// (BumperRenderBackgroundService), который по готовности вызывает . /// public static MediaAsset RegisterGenerated(string displayName) { diff --git a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs index f99f5d8..33774d9 100644 --- a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs +++ b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs @@ -158,8 +158,10 @@ public static class DependencyInjection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddHostedService(); services.AddHostedService(); + services.AddHostedService(); } } diff --git a/backend/src/TeleWave.Infrastructure/Media/BumperRenderBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Media/BumperRenderBackgroundService.cs new file mode 100644 index 0000000..fdd7a71 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/BumperRenderBackgroundService.cs @@ -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; + +/// +/// Асинхронно рендерит ТВ-заставки расписания: планировщик лишь создаёт ассет (Source=Generated) в +/// статусе Pending и кэш-строку , а сам ffmpeg крутится здесь, +/// вне тика планировщика и его транзакции. Источник истины — статус в БД (последовательно берём +/// следующий Pending c Source=Generated, помечаем Processing), поэтому рестарт/краш ничего не теряет +/// (прерванные Processing сбрасываются в Pending на старте). До готовности ассета плейлист отдаёт филлер. +/// +public sealed class BumperRenderBackgroundService( + IBumperRenderQueue queue, + IServiceScopeFactory scopeFactory, + IBumperRenderer renderer, + IBumperTemplateStorage bumperStorage, + IImageStore imageStore, + IOptions bumperOptions, + IOptions streamingOptions, + ILogger 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); + } + } + } + + /// Сброс прерванных рестартом заставок (Generated Processing → Pending) на старте. + private async Task ResetInterruptedAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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); + } + + /// Атомарно захватывает самую раннюю Pending-заставку (Generated): Pending → Processing. + private async Task ClaimNextAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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(); + + 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); + } + } + + /// Восстанавливает по кэш-строке заставки (канал/блок/подблок). + private async Task 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 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 ResolveTemplateBackgroundAsync( + IAppDbContext db, + Guid? backgroundImageId, + CancellationToken cancellationToken + ) + { + if (backgroundImageId is not { } bgId) + return null; + return await ResolveImagePathAsync(db, bgId, cancellationToken); + } + + private async Task 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(); + var asset = await db.MediaAssets.FirstOrDefaultAsync( + a => a.Id == assetId, + cancellationToken + ); + if (asset is null) + return; + asset.MarkFailed(error); + await db.SaveChangesAsync(cancellationToken); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Media/BumperRenderQueue.cs b/backend/src/TeleWave.Infrastructure/Media/BumperRenderQueue.cs new file mode 100644 index 0000000..a978074 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/BumperRenderQueue.cs @@ -0,0 +1,20 @@ +using System.Threading.Channels; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Infrastructure.Media; + +/// Сигнальная очередь-будильник поверх Channel (id ассета — лишь сигнал; работу берём из БД). +public sealed class BumperRenderQueue : IBumperRenderQueue +{ + private readonly Channel _channel = Channel.CreateUnbounded( + 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 _)) { } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaProcessingBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Media/MediaProcessingBackgroundService.cs index 1007e74..27b4d57 100644 --- a/backend/src/TeleWave.Infrastructure/Media/MediaProcessingBackgroundService.cs +++ b/backend/src/TeleWave.Infrastructure/Media/MediaProcessingBackgroundService.cs @@ -130,7 +130,9 @@ public sealed class MediaProcessingBackgroundService( var db = scope.ServiceProvider.GetRequiredService(); 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(); + // 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) diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725202904_BumperAssetRenderContext.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725202904_BumperAssetRenderContext.Designer.cs new file mode 100644 index 0000000..1d2e6fd --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725202904_BumperAssetRenderContext.Designer.cs @@ -0,0 +1,1017 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260725202904_BumperAssetRenderContext")] + partial class BumperAssetRenderContext + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.Property("VariantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("BumperEpisodeChangeChance") + .HasColumnType("double precision"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumperShowChangeChance") + .HasColumnType("double precision"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("NextBumperIndex") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("PreferredWeightMultiplier") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelShowId") + .HasColumnType("uuid"); + + b.Property("EndHour") + .HasColumnType("integer"); + + b.Property("StartHour") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelShowId"); + + b.ToTable("ChannelShowHour"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("EndMinute") + .HasColumnType("integer"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("Recurrence") + .HasColumnType("integer"); + + b.Property("StartMinute") + .HasColumnType("integer"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc"); + + b.ToTable("ProgrammingOverride"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "ShowId"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Category", "CreatedAt"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Ads") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Shows") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ChannelShow", null) + .WithMany("PreferredHours") + .HasForeignKey("ChannelShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null) + .WithMany("Shows") + .HasForeignKey("ProgrammingOverrideId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Overrides") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("Ads"); + + b.Navigation("BumperTemplates"); + + b.Navigation("Overrides"); + + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Navigation("PreferredHours"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725202904_BumperAssetRenderContext.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725202904_BumperAssetRenderContext.cs new file mode 100644 index 0000000..e70c926 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725202904_BumperAssetRenderContext.cs @@ -0,0 +1,52 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class BumperAssetRenderContext : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ChannelId", + table: "BumperAssets", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "TemplateId", + table: "BumperAssets", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "VariantId", + table: "BumperAssets", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ChannelId", + table: "BumperAssets"); + + migrationBuilder.DropColumn( + name: "TemplateId", + table: "BumperAssets"); + + migrationBuilder.DropColumn( + name: "VariantId", + table: "BumperAssets"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 5ceb3e6..5a535b3 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -164,6 +164,9 @@ namespace TeleWave.Infrastructure.Migrations b.Property("Id") .HasColumnType("uuid"); + b.Property("ChannelId") + .HasColumnType("uuid"); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -178,9 +181,15 @@ namespace TeleWave.Infrastructure.Migrations .HasMaxLength(128) .HasColumnType("character varying(128)"); + b.Property("TemplateId") + .HasColumnType("uuid"); + b.Property("ToShowId") .HasColumnType("uuid"); + b.Property("VariantId") + .HasColumnType("uuid"); + b.HasKey("Id"); b.HasIndex("FromShowId", "ToShowId", "Signature"); diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/DomainRecordFactoryTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/DomainRecordFactoryTests.cs index f271744..2e9f725 100644 --- a/backend/tests/TeleWave.Domain.Tests/Broadcast/DomainRecordFactoryTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Broadcast/DomainRecordFactoryTests.cs @@ -25,12 +25,18 @@ public class DomainRecordFactoryTests [Fact] public void BumperAsset_Create_StoresPair() { + var channel = Guid.NewGuid(); + var template = Guid.NewGuid(); + var variant = Guid.NewGuid(); var from = Guid.NewGuid(); var to = 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(to, b.ToShowId); Assert.Equal("sig", b.Signature); diff --git a/backend/tests/TeleWave.Integration.Tests/PostgresFixture.cs b/backend/tests/TeleWave.Integration.Tests/PostgresFixture.cs new file mode 100644 index 0000000..1b0a753 --- /dev/null +++ b/backend/tests/TeleWave.Integration.Tests/PostgresFixture.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Infrastructure.Persistence; +using Testcontainers.PostgreSql; +using Xunit; + +namespace TeleWave.Integration.Tests; + +/// +/// Поднимает одноразовый Postgres в контейнере и применяет к нему реальные миграции. Даёт свежие +/// экземпляры (каждый — своё соединение), чтобы тестировать то, что InMemory +/// не умеет: транзакции, advisory-lock, ExecuteDelete, raw SQL. Требует запущенного Docker. +/// +public sealed class PostgresFixture : IAsyncLifetime +{ + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder() + .WithImage("postgres:16-alpine") + .Build(); + + /// false, если Docker недоступен (напр. CI-раннер без Docker) — тогда тесты пропускаются. + 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().UseNpgsql(ConnectionString).Options); + + public async Task DisposeAsync() => await _container.DisposeAsync(); +} + +[CollectionDefinition("postgres")] +public sealed class PostgresCollection : ICollectionFixture; + +/// Детерминированный источник случайности для планировщика в тестах. +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; + } +} diff --git a/backend/tests/TeleWave.Integration.Tests/ScheduleGeneratorIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/ScheduleGeneratorIntegrationTests.cs new file mode 100644 index 0000000..364c266 --- /dev/null +++ b/backend/tests/TeleWave.Integration.Tests/ScheduleGeneratorIntegrationTests.cs @@ -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 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}]" + ); + } +} diff --git a/backend/tests/TeleWave.Integration.Tests/TeleWave.Integration.Tests.csproj b/backend/tests/TeleWave.Integration.Tests/TeleWave.Integration.Tests.csproj new file mode 100644 index 0000000..7376426 --- /dev/null +++ b/backend/tests/TeleWave.Integration.Tests/TeleWave.Integration.Tests.csproj @@ -0,0 +1,31 @@ + + + net10.0 + enable + enable + false + false + false + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/backend/tests/TeleWave.Integration.Tests/TransactionIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/TransactionIntegrationTests.cs new file mode 100644 index 0000000..853a63f --- /dev/null +++ b/backend/tests/TeleWave.Integration.Tests/TransactionIntegrationTests.cs @@ -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(); + storage + .PromoteToOriginalAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ) + .Returns(Task.FromException(new IOException("disk full"))); + + await using var db = fixture.CreateContext(); + var handler = new RegisterMediaAssetCommandHandler(db, storage); + + await Assert.ThrowsAsync(() => + 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(); + storage + .DeleteAssetArtifactsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .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()); + } +}