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.

This commit is contained in:
Leonid Pershin
2026-07-25 13:24:11 +03:00
parent f640af1fc4
commit a65bcf4258
34 changed files with 2009 additions and 170 deletions
@@ -0,0 +1,8 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Добавить подблок (текст-вариант) в блок заставки.</summary>
public sealed record AddBumperTextVariantCommand(Guid ChannelId, Guid TemplateId, string Name)
: ICommand<Result<Guid>>;
@@ -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<AddBumperTextVariantCommand, Result<Guid>>
{
public async Task<Result<Guid>> 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<Guid>(ChannelErrors.NotFound);
var template = channel.FindBumperTemplate(command.TemplateId);
if (template is null)
return Result.Failure<Guid>(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);
}
}
@@ -0,0 +1,8 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Удалить подблок (кроме последнего) из блока заставки.</summary>
public sealed record RemoveBumperTextVariantCommand(Guid ChannelId, Guid TemplateId, Guid VariantId)
: ICommand<Result>;
@@ -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<RemoveBumperTextVariantCommand, Result>
{
public async Task<Result> 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);
}
}
@@ -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<Guid>(ChannelErrors.NotFound);
@@ -39,7 +41,13 @@ public sealed class RenderBumperPreviewQueryHandler(
if (template is null)
return Result.Failure<Guid>(ChannelErrors.BumperTemplateNotFound);
// Превью показываем по первому подблоку (стиль/звук блока + его текст).
var variant = template.Variants.OrderBy(v => v.Position).FirstOrDefault();
if (variant is null)
return Result.Failure<Guid>(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);
@@ -0,0 +1,19 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Обновить подблок: имя, режим текста, текст и правило показа.</summary>
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<Result>;
@@ -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<UpdateBumperTextVariantCommand, Result>
{
public async Task<Result> 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();
}
}
@@ -0,0 +1,16 @@
using FluentValidation;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class UpdateBumperTextVariantCommandValidator
: AbstractValidator<UpdateBumperTextVariantCommand>
{
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);
}
}
@@ -27,17 +27,27 @@ public sealed record ProgrammingOverrideDto(
IReadOnlyList<OverrideShowDto> Shows
);
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук — на каждом блоке, см. <see cref="BumperTemplateDto"/>).</summary>
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary>
public sealed record BumperSettingsDto(
BumperFont Font,
string NowLabel,
string NextLabel,
int MinIntervalMinutes,
bool OnlyBetweenDifferentShows,
BumperSelection Selection
);
/// <summary>Блок заставки: своё оформление + звук. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
/// <summary>Подблок (текст-вариант): свой текст + правило показа поверх стиля/звука блока.</summary>
public sealed record BumperTextVariantDto(
Guid Id,
int Position,
string Name,
BumperTextKind Kind,
string NowLabel,
string NextLabel,
string Line1,
string Line2,
BumperTrigger Trigger
);
/// <summary>Блок заставки: своё оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
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<BumperTextVariantDto> Variants
);
public sealed record ChannelDto(
@@ -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",
"Медиа-ассет не найден."
@@ -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,
@@ -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(
}
/// <summary>
/// Для каждой уникальной тройки «из→в→блок» из запланированных заставок возвращает id готового
/// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id готового
/// ассета-заставки: из кэша (<see cref="BumperAsset"/>) либо свежесгенерированного.
/// </summary>
private async Task<Dictionary<(Guid From, Guid To, Guid Template), Guid>> ResolveBumperAssetsAsync(
private async Task<Dictionary<(Guid From, Guid To, Guid Variant), Guid>> ResolveBumperAssetsAsync(
Channel channel,
IReadOnlyList<PlannedEntry> entries,
IReadOnlyDictionary<Guid, string> 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<Guid> 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(
@@ -18,9 +18,6 @@ public sealed record UpdateChannelSettingsCommand(
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>).</summary>
public sealed record BumperSettingsInput(
BumperFont Font,
string NowLabel,
string NextLabel,
int MinIntervalMinutes,
bool OnlyBetweenDifferentShows,
BumperSelection Selection
);
@@ -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();
@@ -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);
}
}
@@ -21,7 +21,11 @@ public sealed record BumperRenderSpec(
string? BackgroundFile = null,
string? MusicFile = null,
/// <summary>Постер шоу как фон (используется, если нет загруженного фона канала; затемняется).</summary>
string? PosterFile = null
string? PosterFile = null,
/// <summary>Режим свободного текста: вместо «Сейчас/Далее» рисуются <see cref="FreeLine1"/>/<see cref="FreeLine2"/>.</summary>
bool FreeText = false,
string FreeLine1 = "",
string FreeLine2 = ""
);
/// <summary>Итог рендера заставки — та же форма метаданных, что у обычного ассета после нарезки.</summary>