diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Bumpers.cs b/backend/src/TeleWave.Api/Endpoints/BumperEndpoints.cs similarity index 52% rename from backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Bumpers.cs rename to backend/src/TeleWave.Api/Endpoints/BumperEndpoints.cs index a5116db..26edb7e 100644 --- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Bumpers.cs +++ b/backend/src/TeleWave.Api/Endpoints/BumperEndpoints.cs @@ -1,35 +1,88 @@ using System.Text; using LiteCqrs; using TeleWave.Api.Common; -using TeleWave.Application.Broadcast; using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Common.Interfaces; using TeleWave.Domain.Broadcast; +using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Media; namespace TeleWave.Api.Endpoints; -/// Эндпоинты ТВ-заставок канала: блоки (стиль/аудио/фон), подблоки и рендер превью. -public static partial class ChannelEndpoints +/// +/// Блоки ТВ-заставок: оформление, звук, подблоки с текстом и рендер превью. Блоки общие для всех +/// каналов, поэтому и раздел свой, не канальный — канал только ссылается на них врезками стыков. +/// +public static class BumperEndpoints { - private static async Task AddBumperTemplate( - Guid id, - AddBumperTemplateBody body, + public static IEndpointRouteBuilder MapBumperEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/bumpers") + .WithTags("Admin.Bumpers") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("", List).Produces>(); + admin.MapPost("", Create).Produces(StatusCodes.Status201Created); + admin.MapPut("/{templateId:guid}", Update).Produces(StatusCodes.Status204NoContent); + admin.MapDelete("/{templateId:guid}", Delete).Produces(StatusCodes.Status204NoContent); + + admin + .MapPut("/{templateId:guid}/audio", UploadAudio) + .Produces(StatusCodes.Status204NoContent); + admin + .MapDelete("/{templateId:guid}/audio", ClearAudio) + .Produces(StatusCodes.Status204NoContent); + admin + .MapPut("/{templateId:guid}/background", SetBackground) + .Produces(StatusCodes.Status204NoContent); + admin + .MapDelete("/{templateId:guid}/background", ClearBackground) + .Produces(StatusCodes.Status204NoContent); + + admin + .MapPost("/{templateId:guid}/variants", AddVariant) + .Produces(StatusCodes.Status201Created); + admin + .MapPut("/{templateId:guid}/variants/{variantId:guid}", UpdateVariant) + .Produces(StatusCodes.Status204NoContent); + admin + .MapDelete("/{templateId:guid}/variants/{variantId:guid}", RemoveVariant) + .Produces(StatusCodes.Status204NoContent); + + admin + .MapPost("/{templateId:guid}/preview", RenderPreview) + .Produces(StatusCodes.Status204NoContent); + admin.MapGet("/{templateId:guid}/preview/{variantId:guid}/index.m3u8", PreviewPlaylist); + admin.MapGet("/{templateId:guid}/preview/{variantId:guid}/{file}", PreviewSegment); + + return app; + } + + private static async Task List(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListBumperTemplatesQuery(), cancellationToken); + return Results.Ok(result); + } + + private static async Task Create( + BumperNameBody body, ISender sender, CancellationToken cancellationToken ) { var result = await sender.Send( - new AddBumperTemplateCommand(id, body.Name), + new CreateBumperTemplateCommand(body.Name), cancellationToken ); return result.IsSuccess - ? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value)) + ? Results.Created( + $"/api/admin/bumpers/{result.Value}", + new CreatedIdResponse(result.Value) + ) : result.ToHttpResult(); } - private static async Task UpdateBumperTemplate( - Guid id, + private static async Task Update( Guid templateId, UpdateBumperTemplateBody body, ISender sender, @@ -38,35 +91,37 @@ public static partial class ChannelEndpoints { var result = await sender.Send( new UpdateBumperTemplateCommand( - id, templateId, - body.Name, - body.BackgroundColor, - body.BackgroundColor2, - body.AccentColor, - body.TextColor + new BumperStyle( + body.Name, + body.Font, + body.BackgroundColor, + body.BackgroundColor2, + body.AccentColor, + body.TextColor + ) ), cancellationToken ); return result.ToHttpResult(); } - private static async Task RemoveBumperTemplate( - Guid id, + private static async Task Delete( Guid templateId, ISender sender, CancellationToken cancellationToken ) { var result = await sender.Send( - new RemoveBumperTemplateCommand(id, templateId), + new DeleteBumperTemplateCommand(templateId), cancellationToken ); return result.ToHttpResult(); } - private static async Task UploadTemplateAudio( - [AsParameters] BumperAudioUpload upload, + private static async Task UploadAudio( + Guid templateId, + string fileName, HttpRequest request, IBumperTemplateStorage storage, IAudioProbe probe, @@ -74,124 +129,101 @@ public static partial class ChannelEndpoints CancellationToken cancellationToken ) { - if ( - ResolveBumperExtension(upload.FileName, request, BumperFiles.AudioExtensions) - is not { } ext - ) - return ChannelErrors.InvalidBumperFile.ToProblem(); + if (ResolveExtension(fileName, request) is not { } ext) + return BumperErrors.InvalidFile.ToProblem(); - await storage.SaveAudioAsync(upload.TemplateId, ext, request.Body, cancellationToken); + await storage.SaveAudioAsync(templateId, ext, request.Body, cancellationToken); // Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина). - var path = storage.AudioPath(upload.TemplateId, ext); + var path = storage.AudioPath(templateId, ext); var duration = path is null ? null : await probe.TryGetDurationAsync(path, cancellationToken); var result = await sender.Send( - new SetBumperTemplateAudioCommand( - upload.Id, - upload.TemplateId, - ext, - duration?.TotalSeconds ?? 0 - ), + new SetBumperTemplateAudioCommand(templateId, ext, duration?.TotalSeconds ?? 0), cancellationToken ); if (!result.IsSuccess) - await storage.DeleteAudioAsync(upload.TemplateId, cancellationToken); + await storage.DeleteAudioAsync(templateId, cancellationToken); return result.ToHttpResult(); } - private static async Task ClearTemplateAudio( - Guid id, + private static async Task ClearAudio( Guid templateId, ISender sender, CancellationToken cancellationToken ) { var result = await sender.Send( - new ClearBumperTemplateAudioCommand(id, templateId), + new ClearBumperTemplateAudioCommand(templateId), cancellationToken ); return result.ToHttpResult(); } - private static async Task SetTemplateBackground( - Guid id, + private static async Task SetBackground( Guid templateId, - SetBumperTemplateBackgroundBody body, + SetBumperBackgroundBody body, ISender sender, CancellationToken cancellationToken ) { var result = await sender.Send( - new SetBumperTemplateBackgroundCommand(id, templateId, body.ImageId), + new SetBumperTemplateBackgroundCommand(templateId, body.ImageId), cancellationToken ); return result.ToHttpResult(); } - private static async Task ClearTemplateBackground( - Guid id, + private static async Task ClearBackground( Guid templateId, ISender sender, CancellationToken cancellationToken ) { var result = await sender.Send( - new ClearBumperTemplateBackgroundCommand(id, templateId), + new ClearBumperTemplateBackgroundCommand(templateId), cancellationToken ); return result.ToHttpResult(); } - private static async Task AddBumperVariant( - Guid id, + private static async Task AddVariant( Guid templateId, - AddBumperVariantBody body, + BumperNameBody body, ISender sender, CancellationToken cancellationToken ) { var result = await sender.Send( - new AddBumperTextVariantCommand(id, templateId, body.Name), + new AddBumperVariantCommand(templateId, body.Name), cancellationToken ); return result.IsSuccess - ? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value)) + ? Results.Created( + $"/api/admin/bumpers/{templateId}", + new CreatedIdResponse(result.Value) + ) : result.ToHttpResult(); } - private static async Task UpdateBumperVariant( - Guid id, + private static async Task UpdateVariant( Guid templateId, Guid variantId, - UpdateBumperVariantBody body, + BumperVariantInput input, ISender sender, CancellationToken cancellationToken ) { var result = await sender.Send( - new UpdateBumperTextVariantCommand( - id, - templateId, - variantId, - body.Name, - body.Kind, - body.NowLabel, - body.NextLabel, - body.Line1, - body.Line2, - body.Trigger, - body.Weight - ), + new UpdateBumperVariantCommand(templateId, variantId, input), cancellationToken ); return result.ToHttpResult(); } - private static async Task RemoveBumperVariant( - Guid id, + private static async Task RemoveVariant( Guid templateId, Guid variantId, ISender sender, @@ -199,7 +231,7 @@ public static partial class ChannelEndpoints ) { var result = await sender.Send( - new RemoveBumperTextVariantCommand(id, templateId, variantId), + new RemoveBumperVariantCommand(templateId, variantId), cancellationToken ); return result.ToHttpResult(); @@ -207,33 +239,27 @@ public static partial class ChannelEndpoints /// Синхронно рендерит пример заставки блока (несколько секунд ffmpeg). private static async Task RenderPreview( - Guid id, Guid templateId, + Guid? channelId, ISender sender, CancellationToken cancellationToken ) { var result = await sender.Send( - new RenderBumperPreviewCommand(id, templateId), + new RenderBumperPreviewCommand(templateId, channelId), cancellationToken ); return result.IsSuccess ? Results.NoContent() : result.ToHttpResult(); } /// Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут. - private static IResult PreviewPlaylist( - Guid id, - Guid templateId, - Guid variantId, - MediaPathResolver paths - ) + private static IResult PreviewPlaylist(Guid templateId, Guid variantId, MediaPathResolver paths) { var previewId = BumperPreview.AssetId(variantId); if (SegmentFiles.TryResolveExisting(paths, previewId, "index.m3u8") is not { } indexPath) return Results.NotFound(); - var baseUrl = - $"/api/admin/channels/{id}/bumper/templates/{templateId}/preview/{variantId}/"; + var baseUrl = $"/api/admin/bumpers/{templateId}/preview/{variantId}/"; var sb = new StringBuilder(); foreach (var line in File.ReadLines(indexPath)) { @@ -249,9 +275,8 @@ public static partial class ChannelEndpoints } /// - /// Сегмент превью. Канал и блок в маршруте есть, но хендлеру не нужны: каталог превью - /// адресуется подблоком (см. BumperPreview.AssetId), поэтому в сигнатуре их нет — незаявленные - /// параметры маршрута просто не связываются. + /// Сегмент превью. Блок в маршруте есть, но хендлеру не нужен: каталог превью адресуется + /// подблоком (см. ) — незаявленные параметры не связываются. /// private static IResult PreviewSegment(Guid variantId, string file, MediaPathResolver paths) { @@ -267,50 +292,27 @@ public static partial class ChannelEndpoints /// Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает /// нормализованное расширение (с точкой, нижний регистр) или null при отказе. - private static string? ResolveBumperExtension( - string fileName, - HttpRequest request, - IReadOnlySet allowedExtensions - ) + private static string? ResolveExtension(string fileName, HttpRequest request) { if (request.ContentLength is > BumperFiles.MaxBytes or 0 or null) return null; var ext = Path.GetExtension(fileName).ToLowerInvariant(); - return allowedExtensions.Contains(ext) ? ext : null; + return BumperFiles.AudioExtensions.Contains(ext) ? ext : null; } } -public sealed record AddBumperTemplateBody(string Name); +public sealed record BumperNameBody(string Name); public sealed record UpdateBumperTemplateBody( string Name, + BumperFont Font, string BackgroundColor, string BackgroundColor2, string AccentColor, string TextColor ); -public sealed record SetBumperTemplateBackgroundBody(Guid ImageId); - -public sealed record AddBumperVariantBody(string Name); - -public sealed record UpdateBumperVariantBody( - string Name, - BumperTextKind Kind, - string NowLabel, - string NextLabel, - string Line1, - string Line2, - BumperTrigger Trigger, - int Weight -); - -/// -/// Адрес загружаемого звука: канал и блок из маршрута плюс имя исходного файла из query (по нему -/// проверяется расширение). Свёрнуто в один параметр — кроме него хендлеру нужны ещё запрос, два -/// сервиса, диспетчер и токен отмены, и плоским списком сигнатура перестаёт читаться. -/// -public sealed record BumperAudioUpload(Guid Id, Guid TemplateId, string FileName); +public sealed record SetBumperBackgroundBody(Guid ImageId); /// Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр). internal static class BumperFiles diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs index b0e143a..e941173 100644 --- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs @@ -15,11 +15,11 @@ using TeleWave.Infrastructure.Identity; namespace TeleWave.Api.Endpoints; /// -/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки — -/// в ChannelEndpoints.Bumpers.cs. Что и когда идёт в эфире, задаёт шаблон сетки -/// (TemplateEndpoints). +/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки +/// и стыки общие для всех каналов и живут своими разделами (BumperEndpoints, +/// JunctionEndpoints); что и когда идёт в эфире, задаёт шаблон сетки (TemplateEndpoints). /// -public static partial class ChannelEndpoints +public static class ChannelEndpoints { public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app) { @@ -35,62 +35,6 @@ public static partial class ChannelEndpoints .Produces(StatusCodes.Status204NoContent); admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent); - admin - .MapPost("/{id:guid}/bumper/templates", AddBumperTemplate) - .Produces(StatusCodes.Status201Created); - admin - .MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate) - .Produces(StatusCodes.Status204NoContent); - admin - .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate) - .Produces(StatusCodes.Status204NoContent); - admin - .MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio) - .Produces(StatusCodes.Status204NoContent); - admin - .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio) - .Produces(StatusCodes.Status204NoContent); - admin - .MapPut( - "/{id:guid}/bumper/templates/{templateId:guid}/background", - SetTemplateBackground - ) - .Produces(StatusCodes.Status204NoContent); - admin - .MapDelete( - "/{id:guid}/bumper/templates/{templateId:guid}/background", - ClearTemplateBackground - ) - .Produces(StatusCodes.Status204NoContent); - - admin - .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview) - .Produces(StatusCodes.Status204NoContent); - admin.MapGet( - "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8", - PreviewPlaylist - ); - admin.MapGet( - "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}", - PreviewSegment - ); - - admin - .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant) - .Produces(StatusCodes.Status201Created); - admin - .MapPut( - "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}", - UpdateBumperVariant - ) - .Produces(StatusCodes.Status204NoContent); - admin - .MapDelete( - "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}", - RemoveBumperVariant - ) - .Produces(StatusCodes.Status204NoContent); - admin .MapGet("/{id:guid}/schedule", GetSchedule) .Produces>(); @@ -191,14 +135,7 @@ public static partial class ChannelEndpoints ) { var result = await sender.Send( - new UpdateChannelSettingsCommand( - id, - body.Name, - body.IsEnabled, - body.BumpersEnabled, - body.Bumper, - body.FillerAssetId - ), + new UpdateChannelSettingsCommand(id, body.Name, body.IsEnabled, body.FillerAssetId), cancellationToken ); return result.ToHttpResult(); @@ -229,13 +166,7 @@ public sealed record UpdateChannelTimeBody( TimeOnly DayStartTime ); -public sealed record UpdateChannelSettingsBody( - string Name, - bool IsEnabled, - bool BumpersEnabled, - BumperSettingsInput Bumper, - Guid? FillerAssetId -); +public sealed record UpdateChannelSettingsBody(string Name, bool IsEnabled, Guid? FillerAssetId); /// Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8). public sealed record UpdateViewerSettingsBody( diff --git a/backend/src/TeleWave.Api/Endpoints/JunctionEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/JunctionEndpoints.cs index e5a643f..63c3f4d 100644 --- a/backend/src/TeleWave.Api/Endpoints/JunctionEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/JunctionEndpoints.cs @@ -7,67 +7,50 @@ using TeleWave.Infrastructure.Identity; namespace TeleWave.Api.Endpoints; /// -/// Шаблоны стыков канала: что играет между программами. Как и правка сетки, эфира не двигают — -/// помечают шаблон канала изменённым, а хвост пересобирается применением. +/// Шаблоны стыков: что играет между программами. Стыки общие для всех каналов, канал только +/// ссылается на них слотами. Как и правка сетки, эфира не двигают — помечают шаблоны каналов, +/// которые их используют, изменёнными, а хвост пересобирается применением. /// public static class JunctionEndpoints { public static IEndpointRouteBuilder MapJunctionEndpoints(this IEndpointRouteBuilder app) { - var admin = app.MapGroup("/api/admin") + var admin = app.MapGroup("/api/admin/junctions") .WithTags("Admin.Junctions") .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); - admin - .MapGet("/channels/{channelId:guid}/junctions", List) - .Produces>(); - admin - .MapPost("/channels/{channelId:guid}/junctions", Create) - .Produces(StatusCodes.Status201Created); - admin - .MapPut("/junctions/{junctionId:guid}", Rename) - .Produces(StatusCodes.Status204NoContent); - admin - .MapDelete("/junctions/{junctionId:guid}", Delete) - .Produces(StatusCodes.Status204NoContent); + admin.MapGet("", List).Produces>(); + admin.MapPost("", Create).Produces(StatusCodes.Status201Created); + admin.MapPut("/{junctionId:guid}", Update).Produces(StatusCodes.Status204NoContent); + admin.MapDelete("/{junctionId:guid}", Delete).Produces(StatusCodes.Status204NoContent); admin - .MapPost("/junctions/{junctionId:guid}/elements", AddElement) + .MapPost("/{junctionId:guid}/elements", AddElement) .Produces(StatusCodes.Status201Created); admin - .MapPut("/junctions/{junctionId:guid}/elements/{elementId:guid}", UpdateElement) + .MapPut("/{junctionId:guid}/elements/{elementId:guid}", UpdateElement) .Produces(StatusCodes.Status204NoContent); admin - .MapDelete("/junctions/{junctionId:guid}/elements/{elementId:guid}", RemoveElement) - .Produces(StatusCodes.Status204NoContent); - admin - .MapPut("/junctions/{junctionId:guid}/order", Reorder) + .MapDelete("/{junctionId:guid}/elements/{elementId:guid}", RemoveElement) .Produces(StatusCodes.Status204NoContent); + admin.MapPut("/{junctionId:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent); return app; } - private static async Task List( - Guid channelId, - ISender sender, - CancellationToken cancellationToken - ) + private static async Task List(ISender sender, CancellationToken cancellationToken) { - var result = await sender.Send(new ListJunctionsQuery(channelId), cancellationToken); + var result = await sender.Send(new ListJunctionsQuery(), cancellationToken); return Results.Ok(result); } private static async Task Create( - Guid channelId, JunctionNameBody body, ISender sender, CancellationToken cancellationToken ) { - var result = await sender.Send( - new CreateJunctionCommand(channelId, body.Name), - cancellationToken - ); + var result = await sender.Send(new CreateJunctionCommand(body.Name), cancellationToken); return result.IsSuccess ? Results.Created( $"/api/admin/junctions/{result.Value}", @@ -76,15 +59,15 @@ public static class JunctionEndpoints : result.ToHttpResult(); } - private static async Task Rename( + private static async Task Update( Guid junctionId, - JunctionNameBody body, + UpdateJunctionBody body, ISender sender, CancellationToken cancellationToken ) { var result = await sender.Send( - new RenameJunctionCommand(junctionId, body.Name), + new UpdateJunctionCommand(junctionId, body.Name, body.MaxTotalSeconds), cancellationToken ); return result.ToHttpResult(); @@ -156,7 +139,7 @@ public static class JunctionEndpoints ) { var result = await sender.Send( - new ReorderJunctionCommand(junctionId, body.ElementIdsInOrder), + new ReorderJunctionCommand(junctionId, body.Order), cancellationToken ); return result.ToHttpResult(); @@ -165,6 +148,9 @@ public static class JunctionEndpoints public sealed record JunctionNameBody(string Name); +public sealed record UpdateJunctionBody(string Name, int? MaxTotalSeconds); + public sealed record JunctionElementKindBody(JunctionElementKind Kind); -public sealed record ReorderJunctionBody(IReadOnlyList ElementIdsInOrder); +/// Порядок врезок вместе с их развилками — перетаскивание меняет и то, и другое разом. +public sealed record ReorderJunctionBody(IReadOnlyList Order); diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs index 9e7553e..b379094 100644 --- a/backend/src/TeleWave.Api/Program.cs +++ b/backend/src/TeleWave.Api/Program.cs @@ -130,6 +130,7 @@ app.MapInterstitialEndpoints(); app.MapCollectionEndpoints(); app.MapGroupEndpoints(); app.MapTemplateEndpoints(); +app.MapBumperEndpoints(); app.MapJunctionEndpoints(); app.MapChannelEndpoints(); app.MapStreamingEndpoints(); diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTemplateCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTemplateCommand.cs deleted file mode 100644 index f703fdb..0000000 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTemplateCommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.Bumpers; - -/// Добавить новый блок заставки на канал (звук/фон загружаются отдельно). -public sealed record AddBumperTemplateCommand(Guid ChannelId, string Name) : ICommand>; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTemplateCommandHandler.cs deleted file mode 100644 index 90b82d5..0000000 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTemplateCommandHandler.cs +++ /dev/null @@ -1,28 +0,0 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.Bumpers; - -public sealed class AddBumperTemplateCommandHandler(IAppDbContext dbContext) - : ICommandHandler> -{ - public async Task> Handle( - AddBumperTemplateCommand command, - CancellationToken cancellationToken - ) - { - var channel = await dbContext - .Channels.Include(c => c.BumperTemplates) - .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - var name = string.IsNullOrWhiteSpace(command.Name) - ? $"Заставка {channel.BumperTemplates.Count + 1}" - : command.Name.Trim(); - var template = channel.AddBumperTemplate(name); - return Result.Success(template.Id); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTextVariantCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTextVariantCommand.cs deleted file mode 100644 index bf97df3..0000000 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTextVariantCommand.cs +++ /dev/null @@ -1,8 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.Bumpers; - -/// Добавить подблок (текст-вариант) в блок заставки. -public sealed record AddBumperTextVariantCommand(Guid ChannelId, Guid TemplateId, string Name) - : ICommand>; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTextVariantCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTextVariantCommandHandler.cs deleted file mode 100644 index 3f90c9a..0000000 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTextVariantCommandHandler.cs +++ /dev/null @@ -1,34 +0,0 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.Bumpers; - -public sealed class AddBumperTextVariantCommandHandler(IAppDbContext dbContext) - : ICommandHandler> -{ - public async Task> Handle( - AddBumperTextVariantCommand command, - CancellationToken cancellationToken - ) - { - var channel = await dbContext - .Channels.Include(c => c.BumperTemplates) - .ThenInclude(t => t.Variants) - .AsSplitQuery() - .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - var template = channel.FindBumperTemplate(command.TemplateId); - if (template is null) - return Result.Failure(ChannelErrors.BumperTemplateNotFound); - - var name = string.IsNullOrWhiteSpace(command.Name) - ? $"Текст {template.Variants.Count + 1}" - : command.Name.Trim(); - var variant = template.AddVariant(name); - return Result.Success(variant.Id); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperVariantCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperVariantCommandHandler.cs new file mode 100644 index 0000000..04c6ea1 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperVariantCommandHandler.cs @@ -0,0 +1,27 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.Bumpers; + +public sealed class AddBumperVariantCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public async Task> Handle( + AddBumperVariantCommand command, + CancellationToken cancellationToken + ) + { + var template = await BumperTemplateLoader.LoadAsync( + dbContext, + command.TemplateId, + cancellationToken + ); + if (template is null) + return Result.Failure(BumperErrors.TemplateNotFound); + + var variant = template.AddVariant(command.Name); + variant.SetLines(BumperTemplateLoader.DefaultLines()); + return Result.Success(variant.Id); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperCommands.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperCommands.cs new file mode 100644 index 0000000..9517c98 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperCommands.cs @@ -0,0 +1,96 @@ +using FluentValidation; +using LiteCqrs; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Broadcast; + +namespace TeleWave.Application.Broadcast.Bumpers; + +public sealed record ListBumperTemplatesQuery : IQuery>; + +public sealed record CreateBumperTemplateCommand(string Name) : ICommand>; + +/// Оформление блока: имя, шрифт и палитра. Меняет ревизию — заставки пересобираются. +public sealed record UpdateBumperTemplateCommand(Guid TemplateId, BumperStyle Style) + : ICommand; + +public sealed record DeleteBumperTemplateCommand(Guid TemplateId) : ICommand; + +public sealed record SetBumperTemplateAudioCommand( + Guid TemplateId, + string Extension, + double DurationSeconds +) : ICommand; + +public sealed record ClearBumperTemplateAudioCommand(Guid TemplateId) : ICommand; + +public sealed record SetBumperTemplateBackgroundCommand(Guid TemplateId, Guid ImageId) + : ICommand; + +public sealed record ClearBumperTemplateBackgroundCommand(Guid TemplateId) : ICommand; + +public sealed record AddBumperVariantCommand(Guid TemplateId, string Name) : ICommand>; + +/// Полное содержимое подблока — строки редактор всегда присылает списком целиком. +public sealed record BumperVariantInput( + string Name, + BumperTrigger Trigger, + BumperBackground Background, + int Weight, + IReadOnlyList Lines +); + +public sealed record UpdateBumperVariantCommand( + Guid TemplateId, + Guid VariantId, + BumperVariantInput Input +) : ICommand; + +public sealed record RemoveBumperVariantCommand(Guid TemplateId, Guid VariantId) : ICommand; + +/// +/// Рендер примера заставки. Канал нужен только для образцов подстановки: блок общий, но посмотреть +/// его надо глазами конкретного канала — иначе {channel} не на что заменить. +/// +public sealed record RenderBumperPreviewCommand(Guid TemplateId, Guid? ChannelId) + : ICommand; + +public sealed class CreateBumperTemplateCommandValidator + : AbstractValidator +{ + public CreateBumperTemplateCommandValidator() => + RuleFor(x => x.Name).NotEmpty().MaximumLength(64); +} + +public sealed class UpdateBumperTemplateCommandValidator + : AbstractValidator +{ + public UpdateBumperTemplateCommandValidator() + { + RuleFor(x => x.Style.Name).NotEmpty().MaximumLength(64); + RuleFor(x => x.Style.BackgroundColor).NotEmpty().MaximumLength(32); + RuleFor(x => x.Style.BackgroundColor2).NotEmpty().MaximumLength(32); + RuleFor(x => x.Style.AccentColor).NotEmpty().MaximumLength(32); + RuleFor(x => x.Style.TextColor).NotEmpty().MaximumLength(32); + } +} + +public sealed class AddBumperVariantCommandValidator : AbstractValidator +{ + public AddBumperVariantCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(64); +} + +public sealed class UpdateBumperVariantCommandValidator + : AbstractValidator +{ + /// Больше шести строк в кадр не помещается ни при каком размере шрифта. + private const int MaxLines = 6; + + public UpdateBumperVariantCommandValidator() + { + RuleFor(x => x.Input.Name).NotEmpty().MaximumLength(64); + RuleFor(x => x.Input.Weight).InclusiveBetween(0, 1000); + RuleFor(x => x.Input.Lines).NotNull().Must(l => l.Count <= MaxLines); + RuleForEach(x => x.Input.Lines) + .ChildRules(line => line.RuleFor(l => l.Text).NotEmpty().MaximumLength(120)); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperDtos.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperDtos.cs new file mode 100644 index 0000000..094cb5b --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperDtos.cs @@ -0,0 +1,34 @@ +using TeleWave.Domain.Broadcast; + +namespace TeleWave.Application.Broadcast.Bumpers; + +/// Строка заставки: роль, цвет из палитры блока и текст с плейсхолдерами. +public sealed record BumperLineDto(BumperLineStyle Style, BumperLineColor Color, string Text); + +/// Подблок (текст-вариант): свои строки, фон и правило показа поверх оформления блока. +public sealed record BumperTextVariantDto( + Guid Id, + int Position, + string Name, + BumperTrigger Trigger, + BumperBackground Background, + int Weight, + IReadOnlyList Lines +); + +/// Блок заставки: оформление + звук + подблоки. — длина звука (сек). +public sealed record BumperTemplateDto( + Guid Id, + string Name, + BumperFont Font, + string BackgroundColor, + string BackgroundColor2, + string AccentColor, + string TextColor, + Guid? BackgroundImageId, + bool HasAudio, + double? AudioDurationSeconds, + /// Во скольких врезках стыков используется блок — блоки общие, и это надо видеть до правки. + int UsageCount, + IReadOnlyList Variants +); diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperErrors.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperErrors.cs new file mode 100644 index 0000000..08994d7 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperErrors.cs @@ -0,0 +1,44 @@ +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.Bumpers; + +/// Ошибки блоков заставок. Блоки общие для всех каналов, поэтому и каталог свой, не канальный. +public static class BumperErrors +{ + public static readonly Error TemplateNotFound = Error.NotFound( + "Bumpers.TemplateNotFound", + "Блок заставки не найден." + ); + + public static readonly Error VariantNotFound = Error.NotFound( + "Bumpers.VariantNotFound", + "Подблок заставки не найден." + ); + + public static readonly Error CannotRemoveLastVariant = Error.Validation( + "Bumpers.CannotRemoveLastVariant", + "Нельзя удалить последний подблок — нужен хотя бы один." + ); + + public static readonly Error TemplateInUse = Error.Conflict( + "Bumpers.TemplateInUse", + "Блок заставки используется во врезках стыков." + ); + + public static readonly Error VariantInUse = Error.Conflict( + "Bumpers.VariantInUse", + "Подблок заставки выбран во врезке стыка." + ); + + public static readonly Error InvalidFile = Error.Validation( + "Bumpers.InvalidFile", + "Недопустимый файл заставки (формат или размер)." + ); + + /// Неизвестный плейсхолдер — ошибка ввода: в эфире его бы уже никто не заметил. + public static Error UnknownPlaceholders(IEnumerable tokens) => + Error.Validation( + "Bumpers.UnknownPlaceholders", + $"Неизвестные плейсхолдеры: {string.Join(", ", tokens.Select(t => $"{{{t}}}"))}." + ); +} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperMapper.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperMapper.cs new file mode 100644 index 0000000..6d69ce6 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperMapper.cs @@ -0,0 +1,36 @@ +using TeleWave.Domain.Broadcast; + +namespace TeleWave.Application.Broadcast.Bumpers; + +/// Ручной маппинг блока заставки в DTO — одна точка на список и на карточку. +public static class BumperMapper +{ + public static BumperTemplateDto ToDto(BumperTemplate template, int usageCount) => + new( + template.Id, + template.Name, + template.Font, + template.BackgroundColor, + template.BackgroundColor2, + template.AccentColor, + template.TextColor, + template.BackgroundImageId, + template.AudioExtension is not null, + template.AudioDurationSeconds, + usageCount, + template + .Variants.OrderBy(v => v.Position) + .Select(v => new BumperTextVariantDto( + v.Id, + v.Position, + v.Name, + v.Trigger, + v.Background, + v.Weight, + v.Lines.OrderBy(l => l.Position) + .Select(l => new BumperLineDto(l.Style, l.Color, l.Text)) + .ToList() + )) + .ToList() + ); +} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs new file mode 100644 index 0000000..edf92e6 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs @@ -0,0 +1,145 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace TeleWave.Application.Broadcast.Bumpers; + +/// +/// Чем подставляются плейсхолдеры одной заставки. Собирает контекст планировщик — только он знает +/// и пару соседей, и точное время показа; редактор подставляет те же поля образцами. +/// +public sealed record BumperContext( + string ChannelName, + int? ChannelNumber, + /// Момент показа заставки во времени канала. + DateTimeOffset LocalMoment, + string? NowTitle = null, + string? NextTitle = null, + string? NowEpisode = null, + string? NextEpisode = null, + int? NextYear = null, + string? NextGenre = null, + /// Во сколько начнётся следующая программа (время канала). + TimeOnly? NextTime = null, + string? SlotTitle = null +); + +/// +/// Плейсхолдеры текста заставки: «ДАЛЕЕ В {next.time}» → «ДАЛЕЕ В 21:30». +/// +/// Список закрытый и проверяется при сохранении: незнакомый плейсхолдер — ошибка ввода, а не +/// сюрприз в эфире, где его уже не увидит никто, кроме зрителя. +/// +public static partial class BumperPlaceholders +{ + /// Все допустимые имена. Описания и образцы живут в локалях редактора, не здесь. + public static readonly IReadOnlySet Tokens = new HashSet(StringComparer.Ordinal) + { + "channel", + "channel.number", + "now.title", + "next.title", + "now.episode", + "next.episode", + "next.year", + "next.genre", + "next.time", + "time", + "date", + "weekday", + "slot", + }; + + /// + /// Плейсхолдеры, привязанные к моменту показа. Каждое их значение уникально, поэтому кэш + /// отрендеренных заставок с ними перестаёт работать — редактор обязан об этом предупредить. + /// + public static readonly IReadOnlySet VolatileTokens = new HashSet( + StringComparer.Ordinal + ) + { + "time", + "date", + "weekday", + }; + + // Русская культура фиксирована: заставка рендерится один раз в видео, локали зрителя у неё нет. + private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("ru-RU"); + + [GeneratedRegex(@"\{([a-zA-Z][a-zA-Z.]*)\}", RegexOptions.CultureInvariant)] + private static partial Regex TokenPattern(); + + [GeneratedRegex(@"[ \t]{2,}", RegexOptions.CultureInvariant)] + private static partial Regex ExtraSpaces(); + + /// + /// Подставляет значения. Неизвестное значение даёт пустую строку: «ДАЛЕЕ В {next.time}» без + /// следующей программы должно схлопнуться в «ДАЛЕЕ», а не показать дыру в кадре. + /// + public static string Resolve(string text, BumperContext context) + { + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + var resolved = TokenPattern() + .Replace(text, match => Value(match.Groups[1].Value, context) ?? string.Empty); + return ExtraSpaces().Replace(resolved, " ").Trim(); + } + + /// Какие плейсхолдеры встречаются в текстах — по ним планировщик решает, что грузить. + public static IReadOnlySet TokensIn(IEnumerable texts) + { + var used = new HashSet(StringComparer.Ordinal); + foreach (var text in texts.Where(t => !string.IsNullOrWhiteSpace(t))) + foreach (Match match in TokenPattern().Matches(text)) + used.Add(match.Groups[1].Value); + return used; + } + + /// Плейсхолдеры текста, которых нет в списке допустимых. + public static IReadOnlyList UnknownTokens(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return []; + + return TokenPattern() + .Matches(text) + .Select(m => m.Groups[1].Value) + .Where(token => !Tokens.Contains(token)) + .Distinct(StringComparer.Ordinal) + .ToList(); + } + + /// Есть ли в тексте плейсхолдер, привязанный к моменту показа (ломает кэш рендера). + public static bool IsVolatile(string? text) => + !string.IsNullOrWhiteSpace(text) + && TokenPattern().Matches(text).Any(m => VolatileTokens.Contains(m.Groups[1].Value)); + + private static string? Value(string token, BumperContext context) => + token switch + { + "channel" => context.ChannelName, + "channel.number" => context.ChannelNumber?.ToString(Culture), + "now.title" => context.NowTitle, + "next.title" => context.NextTitle, + "now.episode" => context.NowEpisode, + "next.episode" => context.NextEpisode, + "next.year" => context.NextYear?.ToString(Culture), + "next.genre" => context.NextGenre, + "next.time" => context.NextTime?.ToString("HH:mm", Culture), + "time" => TimeOnly + .FromDateTime(context.LocalMoment.DateTime) + .ToString("HH:mm", Culture), + "date" => context.LocalMoment.ToString("d MMMM", Culture), + "weekday" => context.LocalMoment.ToString("dddd", Culture), + "slot" => context.SlotTitle, + _ => null, + }; + + /// Подпись серии в эфирном виде: «с5э12», либо просто номер, если сезон не распознан. + public static string? Episode(int? season, int? episode) + { + if (season is { } s and > 0 && episode is { } e and > 0) + return string.Create(Culture, $"с{s}э{e}"); + return episode is { } only and > 0 ? string.Create(Culture, $"э{only}") : null; + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperRenderedText.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperRenderedText.cs new file mode 100644 index 0000000..dc3f1b2 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperRenderedText.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Application.Broadcast.Bumpers; + +/// +/// Сериализация готовых строк заставки в кэш-запись. Хранить подставленный текст обязательно: +/// время показа и пара соседей из ссылок задним числом не восстанавливаются, а ffmpeg запускается +/// фоновым сервисом уже после того, как лента записана. +/// +public static class BumperRenderedText +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() }, + }; + + public static string ToJson(IReadOnlyList lines) => + JsonSerializer.Serialize(lines, Options); + + public static IReadOnlyList FromJson(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return []; + + try + { + return JsonSerializer.Deserialize>(json, Options) ?? []; + } + catch (JsonException) + { + return []; + } + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs index b8078c2..c6a3324 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs @@ -4,22 +4,18 @@ 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, BumperSpecInputs inputs - ) - { - var free = variant.Kind == BumperTextKind.Free; - return new BumperRenderSpec( + ) => + new( alignedDurationSeconds, bumper.Width, bumper.Height, @@ -27,17 +23,10 @@ public static class BumperSpecFactory template.BackgroundColor2, template.AccentColor, template.TextColor, - font == BumperFont.Serif ? bumper.FontFileSerif : bumper.FontFileSans, - free ? "" : variant.NowLabel, - free ? "" : inputs.FromName, - free ? "" : variant.NextLabel, - free ? "" : inputs.ToName, + template.Font == BumperFont.Serif ? bumper.FontFileSerif : bumper.FontFileSans, + inputs.Lines, inputs.BackgroundAbsolutePath, inputs.AudioPath, - inputs.PosterAbsolutePath, - free, - variant.Line1, - variant.Line2 + inputs.PosterAbsolutePath ); - } } diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecInputs.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecInputs.cs index 9b7fdbc..5786a4a 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecInputs.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecInputs.cs @@ -1,15 +1,16 @@ +using TeleWave.Application.Common.Interfaces; + namespace TeleWave.Application.Broadcast.Bumpers; /// -/// Уже разрешённые входы рендера заставки: названия шоу «из/в» и пути к файлам. Разрешает их -/// вызывающий (генератор эфира — по реальной паре соседей, превью — по образцам канала), а -/// только раскладывает их по спецификации. +/// Уже разрешённые входы рендера заставки: готовые строки (плейсхолдеры подставлены) и пути к +/// файлам. Разрешает их вызывающий — генератор эфира по реальной паре соседей, редактор по +/// образцам, — а только раскладывает их по спецификации. /// public sealed record BumperSpecInputs( - string FromName, - string ToName, + IReadOnlyList Lines, string? AudioPath = null, - /// Постер «следующего» шоу как фон; в превью не подставляется — шоу ещё неизвестно. + /// Постер шоу как фон; подставляется, только если подблок его запросил. string? PosterAbsolutePath = null, string? BackgroundAbsolutePath = null ); diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs index 2b3a80b..793cacc 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs @@ -8,10 +8,9 @@ using TeleWave.Domain.Broadcast; namespace TeleWave.Application.Broadcast.Bumpers; /// -/// Восстанавливает по кэш-строке заставки: планировщик сохранил только -/// ссылки (канал/блок/подблок/пара шоу), а рендеру нужны названия шоу и абсолютные пути к звуку, -/// постеру и фону. Вынесено из фонового рендерера: чтение и сборка — работа слоя приложения, -/// воркер лишь крутит ffmpeg. +/// Восстанавливает по кэш-строке заставки: планировщик сохранил +/// готовые строки и ссылку на блок, а рендеру нужны ещё абсолютные пути к звуку, постеру и фону. +/// Вынесено из фонового рендерера: чтение и сборка — работа слоя приложения, воркер лишь крутит ffmpeg. /// public sealed class BumperSpecLoader( IAppDbContext dbContext, @@ -38,28 +37,15 @@ public sealed class BumperSpecLoader( if (cache is null) return null; - var channel = await dbContext - .Channels.AsNoTracking() - .Include(c => c.BumperTemplates) - .ThenInclude(t => t.Variants) - .AsSplitQuery() - .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) + var template = await dbContext + .BumperTemplates.AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == cache.TemplateId, cancellationToken); + if (template is null) return null; - var names = await dbContext - .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); - - // Постер шоу-получателя как фон — только для «Сейчас/Далее». - string? posterPath = null; - if (variant.Kind == BumperTextKind.NowNext) - posterPath = await ResolveShowPosterAsync(cache.ToShowId, cancellationToken); - + var posterPath = cache.PosterShowId is { } showId + ? await ResolveShowPosterAsync(showId, cancellationToken) + : null; var bgPath = await ResolveImagePathAsync(template.BackgroundImageId, cancellationToken); var aligned = BumperDuration.Aligned( BumperDuration.TemplateSeconds(template), @@ -68,13 +54,10 @@ public sealed class BumperSpecLoader( return BumperSpecFactory.Build( _bumper, - channel.BumperFont, template, - variant, aligned, new BumperSpecInputs( - names.GetValueOrDefault(cache.FromShowId, "…"), - names.GetValueOrDefault(cache.ToShowId, "…"), + BumperRenderedText.FromJson(cache.RenderedLinesJson), bumperStorage.AudioPath(template.Id, template.AudioExtension), posterPath, bgPath diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperTemplateLoader.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperTemplateLoader.cs new file mode 100644 index 0000000..33018b9 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperTemplateLoader.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Broadcast; + +namespace TeleWave.Application.Broadcast.Bumpers; + +/// Общее для команд блока заставки: загрузка вместе с подблоками и строками. +internal static class BumperTemplateLoader +{ + public static Task LoadAsync( + IAppDbContext dbContext, + Guid templateId, + CancellationToken cancellationToken + ) => + dbContext + .BumperTemplates.Include(t => t.Variants) + .FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken); + + /// Строки подблока «Сейчас / Далее» — с них начинается новый блок. + public static IReadOnlyList DefaultLines() => + [ + BumperLine.Create(0, BumperLineStyle.Label, BumperLineColor.Accent, "СЕЙЧАС"), + BumperLine.Create(1, BumperLineStyle.Title, BumperLineColor.Text, "{now.title}"), + BumperLine.Create(2, BumperLineStyle.Label, BumperLineColor.Accent, "ДАЛЕЕ"), + BumperLine.Create(3, BumperLineStyle.Title, BumperLineColor.Text, "{next.title}"), + ]; +} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommand.cs deleted file mode 100644 index bd9417a..0000000 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommand.cs +++ /dev/null @@ -1,8 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.Bumpers; - -/// Удалить загруженный звук блока (вернуться к синтезированному джинглу). -public sealed record ClearBumperTemplateAudioCommand(Guid ChannelId, Guid TemplateId) - : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommandHandler.cs index a9afa17..8c506b3 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommandHandler.cs @@ -1,5 +1,4 @@ using LiteCqrs; -using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; @@ -15,19 +14,16 @@ public sealed class ClearBumperTemplateAudioCommandHandler( CancellationToken cancellationToken ) { - var channel = await dbContext - .Channels.Include(c => c.BumperTemplates) - .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - var template = channel.FindBumperTemplate(command.TemplateId); + var template = await BumperTemplateLoader.LoadAsync( + dbContext, + command.TemplateId, + cancellationToken + ); if (template is null) - return Result.Failure(ChannelErrors.BumperTemplateNotFound); + return Result.Failure(BumperErrors.TemplateNotFound); template.ClearAudio(); - await dbContext.SaveChangesAsync(cancellationToken); - await storage.DeleteAudioAsync(command.TemplateId, cancellationToken); + await storage.DeleteAudioAsync(template.Id, cancellationToken); return Result.Success(); } } diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommand.cs deleted file mode 100644 index 936c2a6..0000000 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommand.cs +++ /dev/null @@ -1,8 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.Bumpers; - -/// Удалить загруженную фон-картинку блока (вернуться к градиенту/постеру). -public sealed record ClearBumperTemplateBackgroundCommand(Guid ChannelId, Guid TemplateId) - : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommandHandler.cs index 1cc5713..07b109f 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommandHandler.cs @@ -1,5 +1,4 @@ using LiteCqrs; -using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; @@ -13,17 +12,14 @@ public sealed class ClearBumperTemplateBackgroundCommandHandler(IAppDbContext db CancellationToken cancellationToken ) { - var channel = await dbContext - .Channels.Include(c => c.BumperTemplates) - .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - var template = channel.FindBumperTemplate(command.TemplateId); + var template = await BumperTemplateLoader.LoadAsync( + dbContext, + command.TemplateId, + cancellationToken + ); if (template is null) - return Result.Failure(ChannelErrors.BumperTemplateNotFound); + return Result.Failure(BumperErrors.TemplateNotFound); - // Отвязываем фон; сама картинка остаётся в галерее. template.ClearBackgroundImage(); return Result.Success(); } diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/CreateBumperTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/CreateBumperTemplateCommandHandler.cs new file mode 100644 index 0000000..8de56fc --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/CreateBumperTemplateCommandHandler.cs @@ -0,0 +1,24 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Broadcast; + +namespace TeleWave.Application.Broadcast.Bumpers; + +public sealed class CreateBumperTemplateCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + private const string DefaultVariantName = "Текст 1"; + + public Task> Handle( + CreateBumperTemplateCommand command, + CancellationToken cancellationToken + ) + { + var template = BumperTemplate.Create(command.Name, DefaultVariantName); + // Пустой блок в редакторе выглядит поломанным — новый начинается с «Сейчас / Далее». + template.Variants[0].SetLines(BumperTemplateLoader.DefaultLines()); + dbContext.BumperTemplates.Add(template); + return Task.FromResult(Result.Success(template.Id)); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/DeleteBumperTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/DeleteBumperTemplateCommandHandler.cs new file mode 100644 index 0000000..0e4feb0 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/DeleteBumperTemplateCommandHandler.cs @@ -0,0 +1,38 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.Bumpers; + +public sealed class DeleteBumperTemplateCommandHandler( + IAppDbContext dbContext, + IBumperTemplateStorage storage +) : ICommandHandler +{ + public async Task Handle( + DeleteBumperTemplateCommand command, + CancellationToken cancellationToken + ) + { + var template = await BumperTemplateLoader.LoadAsync( + dbContext, + command.TemplateId, + cancellationToken + ); + if (template is null) + return Result.Failure(BumperErrors.TemplateNotFound); + + // Блок общий: удалив используемый, мы бы молча выключили заставки в чужих каналах. + var used = await dbContext.JunctionElements.AnyAsync( + e => e.BumperTemplateId == template.Id, + cancellationToken + ); + if (used) + return Result.Failure(BumperErrors.TemplateInUse); + + dbContext.BumperTemplates.Remove(template); + await storage.DeleteAudioAsync(template.Id, cancellationToken); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/ListBumperTemplatesQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/ListBumperTemplatesQueryHandler.cs new file mode 100644 index 0000000..fb5171e --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/ListBumperTemplatesQueryHandler.cs @@ -0,0 +1,32 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Broadcast.Bumpers; + +public sealed class ListBumperTemplatesQueryHandler(IAppDbContext dbContext) + : IQueryHandler> +{ + public async Task> Handle( + ListBumperTemplatesQuery query, + CancellationToken cancellationToken + ) + { + var templates = await dbContext + .BumperTemplates.AsNoTracking() + .Include(t => t.Variants) + .OrderBy(t => t.Name) + .ToListAsync(cancellationToken); + + // Блоки общие, поэтому «сколько врезок на меня ссылается» — не справка, а условие правки. + var usage = await dbContext + .JunctionElements.AsNoTracking() + .Where(e => e.Kind == JunctionElementKind.Bumper && e.BumperTemplateId != null) + .GroupBy(e => e.BumperTemplateId!.Value) + .Select(g => new { TemplateId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.TemplateId, x => x.Count, cancellationToken); + + return templates.Select(t => BumperMapper.ToDto(t, usage.GetValueOrDefault(t.Id))).ToList(); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommand.cs deleted file mode 100644 index 4323ec5..0000000 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommand.cs +++ /dev/null @@ -1,8 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.Bumpers; - -/// Удалить блок заставки (кроме дефолтного) и его файлы. -public sealed record RemoveBumperTemplateCommand(Guid ChannelId, Guid TemplateId) - : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommandHandler.cs deleted file mode 100644 index 48eae86..0000000 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommandHandler.cs +++ /dev/null @@ -1,35 +0,0 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.Bumpers; - -public sealed class RemoveBumperTemplateCommandHandler( - IAppDbContext dbContext, - IBumperTemplateStorage storage -) : ICommandHandler -{ - public async Task Handle( - RemoveBumperTemplateCommand command, - CancellationToken cancellationToken - ) - { - var channel = await dbContext - .Channels.Include(c => c.BumperTemplates) - .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - var template = channel.FindBumperTemplate(command.TemplateId); - if (template is null) - return Result.Failure(ChannelErrors.BumperTemplateNotFound); - if (template.IsDefault) - return Result.Failure(ChannelErrors.CannotRemoveDefaultBumperTemplate); - - channel.RemoveBumperTemplate(command.TemplateId); - await dbContext.SaveChangesAsync(cancellationToken); - await storage.DeleteTemplateAsync(command.TemplateId, cancellationToken); - return Result.Success(); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTextVariantCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTextVariantCommand.cs deleted file mode 100644 index 39540e6..0000000 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTextVariantCommand.cs +++ /dev/null @@ -1,8 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.Bumpers; - -/// Удалить подблок (кроме последнего) из блока заставки. -public sealed record RemoveBumperTextVariantCommand(Guid ChannelId, Guid TemplateId, Guid VariantId) - : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTextVariantCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTextVariantCommandHandler.cs deleted file mode 100644 index 191c9fd..0000000 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTextVariantCommandHandler.cs +++ /dev/null @@ -1,34 +0,0 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.Bumpers; - -public sealed class RemoveBumperTextVariantCommandHandler(IAppDbContext dbContext) - : ICommandHandler -{ - public async Task Handle( - RemoveBumperTextVariantCommand command, - CancellationToken cancellationToken - ) - { - var channel = await dbContext - .Channels.Include(c => c.BumperTemplates) - .ThenInclude(t => t.Variants) - .AsSplitQuery() - .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - var template = channel.FindBumperTemplate(command.TemplateId); - if (template is null) - return Result.Failure(ChannelErrors.BumperTemplateNotFound); - if (template.FindVariant(command.VariantId) is null) - return Result.Failure(ChannelErrors.BumperTextVariantNotFound); - - return template.RemoveVariant(command.VariantId) - ? Result.Success() - : Result.Failure(ChannelErrors.CannotRemoveLastBumperTextVariant); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperVariantCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperVariantCommandHandler.cs new file mode 100644 index 0000000..2f5ba8a --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperVariantCommandHandler.cs @@ -0,0 +1,39 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.Bumpers; + +public sealed class RemoveBumperVariantCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + RemoveBumperVariantCommand command, + CancellationToken cancellationToken + ) + { + var template = await BumperTemplateLoader.LoadAsync( + dbContext, + command.TemplateId, + cancellationToken + ); + if (template is null) + return Result.Failure(BumperErrors.TemplateNotFound); + + if (template.FindVariant(command.VariantId) is null) + return Result.Failure(BumperErrors.VariantNotFound); + + // Врезка могла выбрать этот подблок жёстко — тогда удаление оставило бы её без текста. + var used = await dbContext.JunctionElements.AnyAsync( + e => e.BumperVariantId == command.VariantId, + cancellationToken + ); + if (used) + return Result.Failure(BumperErrors.VariantInUse); + + return template.RemoveVariant(command.VariantId) + ? Result.Success() + : Result.Failure(BumperErrors.CannotRemoveLastVariant); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommand.cs deleted file mode 100644 index 5c27dc1..0000000 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommand.cs +++ /dev/null @@ -1,11 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.Bumpers; - -/// -/// Синхронно рендерит примеры всех подблоков блока (с примерными названиями шоу). Каждый подблок — -/// в свой ассет-превью (id детерминирован по подблоку). БД не меняет, но пишет артефакты на диск — -/// поэтому это команда (действие с побочным эффектом), а не запрос. -/// -public sealed record RenderBumperPreviewCommand(Guid ChannelId, Guid TemplateId) : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs index b0adc19..efca552 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs @@ -1,6 +1,7 @@ using LiteCqrs; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using TeleWave.Application.Broadcast.Scheduling; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; using TeleWave.Application.Streaming; @@ -9,6 +10,11 @@ using TeleWave.Domain.Programming; namespace TeleWave.Application.Broadcast.Bumpers; +/// +/// Рендерит пример каждого подблока. Блок общий, поэтому образцы подстановки берутся глазами +/// выбранного канала: без него {channel} не на что заменить, а названия шоу были бы +/// случайными из библиотеки. +/// public sealed class RenderBumperPreviewCommandHandler( IAppDbContext dbContext, IBumperRenderer renderer, @@ -21,56 +27,59 @@ public sealed class RenderBumperPreviewCommandHandler( private readonly BumperOptions _bumper = bumperOptions.Value; private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds); - /// Длительность заставки без загруженного звука (сек) — как в генераторе. - private const int DefaultBumperDurationSeconds = 8; - public async Task Handle( - RenderBumperPreviewCommand query, + RenderBumperPreviewCommand command, CancellationToken cancellationToken ) { - var channel = await dbContext - .Channels.AsNoTracking() - .Include(c => c.BumperTemplates) - .ThenInclude(t => t.Variants) - .AsSplitQuery() - .FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId); + var template = await dbContext + .BumperTemplates.AsNoTracking() + .Include(t => t.Variants) + .FirstOrDefaultAsync(t => t.Id == command.TemplateId, cancellationToken); if (template is null) - return Result.Failure(ChannelErrors.BumperTemplateNotFound); + return Result.Failure(BumperErrors.TemplateNotFound); - var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken); - var backgroundPath = await ResolveBackgroundPathAsync(template, cancellationToken); - var seconds = template.AudioDurationSeconds is { } d and > 0 - ? d - : DefaultBumperDurationSeconds; - var aligned = (int)( - Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds + var channel = command.ChannelId is { } channelId + ? await dbContext + .Channels.AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken) + : null; + + var samples = await SampleShowsAsync(channel, cancellationToken); + var context = BuildContext(channel, samples); + var backgroundPath = await ResolveImagePathAsync( + template.BackgroundImageId, + cancellationToken + ); + var posterPath = await ResolveImagePathAsync(samples.NextPosterId, cancellationToken); + var aligned = BumperDuration.Aligned( + BumperDuration.TemplateSeconds(template), + _segmentSeconds ); var audioPath = storage.AudioPath(template.Id, template.AudioExtension); - // Постер зависит от конкретного «следующего» шоу — в превью его не подставляем. - var inputs = new BumperSpecInputs( - fromName, - toName, - audioPath, - PosterAbsolutePath: null, - backgroundPath - ); - - // Рендерим каждый подблок в свой ассет-превью (id по подблоку). foreach (var variant in template.Variants.OrderBy(v => v.Position)) { + var lines = variant + .Lines.OrderBy(l => l.Position) + .Select(l => new BumperRenderLine( + l.Style, + l.Color, + BumperPlaceholders.Resolve(l.Text, context) + )) + .Where(l => !string.IsNullOrWhiteSpace(l.Text)) + .ToList(); + var spec = BumperSpecFactory.Build( _bumper, - channel.BumperFont, template, - variant, aligned, - inputs + new BumperSpecInputs( + lines, + audioPath, + variant.Background == BumperBackground.Template ? null : posterPath, + backgroundPath + ) ); await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken); } @@ -78,49 +87,100 @@ public sealed class RenderBumperPreviewCommandHandler( return Result.Success(); } - /// Путь к фон-картинке блока в общем реестре или null, если она не привязана. - private async Task ResolveBackgroundPathAsync( - BumperTemplate template, - CancellationToken cancellationToken - ) + private static BumperContext BuildContext(Channel? channel, SampleShows samples) { - if (template.BackgroundImageId is not { } imageId) - return null; + var offset = TimeSpan.FromMinutes( + channel?.UtcOffsetMinutes ?? Channel.DefaultUtcOffsetMinutes + ); + var moment = DateTimeOffset.UtcNow.ToOffset(offset); - var extension = await dbContext - .Images.AsNoTracking() - .Where(i => i.Id == imageId) - .Select(i => i.FileExtension) - .FirstOrDefaultAsync(cancellationToken); - - return extension is null ? null : imageStore.ResolvePath(imageId, extension); + return new BumperContext( + channel?.Name ?? "Канал", + channel?.Number, + moment, + samples.NowTitle, + samples.NextTitle, + "с1э5", + "с2э3", + samples.NextYear, + samples.NextGenre, + TimeOnly.FromDateTime(moment.AddMinutes(30).DateTime), + samples.SlotTitle + ); } /// - /// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала: - /// так превью показывает реальные названия этого канала, а не случайные из библиотеки. + /// Пара шоу для образца. Берём те, что реально ходят в этом канале (через группы его слотов), — + /// иначе предпросмотр показывает библиотеку, а не канал. /// - private async Task<(string From, string To)> SampleNamesAsync( - Channel channel, + private async Task SampleShowsAsync( + Channel? channel, CancellationToken cancellationToken ) { - var names = await ( - from slot in dbContext.Slots.AsNoTracking() - join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id - join item in dbContext.GroupItems.AsNoTracking() on slot.GroupId equals item.GroupId - join show in dbContext.Shows.AsNoTracking() on item.ElementId equals show.Id - where - layer.TemplateId == channel.TemplateId && item.ElementKind == GroupElementKind.Show - select show.Name - ) + var query = dbContext.Shows.AsNoTracking().AsQueryable(); + if (channel?.TemplateId is { } templateId) + query = + from show in query + join item in dbContext.GroupItems.AsNoTracking() on show.Id equals item.ElementId + join slot in dbContext.Slots.AsNoTracking() on item.GroupId equals slot.GroupId + join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id + where layer.TemplateId == templateId && item.ElementKind == GroupElementKind.Show + select show; + + var shows = await query + .Select(s => new + { + s.Name, + s.Year, + s.PosterImageId, + }) .Distinct() .Take(2) .ToListAsync(cancellationToken); - return ( - names.ElementAtOrDefault(0) ?? "Первое шоу", - names.ElementAtOrDefault(1) ?? "Второе шоу" + var slotTitle = channel?.TemplateId is { } id + ? await ( + from slot in dbContext.Slots.AsNoTracking() + join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id + where layer.TemplateId == id + select slot.Title + ).FirstOrDefaultAsync(cancellationToken) + : null; + + var next = shows.ElementAtOrDefault(1) ?? shows.ElementAtOrDefault(0); + return new SampleShows( + shows.ElementAtOrDefault(0)?.Name ?? "Первое шоу", + next?.Name ?? "Второе шоу", + next?.Year, + null, + next?.PosterImageId, + slotTitle ?? "Вечернее кино" ); } + + private async Task ResolveImagePathAsync( + Guid? imageId, + CancellationToken cancellationToken + ) + { + if (imageId is not { } id) + return null; + + var extension = await dbContext + .Images.AsNoTracking() + .Where(i => i.Id == id) + .Select(i => i.FileExtension) + .FirstOrDefaultAsync(cancellationToken); + return extension is null ? null : imageStore.ResolvePath(id, extension); + } + + private sealed record SampleShows( + string NowTitle, + string NextTitle, + int? NextYear, + string? NextGenre, + Guid? NextPosterId, + string SlotTitle + ); } diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateAudioCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateAudioCommand.cs deleted file mode 100644 index 1adf55c..0000000 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateAudioCommand.cs +++ /dev/null @@ -1,12 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.Bumpers; - -/// Отметить загруженный звук блока: расширение (с точкой) и длину в секундах (замер ffprobe). -public sealed record SetBumperTemplateAudioCommand( - Guid ChannelId, - Guid TemplateId, - string Extension, - double DurationSeconds -) : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateAudioCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateAudioCommandHandler.cs index ab2e24e..d1791e1 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateAudioCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateAudioCommandHandler.cs @@ -1,5 +1,4 @@ using LiteCqrs; -using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; @@ -13,15 +12,13 @@ public sealed class SetBumperTemplateAudioCommandHandler(IAppDbContext dbContext CancellationToken cancellationToken ) { - var channel = await dbContext - .Channels.Include(c => c.BumperTemplates) - .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - var template = channel.FindBumperTemplate(command.TemplateId); + var template = await BumperTemplateLoader.LoadAsync( + dbContext, + command.TemplateId, + cancellationToken + ); if (template is null) - return Result.Failure(ChannelErrors.BumperTemplateNotFound); + return Result.Failure(BumperErrors.TemplateNotFound); template.SetAudio(command.Extension, command.DurationSeconds); return Result.Success(); diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommand.cs deleted file mode 100644 index 0acbb96..0000000 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommand.cs +++ /dev/null @@ -1,11 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.Bumpers; - -/// Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). -public sealed record SetBumperTemplateBackgroundCommand( - Guid ChannelId, - Guid TemplateId, - Guid ImageId -) : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommandHandler.cs index 14b2b12..ec49d48 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommandHandler.cs @@ -2,6 +2,7 @@ using LiteCqrs; using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; +using TeleWave.Application.Images; namespace TeleWave.Application.Broadcast.Bumpers; @@ -13,15 +14,16 @@ public sealed class SetBumperTemplateBackgroundCommandHandler(IAppDbContext dbCo CancellationToken cancellationToken ) { - var channel = await dbContext - .Channels.Include(c => c.BumperTemplates) - .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - var template = channel.FindBumperTemplate(command.TemplateId); + var template = await BumperTemplateLoader.LoadAsync( + dbContext, + command.TemplateId, + cancellationToken + ); if (template is null) - return Result.Failure(ChannelErrors.BumperTemplateNotFound); + return Result.Failure(BumperErrors.TemplateNotFound); + + if (!await dbContext.Images.AnyAsync(i => i.Id == command.ImageId, cancellationToken)) + return Result.Failure(ImageErrors.NotFound); template.SetBackgroundImage(command.ImageId); return Result.Success(); diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommand.cs deleted file mode 100644 index f73577e..0000000 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommand.cs +++ /dev/null @@ -1,15 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.Bumpers; - -/// Обновить оформление блока заставки: имя и цвета (в нотации ffmpeg). -public sealed record UpdateBumperTemplateCommand( - Guid ChannelId, - Guid TemplateId, - string Name, - string BackgroundColor, - string BackgroundColor2, - string AccentColor, - string TextColor -) : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommandHandler.cs index 95e24e4..ab1ea35 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommandHandler.cs @@ -1,5 +1,4 @@ using LiteCqrs; -using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; @@ -13,23 +12,15 @@ public sealed class UpdateBumperTemplateCommandHandler(IAppDbContext dbContext) CancellationToken cancellationToken ) { - var channel = await dbContext - .Channels.Include(c => c.BumperTemplates) - .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - var template = channel.FindBumperTemplate(command.TemplateId); - if (template is null) - return Result.Failure(ChannelErrors.BumperTemplateNotFound); - - template.UpdateStyle( - command.Name.Trim(), - command.BackgroundColor, - command.BackgroundColor2, - command.AccentColor, - command.TextColor + var template = await BumperTemplateLoader.LoadAsync( + dbContext, + command.TemplateId, + cancellationToken ); + if (template is null) + return Result.Failure(BumperErrors.TemplateNotFound); + + template.UpdateStyle(command.Style); return Result.Success(); } } diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommandValidator.cs deleted file mode 100644 index 45ff5e6..0000000 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommandValidator.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Text.RegularExpressions; -using FluentValidation; - -namespace TeleWave.Application.Broadcast.Bumpers; - -public sealed partial class UpdateBumperTemplateCommandValidator - : AbstractValidator -{ - public UpdateBumperTemplateCommandValidator() - { - RuleFor(x => x.Name).NotEmpty().MaximumLength(64); - - // Цвета уходят в строку ffmpeg-фильтра без экранирования — допускаем только безопасный формат - // (0xRRGGBB[AA], #RRGGBB[AA] или имя цвета), чтобы исключить инъекцию синтаксиса фильтра. - RuleFor(x => x.BackgroundColor).Must(BeSafeColor).WithMessage(ColorMessage); - RuleFor(x => x.BackgroundColor2).Must(BeSafeColor).WithMessage(ColorMessage); - RuleFor(x => x.AccentColor).Must(BeSafeColor).WithMessage(ColorMessage); - RuleFor(x => x.TextColor).Must(BeSafeColor).WithMessage(ColorMessage); - } - - private const string ColorMessage = - "Цвет должен быть в формате 0xRRGGBB, #RRGGBB или именем (например white)."; - - private static bool BeSafeColor(string? value) => - !string.IsNullOrWhiteSpace(value) && ColorRegex().IsMatch(value); - - [GeneratedRegex( - @"^((0x|#)?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?|[A-Za-z]{2,20}(@[0-9]?\.?[0-9]+)?)$" - )] - private static partial Regex ColorRegex(); -} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommand.cs deleted file mode 100644 index fc407bf..0000000 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommand.cs +++ /dev/null @@ -1,20 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; -using TeleWave.Domain.Broadcast; - -namespace TeleWave.Application.Broadcast.Bumpers; - -/// Обновить подблок: имя, режим текста, текст, правило показа и вес. -public sealed record UpdateBumperTextVariantCommand( - Guid ChannelId, - Guid TemplateId, - Guid VariantId, - string Name, - BumperTextKind Kind, - string NowLabel, - string NextLabel, - string Line1, - string Line2, - BumperTrigger Trigger, - int Weight -) : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandHandler.cs deleted file mode 100644 index 90ef437..0000000 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandHandler.cs +++ /dev/null @@ -1,47 +0,0 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; -using TeleWave.Domain.Broadcast; - -namespace TeleWave.Application.Broadcast.Bumpers; - -public sealed class UpdateBumperTextVariantCommandHandler(IAppDbContext dbContext) - : ICommandHandler -{ - public async Task Handle( - UpdateBumperTextVariantCommand command, - CancellationToken cancellationToken - ) - { - var channel = await dbContext - .Channels.Include(c => c.BumperTemplates) - .ThenInclude(t => t.Variants) - .AsSplitQuery() - .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - var template = channel.FindBumperTemplate(command.TemplateId); - if (template is null) - return Result.Failure(ChannelErrors.BumperTemplateNotFound); - - var variant = template.FindVariant(command.VariantId); - if (variant is null) - return Result.Failure(ChannelErrors.BumperTextVariantNotFound); - - variant.Update( - command.Name.Trim(), - new BumperTextContent( - command.Kind, - command.NowLabel, - command.NextLabel, - command.Line1, - command.Line2 - ), - command.Trigger, - command.Weight - ); - return Result.Success(); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandValidator.cs deleted file mode 100644 index 7864035..0000000 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandValidator.cs +++ /dev/null @@ -1,17 +0,0 @@ -using FluentValidation; - -namespace TeleWave.Application.Broadcast.Bumpers; - -public sealed class UpdateBumperTextVariantCommandValidator - : AbstractValidator -{ - public UpdateBumperTextVariantCommandValidator() - { - RuleFor(x => x.Name).NotEmpty().MaximumLength(64); - RuleFor(x => x.NowLabel).MaximumLength(64); - RuleFor(x => x.NextLabel).MaximumLength(64); - RuleFor(x => x.Line1).MaximumLength(120); - RuleFor(x => x.Line2).MaximumLength(120); - RuleFor(x => x.Weight).InclusiveBetween(0, 1000); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperVariantCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperVariantCommandHandler.cs new file mode 100644 index 0000000..04beb59 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperVariantCommandHandler.cs @@ -0,0 +1,51 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Broadcast; + +namespace TeleWave.Application.Broadcast.Bumpers; + +public sealed class UpdateBumperVariantCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + UpdateBumperVariantCommand command, + CancellationToken cancellationToken + ) + { + var template = await BumperTemplateLoader.LoadAsync( + dbContext, + command.TemplateId, + cancellationToken + ); + if (template is null) + return Result.Failure(BumperErrors.TemplateNotFound); + + var variant = template.FindVariant(command.VariantId); + if (variant is null) + return Result.Failure(BumperErrors.VariantNotFound); + + // Незнакомый плейсхолдер ловим здесь: в эфире он превратился бы в пустоту, и заметить это + // было бы уже некому. + var unknown = command + .Input.Lines.SelectMany(l => BumperPlaceholders.UnknownTokens(l.Text)) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (unknown.Count > 0) + return Result.Failure(BumperErrors.UnknownPlaceholders(unknown)); + + variant.Update( + command.Input.Name, + command.Input.Trigger, + command.Input.Background, + command.Input.Weight + ); + variant.SetLines( + command.Input.Lines.Select( + (line, index) => BumperLine.Create(index, line.Style, line.Color, line.Text) + ) + ); + + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs index 819a94c..6a5f016 100644 --- a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs +++ b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs @@ -4,39 +4,6 @@ namespace TeleWave.Application.Broadcast; public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled); -/// Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках). -public sealed record BumperSettingsDto(BumperFont Font, BumperSelection Selection); - -/// Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока. -public sealed record BumperTextVariantDto( - Guid Id, - int Position, - string Name, - BumperTextKind Kind, - string NowLabel, - string NextLabel, - string Line1, - string Line2, - BumperTrigger Trigger, - int Weight -); - -/// Блок заставки: своё оформление + звук + подблоки. — длина звука (сек). -public sealed record BumperTemplateDto( - Guid Id, - int Position, - bool IsDefault, - string Name, - string BackgroundColor, - string BackgroundColor2, - string AccentColor, - string TextColor, - Guid? BackgroundImageId, - bool HasAudio, - double? AudioDurationSeconds, - IReadOnlyList Variants -); - public sealed record ChannelDto( Guid Id, string Name, @@ -46,9 +13,6 @@ public sealed record ChannelDto( int UtcOffsetMinutes, TimeOnly DayStartTime, Guid? TemplateId, - bool BumpersEnabled, - BumperSettingsDto Bumper, - IReadOnlyList BumperTemplates, Guid? FillerAssetId, /// Оверлеи и фильтр зрительской части — всё опционально (см. 6.8). ViewerSettingsDto Viewer diff --git a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs index 615ab1b..f34041b 100644 --- a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs @@ -17,44 +17,10 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext) // (GetChannelTemplateQuery) — здесь только собственные свойства канала. var channel = await dbContext .Channels.AsNoTracking() - .Include(c => c.BumperTemplates) - .ThenInclude(t => t.Variants) - .AsSplitQuery() .FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken); if (channel is null) return Result.Failure(ChannelErrors.NotFound); - var bumperTemplates = channel - .BumperTemplates.OrderBy(t => t.Position) - .Select(t => new BumperTemplateDto( - t.Id, - t.Position, - t.IsDefault, - t.Name, - t.BackgroundColor, - t.BackgroundColor2, - t.AccentColor, - t.TextColor, - t.BackgroundImageId, - t.AudioExtension is not null, - t.AudioDurationSeconds, - t.Variants.OrderBy(v => v.Position) - .Select(v => new BumperTextVariantDto( - v.Id, - v.Position, - v.Name, - v.Kind, - v.NowLabel, - v.NextLabel, - v.Line1, - v.Line2, - v.Trigger, - v.Weight - )) - .ToList() - )) - .ToList(); - return Result.Success( new ChannelDto( channel.Id, @@ -65,9 +31,6 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext) channel.UtcOffsetMinutes, channel.DayStartTime, channel.TemplateId, - channel.BumpersEnabled, - new BumperSettingsDto(channel.BumperFont, channel.BumperSelection), - bumperTemplates, channel.FillerAssetId, new ViewerSettingsDto( channel.LogoImageId, diff --git a/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs index 466c071..f11fde2 100644 --- a/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs @@ -1,5 +1,6 @@ using LiteCqrs; using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; using TeleWave.Application.Library; @@ -43,22 +44,31 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext) .Select(s => new { s.Id, s.Name }) .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); - // Подблоки заставок в окне — чтобы показать в расписании, какая именно заставка и с каким текстом. - var variantIds = entries - .Where(e => - e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper && e.BumperVariantId != null - ) - .Select(e => e.BumperVariantId!.Value) + // Заставки в окне: берём их из кэша по ассету — там лежит ровно тот текст, который играл, + // с уже подставленными плейсхолдерами. Собирать его заново из подблока значило бы гадать. + var bumperAssetIds = entries + .Where(e => e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper) + .Select(e => e.MediaAssetId) .Distinct() .ToList(); - var variants = - variantIds.Count == 0 + var bumpers = + bumperAssetIds.Count == 0 ? [] - : await dbContext - .BumperTextVariants.AsNoTracking() - .Where(v => variantIds.Contains(v.Id)) - .ToListAsync(cancellationToken); - var variantsById = variants.ToDictionary(v => v.Id); + : await ( + from cache in dbContext.BumperAssets.AsNoTracking() + join variant in dbContext.BumperTextVariants.AsNoTracking() + on cache.VariantId equals variant.Id + where bumperAssetIds.Contains(cache.MediaAssetId) + select new + { + cache.MediaAssetId, + variant.Name, + cache.RenderedLinesJson, + } + ).ToListAsync(cancellationToken); + var bumpersByAsset = bumpers + .GroupBy(b => b.MediaAssetId) + .ToDictionary(g => g.Key, g => g.First()); // Имена ассетов программ — чтобы показать реальную метку S16E03 в расписании админки. var assetIds = entries @@ -72,21 +82,15 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext) .Select(a => new { a.Id, a.OriginalFileName }) .ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken); - // «Из какого шоу» для заставки берём из ближайшей предыдущей программы в упорядоченном окне. - Guid? prevProgramShowId = null; var dtos = new List(entries.Count); foreach (var e in entries) { string? bumperName = null; string? bumperText = null; - if ( - e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper - && e.BumperVariantId is { } vid - && variantsById.TryGetValue(vid, out var variant) - ) + if (bumpersByAsset.TryGetValue(e.MediaAssetId, out var bumper)) { - bumperName = variant.Name; - bumperText = BumperText(variant, prevProgramShowId, e.ShowId, showNames); + bumperName = bumper.Name; + bumperText = BumperText(bumper.RenderedLinesJson); } dtos.Add( @@ -106,34 +110,21 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext) bumperText ) ); - - if (e.Kind == Domain.Broadcast.ScheduleEntryKind.Program) - prevProgramShowId = e.ShowId; } return Result.Success>(dtos); } - /// - /// Текст заставки для метки в расписании: для «Сейчас/Далее» — подписи + названия шоу (из→в), - /// для свободного текста — заданные строки. Возвращает null, если показывать нечего. - /// - private static string? BumperText( - Domain.Broadcast.BumperTextVariant variant, - Guid? fromShowId, - Guid? toShowId, - IReadOnlyDictionary showNames - ) + /// Строки сыгравшей заставки одной меткой для расписания; null — показывать нечего. + private static string? BumperText(string? renderedLinesJson) { - if (variant.Kind == Domain.Broadcast.BumperTextKind.Free) - { - var parts = new[] { variant.Line1, variant.Line2 } - .Where(s => !string.IsNullOrWhiteSpace(s)) - .ToArray(); - return parts.Length == 0 ? null : string.Join(" · ", parts); - } - - string Name(Guid? id) => id is { } g ? showNames.GetValueOrDefault(g, "…") : "…"; - return $"{variant.NowLabel} {Name(fromShowId)} · {variant.NextLabel} {Name(toShowId)}"; + var text = string.Join( + " · ", + BumperRenderedText + .FromJson(renderedLinesJson) + .Select(l => l.Text) + .Where(t => !string.IsNullOrWhiteSpace(t)) + ); + return text.Length == 0 ? null : text; } } diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs index 7b8c573..410578d 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs @@ -1,6 +1,5 @@ using LiteCqrs; using TeleWave.Application.Common.Models; -using TeleWave.Domain.Broadcast; namespace TeleWave.Application.Broadcast.UpdateChannelSettings; @@ -8,11 +7,5 @@ public sealed record UpdateChannelSettingsCommand( Guid ChannelId, string Name, bool IsEnabled, - bool BumpersEnabled, - BumperSettingsInput Bumper, Guid? FillerAssetId ) : ICommand; - -/// Общие настройки ТВ-заставок канала (см. Channel.UpdateBumperSettings). Условия -/// показа сюда не входят — они задаются на элементе стыка. -public sealed record BumperSettingsInput(BumperFont Font, BumperSelection Selection); diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs index 5f15cc3..a437509 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs @@ -30,13 +30,7 @@ public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext) return Result.Failure(ChannelErrors.AssetNotFound); } - channel.UpdateSettings( - command.Name, - command.IsEnabled, - command.BumpersEnabled, - command.FillerAssetId - ); - channel.UpdateBumperSettings(command.Bumper.Font, command.Bumper.Selection); + channel.UpdateSettings(command.Name, command.IsEnabled, command.FillerAssetId); return Result.Success(); } } diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs index b49c433..013d7bf 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs @@ -30,6 +30,7 @@ public interface IAppDbContext DbSet JunctionElements { get; } DbSet Channels { get; } DbSet ScheduleEntries { get; } + DbSet BumperTemplates { get; } DbSet BumperTextVariants { get; } DbSet BumperAssets { get; } DbSet AppSettings { get; } diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs b/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs index 1ec606a..45df867 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs @@ -1,7 +1,13 @@ +using TeleWave.Domain.Broadcast; + namespace TeleWave.Application.Common.Interfaces; +/// Готовая строка заставки: роль, цвет из палитры блока и уже подставленный текст. +public sealed record BumperRenderLine(BumperLineStyle Style, BumperLineColor Color, string Text); + /// -/// Полная спецификация одной заставки для рендера: оформление канала + подписи + названия шоу. +/// Полная спецификация одной заставки для рендера: оформление блока + готовые строки. Плейсхолдеры +/// в уже подставлены — рендер работает с текстом, а не с шаблоном. /// уже выровнена на длину сегмента (готовит оркестратор), а /// — абсолютный путь к TTF внутри контейнера. /// @@ -14,18 +20,11 @@ public sealed record BumperRenderSpec( string AccentColor, string TextColor, string FontFile, - string NowLabel, - string NowTitle, - string NextLabel, - string NextTitle, + IReadOnlyList Lines, string? BackgroundFile = null, string? MusicFile = null, - /// Постер шоу как фон (используется, если нет загруженного фона канала; затемняется). - string? PosterFile = null, - /// Режим свободного текста: вместо «Сейчас/Далее» рисуются /. - bool FreeText = false, - string FreeLine1 = "", - string FreeLine2 = "" + /// Постер шоу как фон (используется, если подблок его запросил; затемняется). + string? PosterFile = null ); /// Итог рендера заставки — та же форма метаданных, что у обычного ассета после нарезки. @@ -39,9 +38,9 @@ public sealed record BumperRenderResult( ); /// -/// Порт рендера ТВ-заставок. Реализация синтезирует короткий клип (анимированный фон + текст -/// «Сейчас/Далее» + джингл) по и режет его на HLS-сегменты в -/// assets/{assetId} — так же, как обычный ассет, чтобы раздача эфира не отличала заставку от программы. +/// Порт рендера ТВ-заставок. Реализация синтезирует короткий клип (анимированный фон + строки текста +/// + джингл) по и режет его на HLS-сегменты в assets/{assetId} — так +/// же, как обычный ассет, чтобы раздача эфира не отличала заставку от программы. /// public interface IBumperRenderer { diff --git a/backend/src/TeleWave.Application/Programming/Planning/BumperFacts.cs b/backend/src/TeleWave.Application/Programming/Planning/BumperFacts.cs new file mode 100644 index 0000000..ff4bda2 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Planning/BumperFacts.cs @@ -0,0 +1,223 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Broadcast.Bumpers; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Media; +using TeleWave.Domain.Programming.Planning; + +namespace TeleWave.Application.Programming.Planning; + +/// +/// Данные, которыми подставляются плейсхолдеры заставок одного прогона: названия шоу, годы, жанры, +/// подписи серий, названия слотов. +/// +/// Грузится только запрошенное: если ни в одной строке нет {next.genre}, жанры не читаются +/// вовсе. Иначе каждая генерация тянула бы весь справочник ради текста, который никто не написал. +/// +internal sealed class BumperFacts +{ + private readonly IReadOnlyList _items; + private readonly TimeSpan _offset; + private readonly Channel _channel; + private readonly IReadOnlyDictionary _shows; + private readonly IReadOnlyDictionary _slotTitles; + + private BumperFacts( + IReadOnlyList items, + Channel channel, + IReadOnlyDictionary shows, + IReadOnlyDictionary slotTitles + ) + { + _items = items; + _channel = channel; + _offset = TimeSpan.FromMinutes(channel.UtcOffsetMinutes); + _shows = shows; + _slotTitles = slotTitles; + } + + public static async Task LoadAsync( + IAppDbContext dbContext, + Channel channel, + IReadOnlyList items, + IReadOnlySet tokens, + CancellationToken cancellationToken + ) + { + var showIds = items + .Where(i => i.Kind == PlannedItemKind.Bumper) + .SelectMany(i => new[] { i.FromShowId, i.ToShowId }) + .Where(id => id is not null && id != Guid.Empty) + .Select(id => id!.Value) + .Distinct() + .ToList(); + + var shows = + showIds.Count == 0 + ? [] + : await LoadShowsAsync(dbContext, showIds, tokens, cancellationToken); + + var slotTitles = tokens.Contains("slot") + ? await LoadSlotTitlesAsync(dbContext, items, cancellationToken) + : new Dictionary(); + + return new BumperFacts(items, channel, shows, slotTitles); + } + + /// Контекст одной заставки: соседи по ленте, время показа и данные канала. + public BumperContext Context(PlannedItem item, int index) + { + var from = Show(item.FromShowId); + var to = Show(item.ToShowId); + var nextProgram = FindProgram(index, forward: true); + var previousProgram = FindProgram(index, forward: false); + + return new BumperContext( + _channel.Name, + _channel.Number, + item.StartsAtUtc.ToOffset(_offset), + from?.Name, + to?.Name, + EpisodeOf(from, previousProgram, item.FromShowId), + EpisodeOf(to, nextProgram, item.ToShowId), + to?.Year, + to?.Genre, + nextProgram is { } next + ? TimeOnly.FromDateTime(next.StartsAtUtc.ToOffset(_offset).DateTime) + : null, + item.SlotId is { } slotId && _slotTitles.TryGetValue(slotId, out var title) + ? title + : null + ); + } + + /// Подпись серии соседней программы — только если это та же самая программа. + private static string? EpisodeOf(ShowFact? show, PlannedItem? neighbour, Guid? showId) + { + if (show is null || neighbour is null || neighbour.ShowId != showId) + return null; + return neighbour.UnitIndex is { } index && index >= 0 && index < show.Episodes.Count + ? show.Episodes[index] + : null; + } + + private ShowFact? Show(Guid? showId) => + showId is { } id && _shows.TryGetValue(id, out var fact) ? fact : null; + + /// Ближайшая программа по ленте в заданную сторону — стык может быть длиннее одной врезки. + private PlannedItem? FindProgram(int index, bool forward) + { + var step = forward ? 1 : -1; + for (var i = index + step; i >= 0 && i < _items.Count; i += step) + if (_items[i].Kind == PlannedItemKind.Program) + return _items[i]; + return null; + } + + private static async Task> LoadShowsAsync( + IAppDbContext dbContext, + IReadOnlyList showIds, + IReadOnlySet tokens, + CancellationToken cancellationToken + ) + { + var shows = await dbContext + .Shows.AsNoTracking() + .Where(s => showIds.Contains(s.Id)) + .Select(s => new + { + s.Id, + s.Name, + s.Year, + }) + .ToListAsync(cancellationToken); + + var genres = tokens.Contains("next.genre") + ? await ( + from link in dbContext.ShowGenres.AsNoTracking() + join genre in dbContext.Genres.AsNoTracking() on link.GenreId equals genre.Id + where showIds.Contains(link.ShowId) && link.IsPrimary + select new { link.ShowId, genre.Name } + ).ToDictionaryAsync(g => g.ShowId, g => g.Name, cancellationToken) + : []; + + var episodes = + tokens.Contains("next.episode") || tokens.Contains("now.episode") + ? await LoadEpisodesAsync(dbContext, showIds, cancellationToken) + : []; + + return shows.ToDictionary( + s => s.Id, + s => new ShowFact( + s.Name, + s.Year, + genres.GetValueOrDefault(s.Id), + episodes.GetValueOrDefault(s.Id) ?? [] + ) + ); + } + + /// + /// Подписи серий в том же порядке, в каком их разворачивает планировщик: только серии с готовым + /// ассетом, по позиции. Иначе номер в заставке разошёлся бы с тем, что реально играет. + /// + private static async Task>> LoadEpisodesAsync( + IAppDbContext dbContext, + IReadOnlyList showIds, + CancellationToken cancellationToken + ) + { + var rows = await ( + from show in dbContext.Shows.AsNoTracking() + from episode in show.Episodes + join asset in dbContext.MediaAssets.AsNoTracking() + on episode.MediaAssetId equals asset.Id + where + showIds.Contains(show.Id) + && asset.Status == MediaAssetStatus.Ready + && asset.Duration != null + orderby episode.Position + select new + { + show.Id, + episode.Season, + episode.Episode, + episode.Title, + } + ).ToListAsync(cancellationToken); + + return rows.GroupBy(r => r.Id) + .ToDictionary( + g => g.Key, + g => + g.Select(r => BumperPlaceholders.Episode(r.Season, r.Episode) ?? r.Title) + .ToList() + ); + } + + private static async Task> LoadSlotTitlesAsync( + IAppDbContext dbContext, + IReadOnlyList items, + CancellationToken cancellationToken + ) + { + var slotIds = items + .Select(i => i.SlotId) + .Where(id => id is not null) + .Select(id => id!.Value) + .Distinct() + .ToList(); + + return await dbContext + .Slots.AsNoTracking() + .Where(s => slotIds.Contains(s.Id)) + .ToDictionaryAsync(s => s.Id, s => s.Title, cancellationToken); + } + + private sealed record ShowFact( + string Name, + int? Year, + string? Genre, + IReadOnlyList Episodes + ); +} diff --git a/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs b/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs index 54860c0..05097a0 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Security.Cryptography; using System.Text; using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Common.Interfaces; using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast.Scheduling; @@ -10,21 +11,14 @@ using TeleWave.Domain.Programming.Planning; namespace TeleWave.Application.Programming.Planning; -/// Ключ отрендеренной заставки: блок, подблок и пара шоу, между которыми она стоит. -public readonly record struct BumperKey( - Guid TemplateId, - Guid VariantId, - Guid FromShowId, - Guid ToShowId -); - /// /// Подставляет ассеты заставкам, которые планировщик зарезервировал. Резерв и рендер разделены -/// намеренно: ассет зависит от пары соседей, а пара известна только после того, как слоты наполнены. +/// намеренно: текст заставки зависит от пары соседей и времени показа, а они известны только после +/// того, как слоты наполнены. /// -/// Готовый ассет переиспользуется по сигнатуре (пара названий + версия блока), недостающий -/// регистрируется в и уходит фоновому рендереру. Запись при -/// этом ставится в ленту сразу: горизонт — неделя, к эфиру рендер давно закончится. +/// Готовый ассет переиспользуется по сигнатуре содержимого (оформление блока + подставленный текст), +/// недостающий регистрируется в и уходит фоновому рендереру. +/// Запись при этом ставится в ленту сразу: горизонт — неделя, к эфиру рендер давно закончится. /// public sealed class BumperResolver( IAppDbContext dbContext, @@ -32,120 +26,175 @@ public sealed class BumperResolver( IRandomSource random ) { - public async Task> ResolveAsync( + /// Ассеты заставок по индексу записи в ленте: одна и та же пара шоу может дать разный текст. + public async Task> ResolveAsync( Channel channel, IReadOnlyList items, CancellationToken cancellationToken ) { - var reserved = items.Where(i => i.Kind == PlannedItemKind.Bumper).ToList(); + var reserved = items + .Select((item, index) => (item, index)) + .Where(pair => pair.item.Kind == PlannedItemKind.Bumper) + .ToList(); if (reserved.Count == 0) - return new Dictionary(); + return new Dictionary(); - var showNames = await LoadShowNamesAsync(reserved, cancellationToken); - var result = new Dictionary(); + var templates = await LoadTemplatesAsync(reserved, cancellationToken); + if (templates.Count == 0) + return new Dictionary(); - // Кэш существующих заставок канала: одна пара шоу встречается в горизонте многократно. - var existing = await dbContext - .BumperAssets.Where(b => b.ChannelId == channel.Id) - .ToDictionaryAsync(b => b.Signature, b => b.MediaAssetId, cancellationToken); + var tokens = BumperPlaceholders.TokensIn( + templates + .Values.SelectMany(t => t.Variants) + .SelectMany(v => v.Lines) + .Select(l => l.Text) + ); + var facts = await BumperFacts.LoadAsync( + dbContext, + channel, + items, + tokens, + cancellationToken + ); + var requests = new List<(int Index, BumperRequest Request)>(); - foreach (var item in reserved) + foreach (var (item, index) in reserved) { if ( item.BumperTemplateId is not { } templateId - || channel.FindBumperTemplate(templateId) is not { } template + || !templates.TryGetValue(templateId, out var template) ) continue; var fromShowId = item.FromShowId ?? Guid.Empty; var toShowId = item.ToShowId ?? Guid.Empty; - var variant = PickVariant(template, fromShowId != toShowId, channel, random); + var variant = PickVariant(template, item.BumperVariantId, fromShowId != toShowId); if (variant is null) continue; - var key = new BumperKey(templateId, variant.Id, fromShowId, toShowId); - if (result.ContainsKey(key)) - continue; + var context = facts.Context(item, index); + var lines = variant + .Lines.OrderBy(l => l.Position) + .Select(l => new BumperRenderLine( + l.Style, + l.Color, + BumperPlaceholders.Resolve(l.Text, context) + )) + .Where(l => !string.IsNullOrWhiteSpace(l.Text)) + .ToList(); - var fromName = showNames.GetValueOrDefault(fromShowId, "—"); - var toName = showNames.GetValueOrDefault(toShowId, "—"); - var signature = Signature(template, variant.Id, fromName, toName); - - if (existing.TryGetValue(signature, out var assetId)) + var posterShowId = variant.Background switch { - result[key] = assetId; + BumperBackground.NextPoster when toShowId != Guid.Empty => toShowId, + BumperBackground.NowPoster when fromShowId != Guid.Empty => fromShowId, + _ => (Guid?)null, + }; + + var linesJson = BumperRenderedText.ToJson(lines); + requests.Add( + ( + index, + new BumperRequest( + template, + variant.Id, + linesJson, + posterShowId, + Signature(template, variant.Id, linesJson, posterShowId) + ) + ) + ); + } + + return await MaterializeAsync(requests, cancellationToken); + } + + /// Заводит недостающие ассеты и раздаёт готовые по записям ленты. + private async Task> MaterializeAsync( + IReadOnlyList<(int Index, BumperRequest Request)> requests, + CancellationToken cancellationToken + ) + { + var signatures = requests.Select(r => r.Request.Signature).Distinct().ToList(); + var existing = await dbContext + .BumperAssets.Where(b => signatures.Contains(b.Signature)) + .ToDictionaryAsync(b => b.Signature, b => b.MediaAssetId, cancellationToken); + + var result = new Dictionary(); + foreach (var (index, request) in requests) + { + if (existing.TryGetValue(request.Signature, out var assetId)) + { + result[index] = assetId; continue; } - var asset = MediaAsset.RegisterGenerated($"{template.Name}: {fromName} → {toName}"); + var asset = MediaAsset.RegisterGenerated(request.Caption); dbContext.MediaAssets.Add(asset); dbContext.BumperAssets.Add( BumperAsset.Create( - channel.Id, - templateId, - variant.Id, - fromShowId, - toShowId, - signature, + request.Template.Id, + request.VariantId, + request.Signature, + request.Lines, + request.PosterShowId, asset.Id ) ); - existing[signature] = asset.Id; - result[key] = asset.Id; + existing[request.Signature] = asset.Id; + result[index] = asset.Id; renderQueue.Enqueue(asset.Id); } return result; } - /// - /// Подблок, подходящий под контекст перехода: на смене шоу и между сериями одного играют разные - /// тексты. Стратегия выбора — общая настройка канала. - /// - private static BumperTextVariant? PickVariant( - BumperTemplate template, - bool isShowChange, - Channel channel, - IRandomSource random + private async Task> LoadTemplatesAsync( + IReadOnlyList<(PlannedItem Item, int Index)> reserved, + CancellationToken cancellationToken ) { - var eligible = template - .Variants.Where(v => - v.Trigger switch - { - BumperTrigger.OnShowChange => isShowChange, - BumperTrigger.BetweenEpisodes => !isShowChange, - _ => true, - } - ) - .OrderBy(v => v.Position) + var ids = reserved + .Select(r => r.Item.BumperTemplateId) + .Where(id => id is not null) + .Select(id => id!.Value) + .Distinct() .ToList(); + return await dbContext + .BumperTemplates.AsNoTracking() + .Include(t => t.Variants) + .Where(t => ids.Contains(t.Id)) + .ToDictionaryAsync(t => t.Id, cancellationToken); + } + + /// + /// Подблок: либо жёстко заданный врезкой, либо подходящий под контекст перехода — на смене шоу + /// и между сериями одного играют разные тексты. Среди подходящих выбор по весам. + /// + private BumperTextVariant? PickVariant( + BumperTemplate template, + Guid? fixedVariantId, + bool isShowChange + ) + { + if (fixedVariantId is { } id) + return template.FindVariant(id); + + var eligible = template + .Variants.Where(v => v.Matches(isShowChange)) + .OrderBy(v => v.Position) + .ToList(); if (eligible.Count == 0) return null; - return channel.BumperSelection switch - { - BumperSelection.AlwaysFirst => eligible[0], - BumperSelection.Random => eligible[random.Next(eligible.Count)], - BumperSelection.WeightedRandom => WeightedPick(eligible, random), - _ => eligible[random.Next(eligible.Count)], - }; - } - - private static BumperTextVariant WeightedPick( - IReadOnlyList eligible, - IRandomSource random - ) - { - var total = eligible.Sum(v => (long)Math.Max(0, v.Weight)); + var total = eligible.Sum(v => Math.Max(0, v.Weight)); if (total <= 0) return eligible[random.Next(eligible.Count)]; - var roll = random.Next((int)Math.Min(total, int.MaxValue)); - long accumulated = 0; + var roll = random.Next(total); + var accumulated = 0; foreach (var variant in eligible) { accumulated += Math.Max(0, variant.Weight); @@ -157,41 +206,44 @@ public sealed class BumperResolver( } /// - /// Сигнатура включает версию блока: замена звука или фона обязана пересобрать заставки, иначе - /// в эфире осталась бы старая картинка с новым оформлением рядом. + /// Сигнатура — хэш содержимого: оформление блока с его ревизией плюс подставленный текст. Канала + /// и пары шоу в ней нет намеренно: одинаковая заставка на трёх каналах рендерится один раз, а + /// {channel} в тексте разводит их сам собой. /// private static string Signature( BumperTemplate template, Guid variantId, - string fromName, - string toName + string linesJson, + Guid? posterShowId ) { var raw = string.Create( CultureInfo.InvariantCulture, - $"{template.Id}|{variantId}|{template.Revision}|{fromName}|{toName}" + $"{template.Id}|{template.Revision}|{variantId}|{posterShowId}|{linesJson}" ); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..32]; } - private async Task> LoadShowNamesAsync( - IReadOnlyList reserved, - CancellationToken cancellationToken + /// Что нужно отрендерить для одной записи ленты. + private sealed record BumperRequest( + BumperTemplate Template, + Guid VariantId, + string Lines, + Guid? PosterShowId, + string Signature ) { - var showIds = reserved - .SelectMany(i => new[] { i.FromShowId, i.ToShowId }) - .Where(id => id is not null && id != Guid.Empty) - .Select(id => id!.Value) - .Distinct() - .ToList(); - - if (showIds.Count == 0) - return []; - - return await dbContext - .Shows.AsNoTracking() - .Where(s => showIds.Contains(s.Id)) - .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); + /// Имя ассета для админки: блок и первые строки заставки. + public string Caption + { + get + { + var text = string.Join( + " / ", + BumperRenderedText.FromJson(Lines).Select(l => l.Text).Take(2) + ); + return text.Length == 0 ? Template.Name : $"{Template.Name}: {text}"; + } + } } } diff --git a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs index 2f7eb64..283e497 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs @@ -57,14 +57,10 @@ public sealed class GridScheduleGenerator( CancellationToken cancellationToken ) { - var channel = await dbContext - .Channels.Include(c => c.BumperTemplates) - .ThenInclude(t => t.Variants) - // Вложенные коллекции тянем отдельными запросами: иначе колонки родителя (у шаблона — - // jsonb с правилами, у слоя — jsonb применимости) приезжают по копии на каждую строку - // листа. Тик планировщика повторяет эти два запроса на каждый канал. - .AsSplitQuery() - .FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken); + var channel = await dbContext.Channels.FirstOrDefaultAsync( + c => c.Id == channelId, + cancellationToken + ); if (channel is null || !channel.IsEnabled || channel.TemplateId is null) return new GenerationReport(0, [], ChannelSkipped: true); @@ -129,12 +125,13 @@ public sealed class GridScheduleGenerator( ); var added = 0; - foreach (var item in result.Items) + for (var index = 0; index < result.Items.Count; index++) { + var item = result.Items[index]; var assetId = item.MediaAssetId; if ( item.Kind == PlannedItemKind.Bumper - && !TryResolveBumper(item, bumperAssets, out assetId) + && !bumperAssets.TryGetValue(index, out assetId) ) continue; // Без ассета запись стала бы дырой в ленте. @@ -184,9 +181,6 @@ public sealed class GridScheduleGenerator( { var channel = await dbContext .Channels.AsNoTracking() - .Include(c => c.BumperTemplates) - .ThenInclude(t => t.Variants) - .AsSplitQuery() .FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken); if (channel is null || channel.TemplateId is null) return null; @@ -280,13 +274,17 @@ public sealed class GridScheduleGenerator( ); // Стыки грузим целиком: их немного, а группы врезок надо развернуть тем же проходом, - // что и группы контента. + // что и группы контента. Стыки общие, поэтому фильтра по каналу нет. var junctions = await dbContext .JunctionTemplates.AsNoTracking() .Include(j => j.Elements) - .Where(j => j.ChannelId == channel.Id) .ToDictionaryAsync(j => j.Id, cancellationToken); + // Блоки заставок нужны только длительностью — текст подставит резолвер после сборки ленты. + var bumperTemplates = await dbContext + .BumperTemplates.AsNoTracking() + .ToDictionaryAsync(t => t.Id, cancellationToken); + var groupIds = scheduled .Select(s => s.Slot.GroupId) .Where(id => id is not null) @@ -361,12 +359,19 @@ public sealed class GridScheduleGenerator( elements, cursor, repeatUnits, - BuildJunction(slot.JunctionBetweenId, junctions, elementsByGroup, channel), + BuildJunction( + slot.JunctionBetweenId, + junctions, + elementsByGroup, + bumperTemplates, + slot.Daypart + ), BuildJunction( slot.JunctionAfterId ?? template.DefaultJunctionId, junctions, elementsByGroup, - channel + bumperTemplates, + slot.Daypart ), rules?.AudienceAt( TimeOnly.FromDateTime( @@ -388,44 +393,18 @@ public sealed class GridScheduleGenerator( horizonEnd, slots, fallback, - _segmentSeconds + _segmentSeconds, + channel.UtcOffsetMinutes ); } - /// Ассет заставки по зарезервированной записи; false — подобрать не удалось. - private static bool TryResolveBumper( - PlannedItem item, - IReadOnlyDictionary bumperAssets, - out Guid assetId - ) - { - assetId = Guid.Empty; - if (item.BumperTemplateId is not { } templateId) - return false; - - // Подблок выбирает резолвер, поэтому ищем по блоку и паре шоу. - foreach (var pair in bumperAssets) - { - if ( - pair.Key.TemplateId == templateId - && pair.Key.FromShowId == (item.FromShowId ?? Guid.Empty) - && pair.Key.ToShowId == (item.ToShowId ?? Guid.Empty) - ) - { - assetId = pair.Value; - return true; - } - } - - return false; - } - /// Разворачивает шаблон стыка для планировщика, включая резерв под заставки. private PlanningJunction? BuildJunction( Guid? junctionId, IReadOnlyDictionary junctions, IReadOnlyDictionary> elementsByGroup, - Channel channel + IReadOnlyDictionary bumperTemplates, + Daypart daypart ) { if (junctionId is not { } id || !junctions.TryGetValue(id, out var template)) @@ -437,57 +416,83 @@ public sealed class GridScheduleGenerator( var conditions = JunctionConditions.FromJson(element.ConditionsJson) ?? new JunctionConditions(); - if (element.Kind == JunctionElementKind.Bumper) - { - // Длительность задаётся блоком (по звуку) и выровнена на сегмент: планировщик - // резервирует именно её, ассет подставит резолвер после сборки ленты. - if ( - element.BumperTemplateId is not { } bumperTemplateId - || channel.FindBumperTemplate(bumperTemplateId) is not { } bumperTemplate - ) - continue; - - var seconds = BumperDuration.Aligned( - BumperDuration.TemplateSeconds(bumperTemplate), - _segmentSeconds - ); - - elements.Add( - new PlanningJunctionElement( - element.Kind, - [], - element.AmountMode, - element.AmountValue, - element.IsRequired, - conditions.OnlyOnElementChange, - conditions.MinMinutesBetween, - bumperTemplateId, - TimeSpan.FromSeconds(seconds) - ) - ); + // Дейпарт — свойство слота, а не момента: отсекаем здесь, чтобы домен не знал про сетку. + if (!conditions.AllowsDaypart(daypart)) continue; - } - if ( - element.GroupId is not { } groupId - || !elementsByGroup.TryGetValue(groupId, out var groupElements) - ) + var units = ResolveUnits(element, elementsByGroup, bumperTemplates, out var bumper); + if (units is null) continue; elements.Add( new PlanningJunctionElement( + element.Id, element.Kind, - groupElements.SelectMany(e => e.Units).ToList(), + units, element.AmountMode, element.AmountValue, element.IsRequired, conditions.OnlyOnElementChange, - conditions.MinMinutesBetween + conditions.MinMinutesBetween, + conditions.Chance, + conditions.TimeWindow is { } window + ? new PlanningTimeWindow(window.From, window.To) + : null, + element.ChoiceKey, + element.ChoiceWeight, + element.BumperTemplateId, + element.BumperVariantId, + bumper ) ); } - return elements.Count == 0 ? null : new PlanningJunction(id, elements); + return elements.Count == 0 + ? null + : new PlanningJunction( + id, + elements, + template.MaxTotalSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : null + ); + } + + /// + /// Что играет во врезке: единицы группы либо резерв под заставку. null — врезка настроена + /// не до конца (нет группы или блока), и в эфир ей идти нечем. + /// + private IReadOnlyList? ResolveUnits( + JunctionElement element, + IReadOnlyDictionary> elementsByGroup, + IReadOnlyDictionary bumperTemplates, + out TimeSpan bumperDuration + ) + { + bumperDuration = TimeSpan.Zero; + + if (element.Kind == JunctionElementKind.Bumper) + { + // Длительность задаётся блоком (по звуку) и выровнена на сегмент: планировщик + // резервирует именно её, ассет подставит резолвер после сборки ленты. + if ( + element.BumperTemplateId is not { } templateId + || !bumperTemplates.TryGetValue(templateId, out var bumperTemplate) + ) + return null; + + bumperDuration = TimeSpan.FromSeconds( + BumperDuration.Aligned( + BumperDuration.TemplateSeconds(bumperTemplate), + _segmentSeconds + ) + ); + return []; + } + + return + element.GroupId is { } groupId + && elementsByGroup.TryGetValue(groupId, out var groupElements) + ? groupElements.SelectMany(e => e.Units).ToList() + : null; } /// diff --git a/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommand.cs b/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommand.cs index 6fc95bd..5f16fdf 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommand.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommand.cs @@ -5,22 +5,15 @@ using TeleWave.Application.Common.Models; namespace TeleWave.Application.Programming.Templates.CopyTemplate; /// -/// Копирует сетку канала на другой канал: слои, слоты, стыки и правила. Группы не копируются — -/// они общие для всех каналов. Прежний шаблон канала-приёмника заменяется целиком. +/// Копирует сетку канала на другой канал: слои, слоты и правила. Группы, стыки и заставки +/// не копируются — они общие для всех каналов, копия ссылается на те же. Прежний шаблон +/// канала-приёмника заменяется целиком. /// public sealed record CopyTemplateCommand(Guid SourceChannelId, Guid TargetChannelId) : ICommand>; -/// -/// Что скопировалось. — врезки-заставки, для которых на канале -/// -приёмнике не нашлось блока с таким же именем: ссылка снята, врезку надо донастроить руками. -/// -public sealed record CopyTemplateResultDto( - int Layers, - int Slots, - int Junctions, - int DroppedBumperRefs -); +/// Что скопировалось. +public sealed record CopyTemplateResultDto(int Layers, int Slots); public sealed class CopyTemplateCommandValidator : AbstractValidator { diff --git a/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommandHandler.cs index af7ed16..d019d75 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommandHandler.cs @@ -15,9 +15,10 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext) CancellationToken cancellationToken ) { - var target = await dbContext - .Channels.Include(c => c.BumperTemplates) - .FirstOrDefaultAsync(c => c.Id == command.TargetChannelId, cancellationToken); + var target = await dbContext.Channels.FirstOrDefaultAsync( + c => c.Id == command.TargetChannelId, + cancellationToken + ); if (target is null) return Result.Failure(ChannelErrors.NotFound); @@ -30,138 +31,31 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext) if (source is null) return Result.Failure(ChannelErrors.TemplateNotFound); - var sourceJunctions = await dbContext - .JunctionTemplates.AsNoTracking() - .Include(j => j.Elements) - .Where(j => j.ChannelId == command.SourceChannelId) - .ToListAsync(cancellationToken); - - // Заставки живут на канале и на диске, поэтому не копируются: врезка ищет блок с таким же - // именем у приёмника, а не найдя — остаётся без ссылки, и это возвращается в отчёте. - var bumperByName = target - .BumperTemplates.GroupBy(t => t.Name) - .ToDictionary(g => g.Key, g => g.First().Id); - var sourceBumperNames = await dbContext - .Channels.AsNoTracking() - .Where(c => c.Id == command.SourceChannelId) - .SelectMany(c => c.BumperTemplates) - .Select(t => new { t.Id, t.Name }) - .ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken); - - var (junctionMap, droppedBumperRefs) = CopyJunctions( - sourceJunctions, - target.Id, - sourceBumperNames, - bumperByName - ); - // Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя, // а «добавить поверх» дало бы кашу из пересекающихся слотов. var existing = await dbContext .ScheduleTemplates.Where(t => t.ChannelId == target.Id) .ToListAsync(cancellationToken); dbContext.ScheduleTemplates.RemoveRange(existing); - await dbContext - .JunctionTemplates.Where(j => - j.ChannelId == target.Id && !junctionMap.Values.Contains(j.Id) - ) - .ExecuteDeleteAsync(cancellationToken); var copyTemplate = ScheduleTemplate.Create(target.Id, source.Name); copyTemplate.SetFallbackGroup(source.FallbackGroupId); copyTemplate.SetRules(source.RulesJson); - if ( - source.DefaultJunctionId is { } defaultJunction - && junctionMap.TryGetValue(defaultJunction, out var mappedDefault) - ) - copyTemplate.SetDefaultJunction(mappedDefault); + // Стыки и заставки общие для всех каналов — копия ссылается на те же, без перевешивания. + copyTemplate.SetDefaultJunction(source.DefaultJunctionId); - var (layers, slots) = CopyGrid(source, copyTemplate, junctionMap); + var (layers, slots) = CopyGrid(source, copyTemplate); dbContext.ScheduleTemplates.Add(copyTemplate); target.SetTemplate(copyTemplate.Id); - return Result.Success( - new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs) - ); - } - - /// - /// Копирует стыки на канал-приёмник. Возвращает соответствие «стык источника → копия» (по нему - /// потом перевешиваются ссылки слотов) и число врезок, потерявших ссылку на блок заставки. - /// - private (Dictionary Map, int DroppedBumperRefs) CopyJunctions( - IReadOnlyList sourceJunctions, - Guid targetChannelId, - IReadOnlyDictionary sourceBumperNames, - IReadOnlyDictionary targetBumperByName - ) - { - var map = new Dictionary(); - var dropped = 0; - - foreach (var junction in sourceJunctions) - { - var copy = JunctionTemplate.Create(targetChannelId, junction.Name); - map[junction.Id] = copy.Id; - - foreach (var element in junction.Elements.OrderBy(e => e.Position)) - { - var bumperTemplateId = MapBumper( - element, - sourceBumperNames, - targetBumperByName, - ref dropped - ); - copy.AddElement(element.Kind) - .Update( - element.Kind, - element.GroupId, - bumperTemplateId, - element.AmountMode, - element.AmountValue, - element.IsRequired, - element.ConditionsJson - ); - } - - dbContext.JunctionTemplates.Add(copy); - } - - return (map, dropped); - } - - /// - /// Блок заставки у приёмника, соответствующий блоку источника по имени. Заставки живут на канале - /// и на диске, поэтому не копируются: не нашлось одноимённого — врезка остаётся без ссылки, - /// и это попадает в отчёт. - /// - private static Guid? MapBumper( - JunctionElement element, - IReadOnlyDictionary sourceBumperNames, - IReadOnlyDictionary targetBumperByName, - ref int dropped - ) - { - if (element.Kind != JunctionElementKind.Bumper) - return null; - - if ( - element.BumperTemplateId is { } sourceId - && sourceBumperNames.TryGetValue(sourceId, out var name) - && targetBumperByName.TryGetValue(name, out var mapped) - ) - return mapped; - - dropped++; - return null; + return Result.Success(new CopyTemplateResultDto(layers, slots)); } /// Переносит слои со слотами. Возвращает, сколько слоёв (кроме фонового) и слотов скопировано. private static (int Layers, int Slots) CopyGrid( ScheduleTemplate source, - ScheduleTemplate copyTemplate, - IReadOnlyDictionary junctionMap + ScheduleTemplate copyTemplate ) { var layers = 0; @@ -179,7 +73,7 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext) foreach (var slot in layer.Slots) { - CopySlot(slot, copyLayer, junctionMap); + CopySlot(slot, copyLayer); slots++; } } @@ -187,11 +81,7 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext) return (layers, slots); } - private static void CopySlot( - Slot slot, - GridLayer copyLayer, - IReadOnlyDictionary junctionMap - ) + private static void CopySlot(Slot slot, GridLayer copyLayer) { var copySlot = copyLayer.AddSlot( slot.Title, @@ -220,12 +110,9 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext) slot.BlockMode, slot.BlockValue, slot.OverflowPolicy, - Map(slot.JunctionBetweenId, junctionMap), - Map(slot.JunctionAfterId, junctionMap) + slot.JunctionBetweenId, + slot.JunctionAfterId ) ); } - - private static Guid? Map(Guid? id, IReadOnlyDictionary map) => - id is { } value && map.TryGetValue(value, out var mapped) ? mapped : null; } diff --git a/backend/src/TeleWave.Application/Programming/Templates/JunctionConditions.cs b/backend/src/TeleWave.Application/Programming/Templates/JunctionConditions.cs index 050f558..11fbb6c 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/JunctionConditions.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/JunctionConditions.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using TeleWave.Domain.Programming; namespace TeleWave.Application.Programming.Templates; @@ -11,7 +12,13 @@ public sealed record JunctionConditions( /// Ставить только при смене шоу, а не между сериями одного. bool OnlyOnElementChange = false, /// Не ставить чаще, чем раз в N минут (0 — без ограничения). - int MinMinutesBetween = 0 + int MinMinutesBetween = 0, + /// Только в эти дейпарты (пусто — в любые). + IReadOnlyList? Dayparts = null, + /// Только в это окно суток канала (null — в любое время). + JunctionTimeWindow? TimeWindow = null, + /// Вероятность показа в процентах; 100 — всегда. + int Chance = 100 ) { private static readonly JsonSerializerOptions Options = new() @@ -21,6 +28,10 @@ public sealed record JunctionConditions( Converters = { new JsonStringEnumConverter() }, }; + /// Действует ли врезка в этом дейпарте. + public bool AllowsDaypart(Daypart daypart) => + Dayparts is not { Count: > 0 } || Dayparts.Contains(daypart); + public string ToJson() => JsonSerializer.Serialize(this, Options); public static JunctionConditions? FromJson(string? json) @@ -38,3 +49,6 @@ public sealed record JunctionConditions( } } } + +/// Окно суток канала; допускает переход через полночь («с 23:00 до 06:00»). +public sealed record JunctionTimeWindow(TimeOnly From, TimeOnly To); diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/AddJunctionElementCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/AddJunctionElementCommandHandler.cs index a0f5650..e65a6b1 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/AddJunctionElementCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/AddJunctionElementCommandHandler.cs @@ -21,7 +21,7 @@ public sealed class AddJunctionElementCommandHandler(IAppDbContext dbContext) return Result.Failure(TemplateErrors.JunctionNotFound); var element = junction.AddElement(command.Kind); - await JunctionLoader.MarkTemplateChangedAsync(dbContext, junction, cancellationToken); + await JunctionLoader.MarkTemplatesChangedAsync(dbContext, junction.Id, cancellationToken); return Result.Success(element.Id); } } diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/CreateJunctionCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/CreateJunctionCommandHandler.cs index 52eca80..48bdf05 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/CreateJunctionCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/CreateJunctionCommandHandler.cs @@ -1,6 +1,4 @@ using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Broadcast; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; using TeleWave.Domain.Programming; @@ -10,16 +8,13 @@ namespace TeleWave.Application.Programming.Templates.Junctions; public sealed class CreateJunctionCommandHandler(IAppDbContext dbContext) : ICommandHandler> { - public async Task> Handle( + public Task> Handle( CreateJunctionCommand command, CancellationToken cancellationToken ) { - if (!await dbContext.Channels.AnyAsync(c => c.Id == command.ChannelId, cancellationToken)) - return Result.Failure(ChannelErrors.NotFound); - - var junction = JunctionTemplate.Create(command.ChannelId, command.Name); + var junction = JunctionTemplate.Create(command.Name); dbContext.JunctionTemplates.Add(junction); - return Result.Success(junction.Id); + return Task.FromResult(Result.Success(junction.Id)); } } diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs index b388182..d988061 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs @@ -21,19 +21,19 @@ public sealed class DeleteJunctionCommandHandler(IAppDbContext dbContext) if (junction is null) return Result.Failure(TemplateErrors.JunctionNotFound); - // Слот, ссылающийся на удалённый стык, молча остался бы без врезок — проверяем заранее. - var used = await dbContext.Slots.AnyAsync( + // Стык общий: слот чужого канала, ссылающийся на удалённый стык, молча остался бы без врезок. + var usedBySlot = await dbContext.Slots.AnyAsync( s => s.JunctionBetweenId == junction.Id || s.JunctionAfterId == junction.Id, cancellationToken ); - if (used) + var usedByDefault = await dbContext.ScheduleTemplates.AnyAsync( + t => t.DefaultJunctionId == junction.Id, + cancellationToken + ); + if (usedBySlot || usedByDefault) return Result.Failure(TemplateErrors.JunctionInUse); dbContext.JunctionTemplates.Remove(junction); - return await JunctionLoader.MarkTemplateChangedAsync( - dbContext, - junction, - cancellationToken - ); + return Result.Success(); } } diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionCommands.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionCommands.cs index 63d43c8..16484f1 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionCommands.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionCommands.cs @@ -5,12 +5,12 @@ using TeleWave.Domain.Programming; namespace TeleWave.Application.Programming.Templates.Junctions; -public sealed record ListJunctionsQuery(Guid ChannelId) - : IQuery>; +public sealed record ListJunctionsQuery : IQuery>; -public sealed record CreateJunctionCommand(Guid ChannelId, string Name) : ICommand>; +public sealed record CreateJunctionCommand(string Name) : ICommand>; -public sealed record RenameJunctionCommand(Guid JunctionId, string Name) : ICommand; +public sealed record UpdateJunctionCommand(Guid JunctionId, string Name, int? MaxTotalSeconds) + : ICommand; public sealed record DeleteJunctionCommand(Guid JunctionId) : ICommand; @@ -22,9 +22,13 @@ public sealed record JunctionElementInput( JunctionElementKind Kind, Guid? GroupId, Guid? BumperTemplateId, + Guid? BumperVariantId, JunctionAmountMode AmountMode, int AmountValue, bool IsRequired, + /// Метка развилки: из врезок с одной меткой играет одна, выбранная по весам. + string? ChoiceKey, + int ChoiceWeight, JunctionConditions? Conditions ); @@ -37,17 +41,32 @@ public sealed record UpdateJunctionElementCommand( public sealed record RemoveJunctionElementCommand(Guid JunctionId, Guid ElementId) : ICommand; -public sealed record ReorderJunctionCommand(Guid JunctionId, IReadOnlyList ElementIdsInOrder) - : ICommand; +/// +/// Позиция врезки вместе с её развилкой: перетаскивание в цепочке одновременно меняет и порядок, +/// и принадлежность к развилке, поэтому отдельной команды «сгруппировать» нет. +/// +public sealed record JunctionElementOrder(Guid ElementId, string? ChoiceKey); + +public sealed record ReorderJunctionCommand( + Guid JunctionId, + IReadOnlyList Order +) : ICommand; public sealed class CreateJunctionCommandValidator : AbstractValidator { public CreateJunctionCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(128); } -public sealed class RenameJunctionCommandValidator : AbstractValidator +public sealed class UpdateJunctionCommandValidator : AbstractValidator { - public RenameJunctionCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(128); + public UpdateJunctionCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(128); + // Потолок стыка в сутки — верхняя граница здравого смысла, а не техническое ограничение. + RuleFor(x => x.MaxTotalSeconds) + .InclusiveBetween(1, 24 * 60 * 60) + .When(x => x.MaxTotalSeconds is not null); + } } public sealed class UpdateJunctionElementCommandValidator @@ -57,8 +76,13 @@ public sealed class UpdateJunctionElementCommandValidator { // Верхняя граница — сутки: врезка длиннее вещательного дня бессмысленна. RuleFor(x => x.Input.AmountValue).InclusiveBetween(1, 24 * 60); + RuleFor(x => x.Input.ChoiceKey).MaximumLength(64); + RuleFor(x => x.Input.ChoiceWeight).InclusiveBetween(0, 1000); RuleFor(x => x.Input.Conditions!.MinMinutesBetween) .InclusiveBetween(0, 24 * 60) .When(x => x.Input.Conditions is not null); + RuleFor(x => x.Input.Conditions!.Chance) + .InclusiveBetween(0, 100) + .When(x => x.Input.Conditions is not null); } } diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionDtos.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionDtos.cs index 542cedc..1f40c7c 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionDtos.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionDtos.cs @@ -10,14 +10,21 @@ public sealed record JunctionElementDto( string? GroupName, Guid? BumperTemplateId, string? BumperTemplateName, + Guid? BumperVariantId, + string? BumperVariantName, JunctionAmountMode AmountMode, int AmountValue, bool IsRequired, + string? ChoiceKey, + int ChoiceWeight, JunctionConditions? Conditions ); public sealed record JunctionTemplateDto( Guid Id, string Name, + int? MaxTotalSeconds, + /// Сколько каналов ссылается на стык — он общий, и это надо видеть до правки. + int ChannelUsageCount, IReadOnlyList Elements ); diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionLoader.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionLoader.cs index 2c44303..00d3f8c 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionLoader.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionLoader.cs @@ -17,18 +17,37 @@ internal static class JunctionLoader .JunctionTemplates.Include(j => j.Elements) .FirstOrDefaultAsync(j => j.Id == junctionId, cancellationToken); - /// Правка стыка — тоже правка правил эфира: шаблон канала помечается изменённым. - public static async Task MarkTemplateChangedAsync( + /// + /// Правка стыка — правка правил эфира. Стык общий, поэтому изменёнными помечаются все шаблоны, + /// которые на него ссылаются: иначе чужой канал молча поехал бы по новым врезкам без применения. + /// + public static async Task MarkTemplatesChangedAsync( IAppDbContext dbContext, - JunctionTemplate junction, + Guid junctionId, CancellationToken cancellationToken ) { - var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync( - t => t.ChannelId == junction.ChannelId, - cancellationToken - ); - template?.MarkChanged(); + var viaSlots = await dbContext + .Slots.AsNoTracking() + .Where(s => s.JunctionBetweenId == junctionId || s.JunctionAfterId == junctionId) + .Join( + dbContext.GridLayers.AsNoTracking(), + slot => slot.LayerId, + layer => layer.Id, + (_, layer) => layer.TemplateId + ) + .Distinct() + .ToListAsync(cancellationToken); + + var templates = await dbContext + .ScheduleTemplates.Where(t => + viaSlots.Contains(t.Id) || t.DefaultJunctionId == junctionId + ) + .ToListAsync(cancellationToken); + + foreach (var template in templates) + template.MarkChanged(); + return Result.Success(); } } diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/ListJunctionsQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/ListJunctionsQueryHandler.cs index 7711210..1b0a968 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/ListJunctionsQueryHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/ListJunctionsQueryHandler.cs @@ -15,53 +15,102 @@ public sealed class ListJunctionsQueryHandler(IAppDbContext dbContext) var junctions = await dbContext .JunctionTemplates.AsNoTracking() .Include(j => j.Elements) - .Where(j => j.ChannelId == query.ChannelId) .OrderBy(j => j.Name) .ToListAsync(cancellationToken); - // Имена групп и блоков заставок резолвим одним проходом — редактор показывает их сразу. - var groupIds = junctions - .SelectMany(j => j.Elements) - .Select(e => e.GroupId) - .Where(id => id is not null) - .Select(id => id!.Value) - .Distinct() - .ToList(); + // Имена групп и заставок резолвим одним проходом — редактор показывает их сразу. + var groupIds = Ids(junctions, e => e.GroupId); var groupNames = await dbContext .Groups.AsNoTracking() .Where(g => groupIds.Contains(g.Id)) .ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken); - var bumperNames = await dbContext - .Channels.AsNoTracking() - .Where(c => c.Id == query.ChannelId) - .SelectMany(c => c.BumperTemplates) - .ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken); + var templateIds = Ids(junctions, e => e.BumperTemplateId); + var bumpers = await dbContext + .BumperTemplates.AsNoTracking() + .Where(t => templateIds.Contains(t.Id)) + .Include(t => t.Variants) + .ToListAsync(cancellationToken); + var bumperNames = bumpers.ToDictionary(t => t.Id, t => t.Name); + var variantNames = bumpers.SelectMany(t => t.Variants).ToDictionary(v => v.Id, v => v.Name); + + var usage = await ChannelUsageAsync(cancellationToken); return junctions .Select(j => new JunctionTemplateDto( j.Id, j.Name, + j.MaxTotalSeconds, + usage.GetValueOrDefault(j.Id), j.Elements.OrderBy(e => e.Position) .Select(e => new JunctionElementDto( e.Id, e.Position, e.Kind, e.GroupId, - e.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname) - ? gname - : null, + Lookup(groupNames, e.GroupId), e.BumperTemplateId, - e.BumperTemplateId is { } bid && bumperNames.TryGetValue(bid, out var bname) - ? bname - : null, + Lookup(bumperNames, e.BumperTemplateId), + e.BumperVariantId, + Lookup(variantNames, e.BumperVariantId), e.AmountMode, e.AmountValue, e.IsRequired, + e.ChoiceKey, + e.ChoiceWeight, JunctionConditions.FromJson(e.ConditionsJson) )) .ToList() )) .ToList(); } + + /// Сколько каналов ссылается на каждый стык — слотами сетки либо стыком по умолчанию. + private async Task> ChannelUsageAsync(CancellationToken cancellationToken) + { + var viaSlots = await ( + from slot in dbContext.Slots.AsNoTracking() + join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id + join template in dbContext.ScheduleTemplates.AsNoTracking() + on layer.TemplateId equals template.Id + where slot.JunctionBetweenId != null || slot.JunctionAfterId != null + select new + { + template.ChannelId, + slot.JunctionBetweenId, + slot.JunctionAfterId, + } + ).ToListAsync(cancellationToken); + + var viaDefault = await dbContext + .ScheduleTemplates.AsNoTracking() + .Where(t => t.DefaultJunctionId != null) + .Select(t => new { t.ChannelId, t.DefaultJunctionId }) + .ToListAsync(cancellationToken); + + var pairs = viaSlots + .SelectMany(s => + new[] { s.JunctionBetweenId, s.JunctionAfterId } + .Where(id => id is not null) + .Select(id => (JunctionId: id!.Value, s.ChannelId)) + ) + .Concat(viaDefault.Select(d => (JunctionId: d.DefaultJunctionId!.Value, d.ChannelId))); + + return pairs.Distinct().GroupBy(p => p.JunctionId).ToDictionary(g => g.Key, g => g.Count()); + } + + private static List Ids( + IEnumerable junctions, + Func selector + ) => + junctions + .SelectMany(j => j.Elements) + .Select(selector) + .Where(id => id is not null) + .Select(id => id!.Value) + .Distinct() + .ToList(); + + private static string? Lookup(IReadOnlyDictionary names, Guid? id) => + id is { } value && names.TryGetValue(value, out var name) ? name : null; } diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/RemoveJunctionElementCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/RemoveJunctionElementCommandHandler.cs index 7910852..a2639ea 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/RemoveJunctionElementCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/RemoveJunctionElementCommandHandler.cs @@ -20,9 +20,9 @@ public sealed class RemoveJunctionElementCommandHandler(IAppDbContext dbContext) if (junction is null || !junction.RemoveElement(command.ElementId)) return Result.Failure(TemplateErrors.JunctionElementNotFound); - return await JunctionLoader.MarkTemplateChangedAsync( + return await JunctionLoader.MarkTemplatesChangedAsync( dbContext, - junction, + junction.Id, cancellationToken ); } diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/ReorderJunctionCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/ReorderJunctionCommandHandler.cs index b58bb9b..68b5358 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/ReorderJunctionCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/ReorderJunctionCommandHandler.cs @@ -20,10 +20,15 @@ public sealed class ReorderJunctionCommandHandler(IAppDbContext dbContext) if (junction is null) return Result.Failure(TemplateErrors.JunctionNotFound); - junction.Reorder(command.ElementIdsInOrder); - return await JunctionLoader.MarkTemplateChangedAsync( + // Порядок и принадлежность к развилке приезжают вместе: в цепочке это один жест мышью. + foreach (var position in command.Order) + if (junction.FindElement(position.ElementId) is { } element) + element.SetChoice(position.ChoiceKey, element.ChoiceWeight); + + junction.Reorder(command.Order.Select(o => o.ElementId)); + return await JunctionLoader.MarkTemplatesChangedAsync( dbContext, - junction, + junction.Id, cancellationToken ); } diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/RenameJunctionCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/UpdateJunctionCommandHandler.cs similarity index 66% rename from backend/src/TeleWave.Application/Programming/Templates/Junctions/RenameJunctionCommandHandler.cs rename to backend/src/TeleWave.Application/Programming/Templates/Junctions/UpdateJunctionCommandHandler.cs index ad9e705..f4e4071 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/RenameJunctionCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/UpdateJunctionCommandHandler.cs @@ -4,11 +4,11 @@ using TeleWave.Application.Common.Models; namespace TeleWave.Application.Programming.Templates.Junctions; -public sealed class RenameJunctionCommandHandler(IAppDbContext dbContext) - : ICommandHandler +public sealed class UpdateJunctionCommandHandler(IAppDbContext dbContext) + : ICommandHandler { public async Task Handle( - RenameJunctionCommand command, + UpdateJunctionCommand command, CancellationToken cancellationToken ) { @@ -20,10 +20,10 @@ public sealed class RenameJunctionCommandHandler(IAppDbContext dbContext) if (junction is null) return Result.Failure(TemplateErrors.JunctionNotFound); - junction.Rename(command.Name); - return await JunctionLoader.MarkTemplateChangedAsync( + junction.Update(command.Name, command.MaxTotalSeconds); + return await JunctionLoader.MarkTemplatesChangedAsync( dbContext, - junction, + junction.Id, cancellationToken ); } diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/UpdateJunctionElementCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/UpdateJunctionElementCommandHandler.cs index 8aa4df1..7557d84 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/UpdateJunctionElementCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/UpdateJunctionElementCommandHandler.cs @@ -1,6 +1,6 @@ using LiteCqrs; using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Broadcast; +using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; using TeleWave.Domain.Programming; @@ -25,38 +25,60 @@ public sealed class UpdateJunctionElementCommandHandler(IAppDbContext dbContext) return Result.Failure(TemplateErrors.JunctionElementNotFound); var input = command.Input; - - if (input.Kind == JunctionElementKind.Bumper) - { - var known = await dbContext - .Channels.Where(c => c.Id == junction.ChannelId) - .SelectMany(c => c.BumperTemplates) - .AnyAsync(t => t.Id == input.BumperTemplateId, cancellationToken); - if (!known) - return Result.Failure(ChannelErrors.BumperTemplateNotFound); - } - else - { - if (input.GroupId is not { } groupId) - return Result.Failure(TemplateErrors.JunctionGroupRequired); - if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)) - return Result.Failure(TemplateErrors.GroupNotFound); - } + var check = await ValidateSourceAsync(input, cancellationToken); + if (!check.IsSuccess) + return check; element.Update( - input.Kind, - input.GroupId, - input.BumperTemplateId, - input.AmountMode, - input.AmountValue, - input.IsRequired, - input.Conditions?.ToJson() + new JunctionElementSettings( + input.Kind, + input.GroupId, + input.BumperTemplateId, + input.BumperVariantId, + input.AmountMode, + input.AmountValue, + input.IsRequired, + input.ChoiceKey, + input.ChoiceWeight, + input.Conditions?.ToJson() + ) ); - return await JunctionLoader.MarkTemplateChangedAsync( + return await JunctionLoader.MarkTemplatesChangedAsync( dbContext, - junction, + junction.Id, cancellationToken ); } + + /// Источник врезки должен существовать: молча пустая врезка выглядит как «стык не работает». + private async Task ValidateSourceAsync( + JunctionElementInput input, + CancellationToken cancellationToken + ) + { + if (input.Kind != JunctionElementKind.Bumper) + { + if (input.GroupId is not { } groupId) + return Result.Failure(TemplateErrors.JunctionGroupRequired); + return await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken) + ? Result.Success() + : Result.Failure(TemplateErrors.GroupNotFound); + } + + if (input.BumperTemplateId is not { } templateId) + return Result.Failure(BumperErrors.TemplateNotFound); + if (!await dbContext.BumperTemplates.AnyAsync(t => t.Id == templateId, cancellationToken)) + return Result.Failure(BumperErrors.TemplateNotFound); + + if (input.BumperVariantId is not { } variantId) + return Result.Success(); + + return await dbContext.BumperTextVariants.AnyAsync( + v => v.Id == variantId && v.BumperTemplateId == templateId, + cancellationToken + ) + ? Result.Success() + : Result.Failure(BumperErrors.VariantNotFound); + } } diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs b/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs index 658488f..d04952f 100644 --- a/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs +++ b/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs @@ -1,26 +1,31 @@ namespace TeleWave.Domain.Broadcast; /// -/// Кэш ТВ-заставки перехода. Один сгенерированный на уникальную комбинацию -/// () при данной (хэш названий -/// шоу и версии шаблона). Ассет создаётся в статусе Pending и рендерится ffmpeg'ом асинхронно фоновым -/// сервисом — поэтому здесь же храним, ЧЕМ его рендерить (// -/// ), чтобы фоновый рендерер восстановил спецификацию без участия планировщика. -/// Переиспользуется между днями и каналами; при смене названий/шаблона сигнатура меняется — новый ассет. +/// Кэш отрендеренной ТВ-заставки. Ключ — , хэш **содержимого**: оформление +/// блока с его ревизией плюс уже подставленный текст. Ни канала, ни пары шоу в ключе нет намеренно: +/// одинаковая заставка на трёх каналах рендерится один раз, а плейсхолдер вроде {channel} +/// разводит их по разным сигнатурам сам собой. +/// +/// Подставленный текст () приходится хранить: время показа из ссылок +/// задним числом не восстанавливается, а ffmpeg запускается фоновым сервисом уже после того, как +/// лента записана. /// public class BumperAsset { public Guid Id { get; private set; } - public Guid FromShowId { get; private set; } - public Guid ToShowId { get; private set; } - /// Канал/блок/подблок, по которым фоновый рендерер восстановит спецификацию заставки. - public Guid ChannelId { get; private set; } + /// Хэш содержимого: блок + ревизия + подблок + подставленные строки + фон. + public string Signature { get; private set; } = string.Empty; + + /// Блок и подблок — оформление рендера и чистка осиротевших ассетов. public Guid TemplateId { get; private set; } public Guid VariantId { get; private set; } - /// Хэш входных данных рендера (названия «из/в» + версия шаблона). - public string Signature { get; private set; } = string.Empty; + /// Готовые строки заставки (JSON). Домен их не интерпретирует — схема живёт в Application. + public string RenderedLinesJson { get; private set; } = string.Empty; + + /// Шоу, чей постер идёт фоном, или null (фон блока/градиент). + public Guid? PosterShowId { get; private set; } /// Сгенерированный ассет-заставка (нарезается в assets/{id} фоновым рендерером). public Guid MediaAssetId { get; private set; } @@ -30,23 +35,21 @@ public class BumperAsset private BumperAsset() { } public static BumperAsset Create( - Guid channelId, Guid templateId, Guid variantId, - Guid fromShowId, - Guid toShowId, string signature, + string renderedLinesJson, + Guid? posterShowId, Guid mediaAssetId ) => new() { Id = Guid.NewGuid(), - ChannelId = channelId, TemplateId = templateId, VariantId = variantId, - FromShowId = fromShowId, - ToShowId = toShowId, Signature = signature, + RenderedLinesJson = renderedLinesJson, + PosterShowId = posterShowId, MediaAssetId = mediaAssetId, CreatedAt = DateTimeOffset.UtcNow, }; diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperBackground.cs b/backend/src/TeleWave.Domain/Broadcast/BumperBackground.cs new file mode 100644 index 0000000..9cf6cb7 --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/BumperBackground.cs @@ -0,0 +1,17 @@ +namespace TeleWave.Domain.Broadcast; + +/// +/// Что за картинка под текстом заставки. Раньше постер подставлялся молча и только в режиме +/// «Сейчас/Далее»; при свободных строках такой связи взяться неоткуда, поэтому источник задаётся явно. +/// +public enum BumperBackground +{ + /// Фон блока: загруженная картинка, а если её нет — анимированный градиент палитры. + Template = 0, + + /// Постер следующего шоу (размытый и затемнённый). + NextPoster = 1, + + /// Постер предыдущего шоу. + NowPoster = 2, +} diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperLine.cs b/backend/src/TeleWave.Domain/Broadcast/BumperLine.cs new file mode 100644 index 0000000..3e56ab5 --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/BumperLine.cs @@ -0,0 +1,52 @@ +namespace TeleWave.Domain.Broadcast; + +/// Роль строки в кадре заставки — от неё зависят размер и место. +public enum BumperLineStyle +{ + /// Подпись: мелко, вразрядку — «СЕЙЧАС», «ДАЛЕЕ В 21:30». + Label = 0, + + /// Название: крупно, ужимается под ширину кадра. + Title = 1, + + /// Мелкая строка под названием — год, жанр, номер серии. + Caption = 2, +} + +/// Каким цветом палитры блока рисуется строка. +public enum BumperLineColor +{ + Accent = 0, + Text = 1, +} + +/// +/// Строка текста в заставке. Своих цветов и размеров у строки нет — только роль и цвет из палитры +/// блока: иначе каждый подблок пришлось бы оформлять заново, и общий блок перестал бы быть общим. +/// +/// хранится с плейсхолдерами («ДАЛЕЕ В {next.time}»); подставляет их планировщик +/// в момент, когда пара соседей и время показа уже известны. +/// +public class BumperLine +{ + public int Position { get; private set; } + public BumperLineStyle Style { get; private set; } + public BumperLineColor Color { get; private set; } + public string Text { get; private set; } = string.Empty; + + private BumperLine() { } + + public static BumperLine Create( + int position, + BumperLineStyle style, + BumperLineColor color, + string text + ) => + new() + { + Position = position, + Style = style, + Color = color, + Text = text.Trim(), + }; +} diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperSelection.cs b/backend/src/TeleWave.Domain/Broadcast/BumperSelection.cs deleted file mode 100644 index 525f182..0000000 --- a/backend/src/TeleWave.Domain/Broadcast/BumperSelection.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace TeleWave.Domain.Broadcast; - -/// -/// Как выбирать подблок заставки на переходе. Значения заданы явно: прежний вариант «по кругу» -/// (0) убран — курсора ротации в новом пайплайне нет, и он молча вырождался в случайный выбор. -/// -public enum BumperSelection -{ - /// Случайный подблок на каждом переходе (равновероятно). - Random = 1, - - /// Всегда первый (дефолтный) подблок. - AlwaysFirst = 2, - - /// Случайный подблок с учётом веса (). - WeightedRandom = 3, -} diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperTemplate.cs b/backend/src/TeleWave.Domain/Broadcast/BumperTemplate.cs index 2cfe4e1..e4924fa 100644 --- a/backend/src/TeleWave.Domain/Broadcast/BumperTemplate.cs +++ b/backend/src/TeleWave.Domain/Broadcast/BumperTemplate.cs @@ -1,32 +1,41 @@ namespace TeleWave.Domain.Broadcast; +/// Оформление блока заставки: имя, шрифт и палитра (цвета — в нотации ffmpeg: 0xRRGGBB или имя). +public sealed record BumperStyle( + string Name, + BumperFont Font, + string BackgroundColor, + string BackgroundColor2, + string AccentColor, + string TextColor +); + /// -/// Блок ТВ-заставки канала: свой звук + своё оформление (цвета, опциональная фон-картинка) + набор +/// Блок ТВ-заставки: свой звук + своё оформление (шрифт, цвета, опциональная фон-картинка) + набор /// подблоков () с разным текстом и правилом показа. Длительность заставки — по -/// длине звука (выравнивается на сегмент при рендере). Общий для канала — только шрифт. +/// длине звука (выравнивается на сегмент при рендере). /// -/// Первый блок ( == 0) — дефолтный, не удаляется; если звук в нём не загружен, -/// рендер синтезирует джингл по умолчанию. +/// Блок общий для всех каналов, как группа: файлы звука и фона и так лежат по идентификатору блока, +/// канал в них никогда не участвовал. Поэтому же шрифт живёт здесь, а не на канале — иначе два +/// канала с разным шрифтом делили бы один отрендеренный ассет. /// public class BumperTemplate { private readonly List _variants = []; public Guid Id { get; private set; } - public Guid ChannelId { get; private set; } - - /// Порядковый номер (0 — дефолтный блок). Используется ротацией и как признак дефолта. - public int Position { get; private set; } public string Name { get; private set; } = string.Empty; + public BumperFont Font { get; private set; } + // ── Оформление блока (цвета — в нотации ffmpeg: 0xRRGGBB или имя) ── public string BackgroundColor { get; private set; } = DefaultBackgroundColor; public string BackgroundColor2 { get; private set; } = DefaultBackgroundColor2; public string AccentColor { get; private set; } = DefaultAccentColor; public string TextColor { get; private set; } = DefaultTextColor; - /// Фон-картинка блока — ссылка на запись реестра изображений или null (тогда фон градиент/постер). + /// Фон-картинка блока — ссылка на запись реестра изображений или null (тогда фон градиент). public Guid? BackgroundImageId { get; private set; } /// Расширение загруженного звука (с точкой) или null — тогда синтезируется джингл. @@ -35,7 +44,7 @@ public class BumperTemplate /// Длина загруженного звука в секундах (замер ffprobe) или null, если звука нет. public double? AudioDurationSeconds { get; private set; } - /// Версия файлов блока (звук/фон). Входит в кэш-ключ рендера — замена файла пересобирает заставки. + /// Версия блока (звук/фон/оформление). Входит в сигнатуру рендера — правка пересобирает заставки. public int Revision { get; private set; } public DateTimeOffset CreatedAt { get; private set; } @@ -45,23 +54,19 @@ public class BumperTemplate public const string DefaultAccentColor = "0x38bdf8"; public const string DefaultTextColor = "white"; - public bool IsDefault => Position == 0; - /// Подблоки (текст-варианты); порядок — по . public IReadOnlyList Variants => _variants; - private const string DefaultVariantName = "Текст 1"; - private BumperTemplate() { } - internal static BumperTemplate Create(Guid channelId, int position, string name) + /// Создаёт блок с одним пустым подблоком — текст в него кладёт вызывающий (пресетом). + public static BumperTemplate Create(string name, string variantName) { var template = new BumperTemplate { Id = Guid.NewGuid(), - ChannelId = channelId, - Position = position, - Name = name, + Name = name.Trim(), + Font = BumperFont.Sans, BackgroundColor = DefaultBackgroundColor, BackgroundColor2 = DefaultBackgroundColor2, AccentColor = DefaultAccentColor, @@ -72,9 +77,8 @@ public class BumperTemplate Revision = 0, CreatedAt = DateTimeOffset.UtcNow, }; - // Дефолтный подблок «Сейчас/Далее», показывается на смене шоу. template._variants.Add( - BumperTextVariant.Create(template.Id, 0, DefaultVariantName, BumperTrigger.OnShowChange) + BumperTextVariant.Create(template.Id, 0, variantName, BumperTrigger.OnShowChange) ); return template; } @@ -102,20 +106,19 @@ public class BumperTemplate return true; } - /// Обновить имя и цвета блока. Цвета — в нотации ffmpeg (0xRRGGBB или имя). - public void UpdateStyle( - string name, - string backgroundColor, - string backgroundColor2, - string accentColor, - string textColor - ) + /// + /// Обновить имя, шрифт и цвета блока. Меняет ревизию: оформление входит в сигнатуру рендера, + /// иначе смена шрифта оставила бы в эфире заставки, набранные прежним. + /// + public void UpdateStyle(BumperStyle style) { - Name = name; - BackgroundColor = backgroundColor; - BackgroundColor2 = backgroundColor2; - AccentColor = accentColor; - TextColor = textColor; + Name = style.Name.Trim(); + Font = style.Font; + BackgroundColor = style.BackgroundColor; + BackgroundColor2 = style.BackgroundColor2; + AccentColor = style.AccentColor; + TextColor = style.TextColor; + Revision++; } /// Отметить загруженный звук (extension — с точкой, нижний регистр) и его длину. Меняет ревизию. diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperTextContent.cs b/backend/src/TeleWave.Domain/Broadcast/BumperTextContent.cs deleted file mode 100644 index 59ce1a3..0000000 --- a/backend/src/TeleWave.Domain/Broadcast/BumperTextContent.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace TeleWave.Domain.Broadcast; - -/// -/// Текстовое наполнение подблока заставки. Наборы полей взаимоисключающие: при -/// работают подписи, при — -/// произвольные строки; неиспользуемые просто хранятся, чтобы переключение режима не теряло ввод. -/// -public sealed record BumperTextContent( - BumperTextKind Kind, - string NowLabel, - string NextLabel, - string Line1, - string Line2 -); diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperTextKind.cs b/backend/src/TeleWave.Domain/Broadcast/BumperTextKind.cs deleted file mode 100644 index 7f2a9f8..0000000 --- a/backend/src/TeleWave.Domain/Broadcast/BumperTextKind.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace TeleWave.Domain.Broadcast; - -/// Как формируется текст подблока заставки. -public enum BumperTextKind -{ - /// «Сейчас/Далее»: две подписи + названия текущего и следующего шоу. - NowNext, - - /// Произвольные строки (без названий шоу) — например название канала и совет. - Free, -} diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs b/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs index 4fd5dc2..2fec998 100644 --- a/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs +++ b/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs @@ -2,37 +2,32 @@ namespace TeleWave.Domain.Broadcast; /// /// Подблок заставки (текст-вариант) внутри . Наследует от блока звук, -/// стиль и фон, но задаёт собственный текст и правило показа (). Позволяет иметь -/// несколько текстов на одной музыке/оформлении, не дублируя блок. +/// стиль и шрифт, но задаёт собственный набор строк, фон и правило показа (). +/// Позволяет иметь несколько текстов на одной музыке/оформлении, не дублируя блок. /// public class BumperTextVariant { + private readonly List _lines = []; + public Guid Id { get; private set; } public Guid BumperTemplateId { get; private set; } public int Position { get; private set; } public string Name { get; private set; } = string.Empty; - public BumperTextKind Kind { get; private set; } - - // ── Режим NowNext: подписи (названия шоу подставляет генератор) ── - public string NowLabel { get; private set; } = DefaultNowLabel; - public string NextLabel { get; private set; } = DefaultNextLabel; - - // ── Режим Free: произвольные строки (например название канала и совет) ── - public string Line1 { get; private set; } = string.Empty; - public string Line2 { get; private set; } = string.Empty; - public BumperTrigger Trigger { get; private set; } - /// Вес при стратегии (0 — не выбирается). Иначе игнорируется. + /// Источник картинки под текстом. + public BumperBackground Background { get; private set; } + + /// Вес при выборе подблока на переходе (0 — не выбирается никогда). public int Weight { get; private set; } = DefaultWeight; public DateTimeOffset CreatedAt { get; private set; } public const int DefaultWeight = 1; - public const string DefaultNowLabel = "СЕЙЧАС"; - public const string DefaultNextLabel = "ДАЛЕЕ"; + /// Строки в порядке показа сверху вниз. + public IReadOnlyList Lines => _lines; private BumperTextVariant() { } @@ -48,28 +43,32 @@ public class BumperTextVariant BumperTemplateId = bumperTemplateId, Position = position, Name = name, - Kind = BumperTextKind.NowNext, - NowLabel = DefaultNowLabel, - NextLabel = DefaultNextLabel, - Line1 = string.Empty, - Line2 = string.Empty, Trigger = trigger, + Background = BumperBackground.NextPoster, Weight = DefaultWeight, CreatedAt = DateTimeOffset.UtcNow, }; - public void Update(string name, BumperTextContent text, BumperTrigger trigger, int weight) + public void Update(string name, BumperTrigger trigger, BumperBackground background, int weight) { Name = name; - Kind = text.Kind; - NowLabel = text.NowLabel; - NextLabel = text.NextLabel; - Line1 = text.Line1; - Line2 = text.Line2; Trigger = trigger; + Background = background; Weight = Math.Max(0, weight); } + /// + /// Заменяет набор строк целиком. Построчных команд намеренно нет: редактор всегда знает весь + /// список, а порядок задаётся перетаскиванием — три команды вместо одной ничего бы не дали. + /// + public void SetLines(IEnumerable lines) + { + _lines.Clear(); + var position = 0; + foreach (var line in lines) + _lines.Add(BumperLine.Create(position++, line.Style, line.Color, line.Text)); + } + /// Подходит ли подблок для перехода: — сменилось ли шоу. public bool Matches(bool isShowChange) => Trigger switch diff --git a/backend/src/TeleWave.Domain/Broadcast/Channel.cs b/backend/src/TeleWave.Domain/Broadcast/Channel.cs index 19ad036..1c26d3e 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Channel.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Channel.cs @@ -3,12 +3,13 @@ namespace TeleWave.Domain.Broadcast; /// /// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (, /// см. Domain/Programming); канал хранит только собственные свойства: время, номер, аварийный -/// филлер и общие настройки заставок. +/// филлер и настройки зрительской части. +/// +/// Заставок на канале нет: блоки заставок общие (см. ), а условия их +/// показа — во врезках стыка. /// public class Channel { - private readonly List _bumperTemplates = []; - public Guid Id { get; private set; } public string Name { get; private set; } = string.Empty; public string Slug { get; private set; } = string.Empty; @@ -42,19 +43,6 @@ public class Channel public const int DefaultUtcOffsetMinutes = 180; public static readonly TimeOnly DefaultDayStartTime = new(6, 0); - // ── Настройки ТВ-заставок. Условия показа (как часто, на смене шоу или между сериями) - // живут в элементах стыка; на канале осталось только общее для всех заставок. ── - - /// Вставлять ли ТВ-заставки вообще: общий выключатель канала. - public bool BumpersEnabled { get; private set; } - - /// Как выбирать подблок заставки на переходе (случайно/по весам/всегда первый). - public BumperSelection BumperSelection { get; private set; } - - public BumperFont BumperFont { get; private set; } - - private const string DefaultTemplateName = "Заставка 1"; - /// Ассет-заглушка на случай пустого расписания (аварийная подстраховка). public Guid? FillerAssetId { get; private set; } @@ -77,53 +65,29 @@ public class Channel public DateTimeOffset CreatedAt { get; private set; } - /// Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position. - public IReadOnlyList BumperTemplates => _bumperTemplates; - private Channel() { } - public static Channel Create(string name, string slug, DateTimeOffset epochUtc) - { - var channel = new Channel + public static Channel Create(string name, string slug, DateTimeOffset epochUtc) => + new() { Id = Guid.NewGuid(), Name = name, Slug = slug, IsEnabled = true, EpochUtc = epochUtc, - BumpersEnabled = false, - BumperSelection = BumperSelection.WeightedRandom, - BumperFont = BumperFont.Sans, LogoOpacity = 0.8, UtcOffsetMinutes = DefaultUtcOffsetMinutes, DayStartTime = DefaultDayStartTime, CreatedAt = DateTimeOffset.UtcNow, }; - // На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл). - channel._bumperTemplates.Add(BumperTemplate.Create(channel.Id, 0, DefaultTemplateName)); - return channel; - } - public void UpdateSettings( - string name, - bool isEnabled, - bool bumpersEnabled, - Guid? fillerAssetId - ) + public void UpdateSettings(string name, bool isEnabled, Guid? fillerAssetId) { Name = name; IsEnabled = isEnabled; - BumpersEnabled = bumpersEnabled; FillerAssetId = fillerAssetId; } - /// Общие настройки ТВ-заставок канала: шрифт и стратегия выбора подблока. - public void UpdateBumperSettings(BumperFont font, BumperSelection selection) - { - BumperFont = font; - BumperSelection = selection; - } - /// Оверлеи и фильтр зрительской части. Всё опционально; силы зажимаются в 0..1. public void UpdateViewerSettings( Guid? logoImageId, @@ -140,29 +104,6 @@ public class Channel AnalogFilterStrength = Math.Clamp(analogFilterStrength, 0.0, 1.0); } - /// Добавить блок заставки в конец списка. Возвращает созданный блок. - public BumperTemplate AddBumperTemplate(string name) - { - var nextPosition = - _bumperTemplates.Count == 0 ? 0 : _bumperTemplates.Max(t => t.Position) + 1; - var template = BumperTemplate.Create(Id, nextPosition, name); - _bumperTemplates.Add(template); - return template; - } - - public BumperTemplate? FindBumperTemplate(Guid templateId) => - _bumperTemplates.FirstOrDefault(t => t.Id == templateId); - - /// Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false. - public bool RemoveBumperTemplate(Guid templateId) - { - var template = _bumperTemplates.FirstOrDefault(t => t.Id == templateId); - if (template is null || template.IsDefault) - return false; - _bumperTemplates.Remove(template); - return true; - } - /// Привязать активный шаблон сетки. public void SetTemplate(Guid? templateId) => TemplateId = templateId; diff --git a/backend/src/TeleWave.Domain/Programming/JunctionElement.cs b/backend/src/TeleWave.Domain/Programming/JunctionElement.cs index 08ef25b..a9dde34 100644 --- a/backend/src/TeleWave.Domain/Programming/JunctionElement.cs +++ b/backend/src/TeleWave.Domain/Programming/JunctionElement.cs @@ -29,6 +29,23 @@ public enum JunctionAmountMode Duration = 1, } +/// +/// Полный набор настроек врезки. Собран в запись, потому что по отдельности эти девять значений +/// ехали бы через команду, хендлер и домен плоским списком, в котором ничего не читается. +/// +public sealed record JunctionElementSettings( + JunctionElementKind Kind, + Guid? GroupId, + Guid? BumperTemplateId, + Guid? BumperVariantId, + JunctionAmountMode AmountMode, + int AmountValue, + bool IsRequired, + string? ChoiceKey, + int ChoiceWeight, + string? ConditionsJson +); + /// /// Врезка в шаблоне стыка. Условия показа хранятся структурно (), /// а не выражением: парсер, его валидация и отдельный UI обошлись бы дорого, а покрывают ровно @@ -38,7 +55,10 @@ public class JunctionElement { public Guid Id { get; private set; } public Guid JunctionTemplateId { get; private set; } + + /// Порядок показа в эфире — и только он: обязательность на порядок не влияет. public int Position { get; private set; } + public JunctionElementKind Kind { get; private set; } /// Откуда брать единицы — для , , . @@ -47,6 +67,9 @@ public class JunctionElement /// Какой блок заставки рендерить — для . public Guid? BumperTemplateId { get; private set; } + /// Конкретный подблок заставки; null — выбрать по триггеру перехода и весам. + public Guid? BumperVariantId { get; private set; } + public JunctionAmountMode AmountMode { get; private set; } /// Единиц либо минут. @@ -55,9 +78,20 @@ public class JunctionElement /// Обязательную врезку нельзя выбросить при нехватке времени. public bool IsRequired { get; private set; } + /// + /// Метка развилки: из врезок с одной меткой в эфир идёт одна, выбранная по весам. Врезки одной + /// развилки обязаны занимать непрерывный отрезок позиций — иначе неясно, куда встаёт выбранная. + /// + public string? ChoiceKey { get; private set; } + + /// Вес внутри развилки (0 — не выбирается). Вне развилки не используется. + public int ChoiceWeight { get; private set; } + /// Условия показа (JSON). Домен их не интерпретирует — схема живёт в Application. public string? ConditionsJson { get; private set; } + public const int DefaultChoiceWeight = 1; + private JunctionElement() { } internal static JunctionElement Create( @@ -74,28 +108,36 @@ public class JunctionElement AmountMode = JunctionAmountMode.Count, AmountValue = 1, IsRequired = false, + ChoiceWeight = DefaultChoiceWeight, }; - public void Update( - JunctionElementKind kind, - Guid? groupId, - Guid? bumperTemplateId, - JunctionAmountMode amountMode, - int amountValue, - bool isRequired, - string? conditionsJson - ) + public void Update(JunctionElementSettings settings) { - Kind = kind; - AmountMode = amountMode; - AmountValue = Math.Max(1, amountValue); - IsRequired = isRequired; - ConditionsJson = string.IsNullOrWhiteSpace(conditionsJson) ? null : conditionsJson; + Kind = settings.Kind; + AmountMode = settings.AmountMode; + AmountValue = Math.Max(1, settings.AmountValue); + IsRequired = settings.IsRequired; + ChoiceKey = string.IsNullOrWhiteSpace(settings.ChoiceKey) + ? null + : settings.ChoiceKey.Trim(); + ChoiceWeight = Math.Max(0, settings.ChoiceWeight); + ConditionsJson = string.IsNullOrWhiteSpace(settings.ConditionsJson) + ? null + : settings.ConditionsJson; // Источник зависит от типа врезки: у заставки нет группы, у остальных нет блока заставки. // Оставленная от прежнего типа ссылка потом читалась бы генератором как настройка. - GroupId = kind == JunctionElementKind.Bumper ? null : groupId; - BumperTemplateId = kind == JunctionElementKind.Bumper ? bumperTemplateId : null; + var isBumper = settings.Kind == JunctionElementKind.Bumper; + GroupId = isBumper ? null : settings.GroupId; + BumperTemplateId = isBumper ? settings.BumperTemplateId : null; + BumperVariantId = isBumper ? settings.BumperVariantId : null; + } + + /// Перевесить врезку в другую развилку (или вынести из неё) — это делает перетаскивание. + public void SetChoice(string? choiceKey, int weight) + { + ChoiceKey = string.IsNullOrWhiteSpace(choiceKey) ? null : choiceKey.Trim(); + ChoiceWeight = Math.Max(0, weight); } internal void SetPosition(int position) => Position = position; diff --git a/backend/src/TeleWave.Domain/Programming/JunctionTemplate.cs b/backend/src/TeleWave.Domain/Programming/JunctionTemplate.cs index ebeb6fd..6d02029 100644 --- a/backend/src/TeleWave.Domain/Programming/JunctionTemplate.cs +++ b/backend/src/TeleWave.Domain/Programming/JunctionTemplate.cs @@ -4,14 +4,21 @@ namespace TeleWave.Domain.Programming; /// Шаблон стыка: что играет между программами. Реклама и заставки перестают быть свойствами канала /// и становятся врезками стыка — за счёт этого в прайм можно поставить три ролика и заставку, /// а ночью один длинный ролик, чего одной настройкой на канал не сделать. +/// +/// Стык общий для всех каналов, как группа: «рекламный блок на две минуты с заставкой в конце» — +/// такой же переиспользуемый ресурс, как «Боевики 90-х». Канальным остаётся только выбор, какой +/// стык поставить в слот. /// public class JunctionTemplate { private readonly List _elements = []; public Guid Id { get; private set; } - public Guid ChannelId { get; private set; } public string Name { get; private set; } = string.Empty; + + /// Потолок длины стыка целиком в секундах; null — ограничен только якорем. + public int? MaxTotalSeconds { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } /// Врезки в порядке показа (backing-field для EF). @@ -19,16 +26,19 @@ public class JunctionTemplate private JunctionTemplate() { } - public static JunctionTemplate Create(Guid channelId, string name) => + public static JunctionTemplate Create(string name) => new() { Id = Guid.NewGuid(), - ChannelId = channelId, Name = name.Trim(), CreatedAt = DateTimeOffset.UtcNow, }; - public void Rename(string name) => Name = name.Trim(); + public void Update(string name, int? maxTotalSeconds) + { + Name = name.Trim(); + MaxTotalSeconds = maxTotalSeconds is > 0 ? maxTotalSeconds : null; + } public JunctionElement AddElement(JunctionElementKind kind) { @@ -47,6 +57,7 @@ public class JunctionTemplate if (element is null) return false; _elements.Remove(element); + Normalize(_elements.OrderBy(e => e.Position).Select(e => e.Id)); return true; } @@ -63,8 +74,38 @@ public class JunctionTemplate .OrderBy(e => e.Position) .Select(e => e.Id); + Normalize(requested.Concat(rest)); + } + + /// + /// Расставляет позиции, стягивая врезки одной развилки в непрерывный отрезок: развилка — это одно + /// место в цепочке, и её участники, разбросанные по стыку, не имели бы смысла. Каждая приезжает + /// к первому упоминанию своей метки, поэтому перетащить развилку целиком можно за любого участника. + /// + private void Normalize(IEnumerable order) + { + var byId = _elements.ToDictionary(e => e.Id); + var ordered = order.Select(id => byId[id]).ToList(); + + var clustered = new List(); + var placedChoices = new HashSet(StringComparer.Ordinal); + + foreach (var element in ordered) + { + if (element.ChoiceKey is not { } key) + { + clustered.Add(element); + continue; + } + + if (!placedChoices.Add(key)) + continue; + + clustered.AddRange(ordered.Where(e => e.ChoiceKey == key)); + } + var position = 0; - foreach (var id in requested.Concat(rest)) - _elements.First(e => e.Id == id).SetPosition(position++); + foreach (var element in clustered) + element.SetPosition(position++); } } diff --git a/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs b/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs index 026f26f..ffc6b7f 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs @@ -1,42 +1,76 @@ +using TeleWave.Domain.Broadcast.Scheduling; + namespace TeleWave.Domain.Programming.Planning; -/// Состояние стыков в рамках прогона: когда какая врезка ставилась последний раз. +/// +/// Состояние стыков в рамках прогона: когда какая врезка ставилась последний раз и на какой единице +/// группы она остановилась. +/// +/// Ключ — конкретная врезка, а не её вид: две рекламные врезки в разных стыках это разные +/// ограничения, общий счётчик на вид склеил бы их в одно. +/// public sealed class JunctionHistory { - private readonly Dictionary _lastPlaced = []; + private readonly Dictionary _lastPlaced = []; + private readonly Dictionary _nextUnit = []; public bool Allows(PlanningJunctionElement element, DateTimeOffset moment) { if (element.MinMinutesBetween <= 0) return true; - if (!_lastPlaced.TryGetValue(element.Kind, out var last)) + if (!_lastPlaced.TryGetValue(element.ElementId, out var last)) return true; return moment - last >= TimeSpan.FromMinutes(element.MinMinutesBetween); } public void Record(PlanningJunctionElement element, DateTimeOffset moment) => - _lastPlaced[element.Kind] = moment; + _lastPlaced[element.ElementId] = moment; + + /// + /// С какой единицы группы врезка продолжает набор. Без этого каждый рекламный блок начинался бы + /// с одного и того же ролика — в эфире это слышно с первой же секунды. + /// + public int NextUnit(PlanningJunctionElement element) => + _nextUnit.TryGetValue(element.ElementId, out var index) ? index : 0; + + public void AdvanceUnits(PlanningJunctionElement element, int consumed) + { + if (element.Units.Count == 0 || consumed <= 0) + return; + _nextUnit[element.ElementId] = (NextUnit(element) + consumed) % element.Units.Count; + } } /// -/// Где ставится стык и между чем. Собрано в один параметр: по отдельности эти четыре значения ехали +/// Где ставится стык и между чем. Собрано в один параметр: по отдельности эти значения ехали /// сквозь всю раскладку и вместе с курсором, пределом и накопителями раздували сигнатуры. /// /// Шоу перед стыком, — после; заставке нужна /// именно пара соседей, её ассет рендерится под неё после сборки ленты. +/// Смещение времени канала от UTC — по нему считается окно суток врезки. public sealed record JunctionPlacement( Guid SlotId, Guid? FromShowId, Guid? ToShowId, - bool ElementChanged + bool ElementChanged, + TimeSpan ChannelOffset = default +); + +/// Готовая к постановке врезка: что именно играет и сколько это займёт. +public sealed record JunctionInsert( + PlanningJunctionElement Element, + IReadOnlyList Units, + TimeSpan Duration ); /// -/// Раскладка врезок стыка: реклама, промо, заставка, заполнитель. Ставит только то, что влезает -/// целиком до предела (якорь или горизонт) — обрезать врезку нельзя, а перехлёст сдвинул бы якорь. +/// Раскладка врезок стыка: реклама, промо, заставка, заполнитель. /// -/// Обязательные врезки () идут первыми: при нехватке -/// времени выбрасываются необязательные, а не то, ради чего стык и заведён. +/// Порядок показа — это , и только он. Обязательность +/// () участвует единственным способом: когда до +/// предела (якорь или горизонт) влезает не всё, с конца отбрасываются необязательные врезки. Иначе +/// галочка «обязательно» молча поднимала бы рекламу перед заставкой, и цепочка в редакторе +/// перестала бы соответствовать эфиру. /// public static class JunctionFiller { @@ -47,6 +81,7 @@ public static class JunctionFiller DateTimeOffset limit, JunctionPlacement placement, JunctionHistory history, + IRandomSource random, List items, PlanTrace? trace ) @@ -54,37 +89,191 @@ public static class JunctionFiller if (junction is null || junction.Elements.Count == 0) return cursor; - var ordered = junction - .Elements.OrderByDescending(e => e.IsRequired) - .ThenBy(e => junction.Elements.ToList().IndexOf(e)) + var eligible = junction + .Elements.Where(e => Passes(e, cursor, placement, history, random)) .ToList(); + var chosen = ResolveChoices(eligible, random); - foreach (var element in ordered) + var available = limit - cursor; + if (junction.MaxTotal is { } cap && cap < available) + available = cap; + if (available <= TimeSpan.Zero) + return cursor; + + var inserts = Trim(chosen.Select(e => Build(e, history)).ToList(), available); + + foreach (var insert in inserts) { - if (element.OnlyOnElementChange && !placement.ElementChanged) - continue; - if (!history.Allows(element, cursor)) - continue; - var placedAt = cursor; + var before = items.Count; cursor = - element.Kind == JunctionElementKind.Bumper - ? PlaceBumper(element, cursor, limit, placement, items, trace) - : PlaceUnits(element, cursor, limit, placement.SlotId, items, trace); + insert.Element.Kind == JunctionElementKind.Bumper + ? PlaceBumper(insert, cursor, limit, placement, items, trace) + : PlaceUnits(insert, cursor, limit, placement.SlotId, items, trace); - if (cursor > placedAt) - history.Record(element, placedAt); + if (cursor <= placedAt) + continue; + + history.Record(insert.Element, placedAt); + // Двигаем ротацию ровно на поставленное: часть единиц могла не влезть до предела. + history.AdvanceUnits(insert.Element, items.Count - before); } return cursor; } + /// Проходит ли врезка по своим условиям: смена шоу, интервал, окно суток, жребий. + private static bool Passes( + PlanningJunctionElement element, + DateTimeOffset cursor, + JunctionPlacement placement, + JunctionHistory history, + IRandomSource random + ) + { + if (element.OnlyOnElementChange && !placement.ElementChanged) + return false; + if (!history.Allows(element, cursor)) + return false; + + if (element.TimeWindow is { } window) + { + var local = TimeOnly.FromTimeSpan(cursor.ToOffset(placement.ChannelOffset).TimeOfDay); + if (!window.Contains(local)) + return false; + } + + // Жребий берётся из того же источника, что и весь прогон: он зависит от координат генерации, + // поэтому пересборка хвоста не перетасовывает врезки на каждое применение. + return element.Chance >= 100 || random.Next(100) < Math.Max(0, element.Chance); + } + + /// + /// Схлопывает развилки: из врезок с одной меткой остаётся одна, выбранная по весам. Позиция + /// развилки в цепочке — позиция её первого участника. + /// + private static List ResolveChoices( + IReadOnlyList elements, + IRandomSource random + ) + { + var result = new List(); + var resolved = new HashSet(StringComparer.Ordinal); + + foreach (var element in elements) + { + if (element.ChoiceKey is not { } key) + { + result.Add(element); + continue; + } + + if (!resolved.Add(key)) + continue; + + var candidates = elements.Where(e => e.ChoiceKey == key).ToList(); + result.Add(WeightedPick(candidates, random)); + } + + return result; + } + + private static PlanningJunctionElement WeightedPick( + IReadOnlyList candidates, + IRandomSource random + ) + { + var total = candidates.Sum(c => Math.Max(0, c.ChoiceWeight)); + if (total <= 0) + return candidates[random.Next(candidates.Count)]; + + var roll = random.Next(total); + var accumulated = 0; + foreach (var candidate in candidates) + { + accumulated += Math.Max(0, candidate.ChoiceWeight); + if (roll < accumulated) + return candidate; + } + + return candidates[^1]; + } + + /// Подбирает единицы врезки по её бюджету, не двигая состояние: ставить или нет — решит . + private static JunctionInsert Build(PlanningJunctionElement element, JunctionHistory history) + { + if (element.Kind == JunctionElementKind.Bumper) + return new JunctionInsert(element, [], element.BumperDuration); + + var units = new List(); + var accumulated = TimeSpan.Zero; + var start = history.NextUnit(element); + + for (var taken = 0; taken < element.Units.Count; taken++) + { + var enough = + element.AmountMode == JunctionAmountMode.Count + ? units.Count >= Math.Max(1, element.AmountValue) + // Последняя единица входит целиком: рекламный блок не разрезается. + : accumulated >= TimeSpan.FromMinutes(Math.Max(1, element.AmountValue)); + if (enough) + break; + + var unit = element.Units[(start + taken) % element.Units.Count]; + if (unit.Duration <= TimeSpan.Zero) + continue; + + units.Add(unit); + accumulated += unit.Duration; + } + + return new JunctionInsert(element, units, accumulated); + } + + /// + /// Ужимает стык под : с конца отбрасываются сначала лишние единицы + /// необязательных врезок, потом они сами целиком, и только если этого не хватило — обязательные. + /// + /// Ужимаем поштучно, а не целыми врезками: рекламный блок из четырёх роликов, для которого до + /// якоря осталось место под один, должен поставить один, а не пропасть целиком. + /// + private static List Trim(List inserts, TimeSpan available) + { + var kept = inserts.Where(i => i.Duration > TimeSpan.Zero).ToList(); + var total = kept.Aggregate(TimeSpan.Zero, (sum, i) => sum + i.Duration); + + foreach (var required in new[] { false, true }) + { + for (var i = kept.Count - 1; i >= 0 && total > available; i--) + { + if (kept[i].Element.IsRequired != required) + continue; + + while (total > available && kept[i].Units.Count > 1) + { + var units = kept[i].Units.Take(kept[i].Units.Count - 1).ToList(); + var duration = units.Aggregate(TimeSpan.Zero, (sum, u) => sum + u.Duration); + total -= kept[i].Duration - duration; + kept[i] = kept[i] with { Units = units, Duration = duration }; + } + + if (total <= available) + break; + + total -= kept[i].Duration; + kept.RemoveAt(i); + } + } + + return kept; + } + /// /// Резервирует место под заставку. Ассет пуст: он рендерится под конкретную пару «из/в» уже после /// того, как лента собрана, — до наполнения слотов пара попросту неизвестна. /// private static DateTimeOffset PlaceBumper( - PlanningJunctionElement element, + JunctionInsert insert, DateTimeOffset cursor, DateTimeOffset limit, JunctionPlacement placement, @@ -92,10 +281,10 @@ public static class JunctionFiller PlanTrace? trace ) { - if (element.BumperDuration <= TimeSpan.Zero || cursor + element.BumperDuration > limit) + if (insert.Duration <= TimeSpan.Zero || cursor + insert.Duration > limit) return cursor; - var end = cursor + element.BumperDuration; + var end = cursor + insert.Duration; items.Add( new PlannedItem( Guid.Empty, @@ -106,17 +295,18 @@ public static class JunctionFiller placement.SlotId, PlannedItemKind.Bumper, trace, - element.BumperTemplateId, + insert.Element.BumperTemplateId, placement.FromShowId, - placement.ToShowId + placement.ToShowId, + insert.Element.BumperVariantId ) ); return end; } - /// Ставит единицы врезки по её бюджету: N штук либо пока не наберётся M минут. + /// Ставит подобранные единицы врезки; те, что не влезают до предела, отбрасываются. private static DateTimeOffset PlaceUnits( - PlanningJunctionElement element, + JunctionInsert insert, DateTimeOffset cursor, DateTimeOffset limit, Guid slotId, @@ -124,34 +314,16 @@ public static class JunctionFiller PlanTrace? trace ) { - if (element.Units.Count == 0) - return cursor; - - var kind = element.Kind switch + var kind = insert.Element.Kind switch { JunctionElementKind.Ad => PlannedItemKind.Ad, JunctionElementKind.Promo => PlannedItemKind.Promo, _ => PlannedItemKind.Fallback, }; - var placed = 0; - var accumulated = TimeSpan.Zero; - var index = 0; - - while (index < element.Units.Count) + foreach (var unit in insert.Units) { - var unit = element.Units[index]; - index++; - - if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > limit) - break; - - var enough = - element.AmountMode == JunctionAmountMode.Count - ? placed >= Math.Max(1, element.AmountValue) - // Последняя единица входит целиком: рекламный блок не разрезается. - : accumulated >= TimeSpan.FromMinutes(Math.Max(1, element.AmountValue)); - if (enough) + if (cursor + unit.Duration > limit) break; items.Add( @@ -167,8 +339,6 @@ public static class JunctionFiller ) ); cursor += unit.Duration; - accumulated += unit.Duration; - placed++; } return cursor; diff --git a/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs b/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs index f688906..2390c37 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs @@ -85,11 +85,19 @@ public sealed record PlanningSlot( public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes); } +/// Окно времени суток в часах канала; допускает переход через полночь (22:00 → 06:00). +public sealed record PlanningTimeWindow(TimeOnly From, TimeOnly To) +{ + public bool Contains(TimeOnly moment) => + From <= To ? moment >= From && moment < To : moment >= From || moment < To; +} + /// /// Врезка стыка, развёрнутая для планировщика: единицы уже подобраны оркестратором, домену остаётся /// решить, сколько их поставить и влезают ли они. /// public sealed record PlanningJunctionElement( + Guid ElementId, JunctionElementKind Kind, IReadOnlyList Units, JunctionAmountMode AmountMode, @@ -99,8 +107,17 @@ public sealed record PlanningJunctionElement( bool OnlyOnElementChange = false, /// Не ставить чаще, чем раз в N минут (0 — без ограничения). int MinMinutesBetween = 0, + /// Вероятность показа в процентах (100 — всегда). + int Chance = 100, + /// Окно времени суток, вне которого врезка не ставится (null — всегда). + PlanningTimeWindow? TimeWindow = null, + /// Метка развилки: из врезок с одной меткой ставится одна, выбранная по весам. + string? ChoiceKey = null, + int ChoiceWeight = 1, /// Блок заставки — ассет рендерится позже, планировщик резервирует длительность. Guid? BumperTemplateId = null, + /// Конкретный подблок заставки; null — выберет резолвер по триггеру и весам. + Guid? BumperVariantId = null, /// Длительность резерва под заставку. TimeSpan BumperDuration = default ); @@ -108,7 +125,9 @@ public sealed record PlanningJunctionElement( /// Стык: последовательность врезок между программами. public sealed record PlanningJunction( Guid JunctionId, - IReadOnlyList Elements + IReadOnlyList Elements, + /// Потолок длины стыка целиком (null — ограничен только якорем). + TimeSpan? MaxTotal = null ); /// Полный вход одного прогона генератора. @@ -119,7 +138,9 @@ public sealed record PlanningInput( IReadOnlyList Slots, /// Чем закрывать место, не покрытое слотами и не заполненное контентом. IReadOnlyList FallbackUnits, - int SegmentSeconds + int SegmentSeconds, + /// Смещение времени канала от UTC — по нему считаются окна суток у врезок стыка. + int UtcOffsetMinutes = 0 ); /// Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно. @@ -136,6 +157,8 @@ public sealed record PlannedItem( Guid? BumperTemplateId = null, Guid? FromShowId = null, Guid? ToShowId = null, + /// Подблок заставки, если врезка задала его жёстко; null — выберет резолвер. + Guid? BumperVariantId = null, /// Коллекция, частью которой шла единица (null — шоу играло само по себе). Guid? CollectionId = null ); diff --git a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs index 6fb1150..a172067 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs @@ -31,6 +31,9 @@ public static class SchedulePlanner public List Warnings { get; } = []; public JunctionHistory Junctions { get; } = new(); + /// Смещение времени канала — врезки со своим окном суток считают его по нему. + public TimeSpan ChannelOffset { get; } = TimeSpan.FromMinutes(input.UtcOffsetMinutes); + /// Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент. public Guid? PreviousShowId { get; set; } } @@ -254,9 +257,11 @@ public static class SchedulePlanner slot.SlotId, run.PreviousShowId, unit.ShowId, - ElementChanged: run.PreviousShowId != unit.ShowId + ElementChanged: run.PreviousShowId != unit.ShowId, + run.ChannelOffset ), run.Junctions, + run.Random, run.Items, slotTrace ); @@ -282,8 +287,15 @@ public static class SchedulePlanner slot.JunctionAfter, cursor, limit, - new JunctionPlacement(slot.SlotId, run.PreviousShowId, null, ElementChanged: true), + new JunctionPlacement( + slot.SlotId, + run.PreviousShowId, + null, + ElementChanged: true, + run.ChannelOffset + ), run.Junctions, + run.Random, run.Items, slotTrace ); diff --git a/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs b/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs index 053206c..37b7250 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs @@ -2,14 +2,15 @@ using System.Globalization; using System.Text; using Microsoft.Extensions.Options; using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Broadcast; using static TeleWave.Infrastructure.Media.FfmpegText; namespace TeleWave.Infrastructure.Media; /// /// Синтезирует ТВ-заставку перехода полностью на ffmpeg (без исходного файла) по -/// : анимированный градиентный фон + текст «Сейчас/Далее» + короткий -/// джингл, и режет результат на те же HLS-сегменты, что и обычный ассет. Длительность фиксированная и +/// : анимированный градиентный фон + строки текста + короткий джингл, +/// и режет результат на те же HLS-сегменты, что и обычный ассет. Длительность фиксированная и /// кратная сегменту, поэтому эфирная математика не отличает заставку от программы. /// public sealed class FfmpegBumperRenderer( @@ -22,21 +23,19 @@ public sealed class FfmpegBumperRenderer( private readonly MediaOptions _media = mediaOptions.Value; /// - /// Файлы с динамическим текстом заставки. Всё пользователь-редактируемое (названия шоу, подписи, - /// свободные строки) ffmpeg читает через textfile= с expansion=none: иначе запятая, - /// ;, [ или ] в тексте ломают (или инъектируют звенья в) цепочку - /// -filter_complex. + /// Одна строка, разложенная под drawtext: файл с текстом, размер, цвет и вертикальная позиция. + /// Текст ffmpeg читает через textfile= с expansion=none: иначе запятая, ;, + /// [ или ] в тексте ломают (или инъектируют звенья в) цепочку -filter_complex. /// - private sealed record TextFiles(string Now, string Next, string NowLabel, string NextLabel) - { - public static TextFiles In(string assetDir) => - new( - Path.Combine(assetDir, "now.txt"), - Path.Combine(assetDir, "next.txt"), - Path.Combine(assetDir, "nowlabel.txt"), - Path.Combine(assetDir, "nextlabel.txt") - ); - } + private sealed record LayoutLine( + string File, + string Text, + int FontSize, + string Color, + int Y, + bool Shadow, + double FadeStart + ); public async Task RenderAsync( Guid assetId, @@ -53,33 +52,18 @@ public sealed class FfmpegBumperRenderer( Directory.Delete(assetDir, recursive: true); Directory.CreateDirectory(assetDir); - // Названия шоу / свободные строки. - var text = TextFiles.In(assetDir); - var line1 = spec.FreeText ? spec.FreeLine1 : spec.NowTitle; - var line2 = spec.FreeText ? spec.FreeLine2 : spec.NextTitle; - await File.WriteAllTextAsync(text.Now, line1, new UTF8Encoding(false), cancellationToken); - await File.WriteAllTextAsync(text.Next, line2, new UTF8Encoding(false), cancellationToken); - - // Подписи «Сейчас/Далее» нужны лишь в одноимённом режиме — в FreeText их не рисуют. - if (!spec.FreeText) - { + var layout = Layout(assetDir, spec); + foreach (var line in layout) await File.WriteAllTextAsync( - text.NowLabel, - spec.NowLabel, + line.File, + line.Text, new UTF8Encoding(false), cancellationToken ); - await File.WriteAllTextAsync( - text.NextLabel, - spec.NextLabel, - new UTF8Encoding(false), - cancellationToken - ); - } try { - var args = BuildArgs(assetDir, seg, target, text, spec); + var args = BuildArgs(assetDir, seg, target, layout, spec); var result = await ProcessRunner.RunAsync( _media.FfmpegPath, args, @@ -115,36 +99,83 @@ public sealed class FfmpegBumperRenderer( } finally { - TryDelete(text.Now); - TryDelete(text.Next); - TryDelete(text.NowLabel); - TryDelete(text.NextLabel); + foreach (var line in layout) + TryDelete(line.File); } } + /// + /// Раскладывает строки по кадру: блок центрируется целиком по вертикали, размер зависит от роли + /// и ужимается под ширину, строки проявляются по очереди. Фиксированных мест у ролей нет — + /// иначе набор из двух строк висел бы в верхней трети кадра, как это было у «Сейчас/Далее». + /// + private static List Layout(string assetDir, BumperRenderSpec spec) + { + var h = spec.Height; + var titleSize = Math.Max(24, h / 10); + var labelSize = Math.Max(14, h / 22); + var captionSize = Math.Max(12, h / 28); + var textWidth = (int)(spec.Width * 0.92); // Поля по 4% с каждой стороны. + + var sizes = spec + .Lines.Select(line => + { + var baseSize = line.Style switch + { + BumperLineStyle.Title => titleSize, + BumperLineStyle.Caption => captionSize, + _ => labelSize, + }; + return FitSize(line.Text, baseSize, textWidth); + }) + .ToList(); + + // Между подписью и её названием зазор меньше, чем между смысловыми блоками. + var gaps = new List(); + for (var i = 1; i < spec.Lines.Count; i++) + gaps.Add( + spec.Lines[i - 1].Style == BumperLineStyle.Label + ? (int)(sizes[i] * 0.25) + : (int)(sizes[i] * 0.8) + ); + + var totalHeight = sizes.Sum(s => (int)(s * 1.2)) + gaps.Sum(); + var y = (h - totalHeight) / 2; + + var layout = new List(spec.Lines.Count); + for (var i = 0; i < spec.Lines.Count; i++) + { + if (i > 0) + y += (int)(sizes[i - 1] * 1.2) + gaps[i - 1]; + + var line = spec.Lines[i]; + layout.Add( + new LayoutLine( + Path.Combine(assetDir, $"line{i}.txt"), + line.Text, + sizes[i], + line.Color == BumperLineColor.Accent ? spec.AccentColor : spec.TextColor, + y, + line.Style == BumperLineStyle.Title, + 0.2 + i * 0.3 + ) + ); + } + + return layout; + } + private List BuildArgs( string assetDir, int seg, int target, - TextFiles text, + IReadOnlyList layout, BumperRenderSpec spec ) { var w = spec.Width; var h = spec.Height; - var titleSize = Math.Max(24, h / 10); - var labelSize = Math.Max(14, h / 22); - var gap = (int)(labelSize * 1.4); - - // Доступная ширина под текст (поля по 4% с каждой стороны) — под неё ужимаем длинные строки. - var textWidth = (int)(w * 0.92); - - var nowLabelY = (int)(h * 0.22); - var nowTitleY = nowLabelY + gap; - var nextLabelY = (int)(h * 0.60); - var nextTitleY = nextLabelY + gap; - var font = EscapePath(spec.FontFile); var outStart = Math.Max(0, target - 1); @@ -200,41 +231,8 @@ public sealed class FfmpegBumperRenderer( } var vchain = new StringBuilder(videoPrefix); - if (spec.FreeText) - { - // Свободный текст: две центрированные строки (акцентная + основная), ужатые под ширину кадра. - var line1Size = FitSize(spec.FreeLine1, labelSize + 4, textWidth); - var line2Size = FitSize(spec.FreeLine2, titleSize, textWidth); - var line1Y = (int)(h * 0.40); - var line2Y = line1Y + (int)(line2Size * 1.2); - vchain - .Append(',') - .Append(DrawTitle(font, text.Now, spec.AccentColor, line1Size, line1Y, 0.2)); - vchain - .Append(',') - .Append(DrawTitle(font, text.Next, spec.TextColor, line2Size, line2Y, 0.5)); - } - else - { - var nowSize = FitSize(spec.NowTitle, titleSize, textWidth); - var nextSize = FitSize(spec.NextTitle, titleSize, textWidth); - vchain - .Append(',') - .Append( - DrawLabel(font, text.NowLabel, spec.AccentColor, labelSize, nowLabelY, 0.2) - ); - vchain - .Append(',') - .Append(DrawTitle(font, text.Now, spec.TextColor, nowSize, nowTitleY, 0.3)); - vchain - .Append(',') - .Append( - DrawLabel(font, text.NextLabel, spec.AccentColor, labelSize, nextLabelY, 1.0) - ); - vchain - .Append(',') - .Append(DrawTitle(font, text.Next, spec.TextColor, nextSize, nextTitleY, 1.1)); - } + foreach (var line in layout) + vchain.Append(',').Append(DrawLine(font, line)); vchain.Append("[v]"); var filterComplex = $"{vchain};{audioChain}"; @@ -304,33 +302,20 @@ public sealed class FfmpegBumperRenderer( private static bool IsImage(string path) => ImageExtensions.Contains(Path.GetExtension(path).ToLowerInvariant()); - private static string DrawTitle( - string font, - string textFile, - string color, - int size, - int y, - double fadeStart - ) => - $"drawtext=fontfile={font}:textfile={EscapePath(textFile)}:expansion=none" - + $":fontcolor={color}:fontsize={size}:x=(w-text_w)/2:y={y}" - + ":shadowcolor=black@0.6:shadowx=2:shadowy=2" - + $":alpha='{FadeExpr(fadeStart)}'"; - - private static string DrawLabel( - string font, - string textFile, - string color, - int size, - int y, - double fadeStart - ) => - // Подпись читается из файла (textfile=) с expansion=none — произвольные символы подписи - // не могут сломать/инъектировать цепочку filter_complex (см. запись файлов в RenderAsync). - $"drawtext=fontfile={font}:textfile={EscapePath(textFile)}:expansion=none" - + $":fontcolor={color}:fontsize={size}:x=(w-text_w)/2:y={y}" - + ":shadowcolor=black@0.6:shadowx=1:shadowy=1" - + $":alpha='{FadeExpr(fadeStart)}'"; + /// + /// Звено drawtext для одной строки. Текст читается из файла (textfile=) с expansion=none — + /// произвольные символы не могут сломать/инъектировать цепочку filter_complex. + /// + private static string DrawLine(string font, LayoutLine line) + { + var shadow = line.Shadow + ? ":shadowcolor=black@0.6:shadowx=2:shadowy=2" + : ":shadowcolor=black@0.6:shadowx=1:shadowy=1"; + return $"drawtext=fontfile={font}:textfile={EscapePath(line.File)}:expansion=none" + + $":fontcolor={line.Color}:fontsize={line.FontSize}:x=(w-text_w)/2:y={line.Y}" + + shadow + + $":alpha='{FadeExpr(line.FadeStart)}'"; + } private static string FadeExpr(double start) => $"if(lt(t,{Fmt(start)}),0,min(1,(t-{Fmt(start)})/0.5))"; diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260727182212_SharedJunctionsAndBumperLines.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260727182212_SharedJunctionsAndBumperLines.Designer.cs new file mode 100644 index 0000000..656dfb1 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260727182212_SharedJunctionsAndBumperLines.Designer.cs @@ -0,0 +1,1422 @@ +// +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("20260727182212_SharedJunctionsAndBumperLines")] + partial class SharedJunctionsAndBumperLines + { + /// + 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("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("PosterShowId") + .HasColumnType("uuid"); + + b.Property("RenderedLinesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("VariantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("Signature") + .IsUnique(); + + 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("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Font") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("BumperTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Background") + .HasColumnType("integer"); + + b.Property("BumperTemplateId") + .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("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("AnalogFilterStrength") + .HasColumnType("double precision"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DayStartTime") + .HasColumnType("time without time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LogoCorner") + .HasColumnType("integer"); + + b.Property("LogoImageId") + .HasColumnType("uuid"); + + b.Property("LogoOpacity") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("ShowClock") + .HasColumnType("boolean"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UtcOffsetMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique() + .HasFilter("\"Number\" IS NOT NULL"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .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("SlotId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TraceJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.HasIndex("ChannelId", "ShowId", "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.Collection", 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("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Collections"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ShowId"); + + b.HasIndex("CollectionId", "Position"); + + b.HasIndex("CollectionId", "ShowId") + .IsUnique(); + + b.ToTable("CollectionItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("Value") + .IsUnique(); + + b.ToTable("GenreAliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Audience") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FranchiseExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FranchiseName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + 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.Library.ShowGenre", b => + { + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.HasKey("ShowId", "GenreId"); + + b.HasIndex("GenreId"); + + b.ToTable("ShowGenres"); + }); + + 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("ProcessingDuration") + .HasColumnType("interval"); + + b.Property("ProcessingStartedAt") + .HasColumnType("timestamp with time zone"); + + 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.Programming.GridLayer", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicabilityJson") + .HasColumnType("jsonb"); + + b.Property("IsBackground") + .HasColumnType("boolean"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Priority"); + + b.ToTable("GridLayers"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", 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("FilterJson") + .HasColumnType("jsonb"); + + b.Property("ItemCount") + .HasColumnType("integer"); + + b.Property("Mode") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StatsComputedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalDuration") + .HasColumnType("interval"); + + b.Property("UnitCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ElementId") + .HasColumnType("uuid"); + + b.Property("ElementKind") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Role") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ElementKind", "ElementId"); + + b.HasIndex("GroupId", "Position"); + + b.HasIndex("GroupId", "ElementKind", "ElementId") + .IsUnique(); + + b.ToTable("GroupItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AmountMode") + .HasColumnType("integer"); + + b.Property("AmountValue") + .HasColumnType("integer"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChoiceKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ChoiceWeight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("ConditionsJson") + .HasColumnType("jsonb"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsRequired") + .HasColumnType("boolean"); + + b.Property("JunctionTemplateId") + .HasColumnType("uuid"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId"); + + b.HasIndex("BumperVariantId"); + + b.HasIndex("GroupId"); + + b.HasIndex("JunctionTemplateId", "Position"); + + b.ToTable("JunctionElements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxTotalSeconds") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("JunctionTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppliedRevision") + .HasColumnType("integer"); + + b.Property("AppliedSnapshotJson") + .HasColumnType("jsonb"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultJunctionId") + .HasColumnType("uuid"); + + b.Property("FallbackGroupId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("RulesJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("ScheduleTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("Daypart") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAnchor") + .HasColumnType("boolean"); + + b.Property("JunctionAfterId") + .HasColumnType("uuid"); + + b.Property("JunctionBetweenId") + .HasColumnType("uuid"); + + b.Property("LayerId") + .HasColumnType("uuid"); + + b.Property("MaxDriftMinutes") + .HasColumnType("integer"); + + b.Property("OverflowPolicy") + .HasColumnType("integer"); + + b.Property("RepeatSourceJson") + .HasColumnType("jsonb"); + + b.Property("SlotKind") + .HasColumnType("integer"); + + b.Property("SnapToMinutes") + .HasColumnType("integer"); + + b.Property("StrategyJson") + .HasColumnType("jsonb"); + + b.Property("TargetDurationMinutes") + .HasColumnType("integer"); + + b.Property("TargetStart") + .HasColumnType("time without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Weekday") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("LayerId", "TargetStart"); + + b.ToTable("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("CurrentElementId") + .HasColumnType("uuid"); + + b.Property("CurrentElementKind") + .HasColumnType("integer"); + + b.Property("NextUnitIndex") + .HasColumnType("integer"); + + b.HasKey("SlotId"); + + b.ToTable("SlotStates"); + }); + + 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.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("TeleWave.Domain.Broadcast.BumperLine", "Lines", b1 => + { + b1.Property("BumperTextVariantId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Color"); + + b1.Property("Position"); + + b1.Property("Style"); + + b1.Property("Text") + .IsRequired() + .HasMaxLength(120); + + b1.HasKey("BumperTextVariantId", "__synthesizedOrdinal"); + + b1.ToTable("BumperTextVariants"); + + b1 + .ToJson("Lines") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("BumperTextVariantId"); + }); + + b.Navigation("Lines"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.HasOne("TeleWave.Domain.Library.Collection", null) + .WithMany("Items") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany() + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany("Aliases") + .HasForeignKey("GenreId") + .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.Library.ShowGenre", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Genres") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.HasOne("TeleWave.Domain.Programming.ScheduleTemplate", null) + .WithMany("Layers") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany() + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Broadcast.BumperTextVariant", null) + .WithMany() + .HasForeignKey("BumperVariantId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.JunctionTemplate", null) + .WithMany("Elements") + .HasForeignKey("JunctionTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.GridLayer", null) + .WithMany("Slots") + .HasForeignKey("LayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.HasOne("TeleWave.Domain.Programming.Slot", null) + .WithOne() + .HasForeignKey("TeleWave.Domain.Programming.SlotState", "SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Navigation("Aliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Navigation("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Navigation("Elements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Navigation("Layers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260727182212_SharedJunctionsAndBumperLines.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260727182212_SharedJunctionsAndBumperLines.cs new file mode 100644 index 0000000..9a11f3b --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260727182212_SharedJunctionsAndBumperLines.cs @@ -0,0 +1,465 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class SharedJunctionsAndBumperLines : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Перенос данных идёт до всех схемных операций: старые колонки ещё на месте. + // Текст подблоков превращается в строки, «Сейчас/Далее» — в четыре строки с + // плейсхолдерами, свободный текст — в две. + migrationBuilder.Sql("""ALTER TABLE "BumperTextVariants" ADD COLUMN "Lines" jsonb;"""); + migrationBuilder.Sql( + """ + UPDATE "BumperTextVariants" SET "Lines" = CASE WHEN "Kind" = 1 THEN + jsonb_build_array( + jsonb_build_object('Position', 0, 'Style', 0, 'Color', 0, 'Text', COALESCE("Line1", '')), + jsonb_build_object('Position', 1, 'Style', 1, 'Color', 1, 'Text', COALESCE("Line2", '')) + ) + ELSE + jsonb_build_array( + jsonb_build_object('Position', 0, 'Style', 0, 'Color', 0, 'Text', COALESCE("NowLabel", '')), + jsonb_build_object('Position', 1, 'Style', 1, 'Color', 1, 'Text', '{now.title}'), + jsonb_build_object('Position', 2, 'Style', 0, 'Color', 0, 'Text', COALESCE("NextLabel", '')), + jsonb_build_object('Position', 3, 'Style', 1, 'Color', 1, 'Text', '{next.title}') + ) + END; + """ + ); + // Пустые строки выбрасываем и перенумеровываем — иначе в кадре останутся дыры. + migrationBuilder.Sql( + """ + UPDATE "BumperTextVariants" v SET "Lines" = f.lines + FROM ( + SELECT id, COALESCE(jsonb_agg(jsonb_set(elem, '{Position}', to_jsonb(rn - 1)) ORDER BY rn), '[]'::jsonb) AS lines + FROM ( + SELECT t."Id" AS id, e.elem AS elem, + row_number() OVER (PARTITION BY t."Id" ORDER BY e.ord) AS rn + FROM "BumperTextVariants" t, + LATERAL jsonb_array_elements(t."Lines") WITH ORDINALITY AS e(elem, ord) + WHERE COALESCE(e.elem->>'Text', '') <> '' + ) x GROUP BY id + ) f WHERE v."Id" = f.id; + """ + ); + + // Фон: «Сейчас/Далее» показывал постер следующего шоу, свободный текст — фон блока. + migrationBuilder.Sql( + """ALTER TABLE "BumperTextVariants" ADD COLUMN "Background" integer NOT NULL DEFAULT 0;""" + ); + migrationBuilder.Sql( + """UPDATE "BumperTextVariants" SET "Background" = CASE WHEN "Kind" = 1 THEN 0 ELSE 1 END;""" + ); + + // Шрифт переезжает с канала на блок — у блока он был канальным. + migrationBuilder.Sql( + """ALTER TABLE "BumperTemplate" ADD COLUMN "Font" integer NOT NULL DEFAULT 0;""" + ); + migrationBuilder.Sql( + """UPDATE "BumperTemplate" t SET "Font" = c."BumperFont" FROM "Channels" c WHERE c."Id" = t."ChannelId";""" + ); + + // Кэш заставок: сигнатура теперь считается по содержимому, а подставленного текста + // у старых записей нет. Это кэш — он пересоберётся при ближайшей генерации. + migrationBuilder.Sql("""DELETE FROM "BumperAssets";"""); + + migrationBuilder.DropForeignKey( + name: "FK_BumperTemplate_Channels_ChannelId", + table: "BumperTemplate" + ); + + migrationBuilder.DropForeignKey( + name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId", + table: "BumperTextVariants" + ); + + migrationBuilder.DropIndex( + name: "IX_JunctionTemplates_ChannelId", + table: "JunctionTemplates" + ); + + migrationBuilder.DropIndex( + name: "IX_BumperAssets_FromShowId_ToShowId_Signature", + table: "BumperAssets" + ); + + migrationBuilder.DropPrimaryKey(name: "PK_BumperTemplate", table: "BumperTemplate"); + + migrationBuilder.DropIndex( + name: "IX_BumperTemplate_ChannelId_Position", + table: "BumperTemplate" + ); + + migrationBuilder.DropColumn(name: "ChannelId", table: "JunctionTemplates"); + + migrationBuilder.DropColumn(name: "BumperFont", table: "Channels"); + + migrationBuilder.DropColumn(name: "BumperSelection", table: "Channels"); + + migrationBuilder.DropColumn(name: "BumpersEnabled", table: "Channels"); + + migrationBuilder.DropColumn(name: "Line1", table: "BumperTextVariants"); + + migrationBuilder.DropColumn(name: "Line2", table: "BumperTextVariants"); + + migrationBuilder.DropColumn(name: "NextLabel", table: "BumperTextVariants"); + + migrationBuilder.DropColumn(name: "NowLabel", table: "BumperTextVariants"); + + migrationBuilder.DropColumn(name: "ChannelId", table: "BumperAssets"); + + migrationBuilder.DropColumn(name: "FromShowId", table: "BumperAssets"); + + migrationBuilder.DropColumn(name: "ToShowId", table: "BumperAssets"); + + migrationBuilder.DropColumn(name: "ChannelId", table: "BumperTemplate"); + + migrationBuilder.RenameTable(name: "BumperTemplate", newName: "BumperTemplates"); + + migrationBuilder.DropColumn(name: "Kind", table: "BumperTextVariants"); + + migrationBuilder.DropColumn(name: "Position", table: "BumperTemplates"); + + migrationBuilder.AddColumn( + name: "MaxTotalSeconds", + table: "JunctionTemplates", + type: "integer", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "BumperVariantId", + table: "JunctionElements", + type: "uuid", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "ChoiceKey", + table: "JunctionElements", + type: "character varying(64)", + maxLength: 64, + nullable: true + ); + + migrationBuilder.AddColumn( + name: "ChoiceWeight", + table: "JunctionElements", + type: "integer", + nullable: false, + defaultValue: 1 + ); + + migrationBuilder.AddColumn( + name: "PosterShowId", + table: "BumperAssets", + type: "uuid", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "RenderedLinesJson", + table: "BumperAssets", + type: "jsonb", + nullable: false, + defaultValue: "[]" + ); + + migrationBuilder.AddPrimaryKey( + name: "PK_BumperTemplates", + table: "BumperTemplates", + column: "Id" + ); + + migrationBuilder.CreateIndex( + name: "IX_JunctionTemplates_Name", + table: "JunctionTemplates", + column: "Name" + ); + + migrationBuilder.CreateIndex( + name: "IX_JunctionElements_BumperTemplateId", + table: "JunctionElements", + column: "BumperTemplateId" + ); + + migrationBuilder.CreateIndex( + name: "IX_JunctionElements_BumperVariantId", + table: "JunctionElements", + column: "BumperVariantId" + ); + + migrationBuilder.CreateIndex( + name: "IX_BumperAssets_MediaAssetId", + table: "BumperAssets", + column: "MediaAssetId" + ); + + migrationBuilder.CreateIndex( + name: "IX_BumperAssets_Signature", + table: "BumperAssets", + column: "Signature", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_BumperTemplates_Name", + table: "BumperTemplates", + column: "Name" + ); + + migrationBuilder.AddForeignKey( + name: "FK_BumperTextVariants_BumperTemplates_BumperTemplateId", + table: "BumperTextVariants", + column: "BumperTemplateId", + principalTable: "BumperTemplates", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + + migrationBuilder.AddForeignKey( + name: "FK_JunctionElements_BumperTemplates_BumperTemplateId", + table: "JunctionElements", + column: "BumperTemplateId", + principalTable: "BumperTemplates", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict + ); + + migrationBuilder.AddForeignKey( + name: "FK_JunctionElements_BumperTextVariants_BumperVariantId", + table: "JunctionElements", + column: "BumperVariantId", + principalTable: "BumperTextVariants", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_BumperTextVariants_BumperTemplates_BumperTemplateId", + table: "BumperTextVariants" + ); + + migrationBuilder.DropForeignKey( + name: "FK_JunctionElements_BumperTemplates_BumperTemplateId", + table: "JunctionElements" + ); + + migrationBuilder.DropForeignKey( + name: "FK_JunctionElements_BumperTextVariants_BumperVariantId", + table: "JunctionElements" + ); + + migrationBuilder.DropIndex( + name: "IX_JunctionTemplates_Name", + table: "JunctionTemplates" + ); + + migrationBuilder.DropIndex( + name: "IX_JunctionElements_BumperTemplateId", + table: "JunctionElements" + ); + + migrationBuilder.DropIndex( + name: "IX_JunctionElements_BumperVariantId", + table: "JunctionElements" + ); + + migrationBuilder.DropIndex(name: "IX_BumperAssets_MediaAssetId", table: "BumperAssets"); + + migrationBuilder.DropIndex(name: "IX_BumperAssets_Signature", table: "BumperAssets"); + + migrationBuilder.DropPrimaryKey(name: "PK_BumperTemplates", table: "BumperTemplates"); + + migrationBuilder.DropIndex(name: "IX_BumperTemplates_Name", table: "BumperTemplates"); + + migrationBuilder.DropColumn(name: "MaxTotalSeconds", table: "JunctionTemplates"); + + migrationBuilder.DropColumn(name: "BumperVariantId", table: "JunctionElements"); + + migrationBuilder.DropColumn(name: "ChoiceKey", table: "JunctionElements"); + + migrationBuilder.DropColumn(name: "ChoiceWeight", table: "JunctionElements"); + + migrationBuilder.DropColumn(name: "Lines", table: "BumperTextVariants"); + + migrationBuilder.DropColumn(name: "PosterShowId", table: "BumperAssets"); + + migrationBuilder.DropColumn(name: "RenderedLinesJson", table: "BumperAssets"); + + migrationBuilder.RenameTable(name: "BumperTemplates", newName: "BumperTemplate"); + + migrationBuilder.DropColumn(name: "Background", table: "BumperTextVariants"); + + migrationBuilder.AddColumn( + name: "Kind", + table: "BumperTextVariants", + type: "integer", + nullable: false, + defaultValue: 0 + ); + + migrationBuilder.DropColumn(name: "Font", table: "BumperTemplate"); + + migrationBuilder.AddColumn( + name: "Position", + table: "BumperTemplate", + type: "integer", + nullable: false, + defaultValue: 0 + ); + + migrationBuilder.AddColumn( + name: "ChannelId", + table: "JunctionTemplates", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000") + ); + + migrationBuilder.AddColumn( + name: "BumperFont", + table: "Channels", + type: "integer", + nullable: false, + defaultValue: 0 + ); + + migrationBuilder.AddColumn( + name: "BumperSelection", + table: "Channels", + type: "integer", + nullable: false, + defaultValue: 0 + ); + + migrationBuilder.AddColumn( + name: "BumpersEnabled", + table: "Channels", + type: "boolean", + nullable: false, + defaultValue: false + ); + + migrationBuilder.AddColumn( + name: "Line1", + table: "BumperTextVariants", + type: "character varying(120)", + maxLength: 120, + nullable: false, + defaultValue: "" + ); + + migrationBuilder.AddColumn( + name: "Line2", + table: "BumperTextVariants", + type: "character varying(120)", + maxLength: 120, + nullable: false, + defaultValue: "" + ); + + migrationBuilder.AddColumn( + name: "NextLabel", + table: "BumperTextVariants", + type: "character varying(64)", + maxLength: 64, + nullable: false, + defaultValue: "" + ); + + migrationBuilder.AddColumn( + name: "NowLabel", + table: "BumperTextVariants", + type: "character varying(64)", + maxLength: 64, + nullable: false, + defaultValue: "" + ); + + migrationBuilder.AddColumn( + name: "ChannelId", + table: "BumperAssets", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000") + ); + + migrationBuilder.AddColumn( + name: "FromShowId", + table: "BumperAssets", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000") + ); + + migrationBuilder.AddColumn( + name: "ToShowId", + table: "BumperAssets", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000") + ); + + migrationBuilder.AddColumn( + name: "ChannelId", + table: "BumperTemplate", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000") + ); + + migrationBuilder.AddPrimaryKey( + name: "PK_BumperTemplate", + table: "BumperTemplate", + column: "Id" + ); + + migrationBuilder.CreateIndex( + name: "IX_JunctionTemplates_ChannelId", + table: "JunctionTemplates", + column: "ChannelId" + ); + + migrationBuilder.CreateIndex( + name: "IX_BumperAssets_FromShowId_ToShowId_Signature", + table: "BumperAssets", + columns: new[] { "FromShowId", "ToShowId", "Signature" } + ); + + migrationBuilder.CreateIndex( + name: "IX_BumperTemplate_ChannelId_Position", + table: "BumperTemplate", + columns: new[] { "ChannelId", "Position" } + ); + + migrationBuilder.AddForeignKey( + name: "FK_BumperTemplate_Channels_ChannelId", + table: "BumperTemplate", + column: "ChannelId", + principalTable: "Channels", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + + migrationBuilder.AddForeignKey( + name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId", + table: "BumperTextVariants", + column: "BumperTemplateId", + principalTable: "BumperTemplate", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 079078f..51fb479 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -164,18 +164,19 @@ namespace TeleWave.Infrastructure.Migrations 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("PosterShowId") + .HasColumnType("uuid"); + + b.Property("RenderedLinesJson") + .IsRequired() + .HasColumnType("jsonb"); + b.Property("Signature") .IsRequired() .HasMaxLength(128) @@ -184,15 +185,15 @@ namespace TeleWave.Infrastructure.Migrations b.Property("TemplateId") .HasColumnType("uuid"); - b.Property("ToShowId") - .HasColumnType("uuid"); - b.Property("VariantId") .HasColumnType("uuid"); b.HasKey("Id"); - b.HasIndex("FromShowId", "ToShowId", "Signature"); + b.HasIndex("MediaAssetId"); + + b.HasIndex("Signature") + .IsUnique(); b.ToTable("BumperAssets"); }); @@ -227,20 +228,17 @@ namespace TeleWave.Infrastructure.Migrations b.Property("BackgroundImageId") .HasColumnType("uuid"); - b.Property("ChannelId") - .HasColumnType("uuid"); - b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("Font") + .HasColumnType("integer"); + b.Property("Name") .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)"); - b.Property("Position") - .HasColumnType("integer"); - b.Property("Revision") .HasColumnType("integer"); @@ -251,9 +249,9 @@ namespace TeleWave.Infrastructure.Migrations b.HasKey("Id"); - b.HasIndex("ChannelId", "Position"); + b.HasIndex("Name"); - b.ToTable("BumperTemplate"); + b.ToTable("BumperTemplates"); }); modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => @@ -261,40 +259,20 @@ namespace TeleWave.Infrastructure.Migrations b.Property("Id") .HasColumnType("uuid"); + b.Property("Background") + .HasColumnType("integer"); + 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"); @@ -321,15 +299,6 @@ namespace TeleWave.Infrastructure.Migrations b.Property("AnalogFilterStrength") .HasColumnType("double precision"); - b.Property("BumperFont") - .HasColumnType("integer"); - - b.Property("BumperSelection") - .HasColumnType("integer"); - - b.Property("BumpersEnabled") - .HasColumnType("boolean"); - b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -886,6 +855,18 @@ namespace TeleWave.Infrastructure.Migrations b.Property("BumperTemplateId") .HasColumnType("uuid"); + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChoiceKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ChoiceWeight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + b.Property("ConditionsJson") .HasColumnType("jsonb"); @@ -906,6 +887,10 @@ namespace TeleWave.Infrastructure.Migrations b.HasKey("Id"); + b.HasIndex("BumperTemplateId"); + + b.HasIndex("BumperVariantId"); + b.HasIndex("GroupId"); b.HasIndex("JunctionTemplateId", "Position"); @@ -918,12 +903,12 @@ namespace TeleWave.Infrastructure.Migrations b.Property("Id") .HasColumnType("uuid"); - b.Property("ChannelId") - .HasColumnType("uuid"); - b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("MaxTotalSeconds") + .HasColumnType("integer"); + b.Property("Name") .IsRequired() .HasMaxLength(128) @@ -931,7 +916,7 @@ namespace TeleWave.Infrastructure.Migrations b.HasKey("Id"); - b.HasIndex("ChannelId"); + b.HasIndex("Name"); b.ToTable("JunctionTemplates"); }); @@ -1234,15 +1219,6 @@ namespace TeleWave.Infrastructure.Migrations .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) @@ -1250,6 +1226,37 @@ namespace TeleWave.Infrastructure.Migrations .HasForeignKey("BumperTemplateId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + + b.OwnsMany("TeleWave.Domain.Broadcast.BumperLine", "Lines", b1 => + { + b1.Property("BumperTextVariantId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Color"); + + b1.Property("Position"); + + b1.Property("Style"); + + b1.Property("Text") + .IsRequired() + .HasMaxLength(120); + + b1.HasKey("BumperTextVariantId", "__synthesizedOrdinal"); + + b1.ToTable("BumperTextVariants"); + + b1 + .ToJson("Lines") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("BumperTextVariantId"); + }); + + b.Navigation("Lines"); }); modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => @@ -1320,6 +1327,16 @@ namespace TeleWave.Infrastructure.Migrations modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany() + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Broadcast.BumperTextVariant", null) + .WithMany() + .HasForeignKey("BumperVariantId") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("TeleWave.Domain.Programming.Group", null) .WithMany() .HasForeignKey("GroupId") @@ -1360,11 +1377,6 @@ namespace TeleWave.Infrastructure.Migrations b.Navigation("Variants"); }); - modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => - { - b.Navigation("BumperTemplates"); - }); - modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => { b.Navigation("Items"); diff --git a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs index fe07068..49b2786 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs @@ -40,6 +40,7 @@ public class AppDbContext(DbContextOptions options) public DbSet JunctionElements => Set(); public DbSet Channels => Set(); public DbSet ScheduleEntries => Set(); + public DbSet BumperTemplates => Set(); public DbSet BumperTextVariants => Set(); public DbSet BumperAssets => Set(); public DbSet AppSettings => Set(); diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/BumperAssetConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/BumperAssetConfiguration.cs index 4b9e0d5..e1d67f7 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/BumperAssetConfiguration.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/BumperAssetConfiguration.cs @@ -9,13 +9,10 @@ public class BumperAssetConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.Property(x => x.Signature).IsRequired().HasMaxLength(128); + builder.Property(x => x.RenderedLinesJson).HasColumnType("jsonb"); - // Кэш-ключ заставки: одна отрендеренная пара «из→в» при данной сигнатуре оформления. - builder.HasIndex(x => new - { - x.FromShowId, - x.ToShowId, - x.Signature, - }); + // Кэш-ключ — сигнатура содержимого: одинаковая заставка на трёх каналах рендерится один раз. + builder.HasIndex(x => x.Signature).IsUnique(); + builder.HasIndex(x => x.MediaAssetId); } } diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs index 336f176..9b82861 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs @@ -15,13 +15,6 @@ public class ChannelConfiguration : IEntityTypeConfiguration // Номер канала уникален среди заданных: переключение вверх-вниз по номерам иначе неоднозначно. builder.HasIndex(x => x.Number).IsUnique().HasFilter("\"Number\" IS NOT NULL"); - - builder - .HasMany(x => x.BumperTemplates) - .WithOne() - .HasForeignKey(t => t.ChannelId) - .OnDelete(DeleteBehavior.Cascade); - builder.Navigation(x => x.BumperTemplates).UsePropertyAccessMode(PropertyAccessMode.Field); } } @@ -29,7 +22,7 @@ public class BumperTemplateConfiguration : IEntityTypeConfiguration builder) { - builder.HasIndex(x => new { x.ChannelId, x.Position }); + builder.HasIndex(x => x.Name); builder.Property(x => x.Name).IsRequired().HasMaxLength(64); builder.Property(x => x.BackgroundColor).IsRequired().HasMaxLength(32); builder.Property(x => x.BackgroundColor2).IsRequired().HasMaxLength(32); @@ -52,10 +45,18 @@ public class BumperTextVariantConfiguration : IEntityTypeConfiguration new { x.BumperTemplateId, x.Position }); builder.Property(x => x.Name).IsRequired().HasMaxLength(64); - builder.Property(x => x.NowLabel).IsRequired().HasMaxLength(64); - builder.Property(x => x.NextLabel).IsRequired().HasMaxLength(64); - builder.Property(x => x.Line1).IsRequired().HasMaxLength(120); - builder.Property(x => x.Line2).IsRequired().HasMaxLength(120); builder.Property(x => x.Weight).HasDefaultValue(BumperTextVariant.DefaultWeight); + + // Строки — одной jsonb-колонкой: они всегда читаются и пишутся вместе с подблоком, + // отдельная таблица дала бы join и порядковые правки там, где список заменяется целиком. + builder.OwnsMany( + x => x.Lines, + lines => + { + lines.ToJson(); + lines.Property(l => l.Text).HasMaxLength(120); + } + ); + builder.Navigation(x => x.Lines).UsePropertyAccessMode(PropertyAccessMode.Field); } } diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/JunctionTemplateConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/JunctionTemplateConfiguration.cs index ec7cb16..f46d7fc 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/JunctionTemplateConfiguration.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/JunctionTemplateConfiguration.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TeleWave.Domain.Broadcast; using TeleWave.Domain.Programming; namespace TeleWave.Infrastructure.Persistence.Configurations; @@ -10,7 +11,7 @@ public class JunctionTemplateConfiguration : IEntityTypeConfiguration builder) { builder.Property(x => x.Name).IsRequired().HasMaxLength(128); - builder.HasIndex(x => x.ChannelId); + builder.HasIndex(x => x.Name); builder .HasMany(x => x.Elements) @@ -27,6 +28,8 @@ public class JunctionElementConfiguration : IEntityTypeConfiguration new { x.JunctionTemplateId, x.Position }); builder.Property(x => x.ConditionsJson).HasColumnType("jsonb"); + builder.Property(x => x.ChoiceKey).HasMaxLength(64); + builder.Property(x => x.ChoiceWeight).HasDefaultValue(JunctionElement.DefaultChoiceWeight); // Группа не удаляется, пока на неё ссылается врезка: иначе стык молча перестал бы работать. builder @@ -34,5 +37,19 @@ public class JunctionElementConfiguration : IEntityTypeConfiguration x.GroupId) .OnDelete(DeleteBehavior.Restrict); + + // То же и с заставкой: блок общий, и удаление используемого выключило бы заставки в чужих + // каналах. Проверку дублирует хендлер — ради внятной ошибки вместо нарушения ссылки. + builder + .HasOne() + .WithMany() + .HasForeignKey(x => x.BumperTemplateId) + .OnDelete(DeleteBehavior.Restrict); + + builder + .HasOne() + .WithMany() + .HasForeignKey(x => x.BumperVariantId) + .OnDelete(DeleteBehavior.Restrict); } } diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs index 2c9d5b9..8ee6890 100644 --- a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.Options; using NSubstitute; -using TeleWave.Application.Broadcast; using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Streaming; @@ -16,8 +15,8 @@ namespace TeleWave.Application.Tests.Broadcast; /// /// Превью ТВ-заставки: рендерится каждый подблок блока, длительность выравнивается по длине -/// сегмента HLS, а названия «из/в» берутся из групп этого канала — иначе превью показывало бы -/// случайные шоу из библиотеки. +/// сегмента HLS, а плейсхолдеры подставляются образцами выбранного канала — блок общий, но +/// смотреть на него надо глазами конкретного канала. /// public class BumperPreviewTests { @@ -38,49 +37,49 @@ public class BumperPreviewTests Options.Create(new StreamingOptions { SegmentSeconds = 5 }) ); + private static BumperTemplate NewTemplate() + { + var template = BumperTemplate.Create("Блок", "Текст 1"); + template + .Variants[0] + .SetLines([ + BumperLine.Create(0, BumperLineStyle.Label, BumperLineColor.Accent, "СЕЙЧАС"), + BumperLine.Create(1, BumperLineStyle.Title, BumperLineColor.Text, "{now.title}"), + BumperLine.Create(2, BumperLineStyle.Label, BumperLineColor.Accent, "ДАЛЕЕ"), + BumperLine.Create(3, BumperLineStyle.Title, BumperLineColor.Text, "{next.title}"), + ]); + return template; + } + + private static IReadOnlyList Specs(IBumperRenderer renderer) => + renderer + .ReceivedCalls() + .Select(c => c.GetArguments()[1]) + .OfType() + .ToList(); + [Fact] - public async Task Render_UnknownChannel_ReturnsNotFound() + public async Task Render_UnknownTemplate_ReturnsNotFound() { var fixture = new TestDb(); await using var db = fixture.New(); var result = await Handler(db) - .Handle( - new RenderBumperPreviewCommand(Guid.NewGuid(), Guid.NewGuid()), - CancellationToken.None - ); + .Handle(new RenderBumperPreviewCommand(Guid.NewGuid(), null), CancellationToken.None); - Assert.Equal(ChannelErrors.NotFound, result.Error); + Assert.Equal(BumperErrors.TemplateNotFound, result.Error); } [Fact] - public async Task Render_UnknownTemplate_ReturnsBumperTemplateNotFound() + public async Task Render_DrawsEveryVariant_WithChannelSamplesAndAlignedDuration() { var fixture = new TestDb(); var channel = Channel.Create("Первый", "one", T0); - await using (var seed = fixture.New()) - { - seed.Channels.Add(channel); - await seed.SaveChangesAsync(CancellationToken.None); - } - - await using var db = fixture.New(); - var result = await Handler(db) - .Handle( - new RenderBumperPreviewCommand(channel.Id, Guid.NewGuid()), - CancellationToken.None - ); - - Assert.Equal(ChannelErrors.BumperTemplateNotFound, result.Error); - } - - [Fact] - public async Task Render_DrawsEveryVariant_WithChannelShowNamesAndAlignedDuration() - { - var fixture = new TestDb(); - var channel = Channel.Create("Первый", "one", T0); - var template = channel.BumperTemplates[0]; + var template = NewTemplate(); var second = template.AddVariant("Второй подблок"); + second.SetLines([ + BumperLine.Create(0, BumperLineStyle.Title, BumperLineColor.Text, "{channel}"), + ]); // Шоу канала: слот сетки ссылается на группу, в группе — шоу. var show = Show.Create("Наше шоу", ShowKind.Series); @@ -105,6 +104,7 @@ public class BumperPreviewTests await using (var seed = fixture.New()) { seed.Channels.Add(channel); + seed.BumperTemplates.Add(template); seed.Shows.Add(show); seed.Groups.Add(group); seed.ScheduleTemplates.Add(grid); @@ -115,7 +115,7 @@ public class BumperPreviewTests await using var db = fixture.New(); var result = await Handler(db, renderer) .Handle( - new RenderBumperPreviewCommand(channel.Id, template.Id), + new RenderBumperPreviewCommand(template.Id, channel.Id), CancellationToken.None ); @@ -136,32 +136,28 @@ public class BumperPreviewTests Arg.Any() ); - var specs = renderer - .ReceivedCalls() - .Select(c => c.GetArguments()[1]) - .OfType() - .ToList(); + var specs = Specs(renderer); // Своего звука у блока нет: 8 секунд по умолчанию, выровненные вверх до сегмента в 5 сек. Assert.All(specs, s => Assert.Equal(10, s.DurationSeconds)); - Assert.All(specs, s => Assert.Equal("Наше шоу", s.NowTitle)); - // Второго шоу у канала нет — подставляется заглушка. - Assert.All(specs, s => Assert.Equal("Второе шоу", s.NextTitle)); - Assert.All(specs, s => Assert.Null(s.BackgroundFile)); - Assert.All(specs, s => Assert.Null(s.PosterFile)); + + var nowNext = specs.First(s => s.Lines.Count == 4); + Assert.Equal("Наше шоу", nowNext.Lines[1].Text); + + var channelLine = specs.First(s => s.Lines.Count == 1); + Assert.Equal("Первый", channelLine.Lines[0].Text); } [Fact] public async Task Render_WithBackgroundImage_ResolvesPathFromRegistry() { var fixture = new TestDb(); - var channel = Channel.Create("Первый", "one", T0); - var template = channel.BumperTemplates[0]; + var template = NewTemplate(); var image = Image.Create(ImageCategory.BumperBackground, ".png", "bg.png"); template.SetBackgroundImage(image.Id); await using (var seed = fixture.New()) { - seed.Channels.Add(channel); + seed.BumperTemplates.Add(template); seed.Images.Add(image); await seed.SaveChangesAsync(CancellationToken.None); } @@ -172,19 +168,40 @@ public class BumperPreviewTests await using var db = fixture.New(); var result = await Handler(db, renderer, imageStore: imageStore) - .Handle( - new RenderBumperPreviewCommand(channel.Id, template.Id), - CancellationToken.None - ); + .Handle(new RenderBumperPreviewCommand(template.Id, null), CancellationToken.None); Assert.True(result.IsSuccess); - var spec = renderer - .ReceivedCalls() - .Select(c => c.GetArguments()[1]) - .OfType() - .First(); + var spec = Specs(renderer).First(); Assert.Equal("/data/images/bg.png", spec.BackgroundFile); - // Шоу у канала нет — обе подписи заглушечные. - Assert.Equal("Первое шоу", spec.NowTitle); + // Канала нет — образцы заглушечные, но кадр всё равно собирается. + Assert.Equal("Первое шоу", spec.Lines[1].Text); + } + + [Fact] + public async Task Render_DropsLinesThatResolveToNothing() + { + var fixture = new TestDb(); + var template = BumperTemplate.Create("Блок", "Текст 1"); + template + .Variants[0] + .SetLines([ + BumperLine.Create(0, BumperLineStyle.Label, BumperLineColor.Accent, "{next.genre}"), + BumperLine.Create(1, BumperLineStyle.Title, BumperLineColor.Text, "{next.title}"), + ]); + + await using (var seed = fixture.New()) + { + seed.BumperTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var renderer = Substitute.For(); + await using var db = fixture.New(); + await Handler(db, renderer) + .Handle(new RenderBumperPreviewCommand(template.Id, null), CancellationToken.None); + + // Жанра у образца нет — строка схлопнулась, а не оставила дыру в кадре. + var spec = Specs(renderer).First(); + Assert.Single(spec.Lines); } } diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperVariantHandlersTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperVariantHandlersTests.cs index 8b25b0c..3d5e296 100644 --- a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperVariantHandlersTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperVariantHandlersTests.cs @@ -1,5 +1,4 @@ using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Broadcast; using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Tests.Support; using TeleWave.Domain.Broadcast; @@ -9,23 +8,25 @@ namespace TeleWave.Application.Tests.Broadcast; public class BumperVariantHandlersTests { - private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + private static BumperTemplate NewTemplate() => BumperTemplate.Create("Блок", "Текст 1"); + + private static BumperLineDto Line(string text) => + new(BumperLineStyle.Title, BumperLineColor.Text, text); [Fact] - public async Task AddVariant_AutoNames_WhenBlank() + public async Task AddVariant_StartsFromDefaultPreset() { var fixture = new TestDb(); - var channel = Channel.Create("c", "c", T0); - var template = channel.BumperTemplates[0]; + var template = NewTemplate(); await using (var seed = fixture.New()) { - seed.Channels.Add(channel); + seed.BumperTemplates.Add(template); await seed.SaveChangesAsync(CancellationToken.None); } await using var db = fixture.New(); - var result = await new AddBumperTextVariantCommandHandler(db).Handle( - new AddBumperTextVariantCommand(channel.Id, template.Id, " "), + var result = await new AddBumperVariantCommandHandler(db).Handle( + new AddBumperVariantCommand(template.Id, "Текст 2"), CancellationToken.None ); Assert.True(result.IsSuccess); @@ -33,60 +34,60 @@ public class BumperVariantHandlersTests await using var verify = fixture.New(); var stored = await verify - .Channels.Include(c => c.BumperTemplates) - .ThenInclude(t => t.Variants) - .FirstAsync(c => c.Id == channel.Id); - var variants = stored.BumperTemplates[0].Variants; - Assert.Equal(2, variants.Count); - Assert.Contains(variants, v => v.Name == "Текст 2"); + .BumperTemplates.Include(t => t.Variants) + .FirstAsync(t => t.Id == template.Id); + Assert.Equal(2, stored.Variants.Count); + var added = stored.Variants.First(v => v.Name == "Текст 2"); + // Пустой подблок в редакторе выглядит поломанным — новый начинается с «Сейчас/Далее». + Assert.Equal(4, added.Lines.Count); + Assert.Contains(added.Lines, l => l.Text == "{next.title}"); } [Fact] public async Task AddVariant_UnknownTemplate_ReturnsNotFound() { var fixture = new TestDb(); - var channel = Channel.Create("c", "c", T0); - await using (var seed = fixture.New()) - { - seed.Channels.Add(channel); - await seed.SaveChangesAsync(CancellationToken.None); - } - await using var db = fixture.New(); - var result = await new AddBumperTextVariantCommandHandler(db).Handle( - new AddBumperTextVariantCommand(channel.Id, Guid.NewGuid(), "x"), + + var result = await new AddBumperVariantCommandHandler(db).Handle( + new AddBumperVariantCommand(Guid.NewGuid(), "x"), CancellationToken.None ); - Assert.Equal(ChannelErrors.BumperTemplateNotFound, result.Error); + + Assert.Equal(BumperErrors.TemplateNotFound, result.Error); } [Fact] - public async Task UpdateVariant_ChangesFields() + public async Task UpdateVariant_ReplacesLinesAndFields() { var fixture = new TestDb(); - var channel = Channel.Create("c", "c", T0); - var template = channel.BumperTemplates[0]; + var template = NewTemplate(); var variant = template.Variants[0]; await using (var seed = fixture.New()) { - seed.Channels.Add(channel); + seed.BumperTemplates.Add(template); await seed.SaveChangesAsync(CancellationToken.None); } await using var db = fixture.New(); - var result = await new UpdateBumperTextVariantCommandHandler(db).Handle( - new UpdateBumperTextVariantCommand( - channel.Id, + var result = await new UpdateBumperVariantCommandHandler(db).Handle( + new UpdateBumperVariantCommand( template.Id, variant.Id, - "Custom", - BumperTextKind.Free, - "NOW", - "NEXT", - "line1", - "line2", - BumperTrigger.Both, - 7 + new BumperVariantInput( + "Custom", + BumperTrigger.Both, + BumperBackground.Template, + 7, + [ + new BumperLineDto( + BumperLineStyle.Label, + BumperLineColor.Accent, + "ДАЛЕЕ В {next.time}" + ), + Line("{next.title}"), + ] + ) ), CancellationToken.None ); @@ -95,45 +96,75 @@ public class BumperVariantHandlersTests await using var verify = fixture.New(); var stored = await verify - .Channels.Include(c => c.BumperTemplates) - .ThenInclude(t => t.Variants) - .FirstAsync(c => c.Id == channel.Id); - var v = stored.BumperTemplates[0].Variants[0]; + .BumperTemplates.Include(t => t.Variants) + .FirstAsync(t => t.Id == template.Id); + var v = stored.Variants[0]; Assert.Equal("Custom", v.Name); - Assert.Equal(BumperTextKind.Free, v.Kind); - Assert.Equal(7, v.Weight); Assert.Equal(BumperTrigger.Both, v.Trigger); + Assert.Equal(BumperBackground.Template, v.Background); + Assert.Equal(7, v.Weight); + Assert.Equal(2, v.Lines.Count); + Assert.Equal("ДАЛЕЕ В {next.time}", v.Lines[0].Text); + } + + [Fact] + public async Task UpdateVariant_UnknownPlaceholder_IsRejected() + { + var fixture = new TestDb(); + var template = NewTemplate(); + var variant = template.Variants[0]; + await using (var seed = fixture.New()) + { + seed.BumperTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new UpdateBumperVariantCommandHandler(db).Handle( + new UpdateBumperVariantCommand( + template.Id, + variant.Id, + new BumperVariantInput( + "Custom", + BumperTrigger.Both, + BumperBackground.Template, + 1, + [Line("Далее {next.tittle}")] + ) + ), + CancellationToken.None + ); + + // В эфире опечатка превратилась бы в пустоту, и заметить это было бы уже некому. + Assert.Equal("Bumpers.UnknownPlaceholders", result.Error.Code); } [Fact] public async Task RemoveVariant_CannotRemoveLast_ButRemovesExtra() { var fixture = new TestDb(); - var channel = Channel.Create("c", "c", T0); - var template = channel.BumperTemplates[0]; + var template = NewTemplate(); var only = template.Variants[0]; - var extra = template.AddVariant("Text 2"); + var extra = template.AddVariant("Текст 2"); await using (var seed = fixture.New()) { - seed.Channels.Add(channel); + seed.BumperTemplates.Add(template); await seed.SaveChangesAsync(CancellationToken.None); } await using var db = fixture.New(); - var handler = new RemoveBumperTextVariantCommandHandler(db); - - var okRemove = await handler.Handle( - new RemoveBumperTextVariantCommand(channel.Id, template.Id, extra.Id), + var okRemove = await new RemoveBumperVariantCommandHandler(db).Handle( + new RemoveBumperVariantCommand(template.Id, extra.Id), CancellationToken.None ); Assert.True(okRemove.IsSuccess); await db.SaveChangesAsync(CancellationToken.None); await using var db2 = fixture.New(); - var lastGuard = await new RemoveBumperTextVariantCommandHandler(db2).Handle( - new RemoveBumperTextVariantCommand(channel.Id, template.Id, only.Id), + var lastGuard = await new RemoveBumperVariantCommandHandler(db2).Handle( + new RemoveBumperVariantCommand(template.Id, only.Id), CancellationToken.None ); - Assert.Equal(ChannelErrors.CannotRemoveLastBumperTextVariant, lastGuard.Error); + Assert.Equal(BumperErrors.CannotRemoveLastVariant, lastGuard.Error); } } diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/ChannelDetailsTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/ChannelDetailsTests.cs index 29ad45e..0d6a659 100644 --- a/backend/tests/TeleWave.Application.Tests/Broadcast/ChannelDetailsTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/ChannelDetailsTests.cs @@ -7,26 +7,23 @@ using Xunit; namespace TeleWave.Application.Tests.Broadcast; /// -/// Карточка канала: собственные свойства канала плюс блоки заставки с подблоками. Сетка сюда не -/// входит — она запрашивается отдельно. +/// Карточка канала: только собственные свойства канала. Сетка, стыки и заставки сюда не входят — +/// они общие и запрашиваются отдельно. /// public class ChannelDetailsTests { private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); [Fact] - public async Task GetChannel_MapsSettingsAndBumperTemplates() + public async Task GetChannel_MapsOwnSettings() { var fixture = new TestDb(); var channel = Channel.Create("Первый", "one", T0); var fillerId = Guid.NewGuid(); var logoId = Guid.NewGuid(); - channel.UpdateSettings("Первый", isEnabled: true, bumpersEnabled: true, fillerId); + channel.UpdateSettings("Первый", isEnabled: true, fillerId); channel.UpdateTimeSettings(3, 120, new TimeOnly(5, 0)); - channel.UpdateBumperSettings(BumperFont.Serif, BumperSelection.Random); channel.UpdateViewerSettings(logoId, LogoCorner.BottomLeft, 0.4, showClock: true, 0.25); - // Второй блок должен приехать после дефолтного — порядок задаёт позиция. - var extra = channel.AddBumperTemplate("Ночной"); await using (var seed = fixture.New()) { @@ -48,22 +45,11 @@ public class ChannelDetailsTests Assert.Equal(120, dto.UtcOffsetMinutes); Assert.Equal(new TimeOnly(5, 0), dto.DayStartTime); Assert.Equal(fillerId, dto.FillerAssetId); - Assert.True(dto.BumpersEnabled); - Assert.Equal(BumperFont.Serif, dto.Bumper.Font); - Assert.Equal(BumperSelection.Random, dto.Bumper.Selection); Assert.Equal(logoId, dto.Viewer.LogoImageId); Assert.Equal(LogoCorner.BottomLeft, dto.Viewer.LogoCorner); Assert.Equal(0.4, dto.Viewer.LogoOpacity); Assert.True(dto.Viewer.ShowClock); Assert.Equal(0.25, dto.Viewer.AnalogFilterStrength); - - Assert.Equal(2, dto.BumperTemplates.Count); - Assert.True(dto.BumperTemplates[0].IsDefault); - Assert.Equal(extra.Id, dto.BumperTemplates[1].Id); - Assert.Equal("Ночной", dto.BumperTemplates[1].Name); - // Своего звука у свежего блока нет — джингл синтезируется. - Assert.False(dto.BumperTemplates[1].HasAudio); - Assert.NotEmpty(dto.BumperTemplates[0].Variants); } [Fact] diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/ChannelHandlersTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/ChannelHandlersTests.cs index abedb6e..405e060 100644 --- a/backend/tests/TeleWave.Application.Tests/Broadcast/ChannelHandlersTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/ChannelHandlersTests.cs @@ -48,14 +48,7 @@ public class ChannelHandlersTests await using var db = fixture.New(); var result = await new UpdateChannelSettingsCommandHandler(db).Handle( - new UpdateChannelSettingsCommand( - channel.Id, - "c", - true, - true, - new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom), - null - ), + new UpdateChannelSettingsCommand(channel.Id, "c2", true, null), CancellationToken.None ); Assert.True(result.IsSuccess); @@ -63,7 +56,7 @@ public class ChannelHandlersTests await using var verify = fixture.New(); var stored = await verify.Channels.FindAsync(channel.Id); - Assert.Equal(BumperFont.Sans, stored!.BumperFont); - Assert.Equal(BumperSelection.WeightedRandom, stored.BumperSelection); + Assert.Equal("c2", stored!.Name); + Assert.True(stored.IsEnabled); } } diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/QueryHandlersTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/QueryHandlersTests.cs index 6b70cf1..53ac6cb 100644 --- a/backend/tests/TeleWave.Application.Tests/Broadcast/QueryHandlersTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/QueryHandlersTests.cs @@ -61,8 +61,19 @@ public class QueryHandlersTests var fixture = new TestDb(); var channel = Channel.Create("c", "c", T0); var show = Show.Create("Show A", ShowKind.Series); - var variant = channel.BumperTemplates[0].Variants[0]; + var bumperTemplate = BumperTemplate.Create("Блок", "Текст 1"); + var variant = bumperTemplate.Variants[0]; var asset = MediaAsset.Register("Show.A.S01E01.mkv", ".mkv", MediaSource.Upload); + var bumperAsset = MediaAsset.RegisterGenerated("Блок: СЕЙЧАС / Show A"); + // Метка в расписании берётся из кэша заставки — там лежит ровно тот текст, что играл. + var cache = BumperAsset.Create( + bumperTemplate.Id, + variant.Id, + "sig", + """[{"style":"Label","color":"Accent","text":"СЕЙЧАС"},{"style":"Title","color":"Text","text":"Show A"}]""", + null, + bumperAsset.Id + ); var program = ScheduleEntry.Program( channel.Id, asset.Id, @@ -73,7 +84,7 @@ public class QueryHandlersTests ); var bumper = ScheduleEntry.Bumper( channel.Id, - Guid.NewGuid(), + bumperAsset.Id, T0.AddMinutes(20), T0.AddMinutes(20).AddSeconds(8), show.Id, @@ -83,7 +94,10 @@ public class QueryHandlersTests { seed.Shows.Add(show); seed.Channels.Add(channel); + seed.BumperTemplates.Add(bumperTemplate); seed.MediaAssets.Add(asset); + seed.MediaAssets.Add(bumperAsset); + seed.BumperAssets.Add(cache); seed.ScheduleEntries.Add(program); seed.ScheduleEntries.Add(bumper); await seed.SaveChangesAsync(CancellationToken.None); @@ -98,7 +112,8 @@ public class QueryHandlersTests Assert.True(result.IsSuccess); Assert.Equal(2, result.Value.Count); var bumperDto = result.Value.Single(e => e.Kind == ScheduleEntryKind.Bumper); - Assert.NotNull(bumperDto.BumperName); + Assert.Equal("Текст 1", bumperDto.BumperName); + Assert.Equal("СЕЙЧАС · Show A", bumperDto.BumperText); } [Fact] @@ -150,52 +165,4 @@ public class QueryHandlersTests ); Assert.True(ok.IsSuccess); } - - [Fact] - public async Task AddAndRemoveBumperTemplate_WorkThroughStorage() - { - var fixture = new TestDb(); - var channel = Channel.Create("c", "c", T0); - await using (var seed = fixture.New()) - { - seed.Channels.Add(channel); - await seed.SaveChangesAsync(CancellationToken.None); - } - - Guid templateId; - await using (var db = fixture.New()) - { - var added = await new AddBumperTemplateCommandHandler(db).Handle( - new AddBumperTemplateCommand(channel.Id, ""), - CancellationToken.None - ); - Assert.True(added.IsSuccess); - templateId = added.Value; - await db.SaveChangesAsync(CancellationToken.None); - } - - var storage = Substitute.For(); - await using (var db = fixture.New()) - { - var removed = await new RemoveBumperTemplateCommandHandler(db, storage).Handle( - new RemoveBumperTemplateCommand(channel.Id, templateId), - CancellationToken.None - ); - Assert.True(removed.IsSuccess); - } - await storage.Received(1).DeleteTemplateAsync(templateId, Arg.Any()); - - // дефолтный блок удалить нельзя - await using (var db = fixture.New()) - { - var def = await db - .Channels.Include(c => c.BumperTemplates) - .FirstAsync(c => c.Id == channel.Id); - var result = await new RemoveBumperTemplateCommandHandler(db, storage).Handle( - new RemoveBumperTemplateCommand(channel.Id, def.BumperTemplates[0].Id), - CancellationToken.None - ); - Assert.Equal(ChannelErrors.CannotRemoveDefaultBumperTemplate, result.Error); - } - } } diff --git a/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs b/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs index 740d501..0a0bba2 100644 --- a/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs @@ -125,7 +125,7 @@ public class DeleteGuardsTests var fixture = new TestDb(); var asset = MediaAsset.Register("filler.mkv", ".mkv", MediaSource.Upload); var channel = Channel.Create("c", "c", T0); - channel.UpdateSettings(channel.Name, isEnabled: true, bumpersEnabled: false, asset.Id); + channel.UpdateSettings(channel.Name, isEnabled: true, asset.Id); await using (var seed = fixture.New()) { diff --git a/backend/tests/TeleWave.Application.Tests/Programming/EntryTraceTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/EntryTraceTests.cs index 31d05d8..296fe4a 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/EntryTraceTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/EntryTraceTests.cs @@ -52,7 +52,7 @@ public class EntryTraceTests var show = Show.Create("Фильм", ShowKind.Single); var asset = MediaAsset.Register("film.mkv", ".mkv", MediaSource.Upload); - var junction = JunctionTemplate.Create(channel.Id, "Прайм"); + var junction = JunctionTemplate.Create("Прайм"); var template = ScheduleTemplate.Create(channel.Id, "Сетка"); var layer = template.AddLayer("Прайм", 10); var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120); diff --git a/backend/tests/TeleWave.Application.Tests/Streaming/ListPublicChannelsTests.cs b/backend/tests/TeleWave.Application.Tests/Streaming/ListPublicChannelsTests.cs index 7f11132..51b67b4 100644 --- a/backend/tests/TeleWave.Application.Tests/Streaming/ListPublicChannelsTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Streaming/ListPublicChannelsTests.cs @@ -31,8 +31,8 @@ public class ListPublicChannelsTests var disabled = Channel.Create("Выключенный", "off", T0); foreach (var channel in new[] { numbered, first, unnumbered }) - channel.UpdateSettings(channel.Name, isEnabled: true, bumpersEnabled: false, null); - disabled.UpdateSettings("Выключенный", isEnabled: false, bumpersEnabled: false, null); + channel.UpdateSettings(channel.Name, isEnabled: true, null); + disabled.UpdateSettings("Выключенный", isEnabled: false, null); await using (var seed = fixture.New()) { @@ -57,7 +57,7 @@ public class ListPublicChannelsTests { var fixture = new TestDb(); var channel = Channel.Create("Первый", "one", T0); - channel.UpdateSettings("Первый", isEnabled: true, bumpersEnabled: false, null); + channel.UpdateSettings("Первый", isEnabled: true, null); var logoId = Guid.NewGuid(); channel.UpdateViewerSettings(logoId, LogoCorner.TopRight, 0.5, showClock: true, 0.3); diff --git a/backend/tests/TeleWave.Application.Tests/Validators/ValidatorTests.cs b/backend/tests/TeleWave.Application.Tests/Validators/ValidatorTests.cs index 7120f60..3df78d0 100644 --- a/backend/tests/TeleWave.Application.Tests/Validators/ValidatorTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Validators/ValidatorTests.cs @@ -11,44 +11,57 @@ namespace TeleWave.Application.Tests.Validators; public class ValidatorTests { [Fact] - public void UpdateBumperTextVariant_ChecksLengthsAndWeight() + public void UpdateBumperVariant_ChecksNameWeightAndLines() { - var v = new UpdateBumperTextVariantCommandValidator(); + var v = new UpdateBumperVariantCommandValidator(); - var good = new UpdateBumperTextVariantCommand( + var good = new UpdateBumperVariantCommand( Guid.NewGuid(), Guid.NewGuid(), - Guid.NewGuid(), - "Name", - BumperTextKind.NowNext, - "NOW", - "NEXT", - "l1", - "l2", - BumperTrigger.Both, - 3 + new BumperVariantInput( + "Name", + BumperTrigger.Both, + BumperBackground.NextPoster, + 3, + [new BumperLineDto(BumperLineStyle.Title, BumperLineColor.Text, "{next.title}")] + ) ); Assert.True(v.Validate(good).IsValid); - Assert.False(v.Validate(good with { Name = "" }).IsValid); - Assert.False(v.Validate(good with { Weight = -1 }).IsValid); - Assert.False(v.Validate(good with { Line1 = new string('x', 121) }).IsValid); + Assert.False(v.Validate(Patch(good, input => input with { Name = "" })).IsValid); + Assert.False(v.Validate(Patch(good, input => input with { Weight = -1 })).IsValid); + Assert.False( + v.Validate( + Patch( + good, + input => + input with + { + Lines = + [ + new BumperLineDto( + BumperLineStyle.Title, + BumperLineColor.Text, + new string('x', 121) + ), + ], + } + ) + ).IsValid + ); } + private static UpdateBumperVariantCommand Patch( + UpdateBumperVariantCommand command, + Func change + ) => command with { Input = change(command.Input) }; + [Fact] public void UpdateChannelSettings_ChecksRanges() { var v = new UpdateChannelSettingsCommandValidator(); - var bumper = new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom); - var good = new UpdateChannelSettingsCommand( - Guid.NewGuid(), - "Name", - true, - true, - bumper, - null - ); + var good = new UpdateChannelSettingsCommand(Guid.NewGuid(), "Name", true, null); Assert.True(v.Validate(good).IsValid); Assert.False(v.Validate(good with { Name = "" }).IsValid); diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/BumperTemplateTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/BumperTemplateTests.cs index eb01dd2..118dccb 100644 --- a/backend/tests/TeleWave.Domain.Tests/Broadcast/BumperTemplateTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Broadcast/BumperTemplateTests.cs @@ -5,8 +5,7 @@ namespace TeleWave.Domain.Tests.Broadcast; public class BumperTemplateTests { - private static BumperTemplate NewTemplate() => - Channel.Create("c", "c", DateTimeOffset.UnixEpoch).AddBumperTemplate("Block"); + private static BumperTemplate NewTemplate() => BumperTemplate.Create("Block", "Текст 1"); [Fact] public void Create_HasDefaultsAndOneVariant() @@ -53,12 +52,17 @@ public class BumperTemplateTests { var t = NewTemplate(); - t.UpdateStyle("New", "0x111111", "0x222222", "0x333333", "black"); + t.UpdateStyle( + new BumperStyle("New", BumperFont.Serif, "0x111111", "0x222222", "0x333333", "black") + ); Assert.Equal("New", t.Name); + Assert.Equal(BumperFont.Serif, t.Font); Assert.Equal("0x111111", t.BackgroundColor); Assert.Equal("0x333333", t.AccentColor); Assert.Equal("black", t.TextColor); + // Оформление входит в сигнатуру рендера — правка обязана пересобрать заставки. + Assert.Equal(1, t.Revision); } [Fact] diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/BumperTextVariantTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/BumperTextVariantTests.cs index 54cdf5d..e830036 100644 --- a/backend/tests/TeleWave.Domain.Tests/Broadcast/BumperTextVariantTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Broadcast/BumperTextVariantTests.cs @@ -6,21 +6,17 @@ namespace TeleWave.Domain.Tests.Broadcast; public class BumperTextVariantTests { private static BumperTextVariant NewVariant() => - Channel - .Create("c", "c", DateTimeOffset.UnixEpoch) - .AddBumperTemplate("b") - .AddVariant("Text"); + BumperTemplate.Create("b", "Текст 1").AddVariant("Text"); [Fact] - public void Create_HasDefaultLabelsAndWeight() + public void Create_HasDefaults() { var v = NewVariant(); - Assert.Equal(BumperTextKind.NowNext, v.Kind); - Assert.Equal(BumperTextVariant.DefaultNowLabel, v.NowLabel); - Assert.Equal(BumperTextVariant.DefaultNextLabel, v.NextLabel); Assert.Equal(BumperTextVariant.DefaultWeight, v.Weight); - Assert.Equal(string.Empty, v.Line1); + Assert.Equal(BumperTrigger.OnShowChange, v.Trigger); + Assert.Equal(BumperBackground.NextPoster, v.Background); + Assert.Empty(v.Lines); } [Fact] @@ -28,21 +24,31 @@ public class BumperTextVariantTests { var v = NewVariant(); - v.Update( - "Name", - new BumperTextContent(BumperTextKind.Free, "NOW", "NEXT", "l1", "l2"), - BumperTrigger.Both, - -5 - ); + v.Update("Name", BumperTrigger.Both, BumperBackground.Template, -5); Assert.Equal("Name", v.Name); - Assert.Equal(BumperTextKind.Free, v.Kind); - Assert.Equal("NOW", v.NowLabel); - Assert.Equal("l1", v.Line1); Assert.Equal(BumperTrigger.Both, v.Trigger); + Assert.Equal(BumperBackground.Template, v.Background); Assert.Equal(0, v.Weight); } + [Fact] + public void SetLines_ReplacesAndRenumbers() + { + var v = NewVariant(); + + v.SetLines([ + BumperLine.Create(7, BumperLineStyle.Label, BumperLineColor.Accent, " ДАЛЕЕ "), + BumperLine.Create(9, BumperLineStyle.Title, BumperLineColor.Text, "{next.title}"), + ]); + + Assert.Equal([0, 1], v.Lines.Select(l => l.Position)); + Assert.Equal("ДАЛЕЕ", v.Lines[0].Text); + + v.SetLines([BumperLine.Create(0, BumperLineStyle.Title, BumperLineColor.Text, "один")]); + Assert.Single(v.Lines); + } + [Theory] [InlineData(BumperTrigger.OnShowChange, true, true)] [InlineData(BumperTrigger.OnShowChange, false, false)] @@ -53,7 +59,7 @@ public class BumperTextVariantTests public void Matches_FollowsTrigger(BumperTrigger trigger, bool isShowChange, bool expected) { var v = NewVariant(); - v.Update("n", new BumperTextContent(BumperTextKind.NowNext, "a", "b", "", ""), trigger, 1); + v.Update("n", trigger, BumperBackground.Template, 1); Assert.Equal(expected, v.Matches(isShowChange)); } diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/ChannelTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/ChannelTests.cs index 60d494e..b76568d 100644 --- a/backend/tests/TeleWave.Domain.Tests/Broadcast/ChannelTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Broadcast/ChannelTests.cs @@ -10,7 +10,7 @@ public class ChannelTests private static Channel NewChannel() => Channel.Create("News", "news", Epoch); [Fact] - public void Create_SetsDefaults_AndSeedsDefaultBumperTemplate() + public void Create_SetsDefaults() { var channel = NewChannel(); @@ -19,15 +19,8 @@ public class ChannelTests Assert.Equal("news", channel.Slug); Assert.True(channel.IsEnabled); Assert.Equal(Epoch, channel.EpochUtc); - Assert.False(channel.BumpersEnabled); - Assert.Equal(BumperSelection.WeightedRandom, channel.BumperSelection); Assert.Equal(Channel.DefaultUtcOffsetMinutes, channel.UtcOffsetMinutes); Assert.Equal(Channel.DefaultDayStartTime, channel.DayStartTime); - - var template = Assert.Single(channel.BumperTemplates); - Assert.True(template.IsDefault); - Assert.Equal(0, template.Position); - Assert.Single(template.Variants); // дефолтный подблок } [Fact] @@ -36,25 +29,13 @@ public class ChannelTests var channel = NewChannel(); var filler = Guid.NewGuid(); - channel.UpdateSettings("N2", false, true, filler); + channel.UpdateSettings("N2", false, filler); Assert.Equal("N2", channel.Name); Assert.False(channel.IsEnabled); - Assert.True(channel.BumpersEnabled); Assert.Equal(filler, channel.FillerAssetId); } - [Fact] - public void UpdateBumperSettings_ChangesFontAndSelection() - { - var channel = NewChannel(); - - channel.UpdateBumperSettings(BumperFont.Serif, BumperSelection.AlwaysFirst); - - Assert.Equal(BumperFont.Serif, channel.BumperFont); - Assert.Equal(BumperSelection.AlwaysFirst, channel.BumperSelection); - } - [Theory] [InlineData(-1.0, 2.0, 0.0, 1.0)] [InlineData(0.4, 0.25, 0.4, 0.25)] @@ -88,35 +69,6 @@ public class ChannelTests Assert.Equal(0.0, channel.AnalogFilterStrength); } - [Fact] - public void AddBumperTemplate_AppendsWithNextPosition() - { - var channel = NewChannel(); - - var t1 = channel.AddBumperTemplate("Block 2"); - var t2 = channel.AddBumperTemplate("Block 3"); - - Assert.Equal(1, t1.Position); - Assert.Equal(2, t2.Position); - Assert.False(t1.IsDefault); - Assert.Equal(3, channel.BumperTemplates.Count); - Assert.Equal(t1, channel.FindBumperTemplate(t1.Id)); - Assert.Null(channel.FindBumperTemplate(Guid.NewGuid())); - } - - [Fact] - public void RemoveBumperTemplate_CannotRemoveDefault_CanRemoveOthers() - { - var channel = NewChannel(); - var def = channel.BumperTemplates[0]; - var extra = channel.AddBumperTemplate("Block 2"); - - Assert.False(channel.RemoveBumperTemplate(def.Id)); - Assert.True(channel.RemoveBumperTemplate(extra.Id)); - Assert.False(channel.RemoveBumperTemplate(Guid.NewGuid())); - Assert.Single(channel.BumperTemplates); - } - [Fact] public void UpdateTimeSettings_ClampsOffsetAndDropsNonPositiveNumber() { diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/DomainRecordFactoryTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/DomainRecordFactoryTests.cs index 94f5adc..6e20338 100644 --- a/backend/tests/TeleWave.Domain.Tests/Broadcast/DomainRecordFactoryTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Broadcast/DomainRecordFactoryTests.cs @@ -8,23 +8,20 @@ namespace TeleWave.Domain.Tests.Broadcast; public class DomainRecordFactoryTests { [Fact] - public void BumperAsset_Create_StoresPair() + public void BumperAsset_Create_StoresRenderedText() { - var channel = Guid.NewGuid(); var template = Guid.NewGuid(); var variant = Guid.NewGuid(); - var from = Guid.NewGuid(); - var to = Guid.NewGuid(); + var poster = Guid.NewGuid(); var asset = Guid.NewGuid(); - var b = BumperAsset.Create(channel, template, variant, from, to, "sig", asset); + var b = BumperAsset.Create(template, variant, "sig", "[]", poster, 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); + Assert.Equal("[]", b.RenderedLinesJson); + Assert.Equal(poster, b.PosterShowId); Assert.Equal(asset, b.MediaAssetId); } diff --git a/backend/tests/TeleWave.Domain.Tests/Programming/JunctionFillerTests.cs b/backend/tests/TeleWave.Domain.Tests/Programming/JunctionFillerTests.cs index 49e4460..f3237f9 100644 --- a/backend/tests/TeleWave.Domain.Tests/Programming/JunctionFillerTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Programming/JunctionFillerTests.cs @@ -15,6 +15,12 @@ public class JunctionFillerTests public int Next(int maxExclusive) => 0; } + /// Всегда последний вариант — по нему видно, что жребий вообще участвует в выборе. + private sealed class LastAlways : IRandomSource + { + public int Next(int maxExclusive) => Math.Max(0, maxExclusive - 1); + } + private static PlanningUnit Unit(double minutes, Guid? showId = null, int index = 0) => new(Guid.NewGuid(), TimeSpan.FromMinutes(minutes), showId ?? Guid.NewGuid(), index); @@ -31,16 +37,26 @@ public class JunctionFillerTests double minutesEach = 0.5, bool required = false, int minMinutesBetween = 0, - bool onlyOnElementChange = false + bool onlyOnElementChange = false, + int chance = 100, + PlanningTimeWindow? window = null, + string? choiceKey = null, + int choiceWeight = 1, + int poolSize = 8 ) => new( + Guid.NewGuid(), JunctionElementKind.Ad, - Enumerable.Range(0, 8).Select(_ => Unit(minutesEach)).ToList(), + Enumerable.Range(0, poolSize).Select(_ => Unit(minutesEach)).ToList(), JunctionAmountMode.Count, count, required, onlyOnElementChange, - minMinutesBetween + minMinutesBetween, + chance, + window, + choiceKey, + choiceWeight ); private static PlanningSlot Slot( @@ -68,7 +84,11 @@ public class JunctionFillerTests JunctionAfter: after ); - private static PlanningResult Run(PlanningSlot slot) => + private static PlanningResult Run( + PlanningSlot slot, + IRandomSource? random = null, + int utcOffsetMinutes = 0 + ) => GridPlanner.Plan( new PlanningInput( Guid.NewGuid(), @@ -76,9 +96,10 @@ public class JunctionFillerTests T0.AddHours(5), [slot], [Unit(1)], - SegmentSeconds: 2 + SegmentSeconds: 2, + UtcOffsetMinutes: utcOffsetMinutes ), - new FirstAlways() + random ?? new FirstAlways() ); [Fact] @@ -112,6 +133,7 @@ public class JunctionFillerTests { var element = Series(2, 20, out _); var ads = new PlanningJunctionElement( + Guid.NewGuid(), JunctionElementKind.Ad, Enumerable.Range(0, 6).Select(_ => Unit(0.5)).ToList(), JunctionAmountMode.Duration, @@ -141,6 +163,32 @@ public class JunctionFillerTests Assert.Equal(1, result.Items.Count(i => i.Kind == PlannedItemKind.Ad)); } + [Fact] + public void MinInterval_IsPerElement_NotPerKind() + { + var element = Series(2, 20, out _); + var frequent = Ads(1, minMinutesBetween: 0); + var rare = Ads(1, minMinutesBetween: 600); + + var result = Run( + Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [frequent, rare])) + ); + + // Обе врезки рекламные, но ограничение у каждой своё — общий счётчик на вид склеил бы их. + Assert.Equal(2, result.Items.Count(i => i.Kind == PlannedItemKind.Ad)); + } + + [Fact] + public void Units_RotateBetweenJunctions() + { + var element = Series(3, 20, out _); + var result = Run(Slot(element, 3, between: new PlanningJunction(Guid.NewGuid(), [Ads(1)]))); + + var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList(); + // Иначе каждый рекламный блок начинался бы с одного и того же ролика. + Assert.Equal(2, ads.Select(a => a.MediaAssetId).Distinct().Count()); + } + [Fact] public void OnlyOnElementChange_SkipsWithinSameShow() { @@ -157,17 +205,94 @@ public class JunctionFillerTests Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad); } + [Fact] + public void Chance_Zero_SkipsElement() + { + var element = Series(3, 20, out _); + var result = Run( + Slot(element, 3, between: new PlanningJunction(Guid.NewGuid(), [Ads(1, chance: 0)])) + ); + + Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad); + } + + [Fact] + public void TimeWindow_SkipsOutsideChannelHours() + { + var element = Series(3, 20, out _); + // Стык идёт в 18:20 UTC, канал в UTC+3 — 21:20 по времени канала, окно 06:00–20:00 закрыто. + var window = new PlanningTimeWindow(new TimeOnly(6, 0), new TimeOnly(20, 0)); + var result = Run( + Slot( + element, + 3, + between: new PlanningJunction(Guid.NewGuid(), [Ads(1, window: window)]) + ), + utcOffsetMinutes: 180 + ); + + Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad); + } + + [Fact] + public void Choice_PlacesExactlyOneOfTheFork() + { + var element = Series(2, 20, out _); + var first = Ads(1, choiceKey: "fork"); + var second = new PlanningJunctionElement( + Guid.NewGuid(), + JunctionElementKind.Promo, + [Unit(0.5)], + JunctionAmountMode.Count, + 1, + IsRequired: false, + ChoiceKey: "fork" + ); + + var byFirst = Run( + Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [first, second])), + new FirstAlways() + ); + var byLast = Run( + Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [first, second])), + new LastAlways() + ); + + Assert.Single(byFirst.Items, i => i.Kind == PlannedItemKind.Ad); + Assert.DoesNotContain(byFirst.Items, i => i.Kind == PlannedItemKind.Promo); + Assert.Single(byLast.Items, i => i.Kind == PlannedItemKind.Promo); + Assert.DoesNotContain(byLast.Items, i => i.Kind == PlannedItemKind.Ad); + } + + [Fact] + public void MaxTotal_CapsJunctionLength() + { + var element = Series(2, 20, out _); + var junction = new PlanningJunction( + Guid.NewGuid(), + [Ads(6, minutesEach: 1)], + MaxTotal: TimeSpan.FromMinutes(2) + ); + + var result = Run(Slot(element, 2, between: junction)); + + Assert.Equal(2, result.Items.Count(i => i.Kind == PlannedItemKind.Ad)); + } + [Fact] public void Bumper_ReservesDurationWithEmptyAssetAndShowPair() { var element = Series(2, 20, out var showId); + var variantId = Guid.NewGuid(); var bumper = new PlanningJunctionElement( + Guid.NewGuid(), JunctionElementKind.Bumper, [], JunctionAmountMode.Count, 1, IsRequired: true, BumperTemplateId: Guid.NewGuid(), + BumperVariantId: variantId, BumperDuration: TimeSpan.FromSeconds(10) ); @@ -177,15 +302,17 @@ public class JunctionFillerTests // Ассет подставит рендер после сборки ленты — до неё пара соседей неизвестна. Assert.Equal(Guid.Empty, placed.MediaAssetId); Assert.Equal(showId, placed.ToShowId); + Assert.Equal(variantId, placed.BumperVariantId); Assert.Equal(TimeSpan.FromSeconds(10), placed.EndsAtUtc - placed.StartsAtUtc); } [Fact] - public void RequiredElement_GoesBeforeOptional() + public void OrderFollowsPosition_NotRequiredFlag() { var element = Series(2, 20, out _); var optional = Ads(1, minutesEach: 1); var required = new PlanningJunctionElement( + Guid.NewGuid(), JunctionElementKind.Promo, [Unit(0.5)], JunctionAmountMode.Count, @@ -201,7 +328,34 @@ public class JunctionFillerTests .Items.Where(i => i.Kind is PlannedItemKind.Ad or PlannedItemKind.Promo) .OrderBy(i => i.StartsAtUtc) .ToList(); - Assert.Equal(PlannedItemKind.Promo, junctionItems[0].Kind); + // Обязательность решает, кого выбросить при нехватке времени, а не кто идёт первым. + Assert.Equal(PlannedItemKind.Ad, junctionItems[0].Kind); + Assert.Equal(PlannedItemKind.Promo, junctionItems[1].Kind); + } + + [Fact] + public void RequiredSurvives_WhenOptionalDoesNotFit() + { + var element = Series(2, 20, out _); + var optional = Ads(4, minutesEach: 1); + var required = new PlanningJunctionElement( + Guid.NewGuid(), + JunctionElementKind.Promo, + [Unit(1)], + JunctionAmountMode.Count, + 1, + IsRequired: true + ); + var junction = new PlanningJunction( + Guid.NewGuid(), + [optional, required], + MaxTotal: TimeSpan.FromMinutes(2) + ); + + var result = Run(Slot(element, 2, between: junction)); + + Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Promo); + Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Ad); } [Fact] @@ -245,5 +399,6 @@ public class JunctionFillerTests result.Items.Where(i => i.Kind == PlannedItemKind.Ad), item => Assert.True(item.EndsAtUtc <= anchor.TargetStartUtc) ); + Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Ad); } } diff --git a/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs index 5f47a62..c2e110c 100644 --- a/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs +++ b/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs @@ -251,7 +251,7 @@ public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixtur var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(1)); var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7)); - channel.UpdateSettings(channel.Name, isEnabled: true, bumpersEnabled: false, filler.Id); + channel.UpdateSettings(channel.Name, isEnabled: true, filler.Id); var template = ScheduleTemplate.Create(channel.Id, "Сетка"); var layer = template.AddLayer("Базовый", 10); diff --git a/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs index 426e7a5..a45d774 100644 --- a/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs +++ b/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs @@ -19,7 +19,7 @@ namespace TeleWave.Integration.Tests; public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture) { [SkippableFact] - public async Task Copy_MovesLayersSlotsAndJunctions_AndReplacesTargetGrid() + public async Task Copy_MovesLayersAndSlots_AndReplacesTargetGrid() { Skip.IfNot(fixture.Available, "Docker недоступен"); @@ -36,7 +36,6 @@ public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture) Assert.Equal(1, result.Value.Layers); Assert.Equal(1, result.Value.Slots); - Assert.Equal(1, result.Value.Junctions); await using var verify = fixture.CreateContext(); var target = verify.Channels.Single(c => c.Id == targetId); @@ -54,13 +53,15 @@ public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture) Assert.Equal(groupId, slot.GroupId); Assert.Single(verify.Groups.Where(g => g.Id == groupId)); - // Стык переехал своей копией, и слот ссылается на неё, а не на стык чужого канала. - var junction = verify.JunctionTemplates.Single(j => j.ChannelId == targetId); - Assert.Equal(junction.Id, slot.JunctionAfterId); - Assert.NotEqual( - verify.JunctionTemplates.Single(j => j.ChannelId == sourceId).Id, - slot.JunctionAfterId - ); + // Стыки общие — копия ссылается на тот же стык, а не заводит свой. + var sourceSlot = verify + .ScheduleTemplates.Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .Single(t => t.ChannelId == sourceId) + .Layers.SelectMany(l => l.Slots) + .Single(); + Assert.Equal(sourceSlot.JunctionAfterId, slot.JunctionAfterId); + Assert.NotNull(slot.JunctionAfterId); } [Fact] @@ -148,9 +149,22 @@ public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture) var source = Channel.Create($"Источник {suffix}", $"src-{suffix}", DateTimeOffset.UtcNow); var target = Channel.Create($"Приёмник {suffix}", $"dst-{suffix}", DateTimeOffset.UtcNow); - var junction = JunctionTemplate.Create(source.Id, "Прайм"); + var junction = JunctionTemplate.Create($"Прайм {suffix}"); var ad = junction.AddElement(JunctionElementKind.Ad); - ad.Update(JunctionElementKind.Ad, group.Id, null, JunctionAmountMode.Count, 2, true, null); + ad.Update( + new JunctionElementSettings( + JunctionElementKind.Ad, + group.Id, + null, + null, + JunctionAmountMode.Count, + 2, + true, + null, + JunctionElement.DefaultChoiceWeight, + null + ) + ); db.JunctionTemplates.Add(junction); var sourceTemplate = ScheduleTemplate.Create(source.Id, "Сетка источника"); diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index d89b631..0aa2b57 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -410,32 +410,60 @@ SlotState ``` JunctionTemplate id - channelId name — «Прайм», «День», «Ночь», «Внутри блока» + maxTotalSeconds int? — потолок длины стыка целиком elements [JunctionElement] JunctionElement - position int + position int — порядок показа в эфире kind enum — ad | promo | bumper | filler groupId uuid? — для ad/promo/filler: откуда брать bumperTemplateId uuid? — для bumper: какой блок заставки + bumperVariantId uuid? — для bumper: конкретный подблок; null — по триггеру и весам amountMode enum — count | duration amountValue int — единиц либо минут isRequired bool — нельзя выбросить при нехватке времени + choiceKey string? — метка развилки: из врезок с одной меткой играет одна + choiceWeight int — вес внутри развилки conditions jsonb ``` +**Стык общий для всех каналов**, как группа. Своя цепочка врезок у каждого канала — это ровно та +ошибка, из-за которой копирование сетки сопоставляло заставки по имени и рапортовало о потерянных +ссылках. «Рекламный блок на две минуты с заставкой в конце» — такой же переиспользуемый ресурс, +как «Боевики 90-х»; канальным остаётся только выбор, какой стык поставить в слот. + +**Порядок показа — это `position`, и только он.** Обязательность (`isRequired`) участвует +исключительно в отборе «кого выбросить, если до якоря не влезает»: сначала считается, что помещается, +потом уцелевшее играет в исходном порядке. Иначе галочка «обязательно» молча поднимала бы рекламу +перед заставкой, и цепочка в редакторе перестала бы соответствовать эфиру. + ```json { "onlyOnElementChange": true, - "minMinutesSinceSameKind": 30, - "dayparts": ["prime", "day"] + "minMinutesBetween": 30, + "dayparts": ["prime", "day"], + "timeWindow": { "from": "20:00", "to": "23:00" }, + "chance": 40 } ``` Условия — **структурированные поля, а не выражения-строки**: выражения потребовали бы парсера, валидации и отдельного UI, а покрывают те же три-четыре реальных случая. +`chance` — вероятность показа врезки в процентах, самый дешёвый источник разнообразия: «в 40% +стыков ставим анонс». `minMinutesBetween` считается **по конкретной врезке**, а не по её виду: +две рекламные врезки в разных стыках — это разные ограничения, общий счётчик на вид склеил бы их. + +**Развилка** (`choiceKey`) — несколько врезок с одной меткой, из которых играет одна, выбранная по +весам: «иногда заставка, иногда короткий рекламный блок». Врезки одной развилки обязаны занимать +непрерывный отрезок позиций (иначе неясно, куда встаёт выбранная), обязательность и условия +относятся к развилке целиком. + +Оба жребия — `chance` и выбор внутри развилки — берутся из seed генерации (4.4), а не из живого +`Random`. Иначе пересборка хвоста тасовала бы врезки на каждое применение, и диф из 6.6 показывал бы +изменения там, где ничего не менялось. + **Рекламные ролики — это тоже группы.** Ролик регистрируется как `Show(Kind = Interstitial)` — новое значение `ShowKind` — и складывается в группу. Это убирает `ChannelAd` и бесплатно даёт рекламе всё, что есть у контента: остывание (не крутить один ролик дважды подряд), разные группы на утро и прайм, @@ -461,17 +489,100 @@ JunctionElement и коллекция при этом всегда входит целиком — блок не разрезается. Для смешанных групп правильный режим — `duration`. -**Заставки-переходы переиспользуются как есть.** `BumperTemplate` / `BumperTextVariant`, рендер через -ffmpeg с кэшем по паре шоу, `ScheduleBumperResolver` — вся эта подсистема не меняется, меняется только -точка вызова: раньше вероятности и минимальный интервал жили на канале, теперь это `conditions` -элемента стыка. Настройки `Channel.BumperSelection` / `NextBumperIndex` / `BumperMinIntervalMinutes` / -`BumperShowChangeChance` / `BumperEpisodeChangeChance` уходят, `BumperFont` остаётся общим для канала. +**Заставка-переход — это врезка стыка**, а не настройка канала: `Channel.BumperSelection` / +`NextBumperIndex` / `BumperMinIntervalMinutes` / `BumperShowChangeChance` / +`BumperEpisodeChangeChance` / `BumpersEnabled` уходят с канала, вероятность и интервал выражаются +`conditions`, а выбор подблока — полем `bumperVariantId` врезки. Сама подсистема заставок описана +в 3.7.1. Важно для пайплайна: ассет заставки зависит от **пары соседей**, а пара известна только после наполнения слотов. Значит рендер — обязательный шаг между сборкой ленты и записью в БД, он долгий и может упасть. В предпросмотре заставка показывается плейсхолдером известной длины, реальный рендер — только при применении. +### 3.7.1. Заставки: блок, подблок, текст + +Блок заставки (`BumperTemplate`) — это оформление и звук: палитра, фон-картинка, шрифт, джингл. +Подблок (`BumperTextVariant`) — то, что на нём написано, плюс правило показа и вес. Как и стык, +**оба общие для всех каналов**: файлы звука и фона и так лежат по `templateId`, канал в них +не участвовал никогда. + +``` +BumperTemplate — оформление и звук, общий + id + name + font enum — sans | serif (было на канале) + backgroundColor, backgroundColor2, accentColor, textColor + backgroundImageId uuid? + audioExtension, audioDurationSeconds + revision int — версия файлов; входит в сигнатуру рендера + variants [BumperTextVariant] + +BumperTextVariant + position, name + trigger enum — onShowChange | betweenEpisodes | both + weight int + background enum — template | nextPoster | nowPoster + lines [BumperLine] + +BumperLine + position int + style enum — label | title | caption + color enum — accent | text + text string — с плейсхолдерами +``` + +Шрифт переезжает с канала на блок не ради симметрии: он не входил в сигнатуру кэша, и его смена +не пересобирала уже отрендеренные заставки. На общем блоке эта дыра стала бы видимой сразу — два +канала с разным шрифтом делили бы один ассет. + +**Текст — список строк, а не два фиксированных режима.** Прежние `NowNext` (две подписи + названия +шоу) и `Free` (две произвольные строки) схлопываются: «СЕЙЧАС / {now.title} / ДАЛЕЕ / {next.title}» — +это просто четыре строки, и оно же превращается в «ДАЛЕЕ В 21:30 / {next.title}» правкой текста, +а не переключением режима. Готовые наборы строк вставляются пресетами — это данные редактора, +не сущность. + +Источник фона становится явным полем подблока. Раньше постер шоу подставлялся молча и только +в режиме `NowNext`; при свободных строках такой связи взяться неоткуда. + +**Плейсхолдеры** подставляются в момент планирования — там уже известны канал, пара соседей +и точное время врезки: + +| | | +|---|---| +| `{channel}` `{channel.number}` | канал | +| `{now.title}` `{next.title}` | шоу до и после стыка | +| `{now.episode}` `{next.episode}` | «с5э12» либо название серии | +| `{next.year}` `{next.genre}` | из метаданных | +| `{next.time}` | во сколько начнётся следующая программа | +| `{time}` `{date}` `{weekday}` | момент показа заставки во времени канала | +| `{slot}` | название слота — «Вечернее кино» | + +Неизвестное значение подставляется пустым, строка со схлопнувшимися пробелами не рисуется. +Неизвестный плейсхолдер — **ошибка валидации при сохранении**, а не сюрприз в эфире. + +Цена гибкости — кэш: `{time}` и `{date}` делают каждый показ уникальным, а рендер это ffmpeg +на несколько секунд, помноженный на недельный горизонт. Запрещать нечего, но редактор обязан +предупреждать у таких полей, а предпросмотр — показывать, сколько новых рендеров потребуется. +`{next.time}` при работающих якорях и `snapToMinutes` даёт круглые времена и кэшируется нормально. + +**Кэш ассетов ключуется содержимым, а не ссылками.** + +``` +BumperAsset + id + signature — sha256(templateId | revision | variantId | подставленные строки | фон | posterShowId) + templateId, variantId — оформление рендера и чистка осиротевших + renderedLinesJson — уже подставленный текст + posterShowId uuid? — если фоном стоит постер шоу + mediaAssetId +``` + +`channelId` и пара шоу из ключа уходят. Так одинаковая заставка на трёх каналах рендерится один раз, +а `{channel}` в тексте разводит их по разным сигнатурам сам собой — без единого спецправила. +Подставленный текст приходится хранить: время показа из ссылок задним числом не восстанавливается, +а фоновый рендерер запускается уже после того, как лента записана. + ### 3.8. Правила Делятся на два вида по способу применения — это принципиально, потому что определяет, ломается ли @@ -733,15 +844,39 @@ seed = hash(channelId, date, slotId, occurrenceInDay) ### 6.3. Редактор стыка +Стыки и блоки заставок общие, поэтому живут не на экране канала, а своим разделом админки — рядом +с группами и роликами. На канале остаётся выбор: какой стык поставить в слот и какой считать +стыком по умолчанию, с теми же секциями списка, что у групп («используется в этом канале» → +«все» + поиск) и счётчиком «используется в 3 каналах» у каждого. + Горизонтальная цепочка с перетаскиванием: ``` -[КОНЕЦ] → [Реклама ×2] → [Заставка] → [Промо ×1] → [НАЧАЛО] - обязательно если смена если прайм +[КОНЕЦ] → [Реклама ×2] → [Заставка] → ⌥[Промо ×1 | Реклама 30с] → [НАЧАЛО] + обязательно если смена развилка 70/30 ``` -Под цепочкой — линейка суммарной длительности. Клик по элементу — параметры (тип, группа, количество, -обязательность, условия). +Под цепочкой — линейка суммарной длительности с потолком стыка. Клик по элементу — параметры (тип, +источник, количество, обязательность, условия). Развилка рисуется одной стопкой: врезки внутри +переставляются вместе, а веса показаны процентами прямо на цепочке — иначе «иногда так, иногда +эдак» невозможно прочитать, не открывая каждый элемент. + +Перетаскивание элемента внутрь развилки и наружу — тем же drag & drop, что и переупорядочивание: +отдельной кнопки «сгруппировать» нет, метка развилки проставляется самим перетаскиванием. + +### 6.3.1. Редактор заставки + +Двухпанельный: слева строки, справа — постоянный предпросмотр кадра, который перерисовывается +на каждый ввод (то же оформление, что даёт ffmpeg, но нарисованное в браузере — ждать рендера +ради проверки опечатки нельзя). Полный ffmpeg-рендер остаётся кнопкой и играется плеером. + +- строка — это `[стиль ▾] [цвет ▾] [текст]`, порядок перетаскиванием, добавление кнопкой; +- палитра плейсхолдеров под полем: клик вставляет в позицию курсора, наведение показывает пример + подстановки; в самом поле плейсхолдеры подсвечены; +- пресеты («Сейчас / Далее», «Далее в …», «Логотип канала») — кнопка, заполняющая строки; +- у полей с `{time}`/`{date}` — предупреждение о том, что кэш перестаёт работать; +- образцы подстановки берутся из реального канала, выбранного тут же: заставка общая, но + посмотреть её надо глазами конкретного канала. ### 6.4. Предпросмотр @@ -980,9 +1115,10 @@ drag & drop в календаре, переключатель даты. **Меняется:** -- `Channel` худеет до `id / name / slug / isEnabled / epochUtc / fillerAssetId / bumperFont` +- `Channel` худеет до `id / name / slug / isEnabled / epochUtc / fillerAssetId` плюс новые `utcOffsetMinutes`, `dayStartTime`, `templateId`, `number` и настройки зрительской - части (`logoImageId`, `showClock`, `analogFilterStrength`) — см. 6.8. + части (`logoImageId`, `showClock`, `analogFilterStrength`) — см. 6.8. Заставок на канале + не остаётся вовсе: блоки общие, шрифт переехал на блок, условия показа — в стык (3.7.1). - `ScheduleEntry` — новые `collectionId`, `slotId`, `trace`, расширенный `kind`. - `SchedulerOptions.RetentionHours` → `RetentionDays` (дефолт 90) — история нужна для остывания и для слотов `repeat`. diff --git a/frontend/src/features/admin/bumpers/BumpersPanel.tsx b/frontend/src/features/admin/bumpers/BumpersPanel.tsx new file mode 100644 index 0000000..1a1427f --- /dev/null +++ b/frontend/src/features/admin/bumpers/BumpersPanel.tsx @@ -0,0 +1,111 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Plus } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { listChannels } from '@/features/admin/channels/api' +import { qk } from '@/shared/api/query-keys' +import { useApiError } from '@/shared/lib/use-api-error' +import { Button } from '@/shared/ui/button' +import { Card, CardContent } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { createBumperTemplate, listBumperTemplates } from './api' +import { BumperTemplateEditor } from './components/BumperTemplateEditor' + +/** + * Блоки заставок — общие для всех каналов, как группы. Канал выбирается тут же и только для + * образцов подстановки: посмотреть общий блок надо глазами конкретного канала, иначе + * `{channel}` не на что заменить. + */ +export function BumpersPanel() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const onError = useApiError() + const [name, setName] = useState('') + const [channelId, setChannelId] = useState('') + + const { data: templates } = useQuery({ + queryKey: qk.bumpers.all, + queryFn: listBumperTemplates, + }) + const { data: channels } = useQuery({ queryKey: qk.channels.all, queryFn: listChannels }) + + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: qk.bumpers.all }) + } + + const create = useMutation({ + mutationFn: () => createBumperTemplate(name.trim()), + onSuccess: () => { + setName('') + invalidate() + }, + onError, + }) + + return ( +
+ + +

{t('admin.bumpers.hint')}

+ +
+
+ + + + {t('admin.bumpers.sampleChannelHint')} + +
+ +
+
+ + setName(e.target.value)} + /> +
+ +
+
+
+
+ +
+ {(templates ?? []).map((template) => ( + + ))} + {templates?.length === 0 && ( +

{t('admin.bumpers.empty')}

+ )} +
+
+ ) +} diff --git a/frontend/src/features/admin/bumpers/api.ts b/frontend/src/features/admin/bumpers/api.ts new file mode 100644 index 0000000..6cc3d7c --- /dev/null +++ b/frontend/src/features/admin/bumpers/api.ts @@ -0,0 +1,122 @@ +import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client' +import type { + BumperBackground, + BumperFont, + BumperLineDto, + BumperTemplateDto, + BumperTrigger, + CreatedIdResponse, +} from '@/shared/api/types' + +/** Блоки заставок общие для всех каналов — свой раздел, а не подраздел канала. */ +export function listBumperTemplates() { + return apiRequest('/admin/bumpers') +} + +export function createBumperTemplate(name: string) { + return apiRequest('/admin/bumpers', { method: 'POST', body: { name } }) +} + +export type BumperStyleBody = { + name: string + font: BumperFont + backgroundColor: string + backgroundColor2: string + accentColor: string + textColor: string +} + +export function updateBumperTemplate(templateId: string, body: BumperStyleBody) { + return apiRequest(`/admin/bumpers/${templateId}`, { method: 'PUT', body }) +} + +export function deleteBumperTemplate(templateId: string) { + return apiRequest(`/admin/bumpers/${templateId}`, { method: 'DELETE' }) +} + +export type BumperVariantBody = { + name: string + trigger: BumperTrigger + background: BumperBackground + weight: number + lines: BumperLineDto[] +} + +export function addBumperVariant(templateId: string, name: string) { + return apiRequest(`/admin/bumpers/${templateId}/variants`, { + method: 'POST', + body: { name }, + }) +} + +export function updateBumperVariant( + templateId: string, + variantId: string, + body: BumperVariantBody, +) { + return apiRequest(`/admin/bumpers/${templateId}/variants/${variantId}`, { + method: 'PUT', + body, + }) +} + +export function removeBumperVariant(templateId: string, variantId: string) { + return apiRequest(`/admin/bumpers/${templateId}/variants/${variantId}`, { + method: 'DELETE', + }) +} + +export function setBumperBackground(templateId: string, imageId: string) { + return apiRequest(`/admin/bumpers/${templateId}/background`, { + method: 'PUT', + body: { imageId }, + }) +} + +export function clearBumperBackground(templateId: string) { + return apiRequest(`/admin/bumpers/${templateId}/background`, { method: 'DELETE' }) +} + +export function clearBumperAudio(templateId: string) { + return apiRequest(`/admin/bumpers/${templateId}/audio`, { method: 'DELETE' }) +} + +/** + * Синхронно рендерит примеры всех подблоков. Канал нужен только для образцов подстановки: блок + * общий, но посмотреть его надо глазами конкретного канала — иначе `{channel}` не на что заменить. + */ +export function renderBumperPreviews(templateId: string, channelId: string | null) { + const query = channelId ? `?channelId=${channelId}` : '' + return apiRequest(`/admin/bumpers/${templateId}/preview${query}`, { method: 'POST' }) +} + +export function bumperPreviewPlaylistUrl(templateId: string, variantId: string) { + return `/api/admin/bumpers/${templateId}/preview/${variantId}/index.m3u8` +} + +/** Загрузка звука блока: тело — файл, имя — в query (как в uploadMedia). */ +export function uploadBumperAudio(templateId: string, file: File): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + const query = new URLSearchParams({ fileName: file.name }) + xhr.open('PUT', `/api/admin/bumpers/${templateId}/audio?${query.toString()}`) + const token = getAccessToken() + if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`) + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve() + } else { + let detail = `HTTP ${xhr.status}` + try { + const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string } + detail = problem.detail ?? problem.title ?? detail + } catch { + /* пусто */ + } + reject(new HttpError({ detail }, xhr.status)) + } + } + xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0)) + xhr.send(file) + }) +} diff --git a/frontend/src/features/admin/bumpers/components/BumperFileFields.tsx b/frontend/src/features/admin/bumpers/components/BumperFileFields.tsx new file mode 100644 index 0000000..ff9641d --- /dev/null +++ b/frontend/src/features/admin/bumpers/components/BumperFileFields.tsx @@ -0,0 +1,150 @@ +import { useMutation } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { imageUrl } from '@/features/admin/images/api' +import { ImageGallery } from '@/features/admin/images/ImageGallery' +import { Button } from '@/shared/ui/button' +import { Label } from '@/shared/ui/label' +import { + clearBumperAudio, + clearBumperBackground, + setBumperBackground, + uploadBumperAudio, +} from '../api' + +/** Звук блока: его длина и задаёт длительность заставки, без него синтезируется джингл. */ +export function BumperAudioField({ + templateId, + hasAudio, + onChanged, + onError, +}: Readonly<{ + templateId: string + hasAudio: boolean + onChanged: () => void + onError: (e: unknown) => void +}>) { + const { t } = useTranslation() + const inputId = `bumper-audio-${templateId}` + + const upload = useMutation({ + mutationFn: (file: File) => uploadBumperAudio(templateId, file), + onSuccess: onChanged, + onError, + }) + const clear = useMutation({ + mutationFn: () => clearBumperAudio(templateId), + onSuccess: onChanged, + onError, + }) + const busy = upload.isPending || clear.isPending + + return ( +
+ + {t('admin.bumpers.audioHint')} +
+ { + const file = e.target.files?.[0] + if (file) upload.mutate(file) + e.target.value = '' + }} + /> + + {hasAudio && ( + + )} +
+
+ ) +} + +/** Фон блока — из общего реестра изображений, как и всё остальное с картинками. */ +export function BumperBackgroundField({ + templateId, + backgroundImageId, + onChanged, + onError, +}: Readonly<{ + templateId: string + backgroundImageId: string | null + onChanged: () => void + onError: (e: unknown) => void +}>) { + const { t } = useTranslation() + const [galleryOpen, setGalleryOpen] = useState(false) + + const set = useMutation({ + mutationFn: (imageId: string) => setBumperBackground(templateId, imageId), + onSuccess: onChanged, + onError, + }) + const clear = useMutation({ + mutationFn: () => clearBumperBackground(templateId), + onSuccess: onChanged, + onError, + }) + + return ( +
+ + + {t('admin.bumpers.backgroundFieldHint')} + +
+ {backgroundImageId && ( + + )} + + {backgroundImageId && ( + + )} +
+ set.mutate(img.id)} + /> +
+ ) +} diff --git a/frontend/src/features/admin/bumpers/components/BumperFramePreview.tsx b/frontend/src/features/admin/bumpers/components/BumperFramePreview.tsx new file mode 100644 index 0000000..d4d77c2 --- /dev/null +++ b/frontend/src/features/admin/bumpers/components/BumperFramePreview.tsx @@ -0,0 +1,66 @@ +import { imageUrl } from '@/features/admin/images/api' +import { cssColor } from '@/features/admin/channels/lib/format' +import type { BumperLineDto, BumperTemplateDto } from '@/shared/api/types' +import { resolveSample } from '../placeholders' + +/** Размеры строк в долях высоты кадра — те же пропорции, что и в ffmpeg-раскладке. */ +const SIZE = { Title: 0.1, Label: 0.045, Caption: 0.036 } as const + +/** + * Кадр заставки, нарисованный в браузере. Не замена ffmpeg-рендеру, а то, без чего редактор + * не работает: ждать несколько секунд ffmpeg ради проверки опечатки нельзя, а плейсхолдеры + * в поле ввода выглядят кодом, а не кадром. + */ +export function BumperFramePreview({ + template, + lines, + showPoster, + height = 200, +}: Readonly<{ + template: BumperTemplateDto + lines: BumperLineDto[] + showPoster?: boolean + height?: number +}>) { + const visible = lines.map((l) => ({ ...l, text: resolveSample(l.text) })).filter((l) => l.text) + + const background = template.backgroundImageId + ? { backgroundImage: `url(${imageUrl(template.backgroundImageId)})`, backgroundSize: 'cover' } + : { + backgroundImage: `linear-gradient(135deg, ${cssColor(template.backgroundColor)}, ${cssColor( + template.backgroundColor2, + )})`, + } + + return ( +
+ {/* Постер шоу рендер размывает и затемняет — здесь достаточно затемнения-заглушки. */} + {showPoster && !template.backgroundImageId && ( +
+ )} + {visible.length === 0 && } + {visible.map((line, index) => ( + + {line.text} + + ))} +
+ ) +} diff --git a/frontend/src/features/admin/bumpers/components/BumperLinesEditor.tsx b/frontend/src/features/admin/bumpers/components/BumperLinesEditor.tsx new file mode 100644 index 0000000..e2c26e1 --- /dev/null +++ b/frontend/src/features/admin/bumpers/components/BumperLinesEditor.tsx @@ -0,0 +1,205 @@ +import { GripVertical, Plus, Trash2 } from 'lucide-react' +import { useRef } from 'react' +import { useTranslation } from 'react-i18next' +import type { BumperLineColor, BumperLineDto, BumperLineStyle } from '@/shared/api/types' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { cn } from '@/shared/lib/cn' +import { hasVolatileToken, PLACEHOLDERS, resolveSample, unknownTokens } from '../placeholders' + +const STYLES: BumperLineStyle[] = ['Label', 'Title', 'Caption'] +const COLORS: BumperLineColor[] = ['Accent', 'Text'] +const MAX_LINES = 6 + +/** Готовые наборы строк. Данные редактора, а не сущность: пресет просто заполняет список. */ +function presets(t: (key: string) => string): { key: string; lines: BumperLineDto[] }[] { + const label = (text: string): BumperLineDto => ({ + style: 'Label', + color: 'Accent', + text, + }) + const title = (text: string): BumperLineDto => ({ style: 'Title', color: 'Text', text }) + return [ + { + key: 'nowNext', + lines: [ + label(t('admin.bumpers.presetNow')), + title('{now.title}'), + label(t('admin.bumpers.presetNext')), + title('{next.title}'), + ], + }, + { + key: 'nextAt', + lines: [label(`${t('admin.bumpers.presetNextAt')} {next.time}`), title('{next.title}')], + }, + { + key: 'channel', + lines: [title('{channel}'), { style: 'Caption', color: 'Accent', text: '{weekday}, {time}' }], + }, + ] +} + +/** + * Строки заставки: порядок перетаскиванием, палитра плейсхолдеров под фокусированным полем, + * пресеты кнопкой. Ошибка ввода (незнакомый плейсхолдер) видна сразу — сервер её всё равно + * отвергнет, но узнавать об этом при сохранении неудобно. + */ +export function BumperLinesEditor({ + lines, + onChange, +}: Readonly<{ + lines: BumperLineDto[] + onChange: (lines: BumperLineDto[]) => void +}>) { + const { t } = useTranslation() + const focused = useRef(null) + const inputs = useRef<(HTMLInputElement | null)[]>([]) + const dragged = useRef(null) + + const patch = (index: number, part: Partial) => + onChange(lines.map((line, i) => (i === index ? { ...line, ...part } : line))) + + const add = () => { + const line: BumperLineDto = { style: 'Title', color: 'Text', text: '' } + onChange([...lines, line].slice(0, MAX_LINES)) + } + + const remove = (index: number) => onChange(lines.filter((_, i) => i !== index)) + + const move = (from: number, to: number) => { + if (from === to) return + const next = [...lines] + const [line] = next.splice(from, 1) + next.splice(to, 0, line) + onChange(next) + } + + /** Вставка плейсхолдера в позицию курсора — иначе его пришлось бы допечатывать руками. */ + const insert = (token: string) => { + const index = focused.current ?? lines.length - 1 + if (index < 0) return + const input = inputs.current[index] + const text = lines[index].text + const at = input?.selectionStart ?? text.length + patch(index, { text: `${text.slice(0, at)}{${token}}${text.slice(at)}` }) + requestAnimationFrame(() => { + input?.focus() + const caret = at + token.length + 2 + input?.setSelectionRange(caret, caret) + }) + } + + return ( +
+
+ + {t('admin.bumpers.presets')} + + {presets(t).map((preset) => ( + + ))} +
+ + {lines.map((line, index) => { + const unknown = unknownTokens(line.text) + return ( +
{ + dragged.current = index + }} + onDragOver={(e) => e.preventDefault()} + onDrop={() => { + if (dragged.current !== null) move(dragged.current, index) + dragged.current = null + }} + className="flex flex-wrap items-center gap-2 rounded-md border border-border bg-background/40 p-2" + > + + + + { + inputs.current[index] = el + }} + className={cn('h-8 min-w-40 flex-1', unknown.length > 0 && 'border-destructive')} + value={line.text} + maxLength={120} + onFocus={() => { + focused.current = index + }} + onChange={(e) => patch(index, { text: e.target.value })} + /> + +
+ {unknown.length > 0 ? ( + + {t('admin.bumpers.unknownPlaceholder', { tokens: unknown.join(', ') })} + + ) : ( + + → {resolveSample(line.text) || '—'} + {hasVolatileToken(line.text) && ( + {t('admin.bumpers.volatileHint')} + )} + + )} +
+
+ ) + })} + +
+ +
+ + {/* Палитра: клик вставляет плейсхолдер в фокусированное поле, подсказка показывает образец. */} +
+ {PLACEHOLDERS.map((placeholder) => ( + + ))} +
+
+ ) +} diff --git a/frontend/src/features/admin/channels/components/BumperPreviewPlayer.tsx b/frontend/src/features/admin/bumpers/components/BumperPreviewPlayer.tsx similarity index 67% rename from frontend/src/features/admin/channels/components/BumperPreviewPlayer.tsx rename to frontend/src/features/admin/bumpers/components/BumperPreviewPlayer.tsx index 6bee567..a1bf4fe 100644 --- a/frontend/src/features/admin/channels/components/BumperPreviewPlayer.tsx +++ b/frontend/src/features/admin/bumpers/components/BumperPreviewPlayer.tsx @@ -6,14 +6,18 @@ import { Button } from '@/shared/ui/button' import { HlsVideo } from '@/shared/ui/hls-video' import { bumperPreviewPlaylistUrl, renderBumperPreviews } from '../api' +/** + * Настоящий ffmpeg-рендер примера. Кнопкой, а не автоматически: он занимает несколько секунд, + * а на каждый ввод в поле его гонять нельзя — для этого есть кадр-предпросмотр в браузере. + */ export function BumperPreviewPlayer({ - channelId, templateId, + channelId, variants, onError, }: Readonly<{ - channelId: string templateId: string + channelId: string | null variants: BumperTextVariantDto[] onError: (e: unknown) => void }>) { @@ -22,7 +26,7 @@ export function BumperPreviewPlayer({ const [bust, setBust] = useState(0) const render = useMutation({ - mutationFn: () => renderBumperPreviews(channelId, templateId), + mutationFn: () => renderBumperPreviews(templateId, channelId), onSuccess: () => { setBust(Date.now()) setReady(true) @@ -39,13 +43,9 @@ export function BumperPreviewPlayer({ disabled={render.isPending} onClick={() => render.mutate()} > - {render.isPending - ? t('admin.channels.bumperPreviewRendering') - : t('admin.channels.bumperPreview')} + {render.isPending ? t('admin.bumpers.rendering') : t('admin.bumpers.render')} - - {t('admin.channels.bumperPreviewHint')} - + {t('admin.bumpers.renderHint')}
{ready && (
@@ -54,9 +54,7 @@ export function BumperPreviewPlayer({ .map((v) => (
{v.name} - +
))}
diff --git a/frontend/src/features/admin/channels/components/BumperTemplateEditor.tsx b/frontend/src/features/admin/bumpers/components/BumperTemplateEditor.tsx similarity index 55% rename from frontend/src/features/admin/channels/components/BumperTemplateEditor.tsx rename to frontend/src/features/admin/bumpers/components/BumperTemplateEditor.tsx index 761d7ac..5d45f85 100644 --- a/frontend/src/features/admin/channels/components/BumperTemplateEditor.tsx +++ b/frontend/src/features/admin/bumpers/components/BumperTemplateEditor.tsx @@ -1,41 +1,37 @@ import { useMutation } from '@tanstack/react-query' +import { ChevronDown } from 'lucide-react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { ChevronDown } from 'lucide-react' -import type { BumperTemplateDto } from '@/shared/api/types' +import { cssColor } from '@/features/admin/channels/lib/format' +import type { BumperFont, BumperTemplateDto } from '@/shared/api/types' import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { toast } from '@/shared/ui/toast-store' -import { - addBumperVariant, - clearBumperTemplateAudio, - removeBumperTemplate, - updateBumperTemplate, - uploadBumperTemplateAudio, -} from '../api' -import { cssColor } from '../lib/format' -import { BumperBackgroundField } from './BumperBackgroundField' -import { BumperFileUpload } from './BumperFileUpload' +import { addBumperVariant, deleteBumperTemplate, updateBumperTemplate } from '../api' +import { BumperAudioField, BumperBackgroundField } from './BumperFileFields' import { BumperPreviewPlayer } from './BumperPreviewPlayer' import { BumperVariantEditor } from './BumperVariantEditor' export function BumperTemplateEditor({ - channelId, template, + channelId, onChanged, onError, }: Readonly<{ - channelId: string template: BumperTemplateDto + /** Канал, чьими глазами смотрим на образцы подстановки; null — общие заглушки. */ + channelId: string | null onChanged: () => void onError: (e: unknown) => void }>) { const { t } = useTranslation() const [open, setOpen] = useState(false) - const [name, setName] = useState(template.name) - const [colors, setColors] = useState({ + const [style, setStyle] = useState({ + name: template.name, + font: template.font, backgroundColor: template.backgroundColor, backgroundColor2: template.backgroundColor2, accentColor: template.accentColor, @@ -43,8 +39,9 @@ export function BumperTemplateEditor({ }) useEffect(() => { - setName(template.name) - setColors({ + setStyle({ + name: template.name, + font: template.font, backgroundColor: template.backgroundColor, backgroundColor2: template.backgroundColor2, accentColor: template.accentColor, @@ -53,8 +50,7 @@ export function BumperTemplateEditor({ }, [template]) const save = useMutation({ - mutationFn: () => - updateBumperTemplate(channelId, template.id, { name: name.trim(), ...colors }), + mutationFn: () => updateBumperTemplate(template.id, { ...style, name: style.name.trim() }), onSuccess: () => { toast.success(t('settings.saved')) onChanged() @@ -62,28 +58,25 @@ export function BumperTemplateEditor({ onError, }) const remove = useMutation({ - mutationFn: () => removeBumperTemplate(channelId, template.id), + mutationFn: () => deleteBumperTemplate(template.id), onSuccess: onChanged, onError, }) const addVariant = useMutation({ - mutationFn: () => addBumperVariant(channelId, template.id, ''), + mutationFn: () => addBumperVariant(template.id, t('admin.bumpers.newVariantName')), onSuccess: onChanged, onError, }) - const colorFields: { key: keyof typeof colors; label: string }[] = [ - { key: 'backgroundColor', label: t('admin.channels.bumperBg') }, - { key: 'backgroundColor2', label: t('admin.channels.bumperBg2') }, - { key: 'accentColor', label: t('admin.channels.bumperAccent') }, - { key: 'textColor', label: t('admin.channels.bumperText') }, - ] + const colorFields = [ + { key: 'backgroundColor', label: t('admin.bumpers.colorBg') }, + { key: 'backgroundColor2', label: t('admin.bumpers.colorBg2') }, + { key: 'accentColor', label: t('admin.bumpers.colorAccent') }, + { key: 'textColor', label: t('admin.bumpers.colorText') }, + ] as const return (
- {/* Сворачивание висит на кнопке, а не на всей строке: кликабельный div недоступен с клавиатуры. - Кнопка удаления при этом вынесена наружу — вложенная кнопка внутри кнопки недопустима, - и заодно ей больше не нужен stopPropagation. */}
- {!template.isDefault && ( - - )} +
{open && ( <>
- - setName(e.target.value)} /> + + setStyle((s) => ({ ...s, name: e.target.value }))} + /> +
+
+ +
{colorFields.map(({ key, label }) => (
@@ -127,11 +146,11 @@ export function BumperTemplateEditor({
setColors((c) => ({ ...c, [key]: e.target.value }))} + value={style[key]} + onChange={(e) => setStyle((s) => ({ ...s, [key]: e.target.value }))} />
@@ -139,21 +158,13 @@ export function BumperTemplateEditor({
-
- {/* Подблоки (текст-варианты) */} +
+ +
+
-

{t('admin.channels.bumperVariants')}

-

- {t('admin.channels.bumperVariantsHint')} -

+

{t('admin.bumpers.variants')}

+

{t('admin.bumpers.variantsHint')}

{[...template.variants] .sort((a, b) => a.position - b.position) .map((variant) => ( 1} onChanged={onChanged} @@ -187,25 +200,19 @@ export function BumperTemplateEditor({ disabled={addVariant.isPending} onClick={() => addVariant.mutate()} > - {t('admin.channels.bumperAddVariant')} + {t('admin.bumpers.addVariant')}
- -
- -
)} diff --git a/frontend/src/features/admin/bumpers/components/BumperVariantEditor.tsx b/frontend/src/features/admin/bumpers/components/BumperVariantEditor.tsx new file mode 100644 index 0000000..c26f28b --- /dev/null +++ b/frontend/src/features/admin/bumpers/components/BumperVariantEditor.tsx @@ -0,0 +1,183 @@ +import { useMutation } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { + BumperBackground, + BumperLineDto, + BumperTemplateDto, + BumperTextVariantDto, + BumperTrigger, +} from '@/shared/api/types' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' +import { toast } from '@/shared/ui/toast-store' +import { removeBumperVariant, updateBumperVariant } from '../api' +import { unknownTokens } from '../placeholders' +import { BumperFramePreview } from './BumperFramePreview' +import { BumperLinesEditor } from './BumperLinesEditor' + +const TRIGGERS: BumperTrigger[] = ['OnShowChange', 'BetweenEpisodes', 'Both'] +const BACKGROUNDS: BumperBackground[] = ['Template', 'NextPoster', 'NowPoster'] + +/** + * Подблок: слева строки, справа постоянный предпросмотр кадра. Предпросмотр не опциональный — + * без него текст с плейсхолдерами приходится читать как код. + */ +export function BumperVariantEditor({ + template, + variant, + canRemove, + onChanged, + onError, +}: Readonly<{ + template: BumperTemplateDto + variant: BumperTextVariantDto + canRemove: boolean + onChanged: () => void + onError: (e: unknown) => void +}>) { + const { t } = useTranslation() + const [form, setForm] = useState({ + name: variant.name, + trigger: variant.trigger, + background: variant.background, + weight: variant.weight, + lines: variant.lines, + }) + + useEffect(() => { + setForm({ + name: variant.name, + trigger: variant.trigger, + background: variant.background, + weight: variant.weight, + lines: variant.lines, + }) + }, [variant]) + + const set = (key: K, value: (typeof form)[K]) => + setForm((f) => ({ ...f, [key]: value })) + + const save = useMutation({ + mutationFn: () => + updateBumperVariant(template.id, variant.id, { ...form, name: form.name.trim() }), + onSuccess: () => { + toast.success(t('settings.saved')) + onChanged() + }, + onError, + }) + const remove = useMutation({ + mutationFn: () => removeBumperVariant(template.id, variant.id), + onSuccess: onChanged, + onError, + }) + + const setLines = (lines: BumperLineDto[]) => set('lines', lines) + const broken = form.lines.some((line) => unknownTokens(line.text).length > 0) + const empty = form.lines.every((line) => !line.text.trim()) + + return ( +
+
+
+
+
+ + set('name', e.target.value)} + /> +
+
+ + +
+
+ + +
+
+ + + set('weight', Math.max(0, Math.round(Number(e.target.value)) || 0)) + } + /> +
+
+ + +
+ +
+ + + + {t('admin.bumpers.framePreviewHint')} + +
+
+ +
+ {canRemove && ( + + )} + +
+
+ ) +} diff --git a/frontend/src/features/admin/bumpers/placeholders.ts b/frontend/src/features/admin/bumpers/placeholders.ts new file mode 100644 index 0000000..8f31709 --- /dev/null +++ b/frontend/src/features/admin/bumpers/placeholders.ts @@ -0,0 +1,51 @@ +/** + * Плейсхолдеры текста заставки. Список — зеркало серверного (BumperPlaceholders.Tokens): сервер + * отвергает незнакомые при сохранении, редактор подсказывает знакомые и показывает подстановку. + * + * Образцы нужны и палитре, и живому предпросмотру: без них поле «ДАЛЕЕ В {next.time}» выглядит + * как строка кода, а не как кадр. + */ +export type PlaceholderSample = { + token: string + sample: string +} + +export const PLACEHOLDERS: PlaceholderSample[] = [ + { token: 'channel', sample: 'Первый' }, + { token: 'channel.number', sample: '4' }, + { token: 'now.title', sample: 'Симпсоны' }, + { token: 'next.title', sample: 'Терминатор 2' }, + { token: 'now.episode', sample: 'с5э12' }, + { token: 'next.episode', sample: 'с1э3' }, + { token: 'next.year', sample: '1991' }, + { token: 'next.genre', sample: 'Боевик' }, + { token: 'next.time', sample: '21:30' }, + { token: 'time', sample: '21:24' }, + { token: 'date', sample: '6 апреля' }, + { token: 'weekday', sample: 'понедельник' }, + { token: 'slot', sample: 'Вечернее кино' }, +] + +/** Плейсхолдеры момента показа: с ними каждый показ уникален и кэш рендера перестаёт работать. */ +export const VOLATILE_TOKENS = new Set(['time', 'date', 'weekday']) + +const TOKEN_PATTERN = /\{([a-zA-Z][a-zA-Z.]*)\}/g + +const SAMPLES = new Map(PLACEHOLDERS.map((p) => [p.token, p.sample])) + +/** Как строка будет выглядеть в кадре: подстановка образцами + схлопывание лишних пробелов. */ +export function resolveSample(text: string) { + return text + .replace(TOKEN_PATTERN, (_, token: string) => SAMPLES.get(token) ?? '') + .replace(/[ \t]{2,}/g, ' ') + .trim() +} + +/** Плейсхолдеры строки, которых нет в списке допустимых, — их сервер отвергнет при сохранении. */ +export function unknownTokens(text: string) { + return [...text.matchAll(TOKEN_PATTERN)].map((m) => m[1]).filter((token) => !SAMPLES.has(token)) +} + +export function hasVolatileToken(text: string) { + return [...text.matchAll(TOKEN_PATTERN)].some((m) => VOLATILE_TOKENS.has(m[1])) +} diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index 027d402..c10998c 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -19,7 +19,6 @@ import { restoreChannelTemplate, } from './api' import { ApplyDialog } from './components/ApplyDialog' -import { BumperCard } from './components/BumperCard' import { EntryTraceDialog } from './components/EntryTraceDialog' import { GridTab } from './components/GridTab' import { JunctionsCard } from './components/JunctionsCard' @@ -29,7 +28,7 @@ import { SettingsCard } from './components/SettingsCard' import { ViewerCard } from './components/ViewerCard' /** Вкладки экрана канала: настройки первыми — с них канал и начинается. */ -const TABS = ['settings', 'grid', 'rules', 'junctions', 'bumpers', 'viewer', 'air'] as const +const TABS = ['settings', 'grid', 'rules', 'junctions', 'viewer', 'air'] as const type ChannelTab = (typeof TABS)[number] export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) { @@ -180,17 +179,7 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) { ))} {tab === 'junctions' && ( - - )} - - {tab === 'bumpers' && ( - + )} {tab === 'viewer' && ( diff --git a/frontend/src/features/admin/channels/api.ts b/frontend/src/features/admin/channels/api.ts index 165903a..67fa088 100644 --- a/frontend/src/features/admin/channels/api.ts +++ b/frontend/src/features/admin/channels/api.ts @@ -1,9 +1,6 @@ -import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client' +import { apiRequest } from '@/shared/api/client' import type { ApplyResultDto, - BumperSettings, - BumperTextKind, - BumperTrigger, ChannelDto, ChannelSummaryDto, CopyTemplateResultDto, @@ -14,10 +11,6 @@ import type { GridPlanDto, GridProfileDto, GridProfileKind, - JunctionAmountMode, - JunctionConditions, - JunctionElementKind, - JunctionTemplateDto, LayerApplicability, PlanningRules, RestoreTemplateResultDto, @@ -45,8 +38,6 @@ export function createChannel(body: { name: string; slug: string }) { type ChannelSettingsBody = { name: string isEnabled: boolean - bumpersEnabled: boolean - bumper: BumperSettings fillerAssetId: string | null } @@ -209,207 +200,6 @@ export function deleteSlot(slotId: string) { // ── Стыки канала ────────────────────────────────────────────────────────── -export function listJunctions(channelId: string) { - return apiRequest(`/admin/channels/${channelId}/junctions`) -} - -export function createJunction(channelId: string, name: string) { - return apiRequest(`/admin/channels/${channelId}/junctions`, { - method: 'POST', - body: { name }, - }) -} - -export function renameJunction(junctionId: string, name: string) { - return apiRequest(`/admin/junctions/${junctionId}`, { method: 'PUT', body: { name } }) -} - -export function deleteJunction(junctionId: string) { - return apiRequest(`/admin/junctions/${junctionId}`, { method: 'DELETE' }) -} - -export function addJunctionElement(junctionId: string, kind: JunctionElementKind) { - return apiRequest(`/admin/junctions/${junctionId}/elements`, { - method: 'POST', - body: { kind }, - }) -} - -/** Тело врезки: то же для любого типа — лишние поля сервер обнуляет сам (см. JunctionElement.Update). */ -export type JunctionElementBody = { - kind: JunctionElementKind - groupId: string | null - bumperTemplateId: string | null - amountMode: JunctionAmountMode - amountValue: number - isRequired: boolean - conditions: JunctionConditions | null -} - -export function updateJunctionElement( - junctionId: string, - elementId: string, - body: JunctionElementBody, -) { - return apiRequest(`/admin/junctions/${junctionId}/elements/${elementId}`, { - method: 'PUT', - body, - }) -} - -export function removeJunctionElement(junctionId: string, elementId: string) { - return apiRequest(`/admin/junctions/${junctionId}/elements/${elementId}`, { - method: 'DELETE', - }) -} - -/** Порядок врезок: не упомянутые остаются после перечисленных. */ -export function reorderJunction(junctionId: string, elementIdsInOrder: string[]) { - return apiRequest(`/admin/junctions/${junctionId}/order`, { - method: 'PUT', - body: { elementIdsInOrder }, - }) -} - -type BumperTemplateStyleBody = { - name: string - backgroundColor: string - backgroundColor2: string - accentColor: string - textColor: string -} - -export function addBumperTemplate(id: string, name: string) { - return apiRequest(`/admin/channels/${id}/bumper/templates`, { - method: 'POST', - body: { name }, - }) -} - -export function updateBumperTemplate( - id: string, - templateId: string, - body: BumperTemplateStyleBody, -) { - return apiRequest(`/admin/channels/${id}/bumper/templates/${templateId}`, { - method: 'PUT', - body, - }) -} - -export function removeBumperTemplate(id: string, templateId: string) { - return apiRequest(`/admin/channels/${id}/bumper/templates/${templateId}`, { - method: 'DELETE', - }) -} - -/** Загрузка сырого файла блока (звук/фон): тело — файл, имя — в query (как в uploadMedia). */ -function uploadBumperTemplateFile( - id: string, - templateId: string, - kind: 'audio' | 'background', - file: File, -): Promise { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest() - const query = new URLSearchParams({ fileName: file.name }) - xhr.open( - 'PUT', - `/api/admin/channels/${id}/bumper/templates/${templateId}/${kind}?${query.toString()}`, - ) - const token = getAccessToken() - if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`) - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve() - } else { - let detail = `HTTP ${xhr.status}` - try { - const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string } - detail = problem.detail ?? problem.title ?? detail - } catch { - /* пусто */ - } - reject(new HttpError({ detail }, xhr.status)) - } - } - xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0)) - xhr.send(file) - }) -} - -export function uploadBumperTemplateAudio(id: string, templateId: string, file: File) { - return uploadBumperTemplateFile(id, templateId, 'audio', file) -} - -type BumperVariantBody = { - name: string - kind: BumperTextKind - nowLabel: string - nextLabel: string - line1: string - line2: string - trigger: BumperTrigger - weight: number -} - -export function addBumperVariant(id: string, templateId: string, name: string) { - return apiRequest( - `/admin/channels/${id}/bumper/templates/${templateId}/variants`, - { method: 'POST', body: { name } }, - ) -} - -export function updateBumperVariant( - id: string, - templateId: string, - variantId: string, - body: BumperVariantBody, -) { - return apiRequest( - `/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`, - { method: 'PUT', body }, - ) -} - -export function removeBumperVariant(id: string, templateId: string, variantId: string) { - return apiRequest( - `/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`, - { method: 'DELETE' }, - ) -} - -/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */ -export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) { - return apiRequest(`/admin/channels/${id}/bumper/templates/${templateId}/background`, { - method: 'PUT', - body: { imageId }, - }) -} - -export function clearBumperTemplateAudio(id: string, templateId: string) { - return apiRequest(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, { - method: 'DELETE', - }) -} - -export function clearBumperTemplateBackground(id: string, templateId: string) { - return apiRequest(`/admin/channels/${id}/bumper/templates/${templateId}/background`, { - method: 'DELETE', - }) -} - -/** Синхронно рендерит примеры всех подблоков блока (сервер собирает ffmpeg-клипы). */ -export function renderBumperPreviews(id: string, templateId: string) { - return apiRequest(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, { - method: 'POST', - }) -} - -export function bumperPreviewPlaylistUrl(id: string, templateId: string, variantId: string) { - return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/${variantId}/index.m3u8` -} - export function getSchedule(id: string, from: Date, to: Date) { const query = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() }) return apiRequest(`/admin/channels/${id}/schedule?${query.toString()}`) diff --git a/frontend/src/features/admin/channels/components/BumperBackgroundField.tsx b/frontend/src/features/admin/channels/components/BumperBackgroundField.tsx deleted file mode 100644 index 6ea8317..0000000 --- a/frontend/src/features/admin/channels/components/BumperBackgroundField.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { useMutation } from '@tanstack/react-query' -import { useState } from 'react' -import { useTranslation } from 'react-i18next' -import { imageUrl } from '@/features/admin/images/api' -import { ImageGallery } from '@/features/admin/images/ImageGallery' -import { Button } from '@/shared/ui/button' -import { Label } from '@/shared/ui/label' -import { clearBumperTemplateBackground, setBumperTemplateBackground } from '../api' - -export function BumperBackgroundField({ - channelId, - templateId, - backgroundImageId, - onChanged, - onError, -}: Readonly<{ - channelId: string - templateId: string - backgroundImageId: string | null - onChanged: () => void - onError: (e: unknown) => void -}>) { - const { t } = useTranslation() - const [galleryOpen, setGalleryOpen] = useState(false) - - const setBg = useMutation({ - mutationFn: (imageId: string) => setBumperTemplateBackground(channelId, templateId, imageId), - onSuccess: onChanged, - onError, - }) - const clearBg = useMutation({ - mutationFn: () => clearBumperTemplateBackground(channelId, templateId), - onSuccess: onChanged, - onError, - }) - - return ( -
- - - {t('admin.channels.bumperBackgroundHint')} - -
- {backgroundImageId && ( - - )} - - {backgroundImageId && ( - - )} -
- setBg.mutate(img.id)} - /> -
- ) -} diff --git a/frontend/src/features/admin/channels/components/BumperCard.tsx b/frontend/src/features/admin/channels/components/BumperCard.tsx deleted file mode 100644 index b5d1b70..0000000 --- a/frontend/src/features/admin/channels/components/BumperCard.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { useMutation } from '@tanstack/react-query' -import { useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import type { BumperFont, BumperSelection, BumperSettings, ChannelDto } from '@/shared/api/types' -import { Button } from '@/shared/ui/button' -import { Label } from '@/shared/ui/label' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' -import { toast } from '@/shared/ui/toast-store' -import { addBumperTemplate, updateChannelSettings } from '../api' -import { BumperTemplateEditor } from './BumperTemplateEditor' -import { CollapsibleCard } from './CollapsibleCard' - -export function BumperCard({ - channel, - bare, - onSaved, - onError, -}: Readonly<{ - channel: ChannelDto - bare?: boolean - onSaved: () => void - onError: (e: unknown) => void -}>) { - const { t } = useTranslation() - const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled) - const [bumper, setBumper] = useState(channel.bumper) - - const setField = (key: K, value: BumperSettings[K]) => - setBumper((prev) => ({ ...prev, [key]: value })) - - useEffect(() => { - setBumpersEnabled(channel.bumpersEnabled) - setBumper(channel.bumper) - }, [channel]) - - // Общие настройки заставок сохраняются тем же эндпоинтом, что и настройки канала — остальные - // поля берём из канала без изменений (они правятся в своей карточке). - const save = useMutation({ - mutationFn: () => - updateChannelSettings(channel.id, { - name: channel.name, - isEnabled: channel.isEnabled, - bumpersEnabled, - bumper, - fillerAssetId: channel.fillerAssetId, - }), - onSuccess: () => { - toast.success(t('settings.saved')) - onSaved() - }, - onError, - }) - - const addTemplate = useMutation({ - mutationFn: () => addBumperTemplate(channel.id, ''), - onSuccess: onSaved, - onError, - }) - - const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position) - - return ( - - - - {/* Общие настройки. Как часто ставить заставку — не здесь: это условие элемента стыка. */} -
-
- - -
-
- - -
-
-

{t('admin.channels.bumperConditionsHint')}

-
- -
- - {/* Блоки заставок */} -
-

{t('admin.channels.bumperTemplates')}

-

{t('admin.channels.bumperTemplatesHint')}

-
-
- {templates.map((template) => ( - - ))} -
-
- -
-
- ) -} diff --git a/frontend/src/features/admin/channels/components/BumperFileUpload.tsx b/frontend/src/features/admin/channels/components/BumperFileUpload.tsx deleted file mode 100644 index c1e833c..0000000 --- a/frontend/src/features/admin/channels/components/BumperFileUpload.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { useMutation } from '@tanstack/react-query' -import { useTranslation } from 'react-i18next' -import { Button } from '@/shared/ui/button' -import { Label } from '@/shared/ui/label' - -export function BumperFileUpload({ - channelId, - templateId, - kind, - label, - hint, - has, - accept, - upload, - clear, - onSaved, - onError, -}: Readonly<{ - channelId: string - templateId: string - kind: string - label: string - hint: string - has: boolean - accept: string - upload: (id: string, templateId: string, file: File) => Promise - clear: (id: string, templateId: string) => Promise - onSaved: () => void - onError: (e: unknown) => void -}>) { - const { t } = useTranslation() - const inputId = `bumper-${kind}-${templateId}` - - const uploadMutation = useMutation({ - mutationFn: (file: File) => upload(channelId, templateId, file), - onSuccess: onSaved, - onError, - }) - const clearMutation = useMutation({ - mutationFn: () => clear(channelId, templateId), - onSuccess: onSaved, - onError, - }) - const busy = uploadMutation.isPending || clearMutation.isPending - - return ( -
- - {hint} -
- { - const file = e.target.files?.[0] - if (file) uploadMutation.mutate(file) - e.target.value = '' - }} - /> - - {has && ( - - )} -
-
- ) -} diff --git a/frontend/src/features/admin/channels/components/BumperVariantEditor.tsx b/frontend/src/features/admin/channels/components/BumperVariantEditor.tsx deleted file mode 100644 index 9e9dbb9..0000000 --- a/frontend/src/features/admin/channels/components/BumperVariantEditor.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { useMutation } from '@tanstack/react-query' -import { useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import type { BumperTextKind, BumperTextVariantDto, BumperTrigger } from '@/shared/api/types' -import { Button } from '@/shared/ui/button' -import { Input } from '@/shared/ui/input' -import { Label } from '@/shared/ui/label' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' -import { toast } from '@/shared/ui/toast-store' -import { removeBumperVariant, updateBumperVariant } from '../api' - -export function BumperVariantEditor({ - channelId, - templateId, - variant, - canRemove, - onChanged, - onError, -}: Readonly<{ - channelId: string - templateId: string - variant: BumperTextVariantDto - canRemove: boolean - onChanged: () => void - onError: (e: unknown) => void -}>) { - const { t } = useTranslation() - const [form, setForm] = useState({ - name: variant.name, - kind: variant.kind, - nowLabel: variant.nowLabel, - nextLabel: variant.nextLabel, - line1: variant.line1, - line2: variant.line2, - trigger: variant.trigger, - weight: variant.weight, - }) - - useEffect(() => { - setForm({ - name: variant.name, - kind: variant.kind, - nowLabel: variant.nowLabel, - nextLabel: variant.nextLabel, - line1: variant.line1, - line2: variant.line2, - trigger: variant.trigger, - weight: variant.weight, - }) - }, [variant]) - - const set = (key: K, value: (typeof form)[K]) => - setForm((f) => ({ ...f, [key]: value })) - - const save = useMutation({ - mutationFn: () => - updateBumperVariant(channelId, templateId, variant.id, { ...form, name: form.name.trim() }), - onSuccess: () => { - toast.success(t('settings.saved')) - onChanged() - }, - onError, - }) - const remove = useMutation({ - mutationFn: () => removeBumperVariant(channelId, templateId, variant.id), - onSuccess: onChanged, - onError, - }) - - return ( -
-
-
- - set('name', e.target.value)} /> -
-
- - -
-
- - -
-
- - set('weight', Math.max(0, Math.round(Number(e.target.value)) || 0))} - /> - - {t('admin.channels.bumperVariantWeightHint')} - -
-
- -
- {form.kind === 'NowNext' ? ( - <> -
- - set('nowLabel', e.target.value)} - /> -
-
- - set('nextLabel', e.target.value)} - /> -
- - ) : ( - <> -
- - set('line1', e.target.value)} - /> -
-
- - set('line2', e.target.value)} - /> -
- - )} -
- -
- {canRemove && ( - - )} - -
-
- ) -} diff --git a/frontend/src/features/admin/channels/components/GridTab.tsx b/frontend/src/features/admin/channels/components/GridTab.tsx index 7a28bdd..c897b2a 100644 --- a/frontend/src/features/admin/channels/components/GridTab.tsx +++ b/frontend/src/features/admin/channels/components/GridTab.tsx @@ -371,12 +371,7 @@ export function GridTab({ }} /> {draft && ( - setDraft(null)} - onChanged={onChanged} - /> + setDraft(null)} onChanged={onChanged} /> )} diff --git a/frontend/src/features/admin/channels/components/JunctionElementDialog.tsx b/frontend/src/features/admin/channels/components/JunctionElementDialog.tsx deleted file mode 100644 index 9a943c0..0000000 --- a/frontend/src/features/admin/channels/components/JunctionElementDialog.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import { useMutation, useQuery } from '@tanstack/react-query' -import { useState } from 'react' -import { useTranslation } from 'react-i18next' -import { listGroups } from '@/features/admin/groups/api' -import type { - BumperTemplateDto, - JunctionAmountMode, - JunctionElementDto, - JunctionElementKind, -} from '@/shared/api/types' -import { qk } from '@/shared/api/query-keys' -import { Button } from '@/shared/ui/button' -import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog' -import { Input } from '@/shared/ui/input' -import { Label } from '@/shared/ui/label' -import { removeJunctionElement, updateJunctionElement, type JunctionElementBody } from '../api' - -const KINDS: JunctionElementKind[] = ['Ad', 'Promo', 'Bumper', 'Filler'] -const AMOUNT_MODES: JunctionAmountMode[] = ['Count', 'Duration'] - -function toBody(element: JunctionElementDto): JunctionElementBody { - return { - kind: element.kind, - groupId: element.groupId, - bumperTemplateId: element.bumperTemplateId, - amountMode: element.amountMode, - amountValue: element.amountValue, - isRequired: element.isRequired, - conditions: element.conditions ?? { onlyOnElementChange: false, minMinutesBetween: 0 }, - } -} - -/** Параметры одной врезки: тип, источник, сколько её и при каких условиях ставить. */ -export function JunctionElementDialog({ - junctionId, - element, - bumperTemplates, - onClose, - onChanged, - onError, -}: Readonly<{ - junctionId: string - element: JunctionElementDto - bumperTemplates: BumperTemplateDto[] - onClose: () => void - onChanged: () => void - onError: (error: unknown) => void -}>) { - const { t } = useTranslation() - const [body, setBody] = useState(() => toBody(element)) - const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups }) - - const patch = (part: Partial) => setBody((prev) => ({ ...prev, ...part })) - - const save = useMutation({ - mutationFn: () => updateJunctionElement(junctionId, element.id, body), - onSuccess: () => { - onChanged() - onClose() - }, - onError, - }) - const remove = useMutation({ - mutationFn: () => removeJunctionElement(junctionId, element.id), - onSuccess: () => { - onChanged() - onClose() - }, - onError, - }) - - const isBumper = body.kind === 'Bumper' - const conditions = body.conditions ?? { onlyOnElementChange: false, minMinutesBetween: 0 } - - return ( - !open && onClose()}> - - - {t('admin.channels.junctionElement')} - - -
-
- - -
- - {isBumper ? ( -
- - -
- ) : ( -
- - -
- )} - - {!isBumper && ( - <> -
-
- - -
-
- - patch({ amountValue: Number(e.target.value) })} - /> -
-
-

- {t('admin.channels.junctionAmountHint')} -

- - )} - - - - - -
- - - patch({ conditions: { ...conditions, minMinutesBetween: Number(e.target.value) } }) - } - /> -

- {t('admin.channels.junctionMinIntervalHint')} -

-
-
- - - - - -
-
- ) -} diff --git a/frontend/src/features/admin/channels/components/JunctionsCard.tsx b/frontend/src/features/admin/channels/components/JunctionsCard.tsx index 67ecda3..33b1257 100644 --- a/frontend/src/features/admin/channels/components/JunctionsCard.tsx +++ b/frontend/src/features/admin/channels/components/JunctionsCard.tsx @@ -1,115 +1,39 @@ import { useMutation, useQuery } from '@tanstack/react-query' -import { ChevronRight, Plus, Trash2 } from 'lucide-react' -import { useState } from 'react' +import { Link } from '@tanstack/react-router' +import { ExternalLink } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { listBumperTemplates } from '@/features/admin/bumpers/api' import { listGroups } from '@/features/admin/groups/api' +import { listJunctions } from '@/features/admin/junctions/api' import { formatClock } from '@/features/admin/interstitials/format' -import type { - ChannelDto, - GroupSummaryDto, - JunctionElementDto, - JunctionElementKind, - JunctionTemplateDto, - ScheduleTemplateDto, -} from '@/shared/api/types' +import { stepSeconds, toSteps } from '@/features/admin/junctions/lib' +import type { JunctionTemplateDto, ScheduleTemplateDto } from '@/shared/api/types' import { qk } from '@/shared/api/query-keys' import { Button } from '@/shared/ui/button' -import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' -import { cn } from '@/shared/lib/cn' -import { - addJunctionElement, - createJunction, - deleteJunction, - listJunctions, - renameJunction, - reorderJunction, - updateTemplate, -} from '../api' +import { updateTemplate } from '../api' import { CollapsibleCard } from './CollapsibleCard' -import { JunctionElementDialog } from './JunctionElementDialog' - -/** Заставка без загруженного звука синтезируется фиксированной длины — та же цифра, что на сервере. */ -const DEFAULT_BUMPER_SECONDS = 8 - -const KIND_COLORS: Record = { - Ad: 'bg-amber-500/70', - Promo: 'bg-sky-500/70', - Bumper: 'bg-violet-500/70', - Filler: 'bg-muted-foreground/40', -} - -type Translate = ReturnType['t'] - -/** Что уточняет подпись врезки: имя блока заставки либо объём («×3» / «×5 мин»). */ -function elementSuffix(element: JunctionElementDto, t: Translate) { - if (element.kind === 'Bumper') - return element.bumperTemplateName ? ` · ${element.bumperTemplateName}` : '' - - const units = element.amountMode === 'Duration' ? t('admin.channels.minutesShort') : '' - return ` ×${element.amountValue}${units}` -} - -/** Подсказка сегмента линейки: вид врезки и её оценочная длительность. */ -function elementTitle(element: JunctionElementDto, seconds: number, t: Translate) { - const kind = t(`admin.channels.junctionKinds.${element.kind}`) - return `${kind} · ${formatClock(seconds)}` -} /** - * Прикидка длины врезки для линейки. По длительности — точная, по количеству — среднее единицы - * группы: в смешанной группе «одна единица» это то ли ролик, то ли блок, поэтому число заведомо - * приблизительное и помечается как оценка. + * Стыки канала: что канал выбрал, а не как стык устроен. Сами стыки общие и правятся в своём + * разделе — держать их редактор ещё и здесь значило бы иметь два места для одного и того же. */ -function estimateSeconds( - element: JunctionElementDto, - groups: GroupSummaryDto[] | undefined, - channel: ChannelDto, -): { seconds: number; exact: boolean } { - if (element.kind === 'Bumper') { - const template = channel.bumperTemplates.find((b) => b.id === element.bumperTemplateId) - return { seconds: template?.audioDurationSeconds ?? DEFAULT_BUMPER_SECONDS, exact: true } - } - if (element.amountMode === 'Duration') return { seconds: element.amountValue * 60, exact: true } - - const group = groups?.find((g) => g.id === element.groupId) - if (!group || group.unitCount === 0) return { seconds: 0, exact: false } - return { - seconds: (element.amountValue * group.totalDurationSeconds) / group.unitCount, - exact: false, - } -} - export function JunctionsCard({ - channel, template, bare, onChanged, onError, }: Readonly<{ - channel: ChannelDto template: ScheduleTemplateDto | undefined bare?: boolean onChanged: () => void onError: (error: unknown) => void }>) { const { t } = useTranslation() - const [newName, setNewName] = useState('') - const { data: junctions } = useQuery({ - queryKey: qk.channels.junctions(channel.id), - queryFn: () => listJunctions(channel.id), - }) + const { data: junctions } = useQuery({ queryKey: qk.junctions.all, queryFn: listJunctions }) const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups }) - - const createMutation = useMutation({ - mutationFn: () => createJunction(channel.id, newName.trim()), - onSuccess: () => { - setNewName('') - onChanged() - }, - onError, - }) + const { data: bumpers } = useQuery({ queryKey: qk.bumpers.all, queryFn: listBumperTemplates }) const defaultMutation = useMutation({ mutationFn: (junctionId: string | null) => @@ -123,6 +47,25 @@ export function JunctionsCard({ onError, }) + // Что реально играет в этом канале: стык по умолчанию плюс всё, на что ссылаются слоты. + const usedIds = new Set() + if (template?.defaultJunctionId) usedIds.add(template.defaultJunctionId) + for (const layer of template?.layers ?? []) + for (const slot of layer.slots) { + if (slot.junctionBetweenId) usedIds.add(slot.junctionBetweenId) + if (slot.junctionAfterId) usedIds.add(slot.junctionAfterId) + } + const used = (junctions ?? []).filter((j) => usedIds.has(j.id)) + + const summary = (junction: JunctionTemplateDto) => { + const steps = toSteps(junction.elements) + const total = steps.reduce((sum, step) => sum + stepSeconds(step, groups, bumpers).seconds, 0) + const chain = steps + .map((step) => step.elements.map((e) => t(`admin.junctions.kinds.${e.kind}`)).join(' | ')) + .join(' → ') + return { chain: chain || t('admin.junctions.empty'), total } + } + return (
@@ -146,194 +89,34 @@ export function JunctionsCard({
)} - {(junctions ?? []).map((junction) => ( - - ))} +
+

{t('admin.channels.junctionsUsed')}

+ {used.length === 0 && ( +

{t('admin.channels.junctionsNoneUsed')}

+ )} + {used.map((junction) => { + const { chain, total } = summary(junction) + return ( +
+ {junction.name} + {chain} + ≈ {formatClock(total)} +
+ ) + })} +
-
- setNewName(e.target.value)} - /> -
) } - -const ADDABLE: JunctionElementKind[] = ['Ad', 'Promo', 'Bumper', 'Filler'] - -function JunctionChain({ - junction, - channel, - groups, - onChanged, - onError, -}: Readonly<{ - junction: JunctionTemplateDto - channel: ChannelDto - groups: GroupSummaryDto[] | undefined - onChanged: () => void - onError: (error: unknown) => void -}>) { - const { t } = useTranslation() - const [name, setName] = useState(null) - const [dragged, setDragged] = useState(null) - const [editing, setEditing] = useState(null) - - const renameMutation = useMutation({ - mutationFn: (value: string) => renameJunction(junction.id, value), - onSuccess: () => { - setName(null) - onChanged() - }, - onError, - }) - const deleteMutation = useMutation({ - mutationFn: () => deleteJunction(junction.id), - onSuccess: onChanged, - onError, - }) - const addMutation = useMutation({ - mutationFn: (kind: JunctionElementKind) => addJunctionElement(junction.id, kind), - onSuccess: onChanged, - onError, - }) - const reorderMutation = useMutation({ - mutationFn: (order: string[]) => reorderJunction(junction.id, order), - onSuccess: onChanged, - onError, - }) - - const elements = [...junction.elements].sort((a, b) => a.position - b.position) - const estimates = elements.map((element) => estimateSeconds(element, groups, channel)) - const total = estimates.reduce((sum, e) => sum + e.seconds, 0) - const exact = estimates.every((e) => e.exact) - - const dropOn = (targetId: string) => { - if (!dragged || dragged === targetId) return - const order = elements.map((e) => e.id).filter((id) => id !== dragged) - order.splice(order.indexOf(targetId), 0, dragged) - setDragged(null) - reorderMutation.mutate(order) - } - - return ( -
-
- setName(e.target.value)} - onBlur={() => - name !== null && name.trim() && name !== junction.name - ? renameMutation.mutate(name.trim()) - : setName(null) - } - /> - - - {exact ? '' : '≈ '} - {formatClock(total)} - - -
- - {/* Цепочка: что играет между концом одной программы и началом следующей. */} -
- - {t('admin.channels.junctionFrom')} - - {elements.length === 0 && ( - <> - - {t('admin.channels.junctionEmpty')} - - )} - {elements.map((element) => ( - - - - - ))} - - - {t('admin.channels.junctionTo')} - -
- - {/* Линейка: доля каждой врезки в стыке. Пустые (без группы) в неё не попадают. */} - {total > 0 && ( -
- {elements.map((element, index) => ( -
- ))} -
- )} - - {editing && ( - setEditing(null)} - onChanged={onChanged} - onError={onError} - /> - )} -
- ) -} diff --git a/frontend/src/features/admin/channels/components/SettingsCard.tsx b/frontend/src/features/admin/channels/components/SettingsCard.tsx index fa886de..dd431ca 100644 --- a/frontend/src/features/admin/channels/components/SettingsCard.tsx +++ b/frontend/src/features/admin/channels/components/SettingsCard.tsx @@ -47,9 +47,6 @@ export function SettingsCard({ await updateChannelSettings(channel.id, { name: name.trim(), isEnabled, - // Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений. - bumpersEnabled: channel.bumpersEnabled, - bumper: channel.bumper, fillerAssetId: fillerAssetId || null, }) await updateChannelTime(channel.id, { diff --git a/frontend/src/features/admin/channels/components/SlotInspector.tsx b/frontend/src/features/admin/channels/components/SlotInspector.tsx index 768049c..f689be4 100644 --- a/frontend/src/features/admin/channels/components/SlotInspector.tsx +++ b/frontend/src/features/admin/channels/components/SlotInspector.tsx @@ -3,6 +3,7 @@ import { Trash2 } from 'lucide-react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { listGroups } from '@/features/admin/groups/api' +import { listJunctions } from '@/features/admin/junctions/api' import type { Daypart, OverflowPolicy, @@ -17,14 +18,7 @@ import { Button } from '@/shared/ui/button' import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog' import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' -import { - createSlot, - deleteSlot, - listJunctions, - toSlotBody, - updateSlot, - type SlotBody, -} from '../api' +import { createSlot, deleteSlot, toSlotBody, updateSlot, type SlotBody } from '../api' const DAYPARTS: Daypart[] = ['Morning', 'Day', 'Prime', 'Night'] const SLOT_KINDS: SlotKind[] = ['Content', 'Repeat', 'SignOff'] @@ -65,12 +59,10 @@ function emptyBody(defaults?: Partial): SlotBody { } export function SlotInspector({ - channelId, draft, onClose, onChanged, }: Readonly<{ - channelId: string draft: SlotDraft onClose: () => void onChanged: () => void @@ -85,10 +77,8 @@ export function SlotInspector({ }, [draft]) const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups }) - const { data: junctions } = useQuery({ - queryKey: qk.channels.junctions(channelId), - queryFn: () => listJunctions(channelId), - }) + // Стыки общие для всех каналов — слот только выбирает, какой поставить. + const { data: junctions } = useQuery({ queryKey: qk.junctions.all, queryFn: listJunctions }) const onError = useApiError() diff --git a/frontend/src/features/admin/junctions/JunctionsPanel.tsx b/frontend/src/features/admin/junctions/JunctionsPanel.tsx new file mode 100644 index 0000000..bdb9e8c --- /dev/null +++ b/frontend/src/features/admin/junctions/JunctionsPanel.tsx @@ -0,0 +1,83 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Plus } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { listBumperTemplates } from '@/features/admin/bumpers/api' +import { listGroups } from '@/features/admin/groups/api' +import { qk } from '@/shared/api/query-keys' +import { useApiError } from '@/shared/lib/use-api-error' +import { Button } from '@/shared/ui/button' +import { Card, CardContent } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' +import { createJunction, listJunctions } from './api' +import { JunctionChain } from './components/JunctionChain' + +/** + * Стыки — общие для всех каналов, как группы. Канал только выбирает, какой стык поставить + * в слот; собирается стык здесь. + */ +export function JunctionsPanel() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const onError = useApiError() + const [name, setName] = useState('') + + const { data: junctions } = useQuery({ queryKey: qk.junctions.all, queryFn: listJunctions }) + const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups }) + const { data: bumpers } = useQuery({ queryKey: qk.bumpers.all, queryFn: listBumperTemplates }) + + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: qk.junctions.all }) + } + + const create = useMutation({ + mutationFn: () => createJunction(name.trim()), + onSuccess: () => { + setName('') + invalidate() + }, + onError, + }) + + return ( +
+ + +

{t('admin.junctions.hint')}

+
+ setName(e.target.value)} + /> + +
+
+
+ +
+ {(junctions ?? []).map((junction) => ( + + ))} + {junctions?.length === 0 && ( +

{t('admin.junctions.empty0')}

+ )} +
+
+ ) +} diff --git a/frontend/src/features/admin/junctions/api.ts b/frontend/src/features/admin/junctions/api.ts new file mode 100644 index 0000000..0920175 --- /dev/null +++ b/frontend/src/features/admin/junctions/api.ts @@ -0,0 +1,76 @@ +import { apiRequest } from '@/shared/api/client' +import type { + CreatedIdResponse, + JunctionAmountMode, + JunctionConditions, + JunctionElementKind, + JunctionTemplateDto, +} from '@/shared/api/types' + +/** Стыки общие для всех каналов — свой раздел, а не подраздел канала. */ +export function listJunctions() { + return apiRequest('/admin/junctions') +} + +export function createJunction(name: string) { + return apiRequest('/admin/junctions', { method: 'POST', body: { name } }) +} + +export function updateJunction(junctionId: string, name: string, maxTotalSeconds: number | null) { + return apiRequest(`/admin/junctions/${junctionId}`, { + method: 'PUT', + body: { name, maxTotalSeconds }, + }) +} + +export function deleteJunction(junctionId: string) { + return apiRequest(`/admin/junctions/${junctionId}`, { method: 'DELETE' }) +} + +export function addJunctionElement(junctionId: string, kind: JunctionElementKind) { + return apiRequest(`/admin/junctions/${junctionId}/elements`, { + method: 'POST', + body: { kind }, + }) +} + +/** Тело врезки: то же для любого типа — лишние поля сервер обнуляет сам (см. JunctionElement.Update). */ +export type JunctionElementBody = { + kind: JunctionElementKind + groupId: string | null + bumperTemplateId: string | null + bumperVariantId: string | null + amountMode: JunctionAmountMode + amountValue: number + isRequired: boolean + choiceKey: string | null + choiceWeight: number + conditions: JunctionConditions | null +} + +export function updateJunctionElement( + junctionId: string, + elementId: string, + body: JunctionElementBody, +) { + return apiRequest(`/admin/junctions/${junctionId}/elements/${elementId}`, { + method: 'PUT', + body, + }) +} + +export function removeJunctionElement(junctionId: string, elementId: string) { + return apiRequest(`/admin/junctions/${junctionId}/elements/${elementId}`, { + method: 'DELETE', + }) +} + +/** Порядок и развилки едут вместе: перетаскивание в цепочке меняет и то, и другое. */ +export type JunctionElementOrder = { elementId: string; choiceKey: string | null } + +export function reorderJunction(junctionId: string, order: JunctionElementOrder[]) { + return apiRequest(`/admin/junctions/${junctionId}/order`, { + method: 'PUT', + body: { order }, + }) +} diff --git a/frontend/src/features/admin/junctions/components/JunctionChain.tsx b/frontend/src/features/admin/junctions/components/JunctionChain.tsx new file mode 100644 index 0000000..ed0af94 --- /dev/null +++ b/frontend/src/features/admin/junctions/components/JunctionChain.tsx @@ -0,0 +1,326 @@ +import { useMutation } from '@tanstack/react-query' +import { ChevronRight, Merge, Split, Trash2 } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { formatClock } from '@/features/admin/interstitials/format' +import type { + BumperTemplateDto, + GroupSummaryDto, + JunctionElementDto, + JunctionElementKind, + JunctionTemplateDto, +} from '@/shared/api/types' +import { cn } from '@/shared/lib/cn' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { + addJunctionElement, + deleteJunction, + reorderJunction, + updateJunction, + type JunctionElementOrder, +} from '../api' +import { choicePercent, KIND_COLORS, stepSeconds, toSteps, type ChainStep } from '../lib' +import { JunctionElementDialog } from './JunctionElementDialog' + +const ADDABLE: JunctionElementKind[] = ['Ad', 'Promo', 'Bumper', 'Filler'] + +type Translate = ReturnType['t'] + +/** Что уточняет подпись врезки: имя блока заставки либо объём («×3» / «×5 мин»). */ +function elementSuffix(element: JunctionElementDto, t: Translate) { + if (element.kind === 'Bumper') { + const name = element.bumperVariantName ?? element.bumperTemplateName + return name ? ` · ${name}` : '' + } + const units = element.amountMode === 'Duration' ? t('admin.junctions.minutesShort') : '' + return ` ×${element.amountValue}${units}` +} + +/** Условия врезки одной строкой — иначе их не видно, не открыв каждую. */ +function conditionsHint(element: JunctionElementDto, t: Translate) { + const parts: string[] = [] + const c = element.conditions + if (c?.onlyOnElementChange) parts.push(t('admin.junctions.badgeOnChange')) + if (c && c.chance < 100) parts.push(`${c.chance}%`) + if (c && c.minMinutesBetween > 0) + parts.push(t('admin.junctions.badgeInterval', { minutes: c.minMinutesBetween })) + if (c?.timeWindow) parts.push(`${c.timeWindow.from.slice(0, 5)}–${c.timeWindow.to.slice(0, 5)}`) + if (c?.dayparts?.length) parts.push(c.dayparts.map((d) => t(`admin.channels.dayparts.${d}`)).join('/')) + return parts.join(' · ') +} + +export function JunctionChain({ + junction, + groups, + bumpers, + onChanged, + onError, +}: Readonly<{ + junction: JunctionTemplateDto + groups: GroupSummaryDto[] | undefined + bumpers: BumperTemplateDto[] | undefined + onChanged: () => void + onError: (error: unknown) => void +}>) { + const { t } = useTranslation() + const [name, setName] = useState(null) + const [maxTotal, setMaxTotal] = useState(null) + const [dragged, setDragged] = useState(null) + const [editing, setEditing] = useState(null) + + const steps = toSteps(junction.elements) + const estimates = steps.map((step) => stepSeconds(step, groups, bumpers)) + const total = estimates.reduce((sum, e) => sum + e.seconds, 0) + const exact = estimates.every((e) => e.exact) + const overCap = junction.maxTotalSeconds != null && total > junction.maxTotalSeconds + + const saveHeader = useMutation({ + mutationFn: (next: { name: string; maxTotalSeconds: number | null }) => + updateJunction(junction.id, next.name, next.maxTotalSeconds), + onSuccess: () => { + setName(null) + setMaxTotal(null) + onChanged() + }, + onError, + }) + const removeJunction = useMutation({ + mutationFn: () => deleteJunction(junction.id), + onSuccess: onChanged, + onError, + }) + const addElement = useMutation({ + mutationFn: (kind: JunctionElementKind) => addJunctionElement(junction.id, kind), + onSuccess: onChanged, + onError, + }) + const reorder = useMutation({ + mutationFn: (order: JunctionElementOrder[]) => reorderJunction(junction.id, order), + onSuccess: onChanged, + onError, + }) + + const flat = (): JunctionElementOrder[] => + steps.flatMap((step) => step.elements.map((e) => ({ elementId: e.id, choiceKey: e.choiceKey }))) + + /** Перетаскивание: врезка встаёт перед целевым звеном, метка развилки сохраняется. */ + const dropOn = (targetStep: ChainStep) => { + if (!dragged) return + const order = flat().filter((o) => o.elementId !== dragged) + const draggedItem = flat().find((o) => o.elementId === dragged) + const at = order.findIndex((o) => o.elementId === targetStep.elements[0].id) + if (draggedItem) order.splice(at < 0 ? order.length : at, 0, draggedItem) + setDragged(null) + reorder.mutate(order) + } + + /** Объединить с предыдущим звеном в развилку — одна кнопка вместо отдельного редактора групп. */ + const mergeWithPrevious = (index: number) => { + const previous = steps[index - 1] + const key = previous.choiceKey ?? `fork-${previous.elements[0].id.slice(0, 8)}` + const order = flat().map((o) => + previous.elements.some((e) => e.id === o.elementId) || + steps[index].elements.some((e) => e.id === o.elementId) + ? { ...o, choiceKey: key } + : o, + ) + reorder.mutate(order) + } + + const splitOut = (element: JunctionElementDto) => { + const order = flat().map((o) => (o.elementId === element.id ? { ...o, choiceKey: null } : o)) + reorder.mutate(order) + } + + return ( +
+
+ setName(e.target.value)} + onBlur={() => + name !== null && name.trim() && name !== junction.name + ? saveHeader.mutate({ + name: name.trim(), + maxTotalSeconds: junction.maxTotalSeconds, + }) + : setName(null) + } + /> + + + {junction.channelUsageCount > 0 && ( + + {t('admin.junctions.usedInChannels', { count: junction.channelUsageCount })} + + )} + + {exact ? '' : '≈ '} + {formatClock(total)} + {junction.maxTotalSeconds != null && ` / ${formatClock(junction.maxTotalSeconds)}`} + + +
+ + {/* Цепочка: что играет между концом одной программы и началом следующей. */} +
+ + {t('admin.junctions.from')} + + {steps.length === 0 && ( + <> + + {t('admin.junctions.empty')} + + )} + {steps.map((step, index) => ( +
+ +
e.preventDefault()} + onDrop={() => dropOn(step)} + className={cn( + 'flex flex-col gap-1 rounded', + step.elements.length > 1 && 'border border-dashed border-primary/50 p-1', + )} + > + {step.elements.length > 1 && ( + + {t('admin.junctions.fork')} + + )} + {step.elements.map((element) => ( +
+ + {/* Развилка собирается тут же: отдельный редактор групп ради двух кнопок не нужен. */} + {step.elements.length > 1 ? ( + + ) : ( + index > 0 && ( + + ) + )} +
+ ))} +
+
+ ))} + + + {t('admin.junctions.to')} + +
+ + {/* Линейка: доля каждого звена в стыке. Пустые (без источника) в неё не попадают. */} + {total > 0 && ( +
+ {steps.map((step, index) => ( +
+ ))} +
+ )} + + {editing && ( + setEditing(null)} + onChanged={onChanged} + onError={onError} + /> + )} +
+ ) +} diff --git a/frontend/src/features/admin/junctions/components/JunctionElementDialog.tsx b/frontend/src/features/admin/junctions/components/JunctionElementDialog.tsx new file mode 100644 index 0000000..df636cf --- /dev/null +++ b/frontend/src/features/admin/junctions/components/JunctionElementDialog.tsx @@ -0,0 +1,358 @@ +import { useMutation } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { + BumperTemplateDto, + Daypart, + GroupSummaryDto, + JunctionAmountMode, + JunctionConditions, + JunctionElementDto, + JunctionElementKind, +} from '@/shared/api/types' +import { Button } from '@/shared/ui/button' +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { removeJunctionElement, updateJunctionElement, type JunctionElementBody } from '../api' + +const KINDS: JunctionElementKind[] = ['Ad', 'Promo', 'Bumper', 'Filler'] +const AMOUNT_MODES: JunctionAmountMode[] = ['Count', 'Duration'] +const DAYPARTS: Daypart[] = ['Morning', 'Day', 'Prime', 'Night'] + +const DEFAULT_CONDITIONS: JunctionConditions = { + onlyOnElementChange: false, + minMinutesBetween: 0, + dayparts: null, + timeWindow: null, + chance: 100, +} + +function toBody(element: JunctionElementDto): JunctionElementBody { + return { + kind: element.kind, + groupId: element.groupId, + bumperTemplateId: element.bumperTemplateId, + bumperVariantId: element.bumperVariantId, + amountMode: element.amountMode, + amountValue: element.amountValue, + isRequired: element.isRequired, + choiceKey: element.choiceKey, + choiceWeight: element.choiceWeight, + conditions: element.conditions ?? DEFAULT_CONDITIONS, + } +} + +/** Параметры одной врезки: тип, источник, сколько её и при каких условиях ставить. */ +export function JunctionElementDialog({ + junctionId, + element, + groups, + bumpers, + onClose, + onChanged, + onError, +}: Readonly<{ + junctionId: string + element: JunctionElementDto + groups: GroupSummaryDto[] | undefined + bumpers: BumperTemplateDto[] | undefined + onClose: () => void + onChanged: () => void + onError: (error: unknown) => void +}>) { + const { t } = useTranslation() + const [body, setBody] = useState(() => toBody(element)) + + const patch = (part: Partial) => setBody((prev) => ({ ...prev, ...part })) + const conditions = body.conditions ?? DEFAULT_CONDITIONS + const setConditions = (part: Partial) => + patch({ conditions: { ...conditions, ...part } }) + + const save = useMutation({ + mutationFn: () => updateJunctionElement(junctionId, element.id, body), + onSuccess: () => { + onChanged() + onClose() + }, + onError, + }) + const remove = useMutation({ + mutationFn: () => removeJunctionElement(junctionId, element.id), + onSuccess: () => { + onChanged() + onClose() + }, + onError, + }) + + const isBumper = body.kind === 'Bumper' + const template = bumpers?.find((b) => b.id === body.bumperTemplateId) + const window = conditions.timeWindow + + return ( + !open && onClose()}> + + + {t('admin.junctions.element')} + + +
+
+ + +
+ + {isBumper ? ( + <> +
+ + +
+
+ + +

+ {t('admin.junctions.bumperVariantHint')} +

+
+ + ) : ( +
+ + +
+ )} + + {!isBumper && ( + <> +
+
+ + +
+
+ + patch({ amountValue: Number(e.target.value) })} + /> +
+
+

{t('admin.junctions.amountHint')}

+ + )} + + +

{t('admin.junctions.requiredHint')}

+ + {/* Вес внутри развилки виден только когда врезка в развилке — иначе это лишнее поле. */} + {body.choiceKey && ( +
+ + + patch({ choiceWeight: Math.max(0, Math.round(Number(e.target.value)) || 0) }) + } + /> +

+ {t('admin.junctions.choiceWeightHint')} +

+
+ )} + +
+ {t('admin.junctions.conditions')} +
+ + + +
+
+ + + setConditions({ + chance: Math.min(100, Math.max(0, Math.round(Number(e.target.value)) || 0)), + }) + } + /> +
+
+ + setConditions({ minMinutesBetween: Number(e.target.value) })} + /> +
+
+

{t('admin.junctions.chanceHint')}

+ +
+ +
+ {DAYPARTS.map((daypart) => { + const active = conditions.dayparts?.includes(daypart) ?? false + return ( + + ) + })} +
+

{t('admin.junctions.daypartsHint')}

+
+ +
+ +
+ + setConditions({ + timeWindow: e.target.value + ? { from: e.target.value, to: window?.to ?? '23:59' } + : null, + }) + } + /> + + + setConditions({ + timeWindow: e.target.value + ? { from: window?.from ?? '00:00', to: e.target.value } + : null, + }) + } + /> + {window && ( + + )} +
+

{t('admin.junctions.timeWindowHint')}

+
+
+ + + + + +
+
+ ) +} diff --git a/frontend/src/features/admin/junctions/lib.ts b/frontend/src/features/admin/junctions/lib.ts new file mode 100644 index 0000000..5a81947 --- /dev/null +++ b/frontend/src/features/admin/junctions/lib.ts @@ -0,0 +1,96 @@ +import type { + BumperTemplateDto, + GroupSummaryDto, + JunctionElementDto, + JunctionElementKind, +} from '@/shared/api/types' + +/** Заставка без загруженного звука синтезируется фиксированной длины — та же цифра, что на сервере. */ +export const DEFAULT_BUMPER_SECONDS = 8 + +export const KIND_COLORS: Record = { + Ad: 'bg-amber-500/70', + Promo: 'bg-sky-500/70', + Bumper: 'bg-violet-500/70', + Filler: 'bg-muted-foreground/40', +} + +/** + * Звено цепочки: либо одиночная врезка, либо развилка — несколько врезок с одной меткой, из + * которых в эфир идёт одна. Развилка занимает одно место в цепочке, поэтому и рисуется одним. + */ +export type ChainStep = { + key: string + choiceKey: string | null + elements: JunctionElementDto[] +} + +/** Группирует врезки в звенья по метке развилки; порядок сохраняется (сервер держит их подряд). */ +export function toSteps(elements: JunctionElementDto[]): ChainStep[] { + const steps: ChainStep[] = [] + for (const element of [...elements].sort((a, b) => a.position - b.position)) { + const last = steps.at(-1) + if (element.choiceKey && last?.choiceKey === element.choiceKey) { + last.elements.push(element) + continue + } + steps.push({ + key: element.choiceKey ?? element.id, + choiceKey: element.choiceKey, + elements: [element], + }) + } + return steps +} + +/** + * Прикидка длины врезки для линейки. По длительности — точная, по количеству — среднее единицы + * группы: в смешанной группе «одна единица» это то ли ролик, то ли блок, поэтому число заведомо + * приблизительное и помечается как оценка. + */ +export function estimateSeconds( + element: JunctionElementDto, + groups: GroupSummaryDto[] | undefined, + bumpers: BumperTemplateDto[] | undefined, +): { seconds: number; exact: boolean } { + if (element.kind === 'Bumper') { + const template = bumpers?.find((b) => b.id === element.bumperTemplateId) + return { seconds: template?.audioDurationSeconds ?? DEFAULT_BUMPER_SECONDS, exact: true } + } + if (element.amountMode === 'Duration') return { seconds: element.amountValue * 60, exact: true } + + const group = groups?.find((g) => g.id === element.groupId) + if (!group || group.unitCount === 0) return { seconds: 0, exact: false } + return { + seconds: (element.amountValue * group.totalDurationSeconds) / group.unitCount, + exact: false, + } +} + +/** Длина звена: у развилки — средняя по весам, потому что в эфир пойдёт одна из врезок. */ +export function stepSeconds( + step: ChainStep, + groups: GroupSummaryDto[] | undefined, + bumpers: BumperTemplateDto[] | undefined, +): { seconds: number; exact: boolean } { + const parts = step.elements.map((e) => ({ + ...estimateSeconds(e, groups, bumpers), + weight: Math.max(0, e.choiceWeight), + })) + if (parts.length === 1) return { seconds: parts[0].seconds, exact: parts[0].exact } + + const total = parts.reduce((sum, p) => sum + p.weight, 0) + const seconds = + total > 0 + ? parts.reduce((sum, p) => sum + (p.seconds * p.weight) / total, 0) + : parts.reduce((sum, p) => sum + p.seconds, 0) / parts.length + // У развилки точной длины нет по определению: жребий решает в момент генерации. + return { seconds, exact: false } +} + +/** Доля врезки в развилке в процентах — иначе «иногда так, иногда эдак» невозможно прочитать. */ +export function choicePercent(step: ChainStep, element: JunctionElementDto) { + const total = step.elements.reduce((sum, e) => sum + Math.max(0, e.choiceWeight), 0) + if (total <= 0) return Math.round(100 / step.elements.length) + return Math.round((Math.max(0, element.choiceWeight) / total) * 100) +} diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index b7f19da..d593ca3 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -16,12 +16,14 @@ import { Route as LoginRouteImport } from './routes/login' import { Route as RegisterRouteImport } from './routes/register' import { Route as SettingsRouteImport } from './routes/settings' import { Route as AdminIndexRouteImport } from './routes/admin/index' +import { Route as AdminBumpersRouteImport } from './routes/admin/bumpers' import { Route as AdminChannelsRouteImport } from './routes/admin/channels' import { Route as AdminCollectionsRouteImport } from './routes/admin/collections' import { Route as AdminGalleryRouteImport } from './routes/admin/gallery' import { Route as AdminGenresRouteImport } from './routes/admin/genres' import { Route as AdminGroupsRouteImport } from './routes/admin/groups' import { Route as AdminInterstitialsRouteImport } from './routes/admin/interstitials' +import { Route as AdminJunctionsRouteImport } from './routes/admin/junctions' import { Route as AdminMaintenanceRouteImport } from './routes/admin/maintenance' import { Route as AdminMediaRouteImport } from './routes/admin/media' import { Route as AdminRolesRouteImport } from './routes/admin/roles' @@ -72,6 +74,11 @@ const AdminIndexRoute = AdminIndexRouteImport.update({ path: '/', getParentRoute: () => AdminRoute, } as any) +const AdminBumpersRoute = AdminBumpersRouteImport.update({ + id: '/bumpers', + path: '/bumpers', + getParentRoute: () => AdminRoute, +} as any) const AdminChannelsRoute = AdminChannelsRouteImport.update({ id: '/channels', path: '/channels', @@ -102,6 +109,11 @@ const AdminInterstitialsRoute = AdminInterstitialsRouteImport.update({ path: '/interstitials', getParentRoute: () => AdminRoute, } as any) +const AdminJunctionsRoute = AdminJunctionsRouteImport.update({ + id: '/junctions', + path: '/junctions', + getParentRoute: () => AdminRoute, +} as any) const AdminMaintenanceRoute = AdminMaintenanceRouteImport.update({ id: '/maintenance', path: '/maintenance', @@ -181,12 +193,14 @@ export interface FileRoutesByFullPath { '/login': typeof LoginRoute '/register': typeof RegisterRoute '/settings': typeof SettingsRoute + '/admin/bumpers': typeof AdminBumpersRoute '/admin/channels': typeof AdminChannelsRouteWithChildren '/admin/collections': typeof AdminCollectionsRouteWithChildren '/admin/gallery': typeof AdminGalleryRoute '/admin/genres': typeof AdminGenresRoute '/admin/groups': typeof AdminGroupsRouteWithChildren '/admin/interstitials': typeof AdminInterstitialsRoute + '/admin/junctions': typeof AdminJunctionsRoute '/admin/maintenance': typeof AdminMaintenanceRoute '/admin/media': typeof AdminMediaRoute '/admin/roles': typeof AdminRolesRoute @@ -209,9 +223,11 @@ export interface FileRoutesByTo { '/login': typeof LoginRoute '/register': typeof RegisterRoute '/settings': typeof SettingsRoute + '/admin/bumpers': typeof AdminBumpersRoute '/admin/gallery': typeof AdminGalleryRoute '/admin/genres': typeof AdminGenresRoute '/admin/interstitials': typeof AdminInterstitialsRoute + '/admin/junctions': typeof AdminJunctionsRoute '/admin/maintenance': typeof AdminMaintenanceRoute '/admin/media': typeof AdminMediaRoute '/admin/roles': typeof AdminRolesRoute @@ -235,12 +251,14 @@ export interface FileRoutesById { '/login': typeof LoginRoute '/register': typeof RegisterRoute '/settings': typeof SettingsRoute + '/admin/bumpers': typeof AdminBumpersRoute '/admin/channels': typeof AdminChannelsRouteWithChildren '/admin/collections': typeof AdminCollectionsRouteWithChildren '/admin/gallery': typeof AdminGalleryRoute '/admin/genres': typeof AdminGenresRoute '/admin/groups': typeof AdminGroupsRouteWithChildren '/admin/interstitials': typeof AdminInterstitialsRoute + '/admin/junctions': typeof AdminJunctionsRoute '/admin/maintenance': typeof AdminMaintenanceRoute '/admin/media': typeof AdminMediaRoute '/admin/roles': typeof AdminRolesRoute @@ -266,12 +284,14 @@ export interface FileRouteTypes { | '/login' | '/register' | '/settings' + | '/admin/bumpers' | '/admin/channels' | '/admin/collections' | '/admin/gallery' | '/admin/genres' | '/admin/groups' | '/admin/interstitials' + | '/admin/junctions' | '/admin/maintenance' | '/admin/media' | '/admin/roles' @@ -294,9 +314,11 @@ export interface FileRouteTypes { | '/login' | '/register' | '/settings' + | '/admin/bumpers' | '/admin/gallery' | '/admin/genres' | '/admin/interstitials' + | '/admin/junctions' | '/admin/maintenance' | '/admin/media' | '/admin/roles' @@ -319,12 +341,14 @@ export interface FileRouteTypes { | '/login' | '/register' | '/settings' + | '/admin/bumpers' | '/admin/channels' | '/admin/collections' | '/admin/gallery' | '/admin/genres' | '/admin/groups' | '/admin/interstitials' + | '/admin/junctions' | '/admin/maintenance' | '/admin/media' | '/admin/roles' @@ -402,6 +426,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminIndexRouteImport parentRoute: typeof AdminRoute } + '/admin/bumpers': { + id: '/admin/bumpers' + path: '/bumpers' + fullPath: '/admin/bumpers' + preLoaderRoute: typeof AdminBumpersRouteImport + parentRoute: typeof AdminRoute + } '/admin/channels': { id: '/admin/channels' path: '/channels' @@ -444,6 +475,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminInterstitialsRouteImport parentRoute: typeof AdminRoute } + '/admin/junctions': { + id: '/admin/junctions' + path: '/junctions' + fullPath: '/admin/junctions' + preLoaderRoute: typeof AdminJunctionsRouteImport + parentRoute: typeof AdminRoute + } '/admin/maintenance': { id: '/admin/maintenance' path: '/maintenance' @@ -601,12 +639,14 @@ const AdminShowsRouteWithChildren = AdminShowsRoute._addFileChildren( ) interface AdminRouteChildren { + AdminBumpersRoute: typeof AdminBumpersRoute AdminChannelsRoute: typeof AdminChannelsRouteWithChildren AdminCollectionsRoute: typeof AdminCollectionsRouteWithChildren AdminGalleryRoute: typeof AdminGalleryRoute AdminGenresRoute: typeof AdminGenresRoute AdminGroupsRoute: typeof AdminGroupsRouteWithChildren AdminInterstitialsRoute: typeof AdminInterstitialsRoute + AdminJunctionsRoute: typeof AdminJunctionsRoute AdminMaintenanceRoute: typeof AdminMaintenanceRoute AdminMediaRoute: typeof AdminMediaRoute AdminRolesRoute: typeof AdminRolesRoute @@ -617,12 +657,14 @@ interface AdminRouteChildren { } const AdminRouteChildren: AdminRouteChildren = { + AdminBumpersRoute: AdminBumpersRoute, AdminChannelsRoute: AdminChannelsRouteWithChildren, AdminCollectionsRoute: AdminCollectionsRouteWithChildren, AdminGalleryRoute: AdminGalleryRoute, AdminGenresRoute: AdminGenresRoute, AdminGroupsRoute: AdminGroupsRouteWithChildren, AdminInterstitialsRoute: AdminInterstitialsRoute, + AdminJunctionsRoute: AdminJunctionsRoute, AdminMaintenanceRoute: AdminMaintenanceRoute, AdminMediaRoute: AdminMediaRoute, AdminRolesRoute: AdminRolesRoute, diff --git a/frontend/src/routes/admin.tsx b/frontend/src/routes/admin.tsx index 260a237..1f197bf 100644 --- a/frontend/src/routes/admin.tsx +++ b/frontend/src/routes/admin.tsx @@ -64,6 +64,20 @@ function AdminLayout() { > {t('admin.channels.title')} + + {t('admin.junctions.title')} + + + {t('admin.bumpers.title')} + ['admin', 'channels', id] as const, template: (id: string) => ['admin', 'channels', id, 'template'] as const, schedule: (id: string) => ['admin', 'channels', id, 'schedule'] as const, - junctions: (id: string) => ['admin', 'channels', id, 'junctions'] as const, issues: (id: string) => ['admin', 'channels', id, 'issues'] as const, diff: (id: string) => ['admin', 'channels', id, 'diff'] as const, preview: (id: string, days: number) => ['admin', 'channels', id, 'preview', days] as const, @@ -29,6 +28,14 @@ export const qk = { ['admin', 'channels', id, 'grid-plan', profile, mode] as const, }, + junctions: { + all: ['admin', 'junctions'] as const, + }, + + bumpers: { + all: ['admin', 'bumpers'] as const, + }, + gridProfiles: { all: ['admin', 'grid-profiles'] as const, }, diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 09987d6..78fe418 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -430,37 +430,36 @@ export type ShowDto = { // ── Каналы ──────────────────────────────────────────────────────────────── type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper' | 'Fallback' | 'SignOff' export type BumperFont = 'Sans' | 'Serif' -export type BumperSelection = 'Random' | 'AlwaysFirst' | 'WeightedRandom' -export type BumperTextKind = 'NowNext' | 'Free' export type BumperTrigger = 'OnShowChange' | 'BetweenEpisodes' | 'Both' +export type BumperLineStyle = 'Label' | 'Title' | 'Caption' +export type BumperLineColor = 'Accent' | 'Text' +/** Что за картинка под текстом: фон блока, постер следующего или предыдущего шоу. */ +export type BumperBackground = 'Template' | 'NextPoster' | 'NowPoster' -/** Общие для канала настройки заставок (стиль/звук/текст — на блоках и подблоках). */ -/** Условия показа (как часто, на смене шоу или между сериями) живут на элементе стыка, не здесь. */ -export type BumperSettings = { - font: BumperFont - selection: BumperSelection +/** Строка заставки: роль, цвет из палитры блока и текст с плейсхолдерами. */ +export type BumperLineDto = { + style: BumperLineStyle + color: BumperLineColor + text: string } -/** Подблок (текст-вариант): свой текст + правило показа поверх стиля/звука блока. */ +/** Подблок (текст-вариант): свои строки, фон и правило показа поверх оформления блока. */ export type BumperTextVariantDto = { id: string position: number name: string - kind: BumperTextKind - nowLabel: string - nextLabel: string - line1: string - line2: string trigger: BumperTrigger - /** Вес при стратегии выбора «случайно взвешенный» (0 — не выбирается). */ + background: BumperBackground + /** Вес при выборе подблока на переходе (0 — не выбирается никогда). */ weight: number + lines: BumperLineDto[] } +/** Блок заставки — общий для всех каналов, как группа. */ export type BumperTemplateDto = { id: string - position: number - isDefault: boolean name: string + font: BumperFont backgroundColor: string backgroundColor2: string accentColor: string @@ -468,6 +467,8 @@ export type BumperTemplateDto = { backgroundImageId: string | null hasAudio: boolean audioDurationSeconds: number | null + /** Во скольких врезках стыков используется блок — блоки общие, и это надо видеть до правки. */ + usageCount: number variants: BumperTextVariantDto[] } @@ -490,9 +491,6 @@ export type ChannelDto = { /** Начало вещательных суток в времени канала («06:00:00»). */ dayStartTime: string templateId: string | null - bumpersEnabled: boolean - bumper: BumperSettings - bumperTemplates: BumperTemplateDto[] fillerAssetId: string | null viewer: ViewerSettings } @@ -547,10 +545,16 @@ export type SlotDto = { export type JunctionElementKind = 'Ad' | 'Promo' | 'Bumper' | 'Filler' export type JunctionAmountMode = 'Count' | 'Duration' +/** Окно суток канала; допускает переход через полночь («с 23:00 до 06:00»). */ +export type JunctionTimeWindow = { from: string; to: string } + /** Условия показа врезки — структурные поля, а не выражение-строка. */ export type JunctionConditions = { onlyOnElementChange: boolean minMinutesBetween: number + dayparts?: Daypart[] | null + timeWindow?: JunctionTimeWindow | null + chance: number } export type JunctionElementDto = { @@ -561,15 +565,25 @@ export type JunctionElementDto = { groupName: string | null bumperTemplateId: string | null bumperTemplateName: string | null + /** Конкретный подблок заставки; null — выбирается по триггеру перехода и весам. */ + bumperVariantId: string | null + bumperVariantName: string | null amountMode: JunctionAmountMode amountValue: number isRequired: boolean + /** Метка развилки: из врезок с одной меткой играет одна, выбранная по весам. */ + choiceKey: string | null + choiceWeight: number conditions: JunctionConditions | null } +/** Стык — общий для всех каналов; канал только ссылается на него слотами. */ export type JunctionTemplateDto = { id: string name: string + maxTotalSeconds: number | null + /** Сколько каналов ссылается на стык — он общий, и это надо видеть до правки. */ + channelUsageCount: number elements: JunctionElementDto[] } diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index 94e44bc..68bc3c2 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -457,8 +457,6 @@ export const en = { everyDay: 'every day', daypart: 'Daypart', slotKind: 'Slot type', - group: 'Group', - pickGroup: 'pick a group', strategy: 'Strategy', cooldownDays: 'Cooldown, days', cooldownHint: 'Skip what already aired within this period.', @@ -475,8 +473,6 @@ export const en = { maxDrift: 'Allowance, min', snap: 'Snap', snapOff: 'off', - bumperConditionsHint: - 'How often a bumper is inserted and on which transitions is a junction-element condition, not a channel setting.', resizeSlot: 'Drag the edge to change duration', copyDay: 'Copy day', copyDayFrom: 'Copy {{day}} to:', @@ -497,7 +493,6 @@ export const en = { grid: 'Grid', rules: 'Rules', junctions: 'Junctions', - bumpers: 'Bumpers', viewer: 'Viewer', settings: 'Settings', air: 'On air', @@ -603,30 +598,12 @@ export const en = { 'What plays between programmes: ads, promos, bumpers. A slot may pick its own junction, otherwise the default one is used.', defaultJunction: 'Default junction', noJunction: 'no junction', - newJunctionName: 'New junction', - addJunctionElement: '+ break', - junctionEmpty: 'empty', - junctionFrom: 'end', - junctionTo: 'start', - junctionElement: 'Break', - junctionKind: 'Kind', - junctionKinds: { Ad: 'Ad', Promo: 'Promo', Bumper: 'Bumper', Filler: 'Filler' }, - junctionAmountMode: 'Measured in', - junctionAmountModes: { Count: 'Units', Duration: 'Minutes' }, - junctionCount: 'How many units', - junctionMinutes: 'How many minutes', - junctionAmountHint: - 'For a mixed group (clips and ready-made blocks) count in minutes: one "unit" there is either a clip or a whole block.', - junctionRequired: 'Required — never dropped when time runs short', - junctionOnlyOnChange: 'Only when the show changes', - junctionMinInterval: 'No more often than once per, min', - junctionMinIntervalHint: '0 — no limit.', junctionBetween: 'Junction inside the slot', junctionAfter: 'Junction after the slot', junctionDefault: 'default', - bumperTemplate: 'Bumper block', - pickBumperTemplate: 'pick a block', - minutesShort: ' min', + junctionsUsed: 'Used by this channel', + junctionsNoneUsed: 'The channel references no junction — nothing plays between programmes.', + openJunctionEditor: 'Junction editor', pendingChanges: 'Rules changed — the air still follows the old ones.', restore: 'Discard changes', restoreConfirm: @@ -677,64 +654,150 @@ export const en = { enabled: 'On air', enabledLabel: 'Channel on air', settings: 'Settings', - bumpers: 'TV bumpers', - bumpersLabel: 'Transition bumpers', - bumpersHint: 'Short “Now / Next” bumper between different shows', - bumperSelection: 'Block selection', - bumperSelectionRandom: 'Random', - bumperSelectionWeighted: 'Weighted random', - bumperSelectionAlwaysFirst: 'Always first', - bumperFont: 'Font', - bumperFontSans: 'Sans', - bumperFontSerif: 'Serif', - bumperNowLabel: '“Now” label', - bumperNextLabel: '“Next” label', - bumperBg: 'Background (color 1)', - bumperBg2: 'Background (color 2)', - bumperAccent: 'Accent', - bumperText: 'Text', - bumperTemplates: 'Bumper blocks', - bumperTemplatesHint: - 'Each block has its own sound and style. The first block is the default and cannot be removed. Bumper length follows the sound length.', - bumperAddTemplate: 'Add block', - bumperTemplateName: 'Name', - bumperVariants: 'Sub-blocks (text)', - bumperVariantsHint: - 'Different text over the same music and style. Each sub-block has its own show rule.', - bumperAddVariant: 'Add text', - bumperVariantName: 'Name', - bumperTextKind: 'Text mode', - bumperKindNowNext: 'Now / Next', - bumperKindFree: 'Free text', - bumperLine1: 'Line 1', - bumperLine2: 'Line 2', - bumperTrigger: 'Show on', - bumperTriggerOnShowChange: 'Show change', - bumperTriggerBetweenEpisodes: 'Between episodes', - bumperTriggerBoth: 'Both', - bumperVariantWeight: 'Weight', - bumperVariantWeightHint: - 'For the “weighted random” strategy: higher = more often (0 — never picked)', - bumperDefault: 'default', - bumperSeconds: 's', - bumperDefaultDuration: '≈8 s (jingle)', - bumperAudio: 'Sound', - bumperAudioHint: 'Bumper sound; otherwise a synthesized jingle', - bumperPreview: 'Render samples', - bumperPreviewRendering: 'Rendering…', - bumperPreviewHint: - 'Samples of all sub-blocks with sound and animation (example show names). Uses saved settings.', - bumperBackground: 'Background image', - bumperBackgroundHint: 'Background image; otherwise the show poster or a gradient', - bumperBackgroundPick: 'Pick from gallery', - bumperFileLoaded: 'loaded', - bumperFileDefault: 'default', - bumperUpload: 'Upload', - bumperReset: 'Reset', filler: 'Filler', noFiller: 'No filler', noSchedule: 'Schedule not built yet', }, + junctions: { + title: 'Junctions', + hint: 'What plays between programmes: ads, promos, bumpers. A junction is shared across channels — a channel only picks which one a slot uses.', + newName: 'New junction', + empty0: 'No junctions yet.', + empty: 'empty', + from: 'end', + to: 'start', + fork: 'fork', + addElement: '+ break', + maxTotal: 'Cap, s', + usedInChannels: 'in channels: {{count}}', + cannotDeleteUsed: 'The junction is used by channels — drop the references first.', + splitOut: 'Take out of the fork', + mergeIntoFork: 'Merge with the previous one into a fork', + element: 'Break', + kind: 'Type', + kinds: { + Ad: 'Ad', + Promo: 'Promo', + Bumper: 'Bumper', + Filler: 'Filler', + }, + group: 'Group', + pickGroup: 'pick a group', + bumperTemplate: 'Bumper block', + pickBumper: 'pick a block', + bumperVariant: 'Text variant', + bumperVariantAuto: 'by trigger and weights', + bumperVariantHint: + 'Keep "by trigger and weights" so show changes and episode breaks get different texts.', + amountMode: 'Measured in', + amountModes: { Count: 'Units', Duration: 'Minutes' }, + count: 'How many units', + minutes: 'How many minutes', + minutesShort: ' min', + amountHint: + 'For a mixed group (spots and ready-made blocks) count in minutes: one unit there is either a spot or a whole block.', + required: 'Required', + requiredHint: + 'Required only decides what gets dropped when time runs short. It does not affect the order — that is the position in the chain.', + choiceWeight: 'Weight in the fork', + choiceWeightHint: 'The higher, the more often this break plays (0 — never picked).', + conditions: 'Conditions', + onlyOnChange: 'Only on show change', + chance: 'Chance, %', + chanceHint: + 'Chance and interval are per break; the roll comes from the generation seed, so rebuilds do not reshuffle breaks.', + minInterval: 'No more often than once per, min', + dayparts: 'Dayparts', + daypartsHint: 'Nothing selected — the break plays in any daypart.', + timeWindow: 'Channel time window', + clearWindow: 'Clear', + timeWindowHint: 'Empty — any time. The window may cross midnight.', + badgeOnChange: 'on change', + badgeInterval: 'once per {{minutes}} min', + }, + bumpers: { + title: 'Bumpers', + hint: 'Bumper blocks are shared across channels: look and sound belong to the block, text to its variants. A bumper reaches air as a junction break.', + empty: 'No bumper blocks yet.', + newName: 'New block', + newNamePlaceholder: 'Block name', + newVariantName: 'New text', + sampleChannel: 'Preview as channel', + sampleChannelNone: 'no channel', + sampleChannelHint: 'The block is shared, but sample values come from the selected channel.', + name: 'Name', + font: 'Font', + fontSans: 'Sans', + fontSerif: 'Serif', + colorBg: 'Background (colour 1)', + colorBg2: 'Background (colour 2)', + colorAccent: 'Accent', + colorText: 'Text', + saveStyle: 'Save look', + seconds: 's', + defaultDuration: '≈8 s (jingle)', + variantsCount: 'texts: {{count}}', + usedInJunctions: 'in breaks: {{count}}', + cannotDeleteUsed: 'The block is used by junction breaks — drop the references first.', + audio: 'Sound', + audioHint: 'The sound length sets the bumper duration; without it a jingle is synthesised.', + background: 'Background', + backgroundFieldHint: 'Block background image; otherwise a palette gradient or a show poster.', + backgroundPick: 'Pick from gallery', + backgrounds: { + Template: 'Block background', + NextPoster: 'Next show poster', + NowPoster: 'Previous show poster', + }, + fileLoaded: 'loaded', + fileDefault: 'default', + upload: 'Upload', + reset: 'Reset', + variants: 'Text variants', + variantsHint: + 'Different text over the same music and look. Trigger and background are per variant.', + addVariant: 'Add text', + variantName: 'Name', + trigger: 'Show on', + triggers: { + OnShowChange: 'Show change', + BetweenEpisodes: 'Between episodes', + Both: 'Both', + }, + weight: 'Weight', + lineStyles: { Label: 'Label', Title: 'Title', Caption: 'Caption' }, + lineColors: { Accent: 'Accent', Text: 'Text' }, + addLine: 'Line', + presets: 'Presets', + preset_nowNext: 'Now / Next', + preset_nextAt: 'Next at …', + preset_channel: 'Channel ident', + presetNow: 'NOW', + presetNext: 'NEXT', + presetNextAt: 'NEXT AT', + framePreview: 'Frame', + framePreviewHint: 'Sample substitution: this is how the line looks on air.', + unknownPlaceholder: 'Unknown placeholder: {{tokens}}', + volatileHint: 'every airing is unique — the render cache stops working', + render: 'Render samples', + rendering: 'Rendering…', + renderHint: 'A real ffmpeg render of every variant with sound — takes a few seconds.', + tokens: { + channel: 'Channel name', + 'channel.number': 'Channel number', + 'now.title': 'Current show', + 'next.title': 'Next show', + 'now.episode': 'Current episode', + 'next.episode': 'Next episode', + 'next.year': 'Next show year', + 'next.genre': 'Next show genre', + 'next.time': 'Next start time', + time: 'Bumper airing time', + date: 'Date', + weekday: 'Weekday', + slot: 'Slot title', + }, + }, maintenance: { title: 'Maintenance', warning: 'These actions are irreversible — data and files are deleted permanently.', diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index be08386..f6a3fb8 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -457,8 +457,6 @@ export const ru = { everyDay: 'каждый день', daypart: 'Дейпарт', slotKind: 'Тип слота', - group: 'Группа', - pickGroup: 'выберите группу', strategy: 'Стратегия', cooldownDays: 'Остывание, дней', cooldownHint: 'Не брать то, что уже выходило за этот срок.', @@ -475,8 +473,6 @@ export const ru = { maxDrift: 'Допуск, мин', snap: 'Округление', snapOff: 'нет', - bumperConditionsHint: - 'Как часто ставить заставку и на каких переходах — условия элемента стыка, а не настройка канала.', resizeSlot: 'Потянуть за край — длительность', copyDay: 'Копировать день', copyDayFrom: 'Копировать {{day}} в:', @@ -497,7 +493,6 @@ export const ru = { grid: 'Сетка', rules: 'Правила', junctions: 'Стыки', - bumpers: 'Заставки', viewer: 'Зритель', settings: 'Настройки', air: 'Эфир', @@ -603,35 +598,12 @@ export const ru = { 'Что играет между программами: реклама, анонсы, заставки. Слот может взять свой стык, иначе берётся стык по умолчанию.', defaultJunction: 'Стык по умолчанию', noJunction: 'без стыка', - newJunctionName: 'Новый стык', - addJunctionElement: '+ врезка', - junctionEmpty: 'пусто', - junctionFrom: 'конец', - junctionTo: 'начало', - junctionElement: 'Врезка', - junctionKind: 'Тип', - junctionKinds: { - Ad: 'Реклама', - Promo: 'Анонс', - Bumper: 'Заставка', - Filler: 'Заполнитель', - }, - junctionAmountMode: 'Чем меряется', - junctionAmountModes: { Count: 'Единиц', Duration: 'Минут' }, - junctionCount: 'Сколько единиц', - junctionMinutes: 'Сколько минут', - junctionAmountHint: - 'В смешанной группе (ролики и готовые блоки) считайте минутами: одна «единица» там — то ли ролик, то ли блок.', - junctionRequired: 'Обязательная — не выбрасывать при нехватке времени', - junctionOnlyOnChange: 'Только при смене шоу', - junctionMinInterval: 'Не чаще, чем раз в, мин', - junctionMinIntervalHint: '0 — без ограничения.', junctionBetween: 'Стык внутри слота', junctionAfter: 'Стык после слота', junctionDefault: 'по умолчанию', - bumperTemplate: 'Блок заставки', - pickBumperTemplate: 'выберите блок', - minutesShort: ' мин', + junctionsUsed: 'Используются в этом канале', + junctionsNoneUsed: 'Канал не ссылается ни на один стык — между программами ничего не играет.', + openJunctionEditor: 'Редактор стыков', pendingChanges: 'Правила изменены — эфир идёт по старым.', restore: 'Сбросить изменения', restoreConfirm: @@ -682,64 +654,150 @@ export const ru = { enabled: 'В эфире', enabledLabel: 'Канал в эфире', settings: 'Настройки', - bumpers: 'ТВ-заставки', - bumpersLabel: 'Заставки на переходах', - bumpersHint: 'Короткая заставка «Сейчас / Далее» между разными шоу', - bumperSelection: 'Выбор блока', - bumperSelectionRandom: 'Случайно', - bumperSelectionWeighted: 'Случайно взвешенный', - bumperSelectionAlwaysFirst: 'Всегда первый', - bumperFont: 'Шрифт', - bumperFontSans: 'Гротеск', - bumperFontSerif: 'Антиква', - bumperNowLabel: 'Подпись «Сейчас»', - bumperNextLabel: 'Подпись «Далее»', - bumperBg: 'Фон (цвет 1)', - bumperBg2: 'Фон (цвет 2)', - bumperAccent: 'Акцент', - bumperText: 'Текст', - bumperTemplates: 'Блоки заставок', - bumperTemplatesHint: - 'Каждый блок — свой звук и оформление. Первый блок дефолтный, его нельзя удалить. Длительность заставки — по длине звука.', - bumperAddTemplate: 'Добавить блок', - bumperTemplateName: 'Название', - bumperVariants: 'Подблоки (текст)', - bumperVariantsHint: - 'Разный текст на одной музыке и оформлении блока. Правило показа — у каждого подблока своё.', - bumperAddVariant: 'Добавить текст', - bumperVariantName: 'Название', - bumperTextKind: 'Режим текста', - bumperKindNowNext: 'Сейчас / Далее', - bumperKindFree: 'Свободный текст', - bumperLine1: 'Строка 1', - bumperLine2: 'Строка 2', - bumperTrigger: 'Показывать', - bumperTriggerOnShowChange: 'При смене шоу', - bumperTriggerBetweenEpisodes: 'Между сериями', - bumperTriggerBoth: 'Оба', - bumperVariantWeight: 'Вес', - bumperVariantWeightHint: - 'Для стратегии «случайно взвешенный»: чем больше — тем чаще (0 — не выбирается)', - bumperDefault: 'по умолчанию', - bumperSeconds: 'с', - bumperDefaultDuration: '≈8 с (джингл)', - bumperAudio: 'Звук', - bumperAudioHint: 'Звук заставки; иначе — синтезированный джингл', - bumperPreview: 'Отрендерить примеры', - bumperPreviewRendering: 'Рендерим…', - bumperPreviewHint: - 'Примеры всех подблоков со звуком и анимацией (примерные названия шоу). Использует сохранённые настройки.', - bumperBackground: 'Фон-картинка', - bumperBackgroundHint: 'Картинка фона; иначе — постер шоу или градиент', - bumperBackgroundPick: 'Выбрать из галереи', - bumperFileLoaded: 'загружено', - bumperFileDefault: 'по умолчанию', - bumperUpload: 'Загрузить', - bumperReset: 'Сбросить', filler: 'Заглушка', noFiller: 'Без заглушки', noSchedule: 'Расписание ещё не построено', }, + junctions: { + title: 'Стыки', + hint: 'Что играет между программами: реклама, анонсы, заставки. Стык общий для всех каналов — канал только выбирает, какой поставить в слот.', + newName: 'Новый стык', + empty0: 'Стыков пока нет.', + empty: 'пусто', + from: 'конец', + to: 'начало', + fork: 'развилка', + addElement: '+ врезка', + maxTotal: 'Потолок, с', + usedInChannels: 'в каналах: {{count}}', + cannotDeleteUsed: 'Стык используется каналами — сначала снимите ссылки.', + splitOut: 'Вынести из развилки', + mergeIntoFork: 'Объединить с предыдущей в развилку', + element: 'Врезка', + kind: 'Тип', + kinds: { + Ad: 'Реклама', + Promo: 'Анонс', + Bumper: 'Заставка', + Filler: 'Заполнитель', + }, + group: 'Группа', + pickGroup: 'выберите группу', + bumperTemplate: 'Блок заставки', + pickBumper: 'выберите блок', + bumperVariant: 'Подблок', + bumperVariantAuto: 'по триггеру и весам', + bumperVariantHint: + 'Оставьте «по триггеру и весам», чтобы на смене шоу и между сериями играли разные тексты.', + amountMode: 'Чем меряется', + amountModes: { Count: 'Единиц', Duration: 'Минут' }, + count: 'Сколько единиц', + minutes: 'Сколько минут', + minutesShort: ' мин', + amountHint: + 'В смешанной группе (ролики и готовые блоки) считайте минутами: одна «единица» там — то ли ролик, то ли блок.', + required: 'Обязательная', + requiredHint: + 'Обязательность решает, кого выбросить при нехватке времени. На порядок показа она не влияет — он задаётся местом в цепочке.', + choiceWeight: 'Вес в развилке', + choiceWeightHint: 'Чем больше — тем чаще играет именно эта врезка (0 — не выбирается).', + conditions: 'Условия показа', + onlyOnChange: 'Только при смене шоу', + chance: 'Вероятность, %', + chanceHint: + 'Вероятность и интервал считаются по этой врезке отдельно; жребий берётся из seed генерации, поэтому пересборка не тасует врезки.', + minInterval: 'Не чаще, чем раз в, мин', + dayparts: 'Дейпарты', + daypartsHint: 'Ничего не выбрано — врезка идёт в любых дейпартах.', + timeWindow: 'Окно суток канала', + clearWindow: 'Сбросить', + timeWindowHint: 'Пусто — в любое время. Окно может переходить через полночь.', + badgeOnChange: 'на смене', + badgeInterval: 'раз в {{minutes}} мин', + }, + bumpers: { + title: 'Заставки', + hint: 'Блоки заставок общие для всех каналов: оформление и звук — у блока, текст — у подблоков. В эфир заставка попадает врезкой стыка.', + empty: 'Блоков заставок пока нет.', + newName: 'Новый блок', + newNamePlaceholder: 'Название блока', + newVariantName: 'Новый текст', + sampleChannel: 'Смотреть глазами канала', + sampleChannelNone: 'без канала', + sampleChannelHint: 'Блок общий, но образцы подстановки берутся у выбранного канала.', + name: 'Название', + font: 'Шрифт', + fontSans: 'Гротеск', + fontSerif: 'Антиква', + colorBg: 'Фон (цвет 1)', + colorBg2: 'Фон (цвет 2)', + colorAccent: 'Акцент', + colorText: 'Текст', + saveStyle: 'Сохранить оформление', + seconds: 'с', + defaultDuration: '≈8 с (джингл)', + variantsCount: 'текстов: {{count}}', + usedInJunctions: 'во врезках: {{count}}', + cannotDeleteUsed: 'Блок используется во врезках стыков — сначала снимите ссылки.', + audio: 'Звук', + audioHint: 'Длина звука задаёт длительность заставки; без него — синтезированный джингл.', + background: 'Фон', + backgroundFieldHint: 'Картинка фона блока; иначе — градиент палитры или постер шоу.', + backgroundPick: 'Выбрать из галереи', + backgrounds: { + Template: 'Фон блока', + NextPoster: 'Постер следующего шоу', + NowPoster: 'Постер предыдущего шоу', + }, + fileLoaded: 'загружено', + fileDefault: 'по умолчанию', + upload: 'Загрузить', + reset: 'Сбросить', + variants: 'Подблоки (тексты)', + variantsHint: + 'Разный текст на одной музыке и оформлении. Правило показа и фон — у каждого подблока свои.', + addVariant: 'Добавить текст', + variantName: 'Название', + trigger: 'Показывать', + triggers: { + OnShowChange: 'При смене шоу', + BetweenEpisodes: 'Между сериями', + Both: 'Оба', + }, + weight: 'Вес', + lineStyles: { Label: 'Подпись', Title: 'Название', Caption: 'Мелкая' }, + lineColors: { Accent: 'Акцент', Text: 'Текст' }, + addLine: 'Строка', + presets: 'Пресеты', + preset_nowNext: 'Сейчас / Далее', + preset_nextAt: 'Далее в …', + preset_channel: 'Логотип канала', + presetNow: 'СЕЙЧАС', + presetNext: 'ДАЛЕЕ', + presetNextAt: 'ДАЛЕЕ В', + framePreview: 'Кадр', + framePreviewHint: 'Подстановка образцами: так строка будет выглядеть в эфире.', + unknownPlaceholder: 'Неизвестный плейсхолдер: {{tokens}}', + volatileHint: 'каждый показ уникален — кэш рендера не работает', + render: 'Отрендерить примеры', + rendering: 'Рендерим…', + renderHint: 'Настоящий ffmpeg-рендер всех подблоков со звуком — несколько секунд.', + tokens: { + channel: 'Название канала', + 'channel.number': 'Номер канала', + 'now.title': 'Текущее шоу', + 'next.title': 'Следующее шоу', + 'now.episode': 'Серия текущего', + 'next.episode': 'Серия следующего', + 'next.year': 'Год следующего', + 'next.genre': 'Жанр следующего', + 'next.time': 'Время старта следующего', + time: 'Время показа заставки', + date: 'Дата', + weekday: 'День недели', + slot: 'Название слота', + }, + }, maintenance: { title: 'Обслуживание', warning: 'Операции необратимы — удаляют данные и файлы навсегда.',