From a65bcf42584e87cdfcf87e899e54466d3b87b932 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 25 Jul 2026 13:24:11 +0300 Subject: [PATCH] Add bumper variant management: implement API endpoints for adding, updating, and removing bumper text variants, enhance data models to support variant details, and update scheduling logic to utilize variants. Refactor related components for improved bumper template handling and ensure proper error management for variant operations. --- .../Endpoints/ChannelEndpoints.cs | 87 ++ .../Bumpers/AddBumperTextVariantCommand.cs | 8 + .../AddBumperTextVariantCommandHandler.cs | 33 + .../Bumpers/RemoveBumperTextVariantCommand.cs | 8 + .../RemoveBumperTextVariantCommandHandler.cs | 33 + .../RenderBumperPreviewQueryHandler.cs | 21 +- .../Bumpers/UpdateBumperTextVariantCommand.cs | 19 + .../UpdateBumperTextVariantCommandHandler.cs | 42 + ...UpdateBumperTextVariantCommandValidator.cs | 16 + .../Broadcast/ChannelDtos.cs | 23 +- .../Broadcast/ChannelErrors.cs | 10 + .../GetChannel/GetChannelQueryHandler.cs | 20 +- .../Broadcast/Scheduling/ScheduleGenerator.cs | 79 +- .../UpdateChannelSettingsCommand.cs | 3 - .../UpdateChannelSettingsCommandHandler.cs | 3 - .../UpdateChannelSettingsCommandValidator.cs | 2 - .../Common/Interfaces/IBumperRenderer.cs | 6 +- .../Broadcast/BumperTemplate.cs | 48 +- .../Broadcast/BumperTextKind.cs | 11 + .../Broadcast/BumperTextVariant.cs | 82 ++ .../Broadcast/BumperTrigger.cs | 14 + .../src/TeleWave.Domain/Broadcast/Channel.cs | 18 +- .../Broadcast/Scheduling/SchedulePlanner.cs | 45 +- .../Scheduling/SchedulePlannerModels.cs | 25 +- .../Media/FfmpegBumperRenderer.cs | 29 +- ...60725101820_BumperTextVariants.Designer.cs | 942 ++++++++++++++++++ .../20260725101820_BumperTextVariants.cs | 103 ++ .../Migrations/AppDbContextModelSnapshot.cs | 77 +- .../Configurations/ChannelConfiguration.cs | 20 + .../Broadcast/SchedulePlannerTests.cs | 41 +- .../features/admin/channels/ChannelDetail.tsx | 224 ++++- frontend/src/features/admin/channels/api.ts | 38 + frontend/src/shared/api/types.ts | 21 +- frontend/src/shared/lib/i18n.ts | 28 + 34 files changed, 2009 insertions(+), 170 deletions(-) create mode 100644 backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTextVariantCommand.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTextVariantCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTextVariantCommand.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTextVariantCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommand.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandValidator.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/BumperTextKind.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/BumperTrigger.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725101820_BumperTextVariants.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725101820_BumperTextVariants.cs diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs index 6e4920e..8b0a43b 100644 --- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs @@ -93,6 +93,22 @@ public static class ChannelEndpoints 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 .MapPost("/{id:guid}/overrides", CreateOverride) .Produces(StatusCodes.Status201Created); @@ -348,6 +364,65 @@ public static class ChannelEndpoints return result.ToHttpResult(); } + private static async Task AddBumperVariant( + Guid id, + Guid templateId, + AddBumperVariantBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new AddBumperTextVariantCommand(id, templateId, body.Name), + cancellationToken + ); + return result.IsSuccess + ? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value)) + : result.ToHttpResult(); + } + + private static async Task UpdateBumperVariant( + Guid id, + Guid templateId, + Guid variantId, + UpdateBumperVariantBody body, + 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 + ), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task RemoveBumperVariant( + Guid id, + Guid templateId, + Guid variantId, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new RemoveBumperTextVariantCommand(id, templateId, variantId), + cancellationToken + ); + return result.ToHttpResult(); + } + private static async Task ClearTemplateBackground( Guid id, Guid templateId, @@ -532,6 +607,18 @@ public sealed record AddBumperTemplateBody(string Name); 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 +); + public sealed record UpdateBumperTemplateBody( string Name, string BackgroundColor, diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTextVariantCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTextVariantCommand.cs new file mode 100644 index 0000000..bf97df3 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTextVariantCommand.cs @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..e405452 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTextVariantCommandHandler.cs @@ -0,0 +1,33 @@ +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) + .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/RemoveBumperTextVariantCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTextVariantCommand.cs new file mode 100644 index 0000000..39540e6 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTextVariantCommand.cs @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..5f08215 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTextVariantCommandHandler.cs @@ -0,0 +1,33 @@ +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) + .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/RenderBumperPreviewQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQueryHandler.cs index 2a84250..71beb54 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQueryHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQueryHandler.cs @@ -30,7 +30,9 @@ public sealed class RenderBumperPreviewQueryHandler( { var channel = await dbContext.Channels.AsNoTracking() .Include(c => c.BumperTemplates) + .ThenInclude(t => t.Variants) .Include(c => c.Shows) + .AsSplitQuery() .FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken); if (channel is null) return Result.Failure(ChannelErrors.NotFound); @@ -39,7 +41,13 @@ public sealed class RenderBumperPreviewQueryHandler( if (template is null) return Result.Failure(ChannelErrors.BumperTemplateNotFound); + // Превью показываем по первому подблоку (стиль/звук блока + его текст). + var variant = template.Variants.OrderBy(v => v.Position).FirstOrDefault(); + if (variant is null) + return Result.Failure(ChannelErrors.BumperTemplateNotFound); + var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken); + var free = variant.Kind == BumperTextKind.Free; // Фон блока — из общего реестра по id. string? backgroundPath = null; @@ -68,14 +76,17 @@ public sealed class RenderBumperPreviewQueryHandler( template.AccentColor, template.TextColor, channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans, - channel.BumperNowLabel, - fromName, - channel.BumperNextLabel, - toName, + free ? "" : variant.NowLabel, + free ? "" : fromName, + free ? "" : variant.NextLabel, + free ? "" : toName, backgroundPath, storage.AudioPath(template.Id, template.AudioExtension), // Постер зависит от конкретного «следующего» шоу — в превью не подставляем. - null + null, + free, + variant.Line1, + variant.Line2 ); var previewId = BumperPreview.AssetId(template.Id); diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommand.cs new file mode 100644 index 0000000..25e7c5e --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommand.cs @@ -0,0 +1,19 @@ +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 +) : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandHandler.cs new file mode 100644 index 0000000..7dfcc21 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandHandler.cs @@ -0,0 +1,42 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +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) + .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(), + command.Kind, + command.NowLabel, + command.NextLabel, + command.Line1, + command.Line2, + command.Trigger + ); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandValidator.cs new file mode 100644 index 0000000..e8ea288 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandValidator.cs @@ -0,0 +1,16 @@ +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); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs index 7625a82..85100a2 100644 --- a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs +++ b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs @@ -27,17 +27,27 @@ public sealed record ProgrammingOverrideDto( IReadOnlyList Shows ); -/// Общие для канала настройки ТВ-заставок (стиль/звук — на каждом блоке, см. ). +/// Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках). public sealed record BumperSettingsDto( BumperFont Font, - string NowLabel, - string NextLabel, int MinIntervalMinutes, - bool OnlyBetweenDifferentShows, BumperSelection Selection ); -/// Блок заставки: своё оформление + звук. — длина звука (сек). +/// Подблок (текст-вариант): свой текст + правило показа поверх стиля/звука блока. +public sealed record BumperTextVariantDto( + Guid Id, + int Position, + string Name, + BumperTextKind Kind, + string NowLabel, + string NextLabel, + string Line1, + string Line2, + BumperTrigger Trigger +); + +/// Блок заставки: своё оформление + звук + подблоки. — длина звука (сек). public sealed record BumperTemplateDto( Guid Id, int Position, @@ -49,7 +59,8 @@ public sealed record BumperTemplateDto( string TextColor, Guid? BackgroundImageId, bool HasAudio, - double? AudioDurationSeconds + double? AudioDurationSeconds, + IReadOnlyList Variants ); public sealed record ChannelDto( diff --git a/backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs b/backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs index 9a62eea..121e930 100644 --- a/backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs +++ b/backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs @@ -46,6 +46,16 @@ public static class ChannelErrors "Дефолтный блок заставки удалить нельзя." ); + public static readonly Error BumperTextVariantNotFound = Error.NotFound( + "Channels.BumperTextVariantNotFound", + "Подблок заставки не найден." + ); + + public static readonly Error CannotRemoveLastBumperTextVariant = Error.Validation( + "Channels.CannotRemoveLastBumperTextVariant", + "Нельзя удалить последний подблок — нужен хотя бы один." + ); + public static readonly Error AssetNotFound = Error.NotFound( "Channels.AssetNotFound", "Медиа-ассет не найден." diff --git a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs index fbd5390..97a7e7f 100644 --- a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs @@ -17,6 +17,7 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext) .Include(c => c.Shows) .Include(c => c.Ads) .Include(c => c.BumperTemplates) + .ThenInclude(t => t.Variants) .Include(c => c.Overrides) .ThenInclude(o => o.Shows) .AsSplitQuery() @@ -77,7 +78,21 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext) t.TextColor, t.BackgroundImageId, t.AudioExtension is not null, - t.AudioDurationSeconds + 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 + )) + .ToList() )) .ToList(); @@ -105,10 +120,7 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext) channel.BumpersEnabled, new BumperSettingsDto( channel.BumperFont, - channel.BumperNowLabel, - channel.BumperNextLabel, channel.BumperMinIntervalMinutes, - channel.BumperOnlyBetweenDifferentShows, channel.BumperSelection ), bumperTemplates, diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs index c74d76b..1fa774c 100644 --- a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs @@ -53,6 +53,7 @@ public sealed class ScheduleGenerator( .Include(c => c.Shows) .Include(c => c.Ads) .Include(c => c.BumperTemplates) + .ThenInclude(t => t.Variants) .Include(c => c.Overrides) .ThenInclude(o => o.Shows) .AsSplitQuery() @@ -147,16 +148,16 @@ public sealed class ScheduleGenerator( private static ScheduleEntry? BuildBumperEntry( Guid channelId, PlannedEntry entry, - IReadOnlyDictionary<(Guid From, Guid To, Guid Template), Guid> bumperAssets + IReadOnlyDictionary<(Guid From, Guid To, Guid Variant), Guid> bumperAssets ) { - // Заставка резолвится по паре шоу + выбранному блоку (отрендерена/из кэша). Если рендер не + // Заставка резолвится по паре шоу + выбранному подблоку (отрендерена/из кэша). Если рендер не // удался — пропускаем запись (слот заполнит филлер/следующая программа; длину планировщик учёл). if ( entry.FromShowId is not { } from || entry.ToShowId is not { } to - || entry.BumperTemplateId is not { } template - || !bumperAssets.TryGetValue((from, to, template), out var assetId) + || entry.BumperVariantId is not { } variant + || !bumperAssets.TryGetValue((from, to, variant), out var assetId) ) return null; @@ -164,10 +165,10 @@ public sealed class ScheduleGenerator( } /// - /// Для каждой уникальной тройки «из→в→блок» из запланированных заставок возвращает id готового + /// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id готового /// ассета-заставки: из кэша () либо свежесгенерированного. /// - private async Task> ResolveBumperAssetsAsync( + private async Task> ResolveBumperAssetsAsync( Channel channel, IReadOnlyList entries, IReadOnlyDictionary showNames, @@ -176,17 +177,20 @@ public sealed class ScheduleGenerator( { var result = new Dictionary<(Guid, Guid, Guid), Guid>(); var templatesById = channel.BumperTemplates.ToDictionary(t => t.Id); + var variantsById = channel.BumperTemplates + .SelectMany(t => t.Variants.Select(v => (Variant: v, Template: t))) + .ToDictionary(x => x.Variant.Id); var combos = entries .Where(e => e.Kind == ScheduleEntryKind.Bumper && e.FromShowId is not null && e.ToShowId is not null - && e.BumperTemplateId is not null + && e.BumperVariantId is not null ) .Select(e => ( From: e.FromShowId!.Value, To: e.ToShowId!.Value, - Template: e.BumperTemplateId!.Value + Variant: e.BumperVariantId!.Value )) .Distinct() .ToList(); @@ -256,17 +260,20 @@ public sealed class ScheduleGenerator( foreach (var combo in combos) { - if (!templatesById.TryGetValue(combo.Template, out var template)) + if (!variantsById.TryGetValue(combo.Variant, out var pair)) continue; + var (variant, template) = pair; var fromName = showNames.GetValueOrDefault(combo.From, "…"); var toName = showNames.GetValueOrDefault(combo.To, "…"); - var poster = posterByShow.TryGetValue(combo.To, out var pr) ? pr : default; + // Постер шоу-получателя как фон — только для «Сейчас/Далее» (свободный текст шоу не упоминает). + var usePoster = variant.Kind == BumperTextKind.NowNext; + var poster = usePoster && posterByShow.TryGetValue(combo.To, out var pr) ? pr : default; var posterToken = poster.ImageId == Guid.Empty ? "-" : poster.ImageId.ToString(); var posterAbs = poster.ImageId == Guid.Empty ? null : poster.AbsPath; var bgAbs = bgByTemplate.GetValueOrDefault(template.Id); var aligned = AlignedDurationSeconds(TemplateDurationSeconds(template)); - var signature = ComputeSignature(channel, template, fromName, toName, aligned, posterToken); + var signature = ComputeSignature(channel, template, variant, fromName, toName, aligned, posterToken); var hit = cached.FirstOrDefault(c => c.FromShowId == combo.From @@ -285,6 +292,7 @@ public sealed class ScheduleGenerator( var assetId = await RenderBumperAsync( channel, template, + variant, combo.From, combo.To, fromName, @@ -314,6 +322,7 @@ public sealed class ScheduleGenerator( private async Task RenderBumperAsync( Channel channel, BumperTemplate template, + BumperTextVariant variant, Guid fromShowId, Guid toShowId, string fromName, @@ -331,6 +340,7 @@ public sealed class ScheduleGenerator( BuildSpec( channel, template, + variant, alignedDurationSeconds, fromName, toName, @@ -361,13 +371,16 @@ public sealed class ScheduleGenerator( private BumperRenderSpec BuildSpec( Channel channel, BumperTemplate template, + BumperTextVariant variant, int alignedDurationSeconds, string fromName, string toName, string? posterAbsolutePath, string? backgroundAbsolutePath - ) => - new( + ) + { + var free = variant.Kind == BumperTextKind.Free; + return new BumperRenderSpec( alignedDurationSeconds, _bumper.Width, _bumper.Height, @@ -376,14 +389,18 @@ public sealed class ScheduleGenerator( template.AccentColor, template.TextColor, FontPath(channel.BumperFont), - channel.BumperNowLabel, - fromName, - channel.BumperNextLabel, - toName, + free ? "" : variant.NowLabel, + free ? "" : fromName, + free ? "" : variant.NextLabel, + free ? "" : toName, backgroundAbsolutePath, bumperStorage.AudioPath(template.Id, template.AudioExtension), - posterAbsolutePath + posterAbsolutePath, + free, + variant.Line1, + variant.Line2 ); + } private string FontPath(BumperFont font) => font == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans; @@ -407,6 +424,7 @@ public sealed class ScheduleGenerator( private string ComputeSignature( Channel channel, BumperTemplate template, + BumperTextVariant variant, string fromName, string toName, int alignedDurationSeconds, @@ -420,8 +438,11 @@ public sealed class ScheduleGenerator( _bumper.Height, alignedDurationSeconds, channel.BumperFont, - channel.BumperNowLabel, - channel.BumperNextLabel, + variant.Kind, + variant.NowLabel, + variant.NextLabel, + variant.Line1, + variant.Line2, template.BackgroundColor, template.BackgroundColor2, template.AccentColor, @@ -515,13 +536,16 @@ public sealed class ScheduleGenerator( .Where(durations.ContainsKey) .ToList(); - // Блоки заставок: длительность слота — по звуку (или дефолт), выровнена на сегмент. - var bumperTemplates = channel.BumperTemplates + // Подблоки заставок (плоский список): длительность слота — по звуку блока, выровнена на сегмент. + var bumperVariants = channel.BumperTemplates .OrderBy(t => t.Position) - .Select(t => new PlannerBumperTemplate( - t.Id, - TimeSpan.FromSeconds(AlignedDurationSeconds(TemplateDurationSeconds(t))) - )) + .SelectMany(t => + { + var dur = TimeSpan.FromSeconds(AlignedDurationSeconds(TemplateDurationSeconds(t))); + return t.Variants + .OrderBy(v => v.Position) + .Select(v => new PlannerBumperVariant(v.Id, t.Id, dur, v.Trigger)); + }) .ToList(); var overrides = channel.Overrides @@ -535,10 +559,9 @@ public sealed class ScheduleGenerator( var bumpers = new PlannerBumperConfig( channel.BumpersEnabled, - channel.BumperOnlyBetweenDifferentShows, TimeSpan.FromMinutes(channel.BumperMinIntervalMinutes), channel.BumperSelection, - bumperTemplates + bumperVariants ); return new PlannerInput( diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs index c8d579a..58eaaf4 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs @@ -18,9 +18,6 @@ public sealed record UpdateChannelSettingsCommand( /// Общие настройки ТВ-заставок канала (см. Channel.UpdateBumperSettings). public sealed record BumperSettingsInput( BumperFont Font, - string NowLabel, - string NextLabel, int MinIntervalMinutes, - bool OnlyBetweenDifferentShows, BumperSelection Selection ); diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs index c3100ba..4379730 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs @@ -37,10 +37,7 @@ public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext) ); channel.UpdateBumperSettings( command.Bumper.Font, - command.Bumper.NowLabel, - command.Bumper.NextLabel, command.Bumper.MinIntervalMinutes, - command.Bumper.OnlyBetweenDifferentShows, command.Bumper.Selection ); return Result.Success(); diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs index 8c3be01..5e2871a 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs @@ -11,7 +11,5 @@ public sealed class UpdateChannelSettingsCommandValidator RuleFor(x => x.AdsPerBreak).InclusiveBetween(0, 10); RuleFor(x => x.Bumper.MinIntervalMinutes).InclusiveBetween(0, 1440); - RuleFor(x => x.Bumper.NowLabel).MaximumLength(64); - RuleFor(x => x.Bumper.NextLabel).MaximumLength(64); } } diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs b/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs index 324d358..1ec606a 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs @@ -21,7 +21,11 @@ public sealed record BumperRenderSpec( string? BackgroundFile = null, string? MusicFile = null, /// Постер шоу как фон (используется, если нет загруженного фона канала; затемняется). - string? PosterFile = null + string? PosterFile = null, + /// Режим свободного текста: вместо «Сейчас/Далее» рисуются /. + bool FreeText = false, + string FreeLine1 = "", + string FreeLine2 = "" ); /// Итог рендера заставки — та же форма метаданных, что у обычного ассета после нарезки. diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperTemplate.cs b/backend/src/TeleWave.Domain/Broadcast/BumperTemplate.cs index 034fd78..e2dfe6e 100644 --- a/backend/src/TeleWave.Domain/Broadcast/BumperTemplate.cs +++ b/backend/src/TeleWave.Domain/Broadcast/BumperTemplate.cs @@ -1,16 +1,17 @@ namespace TeleWave.Domain.Broadcast; /// -/// Блок ТВ-заставки канала: свой звук + своё оформление (цвета, опциональная фон-картинка). На -/// переходе между шоу генератор рендерит «Сейчас/Далее» стилем блока поверх его звука; длительность -/// заставки определяется длиной звука (выравнивается на сегмент при рендере). Общие для канала шрифт, -/// подписи и правила показа живут на . +/// Блок ТВ-заставки канала: свой звук + своё оформление (цвета, опциональная фон-картинка) + набор +/// подблоков () с разным текстом и правилом показа. Длительность заставки — по +/// длине звука (выравнивается на сегмент при рендере). Общий для канала — только шрифт. /// /// Первый блок ( == 0) — дефолтный, не удаляется; если звук в нём не загружен, /// рендер синтезирует джингл по умолчанию. /// public class BumperTemplate { + private readonly List _variants = new(); + public Guid Id { get; private set; } public Guid ChannelId { get; private set; } @@ -46,10 +47,16 @@ public class BumperTemplate 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) => - new() + internal static BumperTemplate Create(Guid channelId, int position, string name) + { + var template = new BumperTemplate { Id = Guid.NewGuid(), ChannelId = channelId, @@ -65,6 +72,35 @@ public class BumperTemplate Revision = 0, CreatedAt = DateTimeOffset.UtcNow, }; + // Дефолтный подблок «Сейчас/Далее», показывается на смене шоу. + template._variants.Add( + BumperTextVariant.Create(template.Id, 0, DefaultVariantName, BumperTrigger.OnShowChange) + ); + return template; + } + + public BumperTextVariant AddVariant(string name) + { + var nextPosition = _variants.Count == 0 ? 0 : _variants.Max(v => v.Position) + 1; + var variant = BumperTextVariant.Create(Id, nextPosition, name, BumperTrigger.OnShowChange); + _variants.Add(variant); + return variant; + } + + public BumperTextVariant? FindVariant(Guid variantId) => + _variants.FirstOrDefault(v => v.Id == variantId); + + /// Удалить подблок. Последний подблок удалить нельзя (нужен хотя бы один) — вернёт false. + public bool RemoveVariant(Guid variantId) + { + if (_variants.Count <= 1) + return false; + var variant = _variants.FirstOrDefault(v => v.Id == variantId); + if (variant is null) + return false; + _variants.Remove(variant); + return true; + } /// Обновить имя и цвета блока. Цвета — в нотации ffmpeg (0xRRGGBB или имя). public void UpdateStyle( diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperTextKind.cs b/backend/src/TeleWave.Domain/Broadcast/BumperTextKind.cs new file mode 100644 index 0000000..7f2a9f8 --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/BumperTextKind.cs @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..07f719d --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs @@ -0,0 +1,82 @@ +namespace TeleWave.Domain.Broadcast; + +/// +/// Подблок заставки (текст-вариант) внутри . Наследует от блока звук, +/// стиль и фон, но задаёт собственный текст и правило показа (). Позволяет иметь +/// несколько текстов на одной музыке/оформлении, не дублируя блок. +/// +public class BumperTextVariant +{ + 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; } + + public DateTimeOffset CreatedAt { get; private set; } + + public const string DefaultNowLabel = "СЕЙЧАС"; + public const string DefaultNextLabel = "ДАЛЕЕ"; + + private BumperTextVariant() { } + + internal static BumperTextVariant Create( + Guid bumperTemplateId, + int position, + string name, + BumperTrigger trigger + ) => + new() + { + Id = Guid.NewGuid(), + BumperTemplateId = bumperTemplateId, + Position = position, + Name = name, + Kind = BumperTextKind.NowNext, + NowLabel = DefaultNowLabel, + NextLabel = DefaultNextLabel, + Line1 = string.Empty, + Line2 = string.Empty, + Trigger = trigger, + CreatedAt = DateTimeOffset.UtcNow, + }; + + public void Update( + string name, + BumperTextKind kind, + string nowLabel, + string nextLabel, + string line1, + string line2, + BumperTrigger trigger + ) + { + Name = name; + Kind = kind; + NowLabel = nowLabel; + NextLabel = nextLabel; + Line1 = line1; + Line2 = line2; + Trigger = trigger; + } + + /// Подходит ли подблок для перехода: — сменилось ли шоу. + public bool Matches(bool isShowChange) => + Trigger switch + { + BumperTrigger.OnShowChange => isShowChange, + BumperTrigger.BetweenEpisodes => !isShowChange, + _ => true, + }; +} diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperTrigger.cs b/backend/src/TeleWave.Domain/Broadcast/BumperTrigger.cs new file mode 100644 index 0000000..1a82b00 --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/BumperTrigger.cs @@ -0,0 +1,14 @@ +namespace TeleWave.Domain.Broadcast; + +/// На каких переходах показывать подблок заставки. +public enum BumperTrigger +{ + /// Только при смене шоу (следующее шоу отличается от текущего). + OnShowChange, + + /// Только между блоками одного шоу (шоу не меняется). + BetweenEpisodes, + + /// И на смене шоу, и между блоками одного шоу. + Both, +} diff --git a/backend/src/TeleWave.Domain/Broadcast/Channel.cs b/backend/src/TeleWave.Domain/Broadcast/Channel.cs index cc7a584..0c0e93b 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Channel.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Channel.cs @@ -35,17 +35,10 @@ public class Channel public int NextBumperIndex { get; private set; } public BumperFont BumperFont { get; private set; } - public string BumperNowLabel { get; private set; } = DefaultNowLabel; - public string BumperNextLabel { get; private set; } = DefaultNextLabel; /// Не вставлять заставку чаще, чем раз в N минут (0 — на каждом подходящем переходе). public int BumperMinIntervalMinutes { get; private set; } - /// Ставить заставку только на смене шоу (иначе — и внутри марафона одного шоу). - public bool BumperOnlyBetweenDifferentShows { get; private set; } - - private const string DefaultNowLabel = "СЕЙЧАС"; - private const string DefaultNextLabel = "ДАЛЕЕ"; private const string DefaultTemplateName = "Заставка 1"; /// Ассет-заглушка на случай пустого расписания (аварийная подстраховка). @@ -82,10 +75,7 @@ public class Channel BumperSelection = BumperSelection.Rotation, NextBumperIndex = 0, BumperFont = BumperFont.Sans, - BumperNowLabel = DefaultNowLabel, - BumperNextLabel = DefaultNextLabel, BumperMinIntervalMinutes = 0, - BumperOnlyBetweenDifferentShows = true, NextAdIndex = 0, CreatedAt = DateTimeOffset.UtcNow, }; @@ -111,21 +101,15 @@ public class Channel FillerAssetId = fillerAssetId; } - /// Общие настройки ТВ-заставок канала: шрифт, подписи, правила показа и стратегия выбора блока. + /// Общие настройки ТВ-заставок канала: шрифт, мин. интервал и стратегия выбора подблока. public void UpdateBumperSettings( BumperFont font, - string nowLabel, - string nextLabel, int minIntervalMinutes, - bool onlyBetweenDifferentShows, BumperSelection selection ) { BumperFont = font; - BumperNowLabel = nowLabel; - BumperNextLabel = nextLabel; BumperMinIntervalMinutes = Math.Max(0, minIntervalMinutes); - BumperOnlyBetweenDifferentShows = onlyBetweenDifferentShows; BumperSelection = selection; } diff --git a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs index 0ec8c73..a56a1d8 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs @@ -39,12 +39,11 @@ public static class SchedulePlanner var pick = WeightedPick(candidates, random); - // ТВ-заставка на переходе. Резервируем слот выбранного блока фикс. длины — конкретный - // отрендеренный ассет («Сейчас/Далее» стилем блока поверх его звука) подставит оркестратор. + // ТВ-заставка на переходе. Из подходящих подблоков (по правилу показа vs контексту) + // резервируем слот выбранного блока — ассет подставит оркестратор. if ( prevShowId is { } prev && input.Bumpers is { Enabled: true } bumper - && (!bumper.OnlyBetweenDifferentShows || prev != pick.ShowId) && ( bumper.MinInterval <= TimeSpan.Zero || lastBumperAt is not { } last @@ -94,9 +93,10 @@ public static class SchedulePlanner } /// - /// Ставит на переходе заставку выбранного блока: резервирует слот его длины и оставляет - /// плейсхолдер с парой шоу + id блока (ассет отрендерит оркестратор). Выбор блока — по стратегии - /// канала (ротация двигает курсор). Возвращает true, если заставка добавлена (курсор сдвинут). + /// Ставит на переходе заставку выбранного подблока: из подходящих по правилу показа (контекст — + /// сменилось ли шоу) выбирает один по стратегии канала и резервирует слот длины его блока. Оставляет + /// плейсхолдер с парой шоу + id блока/варианта (ассет отрендерит оркестратор). Возвращает true, если + /// заставка добавлена (курсор сдвинут). /// private static bool TryPlaceBumper( List entries, @@ -108,30 +108,30 @@ public static class SchedulePlanner ref DateTimeOffset cursor ) { - var templates = bumper.Templates; - if (templates is not { Count: > 0 }) + var isShowChange = fromShowId != toShowId; + var eligible = bumper.Variants + .Where(v => v.Duration > TimeSpan.Zero && MatchesTrigger(v.Trigger, isShowChange)) + .ToList(); + if (eligible.Count == 0) return false; - PlannerBumperTemplate template; + PlannerBumperVariant variant; switch (bumper.Selection) { case BumperSelection.Random: - template = templates[random.Next(templates.Count)]; + variant = eligible[random.Next(eligible.Count)]; break; case BumperSelection.AlwaysFirst: - template = templates[0]; + variant = eligible[0]; break; default: // Rotation - var idx = ((nextBumper % templates.Count) + templates.Count) % templates.Count; - template = templates[idx]; + var idx = ((nextBumper % eligible.Count) + eligible.Count) % eligible.Count; + variant = eligible[idx]; nextBumper++; break; } - if (template.Duration <= TimeSpan.Zero) - return false; - - var end = cursor + template.Duration; + var end = cursor + variant.Duration; entries.Add( new PlannedEntry( Guid.Empty, @@ -142,13 +142,22 @@ public static class SchedulePlanner null, fromShowId, toShowId, - template.TemplateId + variant.TemplateId, + variant.VariantId ) ); cursor = end; return true; } + private static bool MatchesTrigger(BumperTrigger trigger, bool isShowChange) => + trigger switch + { + BumperTrigger.OnShowChange => isShowChange, + BumperTrigger.BetweenEpisodes => !isShowChange, + _ => true, + }; + private static List<(PlannerShow Show, int Weight)> ResolvePolicy( DateTimeOffset moment, PlannerInput input, diff --git a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs index 001aeea..1eef3ea 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs @@ -22,21 +22,27 @@ public sealed record PlannerOverride( public sealed record PlannerOverrideShow(Guid ShowId, int Weight); /// -/// Политика ТВ-заставок на переходах. Планировщик выбирает блок () по -/// стратегии и резервирует слот его длины (, -/// уже выровнена генератором на сегмент). Конкретный отрендеренный ассет подставляет оркестратор -/// по паре шоу + выбранному блоку. +/// Политика ТВ-заставок на переходах. Планировщик из подходящих подблоков (, +/// фильтр по и контексту перехода) выбирает один по стратегии +/// и резервирует слот длины его блока. Ассет подставляет оркестратор. /// public sealed record PlannerBumperConfig( bool Enabled, - bool OnlyBetweenDifferentShows, TimeSpan MinInterval, BumperSelection Selection, - IReadOnlyList Templates + IReadOnlyList Variants ); -/// Блок заставки в терминах планировщика: id + длительность слота (кратна сегменту). -public sealed record PlannerBumperTemplate(Guid TemplateId, TimeSpan Duration); +/// +/// Подблок заставки в терминах планировщика: id варианта + id родительского блока (стиль/звук) + +/// длительность слота (кратна сегменту) + правило показа. +/// +public sealed record PlannerBumperVariant( + Guid VariantId, + Guid TemplateId, + TimeSpan Duration, + BumperTrigger Trigger +); /// Полный вход планировщика для одного прогона по каналу. public sealed record PlannerInput( @@ -69,7 +75,8 @@ public sealed record PlannedEntry( int? EpisodeIndex, Guid? FromShowId = null, Guid? ToShowId = null, - Guid? BumperTemplateId = null + Guid? BumperTemplateId = null, + Guid? BumperVariantId = null ); /// Результат прогона: новые записи + обновлённые курсоры (серий по каждому ChannelShow, рекламы, заставок). diff --git a/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs b/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs index 66fd196..446308e 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs @@ -35,12 +35,14 @@ public sealed class FfmpegBumperRenderer( Directory.Delete(assetDir, recursive: true); Directory.CreateDirectory(assetDir); - // Динамический текст (названия шоу) пишем в файлы и читаем через textfile= с expansion=none — - // так произвольные символы/кириллица не ломают синтаксис фильтра. + // Динамический текст (названия шоу / свободные строки) пишем в файлы и читаем через textfile= + // с expansion=none — так произвольные символы/кириллица не ломают синтаксис фильтра. var nowFile = Path.Combine(assetDir, "now.txt"); var nextFile = Path.Combine(assetDir, "next.txt"); - await File.WriteAllTextAsync(nowFile, spec.NowTitle, new UTF8Encoding(false), cancellationToken); - await File.WriteAllTextAsync(nextFile, spec.NextTitle, new UTF8Encoding(false), cancellationToken); + var line1 = spec.FreeText ? spec.FreeLine1 : spec.NowTitle; + var line2 = spec.FreeText ? spec.FreeLine2 : spec.NextTitle; + await File.WriteAllTextAsync(nowFile, line1, new UTF8Encoding(false), cancellationToken); + await File.WriteAllTextAsync(nextFile, line2, new UTF8Encoding(false), cancellationToken); try { @@ -156,10 +158,21 @@ public sealed class FfmpegBumperRenderer( } var vchain = new StringBuilder(videoPrefix); - vchain.Append(',').Append(DrawLabel(font, spec.NowLabel, spec.AccentColor, labelSize, nowLabelY, 0.2)); - vchain.Append(',').Append(DrawTitle(font, nowFile, spec.TextColor, titleSize, nowTitleY, 0.3)); - vchain.Append(',').Append(DrawLabel(font, spec.NextLabel, spec.AccentColor, labelSize, nextLabelY, 1.0)); - vchain.Append(',').Append(DrawTitle(font, nextFile, spec.TextColor, titleSize, nextTitleY, 1.1)); + if (spec.FreeText) + { + // Свободный текст: две центрированные строки (акцентная + основная). + var line1Y = (int)(h * 0.40); + var line2Y = line1Y + (int)(titleSize * 1.2); + vchain.Append(',').Append(DrawTitle(font, nowFile, spec.AccentColor, labelSize + 4, line1Y, 0.2)); + vchain.Append(',').Append(DrawTitle(font, nextFile, spec.TextColor, titleSize, line2Y, 0.5)); + } + else + { + vchain.Append(',').Append(DrawLabel(font, spec.NowLabel, spec.AccentColor, labelSize, nowLabelY, 0.2)); + vchain.Append(',').Append(DrawTitle(font, nowFile, spec.TextColor, titleSize, nowTitleY, 0.3)); + vchain.Append(',').Append(DrawLabel(font, spec.NextLabel, spec.AccentColor, labelSize, nextLabelY, 1.0)); + vchain.Append(',').Append(DrawTitle(font, nextFile, spec.TextColor, titleSize, nextTitleY, 1.1)); + } vchain.Append("[v]"); var filterComplex = $"{vchain};{audioChain}"; diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725101820_BumperTextVariants.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725101820_BumperTextVariants.Designer.cs new file mode 100644 index 0000000..48e2787 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725101820_BumperTextVariants.Designer.cs @@ -0,0 +1,942 @@ +// +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("20260725101820_BumperTextVariants")] + partial class BumperTextVariants + { + /// + 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("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariant"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("NextBumperIndex") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc"); + + b.ToTable("ProgrammingOverride"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "ShowId"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Category", "CreatedAt"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Ads") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Shows") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null) + .WithMany("Shows") + .HasForeignKey("ProgrammingOverrideId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Overrides") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("Ads"); + + b.Navigation("BumperTemplates"); + + b.Navigation("Overrides"); + + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725101820_BumperTextVariants.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725101820_BumperTextVariants.cs new file mode 100644 index 0000000..b1fbc52 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725101820_BumperTextVariants.cs @@ -0,0 +1,103 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class BumperTextVariants : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "BumperTextVariant", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + BumperTemplateId = table.Column(type: "uuid", nullable: false), + Position = table.Column(type: "integer", nullable: false), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Kind = table.Column(type: "integer", nullable: false), + NowLabel = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + NextLabel = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Line1 = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + Line2 = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + Trigger = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BumperTextVariant", x => x.Id); + table.ForeignKey( + name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId", + column: x => x.BumperTemplateId, + principalTable: "BumperTemplate", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_BumperTextVariant_BumperTemplateId_Position", + table: "BumperTextVariant", + columns: new[] { "BumperTemplateId", "Position" }); + + // Каждому блоку — дефолтный подблок «Сейчас/Далее» с прежними подписями канала; правило + // показа переносим из старой галочки (только на смене шоу → OnShowChange=0, иначе Both=2). + migrationBuilder.Sql( + """ + DO $$ + DECLARE r RECORD; + BEGIN + FOR r IN + SELECT t."Id" AS tid, c."BumperNowLabel" AS nl, c."BumperNextLabel" AS xl, + c."BumperOnlyBetweenDifferentShows" AS only_diff + FROM "BumperTemplate" t + JOIN "Channels" c ON c."Id" = t."ChannelId" + LOOP + INSERT INTO "BumperTextVariant" + ("Id", "BumperTemplateId", "Position", "Name", "Kind", + "NowLabel", "NextLabel", "Line1", "Line2", "Trigger", "CreatedAt") + VALUES (gen_random_uuid(), r.tid, 0, 'Текст 1', 0, + COALESCE(NULLIF(r.nl, ''), 'СЕЙЧАС'), COALESCE(NULLIF(r.xl, ''), 'ДАЛЕЕ'), + '', '', CASE WHEN r.only_diff THEN 0 ELSE 2 END, now()); + END LOOP; + END $$; + """ + ); + + migrationBuilder.DropColumn(name: "BumperNextLabel", table: "Channels"); + migrationBuilder.DropColumn(name: "BumperNowLabel", table: "Channels"); + migrationBuilder.DropColumn(name: "BumperOnlyBetweenDifferentShows", table: "Channels"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BumperTextVariant"); + + migrationBuilder.AddColumn( + name: "BumperNextLabel", + table: "Channels", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "BumperNowLabel", + table: "Channels", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "BumperOnlyBetweenDifferentShows", + table: "Channels", + type: "boolean", + nullable: false, + defaultValue: false); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 6d58334..578bc05 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -247,6 +247,58 @@ namespace TeleWave.Infrastructure.Migrations b.ToTable("BumperTemplate"); }); + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariant"); + }); + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => { b.Property("Id") @@ -264,17 +316,6 @@ namespace TeleWave.Infrastructure.Migrations b.Property("BumperMinIntervalMinutes") .HasColumnType("integer"); - b.Property("BumperNextLabel") - .IsRequired() - .HasColumnType("text"); - - b.Property("BumperNowLabel") - .IsRequired() - .HasColumnType("text"); - - b.Property("BumperOnlyBetweenDifferentShows") - .HasColumnType("boolean"); - b.Property("BumperSelection") .HasColumnType("integer"); @@ -813,6 +854,15 @@ namespace TeleWave.Infrastructure.Migrations .IsRequired(); }); + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => { b.HasOne("TeleWave.Domain.Broadcast.Channel", null) @@ -858,6 +908,11 @@ namespace TeleWave.Infrastructure.Migrations .IsRequired(); }); + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => { b.Navigation("Ads"); diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs index 3888b4a..4fa13a9 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs @@ -70,6 +70,26 @@ public class BumperTemplateConfiguration : IEntityTypeConfiguration x.AccentColor).IsRequired().HasMaxLength(32); builder.Property(x => x.TextColor).IsRequired().HasMaxLength(32); builder.Property(x => x.AudioExtension).HasMaxLength(16); + + builder + .HasMany(x => x.Variants) + .WithOne() + .HasForeignKey(v => v.BumperTemplateId) + .OnDelete(DeleteBehavior.Cascade); + builder.Navigation(x => x.Variants).UsePropertyAccessMode(PropertyAccessMode.Field); + } +} + +public class BumperTextVariantConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasIndex(x => 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); } } diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs index 5362f35..884dbad 100644 --- a/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs @@ -21,23 +21,23 @@ public class SchedulePlannerTests items.ToDictionary(x => x.Id, x => TimeSpan.FromMinutes(x.Minutes)); private static readonly Guid DefaultTemplate = Guid.NewGuid(); + private static readonly Guid DefaultVariant = Guid.NewGuid(); - /// Конфиг заставок с одним дефолтным блоком (8с), если явно не заданы блоки. + /// Конфиг заставок с одним дефолтным подблоком (8с) заданного триггера, если варианты не заданы. private static PlannerBumperConfig Bumper( bool enabled, - bool onlyBetweenDifferentShows, + BumperTrigger trigger, TimeSpan minInterval, BumperSelection selection = BumperSelection.Rotation, - params PlannerBumperTemplate[] templates + params PlannerBumperVariant[] variants ) => new( enabled, - onlyBetweenDifferentShows, minInterval, selection, - templates.Length == 0 - ? [new PlannerBumperTemplate(DefaultTemplate, TimeSpan.FromSeconds(8))] - : templates + variants.Length == 0 + ? [new PlannerBumperVariant(DefaultVariant, DefaultTemplate, TimeSpan.FromSeconds(8), trigger)] + : variants ); [Fact] @@ -232,7 +232,7 @@ public class SchedulePlannerTests var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with { - Bumpers = Bumper(true, onlyBetweenDifferentShows: true, TimeSpan.Zero), + Bumpers = Bumper(true, BumperTrigger.OnShowChange, TimeSpan.Zero), }; // Чередуем выбор: roll 0 → a, roll 1 → b (веса 1/1, total 2). @@ -267,7 +267,7 @@ public class SchedulePlannerTests horizonEnd: Start.AddMinutes(50) ) with { - Bumpers = Bumper(true, onlyBetweenDifferentShows: true, TimeSpan.Zero), + Bumpers = Bumper(true, BumperTrigger.OnShowChange, TimeSpan.Zero), }; var result = SchedulePlanner.Plan(input, new FixedRandom(0)); @@ -287,7 +287,7 @@ public class SchedulePlannerTests horizonEnd: Start.AddMinutes(50) ) with { - Bumpers = Bumper(true, onlyBetweenDifferentShows: false, TimeSpan.Zero), + Bumpers = Bumper(true, BumperTrigger.Both, TimeSpan.Zero), }; var result = SchedulePlanner.Plan(input, new FixedRandom(0)); @@ -305,7 +305,7 @@ public class SchedulePlannerTests // Два перехода в горизонте (~на 20-й и ~40-й минуте), но интервал 30 мин пропускает второй. var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with { - Bumpers = Bumper(true, onlyBetweenDifferentShows: true, TimeSpan.FromMinutes(30)), + Bumpers = Bumper(true, BumperTrigger.OnShowChange, TimeSpan.FromMinutes(30)), }; var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); @@ -326,11 +326,11 @@ public class SchedulePlannerTests { Bumpers = Bumper( true, - onlyBetweenDifferentShows: true, + BumperTrigger.Both, TimeSpan.Zero, BumperSelection.Rotation, - new PlannerBumperTemplate(t0, TimeSpan.FromSeconds(8)), - new PlannerBumperTemplate(t1, TimeSpan.FromSeconds(4)) + new PlannerBumperVariant(Guid.NewGuid(), t0, TimeSpan.FromSeconds(8), BumperTrigger.Both), + new PlannerBumperVariant(Guid.NewGuid(), t1, TimeSpan.FromSeconds(4), BumperTrigger.Both) ), }; @@ -338,7 +338,7 @@ public class SchedulePlannerTests var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList(); Assert.True(bumpers.Count >= 2); - // Ротация: первый блок → t0 (8с), второй → t1 (4с). Все — плейсхолдеры по паре шоу. + // Ротация: первый подблок → t0 (8с), второй → t1 (4с). Все — плейсхолдеры по паре шоу. Assert.Equal(t0, bumpers[0].BumperTemplateId); Assert.Equal(TimeSpan.FromSeconds(8), bumpers[0].EndsAtUtc - bumpers[0].StartsAtUtc); Assert.Equal(t1, bumpers[1].BumperTemplateId); @@ -360,11 +360,11 @@ public class SchedulePlannerTests { Bumpers = Bumper( true, - onlyBetweenDifferentShows: true, + BumperTrigger.Both, TimeSpan.Zero, BumperSelection.AlwaysFirst, - new PlannerBumperTemplate(t0, TimeSpan.FromSeconds(8)), - new PlannerBumperTemplate(t1, TimeSpan.FromSeconds(4)) + new PlannerBumperVariant(Guid.NewGuid(), t0, TimeSpan.FromSeconds(8), BumperTrigger.Both), + new PlannerBumperVariant(Guid.NewGuid(), t1, TimeSpan.FromSeconds(4), BumperTrigger.Both) ), }; @@ -387,10 +387,9 @@ public class SchedulePlannerTests { Bumpers = new PlannerBumperConfig( true, - OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero, Selection: BumperSelection.Rotation, - Templates: [] + Variants: [] ), }; @@ -408,7 +407,7 @@ public class SchedulePlannerTests var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with { - Bumpers = Bumper(false, onlyBetweenDifferentShows: true, TimeSpan.Zero), + Bumpers = Bumper(false, BumperTrigger.OnShowChange, TimeSpan.Zero), }; var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index c106f2e..d929563 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -14,6 +14,9 @@ import type { BumperSelection, BumperSettings, BumperTemplateDto, + BumperTextKind, + BumperTextVariantDto, + BumperTrigger, ChannelShowDto, OverrideMode, ScheduleEntryDto, @@ -29,6 +32,7 @@ import { listMedia } from '@/features/admin/media/api' import { listShows } from '@/features/admin/shows/api' import { addBumperTemplate, + addBumperVariant, addChannelAd, addChannelShow, bumperPreviewPlaylistUrl, @@ -40,11 +44,13 @@ import { getSchedule, regenerateSchedule, removeBumperTemplate, + removeBumperVariant, removeChannelAd, removeChannelShow, renderBumperPreview, setBumperTemplateBackground, updateBumperTemplate, + updateBumperVariant, updateChannelSettings, updateChannelShow, uploadBumperTemplateAudio, @@ -483,31 +489,7 @@ function BumperCard({ onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))} /> -
- - setField('nowLabel', e.target.value)} - /> -
-
- - setField('nextLabel', e.target.value)} - /> -
-
+ {/* Подблоки (текст-варианты) */} +
+

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

