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
@@ -93,6 +93,22 @@ public static class ChannelEndpoints
PreviewSegment
);
admin
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant)
.Produces<CreatedIdResponse>(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<CreatedIdResponse>(StatusCodes.Status201Created);
@@ -348,6 +364,65 @@ public static class ChannelEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> 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<IResult> 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<IResult> 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,
@@ -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>
@@ -1,16 +1,17 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Блок ТВ-заставки канала: свой звук + своё оформление (цвета, опциональная фон-картинка). На
/// переходе между шоу генератор рендерит «Сейчас/Далее» стилем блока поверх его звука; длительность
/// заставки определяется длиной звука (выравнивается на сегмент при рендере). Общие для канала шрифт,
/// подписи и правила показа живут на <see cref="Channel"/>.
/// Блок ТВ-заставки канала: свой звук + своё оформление (цвета, опциональная фон-картинка) + набор
/// подблоков (<see cref="Variants"/>) с разным текстом и правилом показа. Длительность заставки — по
/// длине звука (выравнивается на сегмент при рендере). Общий для канала — только шрифт.
///
/// Первый блок (<see cref="Position"/> == 0) — дефолтный, не удаляется; если звук в нём не загружен,
/// рендер синтезирует джингл по умолчанию.
/// </summary>
public class BumperTemplate
{
private readonly List<BumperTextVariant> _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;
/// <summary>Подблоки (текст-варианты); порядок — по <see cref="BumperTextVariant.Position"/>.</summary>
public IReadOnlyList<BumperTextVariant> 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);
/// <summary>Удалить подблок. Последний подблок удалить нельзя (нужен хотя бы один) — вернёт false.</summary>
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;
}
/// <summary>Обновить имя и цвета блока. Цвета — в нотации ffmpeg (0xRRGGBB или имя).</summary>
public void UpdateStyle(
@@ -0,0 +1,11 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Как формируется текст подблока заставки.</summary>
public enum BumperTextKind
{
/// <summary>«Сейчас/Далее»: две подписи + названия текущего и следующего шоу.</summary>
NowNext,
/// <summary>Произвольные строки (без названий шоу) — например название канала и совет.</summary>
Free,
}
@@ -0,0 +1,82 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Подблок заставки (текст-вариант) внутри <see cref="BumperTemplate"/>. Наследует от блока звук,
/// стиль и фон, но задаёт собственный текст и правило показа (<see cref="Trigger"/>). Позволяет иметь
/// несколько текстов на одной музыке/оформлении, не дублируя блок.
/// </summary>
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;
}
/// <summary>Подходит ли подблок для перехода: <paramref name="isShowChange"/> — сменилось ли шоу.</summary>
public bool Matches(bool isShowChange) =>
Trigger switch
{
BumperTrigger.OnShowChange => isShowChange,
BumperTrigger.BetweenEpisodes => !isShowChange,
_ => true,
};
}
@@ -0,0 +1,14 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>На каких переходах показывать подблок заставки.</summary>
public enum BumperTrigger
{
/// <summary>Только при смене шоу (следующее шоу отличается от текущего).</summary>
OnShowChange,
/// <summary>Только между блоками одного шоу (шоу не меняется).</summary>
BetweenEpisodes,
/// <summary>И на смене шоу, и между блоками одного шоу.</summary>
Both,
}
@@ -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;
/// <summary>Не вставлять заставку чаще, чем раз в N минут (0 — на каждом подходящем переходе).</summary>
public int BumperMinIntervalMinutes { get; private set; }
/// <summary>Ставить заставку только на смене шоу (иначе — и внутри марафона одного шоу).</summary>
public bool BumperOnlyBetweenDifferentShows { get; private set; }
private const string DefaultNowLabel = "СЕЙЧАС";
private const string DefaultNextLabel = "ДАЛЕЕ";
private const string DefaultTemplateName = "Заставка 1";
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
@@ -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;
}
/// <summary>Общие настройки ТВ-заставок канала: шрифт, подписи, правила показа и стратегия выбора блока.</summary>
/// <summary>Общие настройки ТВ-заставок канала: шрифт, мин. интервал и стратегия выбора подблока.</summary>
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;
}
@@ -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
}
/// <summary>
/// Ставит на переходе заставку выбранного блока: резервирует слот его длины и оставляет
/// плейсхолдер с парой шоу + id блока (ассет отрендерит оркестратор). Выбор блока — по стратегии
/// канала (ротация двигает курсор). Возвращает true, если заставка добавлена (курсор сдвинут).
/// Ставит на переходе заставку выбранного подблока: из подходящих по правилу показа (контекст —
/// сменилось ли шоу) выбирает один по стратегии канала и резервирует слот длины его блока. Оставляет
/// плейсхолдер с парой шоу + id блока/варианта (ассет отрендерит оркестратор). Возвращает true, если
/// заставка добавлена (курсор сдвинут).
/// </summary>
private static bool TryPlaceBumper(
List<PlannedEntry> 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,
@@ -22,21 +22,27 @@ public sealed record PlannerOverride(
public sealed record PlannerOverrideShow(Guid ShowId, int Weight);
/// <summary>
/// Политика ТВ-заставок на переходах. Планировщик выбирает блок (<see cref="Templates"/>) по
/// стратегии <see cref="Selection"/> и резервирует слот его длины (<see cref="PlannerBumperTemplate.Duration"/>,
/// уже выровнена генератором на сегмент). Конкретный отрендеренный ассет подставляет оркестратор
/// по паре шоу + выбранному блоку.
/// Политика ТВ-заставок на переходах. Планировщик из подходящих подблоков (<see cref="Variants"/>,
/// фильтр по <see cref="PlannerBumperVariant.Trigger"/> и контексту перехода) выбирает один по стратегии
/// <see cref="Selection"/> и резервирует слот длины его блока. Ассет подставляет оркестратор.
/// </summary>
public sealed record PlannerBumperConfig(
bool Enabled,
bool OnlyBetweenDifferentShows,
TimeSpan MinInterval,
BumperSelection Selection,
IReadOnlyList<PlannerBumperTemplate> Templates
IReadOnlyList<PlannerBumperVariant> Variants
);
/// <summary>Блок заставки в терминах планировщика: id + длительность слота (кратна сегменту).</summary>
public sealed record PlannerBumperTemplate(Guid TemplateId, TimeSpan Duration);
/// <summary>
/// Подблок заставки в терминах планировщика: id варианта + id родительского блока (стиль/звук) +
/// длительность слота (кратна сегменту) + правило показа.
/// </summary>
public sealed record PlannerBumperVariant(
Guid VariantId,
Guid TemplateId,
TimeSpan Duration,
BumperTrigger Trigger
);
/// <summary>Полный вход планировщика для одного прогона по каналу.</summary>
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
);
/// <summary>Результат прогона: новые записи + обновлённые курсоры (серий по каждому ChannelShow, рекламы, заставок).</summary>
@@ -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}";
@@ -0,0 +1,942 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReplacedByTokenHash")
.HasColumnType("text");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FromShowId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<string>("Signature")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("ToShowId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("FromShowId", "ToShowId", "Signature");
b.ToTable("BumperAssets");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("AccentColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<double?>("AudioDurationSeconds")
.HasColumnType("double precision");
b.Property<string>("AudioExtension")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("BackgroundColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("BackgroundColor2")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid?>("BackgroundImageId")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("Revision")
.HasColumnType("integer");
b.Property<string>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("BumperTemplateId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<string>("Line1")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Line2")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("NextLabel")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("NowLabel")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("Trigger")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("BumperTemplateId", "Position");
b.ToTable("BumperTextVariant");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("AdInsertion")
.HasColumnType("integer");
b.Property<int>("AdsPerBreak")
.HasColumnType("integer");
b.Property<int>("BumperFont")
.HasColumnType("integer");
b.Property<int>("BumperMinIntervalMinutes")
.HasColumnType("integer");
b.Property<int>("BumperSelection")
.HasColumnType("integer");
b.Property<bool>("BumpersEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("EpochUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("FillerAssetId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("NextAdIndex")
.HasColumnType("integer");
b.Property<int>("NextBumperIndex")
.HasColumnType("integer");
b.Property<string>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "Position");
b.ToTable("ChannelAd");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("BlockMode")
.HasColumnType("integer");
b.Property<int>("BlockValue")
.HasColumnType("integer");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<int>("NextEpisodeIndex")
.HasColumnType("integer");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "ShowId");
b.ToTable("ChannelShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ProgrammingOverrideId")
.HasColumnType("uuid");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ProgrammingOverrideId");
b.ToTable("OverrideShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Mode")
.HasColumnType("integer");
b.Property<DateTimeOffset>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int?>("EpisodeIndex")
.HasColumnType("integer");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<Guid?>("ShowId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("Category")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FileExtension")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<string>("MetadataExternalId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("MetadataProvider")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("OriginalName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("PosterImageId")
.HasColumnType("uuid");
b.Property<int?>("Year")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateOnly?>("AirDate")
.HasColumnType("date");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("Episode")
.HasColumnType("integer");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<string>("Overview")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int?>("Season")
.HasColumnType("integer");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<Guid?>("StillImageId")
.HasColumnType("uuid");
b.Property<string>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("AudioCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<TimeSpan?>("Duration")
.HasColumnType("interval");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int?>("Height")
.HasColumnType("integer");
b.Property<string>("OriginalExtension")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RelativePath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int?>("SegmentCount")
.HasColumnType("integer");
b.Property<int?>("SegmentSeconds")
.HasColumnType("integer");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("VideoCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int?>("Width")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Status");
b.ToTable("MediaAssets");
});
modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Key");
b.ToTable("AppSettings");
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsBlocked")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("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<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", 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<System.Guid>", 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
}
}
}
@@ -0,0 +1,103 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class BumperTextVariants : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "BumperTextVariant",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
BumperTemplateId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Kind = table.Column<int>(type: "integer", nullable: false),
NowLabel = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
NextLabel = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Line1 = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
Line2 = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
Trigger = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BumperTextVariant");
migrationBuilder.AddColumn<string>(
name: "BumperNextLabel",
table: "Channels",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "BumperNowLabel",
table: "Channels",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<bool>(
name: "BumperOnlyBetweenDifferentShows",
table: "Channels",
type: "boolean",
nullable: false,
defaultValue: false);
}
}
}
@@ -247,6 +247,58 @@ namespace TeleWave.Infrastructure.Migrations
b.ToTable("BumperTemplate");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("BumperTemplateId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<string>("Line1")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Line2")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("NextLabel")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("NowLabel")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("Trigger")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("BumperTemplateId", "Position");
b.ToTable("BumperTextVariant");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
{
b.Property<Guid>("Id")
@@ -264,17 +316,6 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<int>("BumperMinIntervalMinutes")
.HasColumnType("integer");
b.Property<string>("BumperNextLabel")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BumperNowLabel")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("BumperOnlyBetweenDifferentShows")
.HasColumnType("boolean");
b.Property<int>("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");
@@ -70,6 +70,26 @@ public class BumperTemplateConfiguration : IEntityTypeConfiguration<BumperTempla
builder.Property(x => 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<BumperTextVariant>
{
public void Configure(EntityTypeBuilder<BumperTextVariant> 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);
}
}
@@ -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();
/// <summary>Конфиг заставок с одним дефолтным блоком (8с), если явно не заданы блоки.</summary>
/// <summary>Конфиг заставок с одним дефолтным подблоком (8с) заданного триггера, если варианты не заданы.</summary>
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));
@@ -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))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperNowLabel')}</Label>
<Input
value={bumper.nowLabel}
maxLength={64}
onChange={(e) => setField('nowLabel', e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperNextLabel')}</Label>
<Input
value={bumper.nextLabel}
maxLength={64}
onChange={(e) => setField('nextLabel', e.target.value)}
/>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={bumper.onlyBetweenDifferentShows}
onChange={(e) => setField('onlyBetweenDifferentShows', e.target.checked)}
/>
{t('admin.channels.bumperOnlyDifferent')}
</label>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
@@ -589,6 +571,11 @@ function BumperTemplateEditor({
onSuccess: onChanged,
onError,
})
const addVariant = useMutation({
mutationFn: () => addBumperVariant(channelId, template.id, ''),
onSuccess: onChanged,
onError,
})
const colorFields: { key: keyof typeof colors; label: string }[] = [
{ key: 'backgroundColor', label: t('admin.channels.bumperBg') },
@@ -677,6 +664,35 @@ function BumperTemplateEditor({
/>
</div>
{/* Подблоки (текст-варианты) */}
<div className="flex flex-col gap-2 border-t border-border pt-3">
<p className="text-sm font-medium">{t('admin.channels.bumperVariants')}</p>
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperVariantsHint')}</p>
{[...template.variants]
.sort((a, b) => a.position - b.position)
.map((variant) => (
<BumperVariantEditor
key={variant.id}
channelId={channelId}
templateId={template.id}
variant={variant}
canRemove={template.variants.length > 1}
onChanged={onChanged}
onError={onError}
/>
))}
<div>
<Button
size="sm"
variant="outline"
disabled={addVariant.isPending}
onClick={() => addVariant.mutate()}
>
{t('admin.channels.bumperAddVariant')}
</Button>
</div>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<BumperPreviewPlayer channelId={channelId} templateId={template.id} onError={onError} />
</div>
@@ -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 = <K extends keyof typeof form>(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 (
<div className="flex flex-col gap-2 rounded-md border border-border bg-background/40 p-3">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperVariantName')}</Label>
<Input value={form.name} maxLength={64} onChange={(e) => set('name', e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperTextKind')}</Label>
<Select value={form.kind} onValueChange={(v) => set('kind', v as BumperTextKind)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="NowNext">{t('admin.channels.bumperKindNowNext')}</SelectItem>
<SelectItem value="Free">{t('admin.channels.bumperKindFree')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperTrigger')}</Label>
<Select value={form.trigger} onValueChange={(v) => set('trigger', v as BumperTrigger)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="OnShowChange">
{t('admin.channels.bumperTriggerOnShowChange')}
</SelectItem>
<SelectItem value="BetweenEpisodes">
{t('admin.channels.bumperTriggerBetweenEpisodes')}
</SelectItem>
<SelectItem value="Both">{t('admin.channels.bumperTriggerBoth')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{form.kind === 'NowNext' ? (
<>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperNowLabel')}</Label>
<Input
value={form.nowLabel}
maxLength={64}
onChange={(e) => set('nowLabel', e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperNextLabel')}</Label>
<Input
value={form.nextLabel}
maxLength={64}
onChange={(e) => set('nextLabel', e.target.value)}
/>
</div>
</>
) : (
<>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperLine1')}</Label>
<Input
value={form.line1}
maxLength={120}
onChange={(e) => set('line1', e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperLine2')}</Label>
<Input
value={form.line2}
maxLength={120}
onChange={(e) => set('line2', e.target.value)}
/>
</div>
</>
)}
</div>
<div className="flex items-center justify-end gap-2">
{canRemove && (
<Button
size="sm"
variant="ghost"
disabled={remove.isPending}
onClick={() => remove.mutate()}
>
{t('common.delete')}
</Button>
)}
<Button
size="sm"
variant="outline"
disabled={save.isPending || !form.name.trim()}
onClick={() => save.mutate()}
>
{t('common.save')}
</Button>
</div>
</div>
)
}
function BumperPreviewPlayer({
channelId,
templateId,
@@ -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<CreatedIdResponse>(
`/admin/channels/${id}/bumper/templates/${templateId}/variants`,
{ method: 'POST', body: { name } },
)
}
export function updateBumperVariant(
id: string,
templateId: string,
variantId: string,
body: BumperVariantBody,
) {
return apiRequest<void>(
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
{ method: 'PUT', body },
)
}
export function removeBumperVariant(id: string, templateId: string, variantId: string) {
return apiRequest<void>(
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
{ method: 'DELETE' },
)
}
/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */
export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
+17 -4
View File
@@ -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 = {
+28
View File
@@ -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)',