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)',