+

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

+ {[...template.variants] + .sort((a, b) => a.position - b.position) + .map((variant) => ( + 1} + onChanged={onChanged} + onError={onError} + /> + ))} +
+ +
+
+
@@ -692,6 +708,166 @@ function BumperTemplateEditor({ ) } +function BumperVariantEditor({ + channelId, + templateId, + variant, + canRemove, + onChanged, + onError, +}: { + 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, + }) + + useEffect(() => { + setForm({ + name: variant.name, + kind: variant.kind, + nowLabel: variant.nowLabel, + nextLabel: variant.nextLabel, + line1: variant.line1, + line2: variant.line2, + trigger: variant.trigger, + }) + }, [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)} /> +
+
+ + +
+
+ + +
+
+ +
+ {form.kind === 'NowNext' ? ( + <> +
+ + set('nowLabel', e.target.value)} + /> +
+
+ + set('nextLabel', e.target.value)} + /> +
+ + ) : ( + <> +
+ + set('line1', e.target.value)} + /> +
+
+ + set('line2', e.target.value)} + /> +
+ + )} +
+ +
+ {canRemove && ( + + )} + +
+
+ ) +} + function BumperPreviewPlayer({ channelId, templateId, diff --git a/frontend/src/features/admin/channels/api.ts b/frontend/src/features/admin/channels/api.ts index 49f432d..e6a74b5 100644 --- a/frontend/src/features/admin/channels/api.ts +++ b/frontend/src/features/admin/channels/api.ts @@ -3,6 +3,8 @@ import type { AdInsertion, BlockMode, BumperSettings, + BumperTextKind, + BumperTrigger, ChannelDto, ChannelSummaryDto, CreatedIdResponse, @@ -137,6 +139,42 @@ export function uploadBumperTemplateAudio(id: string, templateId: string, file: return uploadBumperTemplateFile(id, templateId, 'audio', file) } +export type BumperVariantBody = { + name: string + kind: BumperTextKind + nowLabel: string + nextLabel: string + line1: string + line2: string + trigger: BumperTrigger +} + +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`, { diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index eee8100..062a9c4 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -128,15 +128,27 @@ export type OverrideMode = 'Exclusive' | 'Boost' export type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper' export type BumperFont = 'Sans' | 'Serif' export type BumperSelection = 'Rotation' | 'Random' | 'AlwaysFirst' +export type BumperTextKind = 'NowNext' | 'Free' +export type BumperTrigger = 'OnShowChange' | 'BetweenEpisodes' | 'Both' -/** Общие для канала настройки заставок (стиль/звук — на каждом блоке, см. BumperTemplateDto). */ +/** Общие для канала настройки заставок (стиль/звук/текст — на блоках и подблоках). */ export type BumperSettings = { font: BumperFont + minIntervalMinutes: number + selection: BumperSelection +} + +/** Подблок (текст-вариант): свой текст + правило показа поверх стиля/звука блока. */ +export type BumperTextVariantDto = { + id: string + position: number + name: string + kind: BumperTextKind nowLabel: string nextLabel: string - minIntervalMinutes: number - onlyBetweenDifferentShows: boolean - selection: BumperSelection + line1: string + line2: string + trigger: BumperTrigger } export type BumperTemplateDto = { @@ -151,6 +163,7 @@ export type BumperTemplateDto = { backgroundImageId: string | null hasAudio: boolean audioDurationSeconds: number | null + variants: BumperTextVariantDto[] } export type ChannelSummaryDto = { diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 6b3a424..2b7734e 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -220,6 +220,20 @@ const resources = { 'Каждый блок — свой звук и оформление. Первый блок дефолтный, его нельзя удалить. Длительность заставки — по длине звука.', bumperAddTemplate: 'Добавить блок', bumperTemplateName: 'Название', + bumperVariants: 'Подблоки (текст)', + bumperVariantsHint: + 'Разный текст на одной музыке и оформлении блока. Правило показа — у каждого подблока своё.', + bumperAddVariant: 'Добавить текст', + bumperVariantName: 'Название', + bumperTextKind: 'Режим текста', + bumperKindNowNext: 'Сейчас / Далее', + bumperKindFree: 'Свободный текст', + bumperLine1: 'Строка 1', + bumperLine2: 'Строка 2', + bumperTrigger: 'Показывать', + bumperTriggerOnShowChange: 'При смене шоу', + bumperTriggerBetweenEpisodes: 'Между сериями', + bumperTriggerBoth: 'Оба', bumperDefault: 'по умолчанию', bumperSeconds: 'с', bumperDefaultDuration: '≈8 с (джингл)', @@ -526,6 +540,20 @@ const resources = { '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', bumperDefault: 'default', bumperSeconds: 's', bumperDefaultDuration: '≈8 s (jingle)',