diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Bumpers.cs b/backend/src/TeleWave.Api/Endpoints/BumperEndpoints.cs
similarity index 52%
rename from backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Bumpers.cs
rename to backend/src/TeleWave.Api/Endpoints/BumperEndpoints.cs
index a5116db..26edb7e 100644
--- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Bumpers.cs
+++ b/backend/src/TeleWave.Api/Endpoints/BumperEndpoints.cs
@@ -1,35 +1,88 @@
using System.Text;
using LiteCqrs;
using TeleWave.Api.Common;
-using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
+using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Endpoints;
-/// Эндпоинты ТВ-заставок канала: блоки (стиль/аудио/фон), подблоки и рендер превью.
-public static partial class ChannelEndpoints
+///
+/// Блоки ТВ-заставок: оформление, звук, подблоки с текстом и рендер превью. Блоки общие для всех
+/// каналов, поэтому и раздел свой, не канальный — канал только ссылается на них врезками стыков.
+///
+public static class BumperEndpoints
{
- private static async Task AddBumperTemplate(
- Guid id,
- AddBumperTemplateBody body,
+ public static IEndpointRouteBuilder MapBumperEndpoints(this IEndpointRouteBuilder app)
+ {
+ var admin = app.MapGroup("/api/admin/bumpers")
+ .WithTags("Admin.Bumpers")
+ .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
+
+ admin.MapGet("", List).Produces>();
+ admin.MapPost("", Create).Produces(StatusCodes.Status201Created);
+ admin.MapPut("/{templateId:guid}", Update).Produces(StatusCodes.Status204NoContent);
+ admin.MapDelete("/{templateId:guid}", Delete).Produces(StatusCodes.Status204NoContent);
+
+ admin
+ .MapPut("/{templateId:guid}/audio", UploadAudio)
+ .Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapDelete("/{templateId:guid}/audio", ClearAudio)
+ .Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapPut("/{templateId:guid}/background", SetBackground)
+ .Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapDelete("/{templateId:guid}/background", ClearBackground)
+ .Produces(StatusCodes.Status204NoContent);
+
+ admin
+ .MapPost("/{templateId:guid}/variants", AddVariant)
+ .Produces(StatusCodes.Status201Created);
+ admin
+ .MapPut("/{templateId:guid}/variants/{variantId:guid}", UpdateVariant)
+ .Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapDelete("/{templateId:guid}/variants/{variantId:guid}", RemoveVariant)
+ .Produces(StatusCodes.Status204NoContent);
+
+ admin
+ .MapPost("/{templateId:guid}/preview", RenderPreview)
+ .Produces(StatusCodes.Status204NoContent);
+ admin.MapGet("/{templateId:guid}/preview/{variantId:guid}/index.m3u8", PreviewPlaylist);
+ admin.MapGet("/{templateId:guid}/preview/{variantId:guid}/{file}", PreviewSegment);
+
+ return app;
+ }
+
+ private static async Task List(ISender sender, CancellationToken cancellationToken)
+ {
+ var result = await sender.Send(new ListBumperTemplatesQuery(), cancellationToken);
+ return Results.Ok(result);
+ }
+
+ private static async Task Create(
+ BumperNameBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
- new AddBumperTemplateCommand(id, body.Name),
+ new CreateBumperTemplateCommand(body.Name),
cancellationToken
);
return result.IsSuccess
- ? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
+ ? Results.Created(
+ $"/api/admin/bumpers/{result.Value}",
+ new CreatedIdResponse(result.Value)
+ )
: result.ToHttpResult();
}
- private static async Task UpdateBumperTemplate(
- Guid id,
+ private static async Task Update(
Guid templateId,
UpdateBumperTemplateBody body,
ISender sender,
@@ -38,35 +91,37 @@ public static partial class ChannelEndpoints
{
var result = await sender.Send(
new UpdateBumperTemplateCommand(
- id,
templateId,
- body.Name,
- body.BackgroundColor,
- body.BackgroundColor2,
- body.AccentColor,
- body.TextColor
+ new BumperStyle(
+ body.Name,
+ body.Font,
+ body.BackgroundColor,
+ body.BackgroundColor2,
+ body.AccentColor,
+ body.TextColor
+ )
),
cancellationToken
);
return result.ToHttpResult();
}
- private static async Task RemoveBumperTemplate(
- Guid id,
+ private static async Task Delete(
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
- new RemoveBumperTemplateCommand(id, templateId),
+ new DeleteBumperTemplateCommand(templateId),
cancellationToken
);
return result.ToHttpResult();
}
- private static async Task UploadTemplateAudio(
- [AsParameters] BumperAudioUpload upload,
+ private static async Task UploadAudio(
+ Guid templateId,
+ string fileName,
HttpRequest request,
IBumperTemplateStorage storage,
IAudioProbe probe,
@@ -74,124 +129,101 @@ public static partial class ChannelEndpoints
CancellationToken cancellationToken
)
{
- if (
- ResolveBumperExtension(upload.FileName, request, BumperFiles.AudioExtensions)
- is not { } ext
- )
- return ChannelErrors.InvalidBumperFile.ToProblem();
+ if (ResolveExtension(fileName, request) is not { } ext)
+ return BumperErrors.InvalidFile.ToProblem();
- await storage.SaveAudioAsync(upload.TemplateId, ext, request.Body, cancellationToken);
+ await storage.SaveAudioAsync(templateId, ext, request.Body, cancellationToken);
// Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина).
- var path = storage.AudioPath(upload.TemplateId, ext);
+ var path = storage.AudioPath(templateId, ext);
var duration = path is null
? null
: await probe.TryGetDurationAsync(path, cancellationToken);
var result = await sender.Send(
- new SetBumperTemplateAudioCommand(
- upload.Id,
- upload.TemplateId,
- ext,
- duration?.TotalSeconds ?? 0
- ),
+ new SetBumperTemplateAudioCommand(templateId, ext, duration?.TotalSeconds ?? 0),
cancellationToken
);
if (!result.IsSuccess)
- await storage.DeleteAudioAsync(upload.TemplateId, cancellationToken);
+ await storage.DeleteAudioAsync(templateId, cancellationToken);
return result.ToHttpResult();
}
- private static async Task ClearTemplateAudio(
- Guid id,
+ private static async Task ClearAudio(
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
- new ClearBumperTemplateAudioCommand(id, templateId),
+ new ClearBumperTemplateAudioCommand(templateId),
cancellationToken
);
return result.ToHttpResult();
}
- private static async Task SetTemplateBackground(
- Guid id,
+ private static async Task SetBackground(
Guid templateId,
- SetBumperTemplateBackgroundBody body,
+ SetBumperBackgroundBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
- new SetBumperTemplateBackgroundCommand(id, templateId, body.ImageId),
+ new SetBumperTemplateBackgroundCommand(templateId, body.ImageId),
cancellationToken
);
return result.ToHttpResult();
}
- private static async Task ClearTemplateBackground(
- Guid id,
+ private static async Task ClearBackground(
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
- new ClearBumperTemplateBackgroundCommand(id, templateId),
+ new ClearBumperTemplateBackgroundCommand(templateId),
cancellationToken
);
return result.ToHttpResult();
}
- private static async Task AddBumperVariant(
- Guid id,
+ private static async Task AddVariant(
Guid templateId,
- AddBumperVariantBody body,
+ BumperNameBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
- new AddBumperTextVariantCommand(id, templateId, body.Name),
+ new AddBumperVariantCommand(templateId, body.Name),
cancellationToken
);
return result.IsSuccess
- ? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
+ ? Results.Created(
+ $"/api/admin/bumpers/{templateId}",
+ new CreatedIdResponse(result.Value)
+ )
: result.ToHttpResult();
}
- private static async Task UpdateBumperVariant(
- Guid id,
+ private static async Task UpdateVariant(
Guid templateId,
Guid variantId,
- UpdateBumperVariantBody body,
+ BumperVariantInput input,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
- new UpdateBumperTextVariantCommand(
- id,
- templateId,
- variantId,
- body.Name,
- body.Kind,
- body.NowLabel,
- body.NextLabel,
- body.Line1,
- body.Line2,
- body.Trigger,
- body.Weight
- ),
+ new UpdateBumperVariantCommand(templateId, variantId, input),
cancellationToken
);
return result.ToHttpResult();
}
- private static async Task RemoveBumperVariant(
- Guid id,
+ private static async Task RemoveVariant(
Guid templateId,
Guid variantId,
ISender sender,
@@ -199,7 +231,7 @@ public static partial class ChannelEndpoints
)
{
var result = await sender.Send(
- new RemoveBumperTextVariantCommand(id, templateId, variantId),
+ new RemoveBumperVariantCommand(templateId, variantId),
cancellationToken
);
return result.ToHttpResult();
@@ -207,33 +239,27 @@ public static partial class ChannelEndpoints
/// Синхронно рендерит пример заставки блока (несколько секунд ffmpeg).
private static async Task RenderPreview(
- Guid id,
Guid templateId,
+ Guid? channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
- new RenderBumperPreviewCommand(id, templateId),
+ new RenderBumperPreviewCommand(templateId, channelId),
cancellationToken
);
return result.IsSuccess ? Results.NoContent() : result.ToHttpResult();
}
/// Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.
- private static IResult PreviewPlaylist(
- Guid id,
- Guid templateId,
- Guid variantId,
- MediaPathResolver paths
- )
+ private static IResult PreviewPlaylist(Guid templateId, Guid variantId, MediaPathResolver paths)
{
var previewId = BumperPreview.AssetId(variantId);
if (SegmentFiles.TryResolveExisting(paths, previewId, "index.m3u8") is not { } indexPath)
return Results.NotFound();
- var baseUrl =
- $"/api/admin/channels/{id}/bumper/templates/{templateId}/preview/{variantId}/";
+ var baseUrl = $"/api/admin/bumpers/{templateId}/preview/{variantId}/";
var sb = new StringBuilder();
foreach (var line in File.ReadLines(indexPath))
{
@@ -249,9 +275,8 @@ public static partial class ChannelEndpoints
}
///
- /// Сегмент превью. Канал и блок в маршруте есть, но хендлеру не нужны: каталог превью
- /// адресуется подблоком (см. BumperPreview.AssetId), поэтому в сигнатуре их нет — незаявленные
- /// параметры маршрута просто не связываются.
+ /// Сегмент превью. Блок в маршруте есть, но хендлеру не нужен: каталог превью адресуется
+ /// подблоком (см. ) — незаявленные параметры не связываются.
///
private static IResult PreviewSegment(Guid variantId, string file, MediaPathResolver paths)
{
@@ -267,50 +292,27 @@ public static partial class ChannelEndpoints
/// Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает
/// нормализованное расширение (с точкой, нижний регистр) или null при отказе.
- private static string? ResolveBumperExtension(
- string fileName,
- HttpRequest request,
- IReadOnlySet allowedExtensions
- )
+ private static string? ResolveExtension(string fileName, HttpRequest request)
{
if (request.ContentLength is > BumperFiles.MaxBytes or 0 or null)
return null;
var ext = Path.GetExtension(fileName).ToLowerInvariant();
- return allowedExtensions.Contains(ext) ? ext : null;
+ return BumperFiles.AudioExtensions.Contains(ext) ? ext : null;
}
}
-public sealed record AddBumperTemplateBody(string Name);
+public sealed record BumperNameBody(string Name);
public sealed record UpdateBumperTemplateBody(
string Name,
+ BumperFont Font,
string BackgroundColor,
string BackgroundColor2,
string AccentColor,
string TextColor
);
-public sealed record SetBumperTemplateBackgroundBody(Guid ImageId);
-
-public sealed record AddBumperVariantBody(string Name);
-
-public sealed record UpdateBumperVariantBody(
- string Name,
- BumperTextKind Kind,
- string NowLabel,
- string NextLabel,
- string Line1,
- string Line2,
- BumperTrigger Trigger,
- int Weight
-);
-
-///
-/// Адрес загружаемого звука: канал и блок из маршрута плюс имя исходного файла из query (по нему
-/// проверяется расширение). Свёрнуто в один параметр — кроме него хендлеру нужны ещё запрос, два
-/// сервиса, диспетчер и токен отмены, и плоским списком сигнатура перестаёт читаться.
-///
-public sealed record BumperAudioUpload(Guid Id, Guid TemplateId, string FileName);
+public sealed record SetBumperBackgroundBody(Guid ImageId);
/// Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр).
internal static class BumperFiles
diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs
index b0e143a..e941173 100644
--- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs
+++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs
@@ -15,11 +15,11 @@ using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
///
-/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки —
-/// в ChannelEndpoints.Bumpers.cs. Что и когда идёт в эфире, задаёт шаблон сетки
-/// (TemplateEndpoints).
+/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки
+/// и стыки общие для всех каналов и живут своими разделами (BumperEndpoints,
+/// JunctionEndpoints); что и когда идёт в эфире, задаёт шаблон сетки (TemplateEndpoints).
///
-public static partial class ChannelEndpoints
+public static class ChannelEndpoints
{
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
{
@@ -35,62 +35,6 @@ public static partial class ChannelEndpoints
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent);
- admin
- .MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
- .Produces(StatusCodes.Status201Created);
- admin
- .MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate)
- .Produces(StatusCodes.Status204NoContent);
- admin
- .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate)
- .Produces(StatusCodes.Status204NoContent);
- admin
- .MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio)
- .Produces(StatusCodes.Status204NoContent);
- admin
- .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio)
- .Produces(StatusCodes.Status204NoContent);
- admin
- .MapPut(
- "/{id:guid}/bumper/templates/{templateId:guid}/background",
- SetTemplateBackground
- )
- .Produces(StatusCodes.Status204NoContent);
- admin
- .MapDelete(
- "/{id:guid}/bumper/templates/{templateId:guid}/background",
- ClearTemplateBackground
- )
- .Produces(StatusCodes.Status204NoContent);
-
- admin
- .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
- .Produces(StatusCodes.Status204NoContent);
- admin.MapGet(
- "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8",
- PreviewPlaylist
- );
- admin.MapGet(
- "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}",
- PreviewSegment
- );
-
- admin
- .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant)
- .Produces(StatusCodes.Status201Created);
- admin
- .MapPut(
- "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
- UpdateBumperVariant
- )
- .Produces(StatusCodes.Status204NoContent);
- admin
- .MapDelete(
- "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
- RemoveBumperVariant
- )
- .Produces(StatusCodes.Status204NoContent);
-
admin
.MapGet("/{id:guid}/schedule", GetSchedule)
.Produces>();
@@ -191,14 +135,7 @@ public static partial class ChannelEndpoints
)
{
var result = await sender.Send(
- new UpdateChannelSettingsCommand(
- id,
- body.Name,
- body.IsEnabled,
- body.BumpersEnabled,
- body.Bumper,
- body.FillerAssetId
- ),
+ new UpdateChannelSettingsCommand(id, body.Name, body.IsEnabled, body.FillerAssetId),
cancellationToken
);
return result.ToHttpResult();
@@ -229,13 +166,7 @@ public sealed record UpdateChannelTimeBody(
TimeOnly DayStartTime
);
-public sealed record UpdateChannelSettingsBody(
- string Name,
- bool IsEnabled,
- bool BumpersEnabled,
- BumperSettingsInput Bumper,
- Guid? FillerAssetId
-);
+public sealed record UpdateChannelSettingsBody(string Name, bool IsEnabled, Guid? FillerAssetId);
/// Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).
public sealed record UpdateViewerSettingsBody(
diff --git a/backend/src/TeleWave.Api/Endpoints/JunctionEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/JunctionEndpoints.cs
index e5a643f..63c3f4d 100644
--- a/backend/src/TeleWave.Api/Endpoints/JunctionEndpoints.cs
+++ b/backend/src/TeleWave.Api/Endpoints/JunctionEndpoints.cs
@@ -7,67 +7,50 @@ using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
///
-/// Шаблоны стыков канала: что играет между программами. Как и правка сетки, эфира не двигают —
-/// помечают шаблон канала изменённым, а хвост пересобирается применением.
+/// Шаблоны стыков: что играет между программами. Стыки общие для всех каналов, канал только
+/// ссылается на них слотами. Как и правка сетки, эфира не двигают — помечают шаблоны каналов,
+/// которые их используют, изменёнными, а хвост пересобирается применением.
///
public static class JunctionEndpoints
{
public static IEndpointRouteBuilder MapJunctionEndpoints(this IEndpointRouteBuilder app)
{
- var admin = app.MapGroup("/api/admin")
+ var admin = app.MapGroup("/api/admin/junctions")
.WithTags("Admin.Junctions")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
- admin
- .MapGet("/channels/{channelId:guid}/junctions", List)
- .Produces>();
- admin
- .MapPost("/channels/{channelId:guid}/junctions", Create)
- .Produces(StatusCodes.Status201Created);
- admin
- .MapPut("/junctions/{junctionId:guid}", Rename)
- .Produces(StatusCodes.Status204NoContent);
- admin
- .MapDelete("/junctions/{junctionId:guid}", Delete)
- .Produces(StatusCodes.Status204NoContent);
+ admin.MapGet("", List).Produces>();
+ admin.MapPost("", Create).Produces(StatusCodes.Status201Created);
+ admin.MapPut("/{junctionId:guid}", Update).Produces(StatusCodes.Status204NoContent);
+ admin.MapDelete("/{junctionId:guid}", Delete).Produces(StatusCodes.Status204NoContent);
admin
- .MapPost("/junctions/{junctionId:guid}/elements", AddElement)
+ .MapPost("/{junctionId:guid}/elements", AddElement)
.Produces(StatusCodes.Status201Created);
admin
- .MapPut("/junctions/{junctionId:guid}/elements/{elementId:guid}", UpdateElement)
+ .MapPut("/{junctionId:guid}/elements/{elementId:guid}", UpdateElement)
.Produces(StatusCodes.Status204NoContent);
admin
- .MapDelete("/junctions/{junctionId:guid}/elements/{elementId:guid}", RemoveElement)
- .Produces(StatusCodes.Status204NoContent);
- admin
- .MapPut("/junctions/{junctionId:guid}/order", Reorder)
+ .MapDelete("/{junctionId:guid}/elements/{elementId:guid}", RemoveElement)
.Produces(StatusCodes.Status204NoContent);
+ admin.MapPut("/{junctionId:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
return app;
}
- private static async Task List(
- Guid channelId,
- ISender sender,
- CancellationToken cancellationToken
- )
+ private static async Task List(ISender sender, CancellationToken cancellationToken)
{
- var result = await sender.Send(new ListJunctionsQuery(channelId), cancellationToken);
+ var result = await sender.Send(new ListJunctionsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task Create(
- Guid channelId,
JunctionNameBody body,
ISender sender,
CancellationToken cancellationToken
)
{
- var result = await sender.Send(
- new CreateJunctionCommand(channelId, body.Name),
- cancellationToken
- );
+ var result = await sender.Send(new CreateJunctionCommand(body.Name), cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/junctions/{result.Value}",
@@ -76,15 +59,15 @@ public static class JunctionEndpoints
: result.ToHttpResult();
}
- private static async Task Rename(
+ private static async Task Update(
Guid junctionId,
- JunctionNameBody body,
+ UpdateJunctionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
- new RenameJunctionCommand(junctionId, body.Name),
+ new UpdateJunctionCommand(junctionId, body.Name, body.MaxTotalSeconds),
cancellationToken
);
return result.ToHttpResult();
@@ -156,7 +139,7 @@ public static class JunctionEndpoints
)
{
var result = await sender.Send(
- new ReorderJunctionCommand(junctionId, body.ElementIdsInOrder),
+ new ReorderJunctionCommand(junctionId, body.Order),
cancellationToken
);
return result.ToHttpResult();
@@ -165,6 +148,9 @@ public static class JunctionEndpoints
public sealed record JunctionNameBody(string Name);
+public sealed record UpdateJunctionBody(string Name, int? MaxTotalSeconds);
+
public sealed record JunctionElementKindBody(JunctionElementKind Kind);
-public sealed record ReorderJunctionBody(IReadOnlyList ElementIdsInOrder);
+/// Порядок врезок вместе с их развилками — перетаскивание меняет и то, и другое разом.
+public sealed record ReorderJunctionBody(IReadOnlyList Order);
diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs
index 9e7553e..b379094 100644
--- a/backend/src/TeleWave.Api/Program.cs
+++ b/backend/src/TeleWave.Api/Program.cs
@@ -130,6 +130,7 @@ app.MapInterstitialEndpoints();
app.MapCollectionEndpoints();
app.MapGroupEndpoints();
app.MapTemplateEndpoints();
+app.MapBumperEndpoints();
app.MapJunctionEndpoints();
app.MapChannelEndpoints();
app.MapStreamingEndpoints();
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTemplateCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTemplateCommand.cs
deleted file mode 100644
index f703fdb..0000000
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTemplateCommand.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-using LiteCqrs;
-using TeleWave.Application.Common.Models;
-
-namespace TeleWave.Application.Broadcast.Bumpers;
-
-/// Добавить новый блок заставки на канал (звук/фон загружаются отдельно).
-public sealed record AddBumperTemplateCommand(Guid ChannelId, string Name) : ICommand>;
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTemplateCommandHandler.cs
deleted file mode 100644
index 90b82d5..0000000
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTemplateCommandHandler.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-using LiteCqrs;
-using Microsoft.EntityFrameworkCore;
-using TeleWave.Application.Common.Interfaces;
-using TeleWave.Application.Common.Models;
-
-namespace TeleWave.Application.Broadcast.Bumpers;
-
-public sealed class AddBumperTemplateCommandHandler(IAppDbContext dbContext)
- : ICommandHandler>
-{
- public async Task> Handle(
- AddBumperTemplateCommand command,
- CancellationToken cancellationToken
- )
- {
- var channel = await dbContext
- .Channels.Include(c => c.BumperTemplates)
- .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
- if (channel is null)
- return Result.Failure(ChannelErrors.NotFound);
-
- var name = string.IsNullOrWhiteSpace(command.Name)
- ? $"Заставка {channel.BumperTemplates.Count + 1}"
- : command.Name.Trim();
- var template = channel.AddBumperTemplate(name);
- return Result.Success(template.Id);
- }
-}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTextVariantCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTextVariantCommand.cs
deleted file mode 100644
index bf97df3..0000000
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTextVariantCommand.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-using LiteCqrs;
-using TeleWave.Application.Common.Models;
-
-namespace TeleWave.Application.Broadcast.Bumpers;
-
-/// Добавить подблок (текст-вариант) в блок заставки.
-public sealed record AddBumperTextVariantCommand(Guid ChannelId, Guid TemplateId, string Name)
- : ICommand>;
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTextVariantCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTextVariantCommandHandler.cs
deleted file mode 100644
index 3f90c9a..0000000
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTextVariantCommandHandler.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-using LiteCqrs;
-using Microsoft.EntityFrameworkCore;
-using TeleWave.Application.Common.Interfaces;
-using TeleWave.Application.Common.Models;
-
-namespace TeleWave.Application.Broadcast.Bumpers;
-
-public sealed class AddBumperTextVariantCommandHandler(IAppDbContext dbContext)
- : ICommandHandler>
-{
- public async Task> Handle(
- AddBumperTextVariantCommand command,
- CancellationToken cancellationToken
- )
- {
- var channel = await dbContext
- .Channels.Include(c => c.BumperTemplates)
- .ThenInclude(t => t.Variants)
- .AsSplitQuery()
- .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
- if (channel is null)
- return Result.Failure(ChannelErrors.NotFound);
-
- var template = channel.FindBumperTemplate(command.TemplateId);
- if (template is null)
- return Result.Failure(ChannelErrors.BumperTemplateNotFound);
-
- var name = string.IsNullOrWhiteSpace(command.Name)
- ? $"Текст {template.Variants.Count + 1}"
- : command.Name.Trim();
- var variant = template.AddVariant(name);
- return Result.Success(variant.Id);
- }
-}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperVariantCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperVariantCommandHandler.cs
new file mode 100644
index 0000000..04c6ea1
--- /dev/null
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperVariantCommandHandler.cs
@@ -0,0 +1,27 @@
+using LiteCqrs;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Application.Common.Models;
+
+namespace TeleWave.Application.Broadcast.Bumpers;
+
+public sealed class AddBumperVariantCommandHandler(IAppDbContext dbContext)
+ : ICommandHandler>
+{
+ public async Task> Handle(
+ AddBumperVariantCommand command,
+ CancellationToken cancellationToken
+ )
+ {
+ var template = await BumperTemplateLoader.LoadAsync(
+ dbContext,
+ command.TemplateId,
+ cancellationToken
+ );
+ if (template is null)
+ return Result.Failure(BumperErrors.TemplateNotFound);
+
+ var variant = template.AddVariant(command.Name);
+ variant.SetLines(BumperTemplateLoader.DefaultLines());
+ return Result.Success(variant.Id);
+ }
+}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperCommands.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperCommands.cs
new file mode 100644
index 0000000..9517c98
--- /dev/null
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperCommands.cs
@@ -0,0 +1,96 @@
+using FluentValidation;
+using LiteCqrs;
+using TeleWave.Application.Common.Models;
+using TeleWave.Domain.Broadcast;
+
+namespace TeleWave.Application.Broadcast.Bumpers;
+
+public sealed record ListBumperTemplatesQuery : IQuery>;
+
+public sealed record CreateBumperTemplateCommand(string Name) : ICommand>;
+
+/// Оформление блока: имя, шрифт и палитра. Меняет ревизию — заставки пересобираются.
+public sealed record UpdateBumperTemplateCommand(Guid TemplateId, BumperStyle Style)
+ : ICommand;
+
+public sealed record DeleteBumperTemplateCommand(Guid TemplateId) : ICommand;
+
+public sealed record SetBumperTemplateAudioCommand(
+ Guid TemplateId,
+ string Extension,
+ double DurationSeconds
+) : ICommand;
+
+public sealed record ClearBumperTemplateAudioCommand(Guid TemplateId) : ICommand;
+
+public sealed record SetBumperTemplateBackgroundCommand(Guid TemplateId, Guid ImageId)
+ : ICommand;
+
+public sealed record ClearBumperTemplateBackgroundCommand(Guid TemplateId) : ICommand;
+
+public sealed record AddBumperVariantCommand(Guid TemplateId, string Name) : ICommand>;
+
+/// Полное содержимое подблока — строки редактор всегда присылает списком целиком.
+public sealed record BumperVariantInput(
+ string Name,
+ BumperTrigger Trigger,
+ BumperBackground Background,
+ int Weight,
+ IReadOnlyList Lines
+);
+
+public sealed record UpdateBumperVariantCommand(
+ Guid TemplateId,
+ Guid VariantId,
+ BumperVariantInput Input
+) : ICommand;
+
+public sealed record RemoveBumperVariantCommand(Guid TemplateId, Guid VariantId) : ICommand;
+
+///
+/// Рендер примера заставки. Канал нужен только для образцов подстановки: блок общий, но посмотреть
+/// его надо глазами конкретного канала — иначе {channel} не на что заменить.
+///
+public sealed record RenderBumperPreviewCommand(Guid TemplateId, Guid? ChannelId)
+ : ICommand;
+
+public sealed class CreateBumperTemplateCommandValidator
+ : AbstractValidator
+{
+ public CreateBumperTemplateCommandValidator() =>
+ RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
+}
+
+public sealed class UpdateBumperTemplateCommandValidator
+ : AbstractValidator
+{
+ public UpdateBumperTemplateCommandValidator()
+ {
+ RuleFor(x => x.Style.Name).NotEmpty().MaximumLength(64);
+ RuleFor(x => x.Style.BackgroundColor).NotEmpty().MaximumLength(32);
+ RuleFor(x => x.Style.BackgroundColor2).NotEmpty().MaximumLength(32);
+ RuleFor(x => x.Style.AccentColor).NotEmpty().MaximumLength(32);
+ RuleFor(x => x.Style.TextColor).NotEmpty().MaximumLength(32);
+ }
+}
+
+public sealed class AddBumperVariantCommandValidator : AbstractValidator
+{
+ public AddBumperVariantCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
+}
+
+public sealed class UpdateBumperVariantCommandValidator
+ : AbstractValidator
+{
+ /// Больше шести строк в кадр не помещается ни при каком размере шрифта.
+ private const int MaxLines = 6;
+
+ public UpdateBumperVariantCommandValidator()
+ {
+ RuleFor(x => x.Input.Name).NotEmpty().MaximumLength(64);
+ RuleFor(x => x.Input.Weight).InclusiveBetween(0, 1000);
+ RuleFor(x => x.Input.Lines).NotNull().Must(l => l.Count <= MaxLines);
+ RuleForEach(x => x.Input.Lines)
+ .ChildRules(line => line.RuleFor(l => l.Text).NotEmpty().MaximumLength(120));
+ }
+}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperDtos.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperDtos.cs
new file mode 100644
index 0000000..094cb5b
--- /dev/null
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperDtos.cs
@@ -0,0 +1,34 @@
+using TeleWave.Domain.Broadcast;
+
+namespace TeleWave.Application.Broadcast.Bumpers;
+
+/// Строка заставки: роль, цвет из палитры блока и текст с плейсхолдерами.
+public sealed record BumperLineDto(BumperLineStyle Style, BumperLineColor Color, string Text);
+
+/// Подблок (текст-вариант): свои строки, фон и правило показа поверх оформления блока.
+public sealed record BumperTextVariantDto(
+ Guid Id,
+ int Position,
+ string Name,
+ BumperTrigger Trigger,
+ BumperBackground Background,
+ int Weight,
+ IReadOnlyList Lines
+);
+
+/// Блок заставки: оформление + звук + подблоки. — длина звука (сек).
+public sealed record BumperTemplateDto(
+ Guid Id,
+ string Name,
+ BumperFont Font,
+ string BackgroundColor,
+ string BackgroundColor2,
+ string AccentColor,
+ string TextColor,
+ Guid? BackgroundImageId,
+ bool HasAudio,
+ double? AudioDurationSeconds,
+ /// Во скольких врезках стыков используется блок — блоки общие, и это надо видеть до правки.
+ int UsageCount,
+ IReadOnlyList Variants
+);
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperErrors.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperErrors.cs
new file mode 100644
index 0000000..08994d7
--- /dev/null
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperErrors.cs
@@ -0,0 +1,44 @@
+using TeleWave.Application.Common.Models;
+
+namespace TeleWave.Application.Broadcast.Bumpers;
+
+/// Ошибки блоков заставок. Блоки общие для всех каналов, поэтому и каталог свой, не канальный.
+public static class BumperErrors
+{
+ public static readonly Error TemplateNotFound = Error.NotFound(
+ "Bumpers.TemplateNotFound",
+ "Блок заставки не найден."
+ );
+
+ public static readonly Error VariantNotFound = Error.NotFound(
+ "Bumpers.VariantNotFound",
+ "Подблок заставки не найден."
+ );
+
+ public static readonly Error CannotRemoveLastVariant = Error.Validation(
+ "Bumpers.CannotRemoveLastVariant",
+ "Нельзя удалить последний подблок — нужен хотя бы один."
+ );
+
+ public static readonly Error TemplateInUse = Error.Conflict(
+ "Bumpers.TemplateInUse",
+ "Блок заставки используется во врезках стыков."
+ );
+
+ public static readonly Error VariantInUse = Error.Conflict(
+ "Bumpers.VariantInUse",
+ "Подблок заставки выбран во врезке стыка."
+ );
+
+ public static readonly Error InvalidFile = Error.Validation(
+ "Bumpers.InvalidFile",
+ "Недопустимый файл заставки (формат или размер)."
+ );
+
+ /// Неизвестный плейсхолдер — ошибка ввода: в эфире его бы уже никто не заметил.
+ public static Error UnknownPlaceholders(IEnumerable tokens) =>
+ Error.Validation(
+ "Bumpers.UnknownPlaceholders",
+ $"Неизвестные плейсхолдеры: {string.Join(", ", tokens.Select(t => $"{{{t}}}"))}."
+ );
+}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperMapper.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperMapper.cs
new file mode 100644
index 0000000..6d69ce6
--- /dev/null
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperMapper.cs
@@ -0,0 +1,36 @@
+using TeleWave.Domain.Broadcast;
+
+namespace TeleWave.Application.Broadcast.Bumpers;
+
+/// Ручной маппинг блока заставки в DTO — одна точка на список и на карточку.
+public static class BumperMapper
+{
+ public static BumperTemplateDto ToDto(BumperTemplate template, int usageCount) =>
+ new(
+ template.Id,
+ template.Name,
+ template.Font,
+ template.BackgroundColor,
+ template.BackgroundColor2,
+ template.AccentColor,
+ template.TextColor,
+ template.BackgroundImageId,
+ template.AudioExtension is not null,
+ template.AudioDurationSeconds,
+ usageCount,
+ template
+ .Variants.OrderBy(v => v.Position)
+ .Select(v => new BumperTextVariantDto(
+ v.Id,
+ v.Position,
+ v.Name,
+ v.Trigger,
+ v.Background,
+ v.Weight,
+ v.Lines.OrderBy(l => l.Position)
+ .Select(l => new BumperLineDto(l.Style, l.Color, l.Text))
+ .ToList()
+ ))
+ .ToList()
+ );
+}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs
new file mode 100644
index 0000000..edf92e6
--- /dev/null
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs
@@ -0,0 +1,145 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace TeleWave.Application.Broadcast.Bumpers;
+
+///
+/// Чем подставляются плейсхолдеры одной заставки. Собирает контекст планировщик — только он знает
+/// и пару соседей, и точное время показа; редактор подставляет те же поля образцами.
+///
+public sealed record BumperContext(
+ string ChannelName,
+ int? ChannelNumber,
+ /// Момент показа заставки во времени канала.
+ DateTimeOffset LocalMoment,
+ string? NowTitle = null,
+ string? NextTitle = null,
+ string? NowEpisode = null,
+ string? NextEpisode = null,
+ int? NextYear = null,
+ string? NextGenre = null,
+ /// Во сколько начнётся следующая программа (время канала).
+ TimeOnly? NextTime = null,
+ string? SlotTitle = null
+);
+
+///
+/// Плейсхолдеры текста заставки: «ДАЛЕЕ В {next.time}» → «ДАЛЕЕ В 21:30».
+///
+/// Список закрытый и проверяется при сохранении: незнакомый плейсхолдер — ошибка ввода, а не
+/// сюрприз в эфире, где его уже не увидит никто, кроме зрителя.
+///
+public static partial class BumperPlaceholders
+{
+ /// Все допустимые имена. Описания и образцы живут в локалях редактора, не здесь.
+ public static readonly IReadOnlySet Tokens = new HashSet(StringComparer.Ordinal)
+ {
+ "channel",
+ "channel.number",
+ "now.title",
+ "next.title",
+ "now.episode",
+ "next.episode",
+ "next.year",
+ "next.genre",
+ "next.time",
+ "time",
+ "date",
+ "weekday",
+ "slot",
+ };
+
+ ///
+ /// Плейсхолдеры, привязанные к моменту показа. Каждое их значение уникально, поэтому кэш
+ /// отрендеренных заставок с ними перестаёт работать — редактор обязан об этом предупредить.
+ ///
+ public static readonly IReadOnlySet VolatileTokens = new HashSet(
+ StringComparer.Ordinal
+ )
+ {
+ "time",
+ "date",
+ "weekday",
+ };
+
+ // Русская культура фиксирована: заставка рендерится один раз в видео, локали зрителя у неё нет.
+ private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("ru-RU");
+
+ [GeneratedRegex(@"\{([a-zA-Z][a-zA-Z.]*)\}", RegexOptions.CultureInvariant)]
+ private static partial Regex TokenPattern();
+
+ [GeneratedRegex(@"[ \t]{2,}", RegexOptions.CultureInvariant)]
+ private static partial Regex ExtraSpaces();
+
+ ///
+ /// Подставляет значения. Неизвестное значение даёт пустую строку: «ДАЛЕЕ В {next.time}» без
+ /// следующей программы должно схлопнуться в «ДАЛЕЕ», а не показать дыру в кадре.
+ ///
+ public static string Resolve(string text, BumperContext context)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ return string.Empty;
+
+ var resolved = TokenPattern()
+ .Replace(text, match => Value(match.Groups[1].Value, context) ?? string.Empty);
+ return ExtraSpaces().Replace(resolved, " ").Trim();
+ }
+
+ /// Какие плейсхолдеры встречаются в текстах — по ним планировщик решает, что грузить.
+ public static IReadOnlySet TokensIn(IEnumerable texts)
+ {
+ var used = new HashSet(StringComparer.Ordinal);
+ foreach (var text in texts.Where(t => !string.IsNullOrWhiteSpace(t)))
+ foreach (Match match in TokenPattern().Matches(text))
+ used.Add(match.Groups[1].Value);
+ return used;
+ }
+
+ /// Плейсхолдеры текста, которых нет в списке допустимых.
+ public static IReadOnlyList UnknownTokens(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ return [];
+
+ return TokenPattern()
+ .Matches(text)
+ .Select(m => m.Groups[1].Value)
+ .Where(token => !Tokens.Contains(token))
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+ }
+
+ /// Есть ли в тексте плейсхолдер, привязанный к моменту показа (ломает кэш рендера).
+ public static bool IsVolatile(string? text) =>
+ !string.IsNullOrWhiteSpace(text)
+ && TokenPattern().Matches(text).Any(m => VolatileTokens.Contains(m.Groups[1].Value));
+
+ private static string? Value(string token, BumperContext context) =>
+ token switch
+ {
+ "channel" => context.ChannelName,
+ "channel.number" => context.ChannelNumber?.ToString(Culture),
+ "now.title" => context.NowTitle,
+ "next.title" => context.NextTitle,
+ "now.episode" => context.NowEpisode,
+ "next.episode" => context.NextEpisode,
+ "next.year" => context.NextYear?.ToString(Culture),
+ "next.genre" => context.NextGenre,
+ "next.time" => context.NextTime?.ToString("HH:mm", Culture),
+ "time" => TimeOnly
+ .FromDateTime(context.LocalMoment.DateTime)
+ .ToString("HH:mm", Culture),
+ "date" => context.LocalMoment.ToString("d MMMM", Culture),
+ "weekday" => context.LocalMoment.ToString("dddd", Culture),
+ "slot" => context.SlotTitle,
+ _ => null,
+ };
+
+ /// Подпись серии в эфирном виде: «с5э12», либо просто номер, если сезон не распознан.
+ public static string? Episode(int? season, int? episode)
+ {
+ if (season is { } s and > 0 && episode is { } e and > 0)
+ return string.Create(Culture, $"с{s}э{e}");
+ return episode is { } only and > 0 ? string.Create(Culture, $"э{only}") : null;
+ }
+}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperRenderedText.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperRenderedText.cs
new file mode 100644
index 0000000..dc3f1b2
--- /dev/null
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperRenderedText.cs
@@ -0,0 +1,37 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using TeleWave.Application.Common.Interfaces;
+
+namespace TeleWave.Application.Broadcast.Bumpers;
+
+///
+/// Сериализация готовых строк заставки в кэш-запись. Хранить подставленный текст обязательно:
+/// время показа и пара соседей из ссылок задним числом не восстанавливаются, а ffmpeg запускается
+/// фоновым сервисом уже после того, как лента записана.
+///
+public static class BumperRenderedText
+{
+ private static readonly JsonSerializerOptions Options = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ public static string ToJson(IReadOnlyList lines) =>
+ JsonSerializer.Serialize(lines, Options);
+
+ public static IReadOnlyList FromJson(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return [];
+
+ try
+ {
+ return JsonSerializer.Deserialize>(json, Options) ?? [];
+ }
+ catch (JsonException)
+ {
+ return [];
+ }
+ }
+}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs
index b8078c2..c6a3324 100644
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs
@@ -4,22 +4,18 @@ using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
///
-/// Чистая сборка из уже разрешённых входов (пути к постеру/фону/звуку,
-/// названия шоу). Общая точка для фонового рендерера заставок расписания и превью в админке.
+/// Чистая сборка из уже разрешённых входов (готовые строки, пути к
+/// постеру/фону/звуку). Общая точка для фонового рендерера заставок расписания и превью в админке.
///
public static class BumperSpecFactory
{
public static BumperRenderSpec Build(
BumperOptions bumper,
- BumperFont font,
BumperTemplate template,
- BumperTextVariant variant,
int alignedDurationSeconds,
BumperSpecInputs inputs
- )
- {
- var free = variant.Kind == BumperTextKind.Free;
- return new BumperRenderSpec(
+ ) =>
+ new(
alignedDurationSeconds,
bumper.Width,
bumper.Height,
@@ -27,17 +23,10 @@ public static class BumperSpecFactory
template.BackgroundColor2,
template.AccentColor,
template.TextColor,
- font == BumperFont.Serif ? bumper.FontFileSerif : bumper.FontFileSans,
- free ? "" : variant.NowLabel,
- free ? "" : inputs.FromName,
- free ? "" : variant.NextLabel,
- free ? "" : inputs.ToName,
+ template.Font == BumperFont.Serif ? bumper.FontFileSerif : bumper.FontFileSans,
+ inputs.Lines,
inputs.BackgroundAbsolutePath,
inputs.AudioPath,
- inputs.PosterAbsolutePath,
- free,
- variant.Line1,
- variant.Line2
+ inputs.PosterAbsolutePath
);
- }
}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecInputs.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecInputs.cs
index 9b7fdbc..5786a4a 100644
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecInputs.cs
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecInputs.cs
@@ -1,15 +1,16 @@
+using TeleWave.Application.Common.Interfaces;
+
namespace TeleWave.Application.Broadcast.Bumpers;
///
-/// Уже разрешённые входы рендера заставки: названия шоу «из/в» и пути к файлам. Разрешает их
-/// вызывающий (генератор эфира — по реальной паре соседей, превью — по образцам канала), а
-/// только раскладывает их по спецификации.
+/// Уже разрешённые входы рендера заставки: готовые строки (плейсхолдеры подставлены) и пути к
+/// файлам. Разрешает их вызывающий — генератор эфира по реальной паре соседей, редактор по
+/// образцам, — а только раскладывает их по спецификации.
///
public sealed record BumperSpecInputs(
- string FromName,
- string ToName,
+ IReadOnlyList Lines,
string? AudioPath = null,
- /// Постер «следующего» шоу как фон; в превью не подставляется — шоу ещё неизвестно.
+ /// Постер шоу как фон; подставляется, только если подблок его запросил.
string? PosterAbsolutePath = null,
string? BackgroundAbsolutePath = null
);
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs
index 2b3a80b..793cacc 100644
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs
@@ -8,10 +8,9 @@ using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
///
-/// Восстанавливает по кэш-строке заставки: планировщик сохранил только
-/// ссылки (канал/блок/подблок/пара шоу), а рендеру нужны названия шоу и абсолютные пути к звуку,
-/// постеру и фону. Вынесено из фонового рендерера: чтение и сборка — работа слоя приложения,
-/// воркер лишь крутит ffmpeg.
+/// Восстанавливает по кэш-строке заставки: планировщик сохранил
+/// готовые строки и ссылку на блок, а рендеру нужны ещё абсолютные пути к звуку, постеру и фону.
+/// Вынесено из фонового рендерера: чтение и сборка — работа слоя приложения, воркер лишь крутит ffmpeg.
///
public sealed class BumperSpecLoader(
IAppDbContext dbContext,
@@ -38,28 +37,15 @@ public sealed class BumperSpecLoader(
if (cache is null)
return null;
- var channel = await dbContext
- .Channels.AsNoTracking()
- .Include(c => c.BumperTemplates)
- .ThenInclude(t => t.Variants)
- .AsSplitQuery()
- .FirstOrDefaultAsync(c => c.Id == cache.ChannelId, cancellationToken);
- var template = channel?.BumperTemplates.FirstOrDefault(t => t.Id == cache.TemplateId);
- var variant = template?.Variants.FirstOrDefault(v => v.Id == cache.VariantId);
- if (channel is null || template is null || variant is null)
+ var template = await dbContext
+ .BumperTemplates.AsNoTracking()
+ .FirstOrDefaultAsync(t => t.Id == cache.TemplateId, cancellationToken);
+ if (template is null)
return null;
- var names = await dbContext
- .Shows.AsNoTracking()
- .Where(s => s.Id == cache.FromShowId || s.Id == cache.ToShowId)
- .Select(s => new { s.Id, s.Name })
- .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
-
- // Постер шоу-получателя как фон — только для «Сейчас/Далее».
- string? posterPath = null;
- if (variant.Kind == BumperTextKind.NowNext)
- posterPath = await ResolveShowPosterAsync(cache.ToShowId, cancellationToken);
-
+ var posterPath = cache.PosterShowId is { } showId
+ ? await ResolveShowPosterAsync(showId, cancellationToken)
+ : null;
var bgPath = await ResolveImagePathAsync(template.BackgroundImageId, cancellationToken);
var aligned = BumperDuration.Aligned(
BumperDuration.TemplateSeconds(template),
@@ -68,13 +54,10 @@ public sealed class BumperSpecLoader(
return BumperSpecFactory.Build(
_bumper,
- channel.BumperFont,
template,
- variant,
aligned,
new BumperSpecInputs(
- names.GetValueOrDefault(cache.FromShowId, "…"),
- names.GetValueOrDefault(cache.ToShowId, "…"),
+ BumperRenderedText.FromJson(cache.RenderedLinesJson),
bumperStorage.AudioPath(template.Id, template.AudioExtension),
posterPath,
bgPath
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperTemplateLoader.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperTemplateLoader.cs
new file mode 100644
index 0000000..33018b9
--- /dev/null
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperTemplateLoader.cs
@@ -0,0 +1,27 @@
+using Microsoft.EntityFrameworkCore;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Domain.Broadcast;
+
+namespace TeleWave.Application.Broadcast.Bumpers;
+
+/// Общее для команд блока заставки: загрузка вместе с подблоками и строками.
+internal static class BumperTemplateLoader
+{
+ public static Task LoadAsync(
+ IAppDbContext dbContext,
+ Guid templateId,
+ CancellationToken cancellationToken
+ ) =>
+ dbContext
+ .BumperTemplates.Include(t => t.Variants)
+ .FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken);
+
+ /// Строки подблока «Сейчас / Далее» — с них начинается новый блок.
+ public static IReadOnlyList DefaultLines() =>
+ [
+ BumperLine.Create(0, BumperLineStyle.Label, BumperLineColor.Accent, "СЕЙЧАС"),
+ BumperLine.Create(1, BumperLineStyle.Title, BumperLineColor.Text, "{now.title}"),
+ BumperLine.Create(2, BumperLineStyle.Label, BumperLineColor.Accent, "ДАЛЕЕ"),
+ BumperLine.Create(3, BumperLineStyle.Title, BumperLineColor.Text, "{next.title}"),
+ ];
+}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommand.cs
deleted file mode 100644
index bd9417a..0000000
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommand.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-using LiteCqrs;
-using TeleWave.Application.Common.Models;
-
-namespace TeleWave.Application.Broadcast.Bumpers;
-
-/// Удалить загруженный звук блока (вернуться к синтезированному джинглу).
-public sealed record ClearBumperTemplateAudioCommand(Guid ChannelId, Guid TemplateId)
- : ICommand;
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommandHandler.cs
index a9afa17..8c506b3 100644
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommandHandler.cs
@@ -1,5 +1,4 @@
using LiteCqrs;
-using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
@@ -15,19 +14,16 @@ public sealed class ClearBumperTemplateAudioCommandHandler(
CancellationToken cancellationToken
)
{
- var channel = await dbContext
- .Channels.Include(c => c.BumperTemplates)
- .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
- if (channel is null)
- return Result.Failure(ChannelErrors.NotFound);
-
- var template = channel.FindBumperTemplate(command.TemplateId);
+ var template = await BumperTemplateLoader.LoadAsync(
+ dbContext,
+ command.TemplateId,
+ cancellationToken
+ );
if (template is null)
- return Result.Failure(ChannelErrors.BumperTemplateNotFound);
+ return Result.Failure(BumperErrors.TemplateNotFound);
template.ClearAudio();
- await dbContext.SaveChangesAsync(cancellationToken);
- await storage.DeleteAudioAsync(command.TemplateId, cancellationToken);
+ await storage.DeleteAudioAsync(template.Id, cancellationToken);
return Result.Success();
}
}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommand.cs
deleted file mode 100644
index 936c2a6..0000000
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommand.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-using LiteCqrs;
-using TeleWave.Application.Common.Models;
-
-namespace TeleWave.Application.Broadcast.Bumpers;
-
-/// Удалить загруженную фон-картинку блока (вернуться к градиенту/постеру).
-public sealed record ClearBumperTemplateBackgroundCommand(Guid ChannelId, Guid TemplateId)
- : ICommand;
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommandHandler.cs
index 1cc5713..07b109f 100644
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommandHandler.cs
@@ -1,5 +1,4 @@
using LiteCqrs;
-using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
@@ -13,17 +12,14 @@ public sealed class ClearBumperTemplateBackgroundCommandHandler(IAppDbContext db
CancellationToken cancellationToken
)
{
- var channel = await dbContext
- .Channels.Include(c => c.BumperTemplates)
- .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
- if (channel is null)
- return Result.Failure(ChannelErrors.NotFound);
-
- var template = channel.FindBumperTemplate(command.TemplateId);
+ var template = await BumperTemplateLoader.LoadAsync(
+ dbContext,
+ command.TemplateId,
+ cancellationToken
+ );
if (template is null)
- return Result.Failure(ChannelErrors.BumperTemplateNotFound);
+ return Result.Failure(BumperErrors.TemplateNotFound);
- // Отвязываем фон; сама картинка остаётся в галерее.
template.ClearBackgroundImage();
return Result.Success();
}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/CreateBumperTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/CreateBumperTemplateCommandHandler.cs
new file mode 100644
index 0000000..8de56fc
--- /dev/null
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/CreateBumperTemplateCommandHandler.cs
@@ -0,0 +1,24 @@
+using LiteCqrs;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Application.Common.Models;
+using TeleWave.Domain.Broadcast;
+
+namespace TeleWave.Application.Broadcast.Bumpers;
+
+public sealed class CreateBumperTemplateCommandHandler(IAppDbContext dbContext)
+ : ICommandHandler>
+{
+ private const string DefaultVariantName = "Текст 1";
+
+ public Task> Handle(
+ CreateBumperTemplateCommand command,
+ CancellationToken cancellationToken
+ )
+ {
+ var template = BumperTemplate.Create(command.Name, DefaultVariantName);
+ // Пустой блок в редакторе выглядит поломанным — новый начинается с «Сейчас / Далее».
+ template.Variants[0].SetLines(BumperTemplateLoader.DefaultLines());
+ dbContext.BumperTemplates.Add(template);
+ return Task.FromResult(Result.Success(template.Id));
+ }
+}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/DeleteBumperTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/DeleteBumperTemplateCommandHandler.cs
new file mode 100644
index 0000000..0e4feb0
--- /dev/null
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/DeleteBumperTemplateCommandHandler.cs
@@ -0,0 +1,38 @@
+using LiteCqrs;
+using Microsoft.EntityFrameworkCore;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Application.Common.Models;
+
+namespace TeleWave.Application.Broadcast.Bumpers;
+
+public sealed class DeleteBumperTemplateCommandHandler(
+ IAppDbContext dbContext,
+ IBumperTemplateStorage storage
+) : ICommandHandler
+{
+ public async Task Handle(
+ DeleteBumperTemplateCommand command,
+ CancellationToken cancellationToken
+ )
+ {
+ var template = await BumperTemplateLoader.LoadAsync(
+ dbContext,
+ command.TemplateId,
+ cancellationToken
+ );
+ if (template is null)
+ return Result.Failure(BumperErrors.TemplateNotFound);
+
+ // Блок общий: удалив используемый, мы бы молча выключили заставки в чужих каналах.
+ var used = await dbContext.JunctionElements.AnyAsync(
+ e => e.BumperTemplateId == template.Id,
+ cancellationToken
+ );
+ if (used)
+ return Result.Failure(BumperErrors.TemplateInUse);
+
+ dbContext.BumperTemplates.Remove(template);
+ await storage.DeleteAudioAsync(template.Id, cancellationToken);
+ return Result.Success();
+ }
+}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/ListBumperTemplatesQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/ListBumperTemplatesQueryHandler.cs
new file mode 100644
index 0000000..fb5171e
--- /dev/null
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/ListBumperTemplatesQueryHandler.cs
@@ -0,0 +1,32 @@
+using LiteCqrs;
+using Microsoft.EntityFrameworkCore;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Domain.Programming;
+
+namespace TeleWave.Application.Broadcast.Bumpers;
+
+public sealed class ListBumperTemplatesQueryHandler(IAppDbContext dbContext)
+ : IQueryHandler>
+{
+ public async Task> Handle(
+ ListBumperTemplatesQuery query,
+ CancellationToken cancellationToken
+ )
+ {
+ var templates = await dbContext
+ .BumperTemplates.AsNoTracking()
+ .Include(t => t.Variants)
+ .OrderBy(t => t.Name)
+ .ToListAsync(cancellationToken);
+
+ // Блоки общие, поэтому «сколько врезок на меня ссылается» — не справка, а условие правки.
+ var usage = await dbContext
+ .JunctionElements.AsNoTracking()
+ .Where(e => e.Kind == JunctionElementKind.Bumper && e.BumperTemplateId != null)
+ .GroupBy(e => e.BumperTemplateId!.Value)
+ .Select(g => new { TemplateId = g.Key, Count = g.Count() })
+ .ToDictionaryAsync(x => x.TemplateId, x => x.Count, cancellationToken);
+
+ return templates.Select(t => BumperMapper.ToDto(t, usage.GetValueOrDefault(t.Id))).ToList();
+ }
+}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommand.cs
deleted file mode 100644
index 4323ec5..0000000
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommand.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-using LiteCqrs;
-using TeleWave.Application.Common.Models;
-
-namespace TeleWave.Application.Broadcast.Bumpers;
-
-/// Удалить блок заставки (кроме дефолтного) и его файлы.
-public sealed record RemoveBumperTemplateCommand(Guid ChannelId, Guid TemplateId)
- : ICommand;
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommandHandler.cs
deleted file mode 100644
index 48eae86..0000000
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommandHandler.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-using LiteCqrs;
-using Microsoft.EntityFrameworkCore;
-using TeleWave.Application.Common.Interfaces;
-using TeleWave.Application.Common.Models;
-
-namespace TeleWave.Application.Broadcast.Bumpers;
-
-public sealed class RemoveBumperTemplateCommandHandler(
- IAppDbContext dbContext,
- IBumperTemplateStorage storage
-) : ICommandHandler
-{
- public async Task Handle(
- RemoveBumperTemplateCommand command,
- CancellationToken cancellationToken
- )
- {
- var channel = await dbContext
- .Channels.Include(c => c.BumperTemplates)
- .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
- if (channel is null)
- return Result.Failure(ChannelErrors.NotFound);
-
- var template = channel.FindBumperTemplate(command.TemplateId);
- if (template is null)
- return Result.Failure(ChannelErrors.BumperTemplateNotFound);
- if (template.IsDefault)
- return Result.Failure(ChannelErrors.CannotRemoveDefaultBumperTemplate);
-
- channel.RemoveBumperTemplate(command.TemplateId);
- await dbContext.SaveChangesAsync(cancellationToken);
- await storage.DeleteTemplateAsync(command.TemplateId, cancellationToken);
- return Result.Success();
- }
-}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTextVariantCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTextVariantCommand.cs
deleted file mode 100644
index 39540e6..0000000
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTextVariantCommand.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-using LiteCqrs;
-using TeleWave.Application.Common.Models;
-
-namespace TeleWave.Application.Broadcast.Bumpers;
-
-/// Удалить подблок (кроме последнего) из блока заставки.
-public sealed record RemoveBumperTextVariantCommand(Guid ChannelId, Guid TemplateId, Guid VariantId)
- : ICommand;
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTextVariantCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTextVariantCommandHandler.cs
deleted file mode 100644
index 191c9fd..0000000
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTextVariantCommandHandler.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-using LiteCqrs;
-using Microsoft.EntityFrameworkCore;
-using TeleWave.Application.Common.Interfaces;
-using TeleWave.Application.Common.Models;
-
-namespace TeleWave.Application.Broadcast.Bumpers;
-
-public sealed class RemoveBumperTextVariantCommandHandler(IAppDbContext dbContext)
- : ICommandHandler
-{
- public async Task Handle(
- RemoveBumperTextVariantCommand command,
- CancellationToken cancellationToken
- )
- {
- var channel = await dbContext
- .Channels.Include(c => c.BumperTemplates)
- .ThenInclude(t => t.Variants)
- .AsSplitQuery()
- .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
- if (channel is null)
- return Result.Failure(ChannelErrors.NotFound);
-
- var template = channel.FindBumperTemplate(command.TemplateId);
- if (template is null)
- return Result.Failure(ChannelErrors.BumperTemplateNotFound);
- if (template.FindVariant(command.VariantId) is null)
- return Result.Failure(ChannelErrors.BumperTextVariantNotFound);
-
- return template.RemoveVariant(command.VariantId)
- ? Result.Success()
- : Result.Failure(ChannelErrors.CannotRemoveLastBumperTextVariant);
- }
-}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperVariantCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperVariantCommandHandler.cs
new file mode 100644
index 0000000..2f5ba8a
--- /dev/null
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperVariantCommandHandler.cs
@@ -0,0 +1,39 @@
+using LiteCqrs;
+using Microsoft.EntityFrameworkCore;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Application.Common.Models;
+
+namespace TeleWave.Application.Broadcast.Bumpers;
+
+public sealed class RemoveBumperVariantCommandHandler(IAppDbContext dbContext)
+ : ICommandHandler
+{
+ public async Task Handle(
+ RemoveBumperVariantCommand command,
+ CancellationToken cancellationToken
+ )
+ {
+ var template = await BumperTemplateLoader.LoadAsync(
+ dbContext,
+ command.TemplateId,
+ cancellationToken
+ );
+ if (template is null)
+ return Result.Failure(BumperErrors.TemplateNotFound);
+
+ if (template.FindVariant(command.VariantId) is null)
+ return Result.Failure(BumperErrors.VariantNotFound);
+
+ // Врезка могла выбрать этот подблок жёстко — тогда удаление оставило бы её без текста.
+ var used = await dbContext.JunctionElements.AnyAsync(
+ e => e.BumperVariantId == command.VariantId,
+ cancellationToken
+ );
+ if (used)
+ return Result.Failure(BumperErrors.VariantInUse);
+
+ return template.RemoveVariant(command.VariantId)
+ ? Result.Success()
+ : Result.Failure(BumperErrors.CannotRemoveLastVariant);
+ }
+}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommand.cs
deleted file mode 100644
index 5c27dc1..0000000
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommand.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using LiteCqrs;
-using TeleWave.Application.Common.Models;
-
-namespace TeleWave.Application.Broadcast.Bumpers;
-
-///
-/// Синхронно рендерит примеры всех подблоков блока (с примерными названиями шоу). Каждый подблок —
-/// в свой ассет-превью (id детерминирован по подблоку). БД не меняет, но пишет артефакты на диск —
-/// поэтому это команда (действие с побочным эффектом), а не запрос.
-///
-public sealed record RenderBumperPreviewCommand(Guid ChannelId, Guid TemplateId) : ICommand;
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs
index b0adc19..efca552 100644
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs
@@ -1,6 +1,7 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
+using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Streaming;
@@ -9,6 +10,11 @@ using TeleWave.Domain.Programming;
namespace TeleWave.Application.Broadcast.Bumpers;
+///
+/// Рендерит пример каждого подблока. Блок общий, поэтому образцы подстановки берутся глазами
+/// выбранного канала: без него {channel} не на что заменить, а названия шоу были бы
+/// случайными из библиотеки.
+///
public sealed class RenderBumperPreviewCommandHandler(
IAppDbContext dbContext,
IBumperRenderer renderer,
@@ -21,56 +27,59 @@ public sealed class RenderBumperPreviewCommandHandler(
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
- /// Длительность заставки без загруженного звука (сек) — как в генераторе.
- private const int DefaultBumperDurationSeconds = 8;
-
public async Task Handle(
- RenderBumperPreviewCommand query,
+ RenderBumperPreviewCommand command,
CancellationToken cancellationToken
)
{
- var channel = await dbContext
- .Channels.AsNoTracking()
- .Include(c => c.BumperTemplates)
- .ThenInclude(t => t.Variants)
- .AsSplitQuery()
- .FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
- if (channel is null)
- return Result.Failure(ChannelErrors.NotFound);
-
- var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId);
+ var template = await dbContext
+ .BumperTemplates.AsNoTracking()
+ .Include(t => t.Variants)
+ .FirstOrDefaultAsync(t => t.Id == command.TemplateId, cancellationToken);
if (template is null)
- return Result.Failure(ChannelErrors.BumperTemplateNotFound);
+ return Result.Failure(BumperErrors.TemplateNotFound);
- var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
- var backgroundPath = await ResolveBackgroundPathAsync(template, cancellationToken);
- var seconds = template.AudioDurationSeconds is { } d and > 0
- ? d
- : DefaultBumperDurationSeconds;
- var aligned = (int)(
- Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds
+ var channel = command.ChannelId is { } channelId
+ ? await dbContext
+ .Channels.AsNoTracking()
+ .FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken)
+ : null;
+
+ var samples = await SampleShowsAsync(channel, cancellationToken);
+ var context = BuildContext(channel, samples);
+ var backgroundPath = await ResolveImagePathAsync(
+ template.BackgroundImageId,
+ cancellationToken
+ );
+ var posterPath = await ResolveImagePathAsync(samples.NextPosterId, cancellationToken);
+ var aligned = BumperDuration.Aligned(
+ BumperDuration.TemplateSeconds(template),
+ _segmentSeconds
);
var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
- // Постер зависит от конкретного «следующего» шоу — в превью его не подставляем.
- var inputs = new BumperSpecInputs(
- fromName,
- toName,
- audioPath,
- PosterAbsolutePath: null,
- backgroundPath
- );
-
- // Рендерим каждый подблок в свой ассет-превью (id по подблоку).
foreach (var variant in template.Variants.OrderBy(v => v.Position))
{
+ var lines = variant
+ .Lines.OrderBy(l => l.Position)
+ .Select(l => new BumperRenderLine(
+ l.Style,
+ l.Color,
+ BumperPlaceholders.Resolve(l.Text, context)
+ ))
+ .Where(l => !string.IsNullOrWhiteSpace(l.Text))
+ .ToList();
+
var spec = BumperSpecFactory.Build(
_bumper,
- channel.BumperFont,
template,
- variant,
aligned,
- inputs
+ new BumperSpecInputs(
+ lines,
+ audioPath,
+ variant.Background == BumperBackground.Template ? null : posterPath,
+ backgroundPath
+ )
);
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
}
@@ -78,49 +87,100 @@ public sealed class RenderBumperPreviewCommandHandler(
return Result.Success();
}
- /// Путь к фон-картинке блока в общем реестре или null, если она не привязана.
- private async Task ResolveBackgroundPathAsync(
- BumperTemplate template,
- CancellationToken cancellationToken
- )
+ private static BumperContext BuildContext(Channel? channel, SampleShows samples)
{
- if (template.BackgroundImageId is not { } imageId)
- return null;
+ var offset = TimeSpan.FromMinutes(
+ channel?.UtcOffsetMinutes ?? Channel.DefaultUtcOffsetMinutes
+ );
+ var moment = DateTimeOffset.UtcNow.ToOffset(offset);
- var extension = await dbContext
- .Images.AsNoTracking()
- .Where(i => i.Id == imageId)
- .Select(i => i.FileExtension)
- .FirstOrDefaultAsync(cancellationToken);
-
- return extension is null ? null : imageStore.ResolvePath(imageId, extension);
+ return new BumperContext(
+ channel?.Name ?? "Канал",
+ channel?.Number,
+ moment,
+ samples.NowTitle,
+ samples.NextTitle,
+ "с1э5",
+ "с2э3",
+ samples.NextYear,
+ samples.NextGenre,
+ TimeOnly.FromDateTime(moment.AddMinutes(30).DateTime),
+ samples.SlotTitle
+ );
}
///
- /// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала:
- /// так превью показывает реальные названия этого канала, а не случайные из библиотеки.
+ /// Пара шоу для образца. Берём те, что реально ходят в этом канале (через группы его слотов), —
+ /// иначе предпросмотр показывает библиотеку, а не канал.
///
- private async Task<(string From, string To)> SampleNamesAsync(
- Channel channel,
+ private async Task SampleShowsAsync(
+ Channel? channel,
CancellationToken cancellationToken
)
{
- var names = await (
- from slot in dbContext.Slots.AsNoTracking()
- join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
- join item in dbContext.GroupItems.AsNoTracking() on slot.GroupId equals item.GroupId
- join show in dbContext.Shows.AsNoTracking() on item.ElementId equals show.Id
- where
- layer.TemplateId == channel.TemplateId && item.ElementKind == GroupElementKind.Show
- select show.Name
- )
+ var query = dbContext.Shows.AsNoTracking().AsQueryable();
+ if (channel?.TemplateId is { } templateId)
+ query =
+ from show in query
+ join item in dbContext.GroupItems.AsNoTracking() on show.Id equals item.ElementId
+ join slot in dbContext.Slots.AsNoTracking() on item.GroupId equals slot.GroupId
+ join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
+ where layer.TemplateId == templateId && item.ElementKind == GroupElementKind.Show
+ select show;
+
+ var shows = await query
+ .Select(s => new
+ {
+ s.Name,
+ s.Year,
+ s.PosterImageId,
+ })
.Distinct()
.Take(2)
.ToListAsync(cancellationToken);
- return (
- names.ElementAtOrDefault(0) ?? "Первое шоу",
- names.ElementAtOrDefault(1) ?? "Второе шоу"
+ var slotTitle = channel?.TemplateId is { } id
+ ? await (
+ from slot in dbContext.Slots.AsNoTracking()
+ join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
+ where layer.TemplateId == id
+ select slot.Title
+ ).FirstOrDefaultAsync(cancellationToken)
+ : null;
+
+ var next = shows.ElementAtOrDefault(1) ?? shows.ElementAtOrDefault(0);
+ return new SampleShows(
+ shows.ElementAtOrDefault(0)?.Name ?? "Первое шоу",
+ next?.Name ?? "Второе шоу",
+ next?.Year,
+ null,
+ next?.PosterImageId,
+ slotTitle ?? "Вечернее кино"
);
}
+
+ private async Task ResolveImagePathAsync(
+ Guid? imageId,
+ CancellationToken cancellationToken
+ )
+ {
+ if (imageId is not { } id)
+ return null;
+
+ var extension = await dbContext
+ .Images.AsNoTracking()
+ .Where(i => i.Id == id)
+ .Select(i => i.FileExtension)
+ .FirstOrDefaultAsync(cancellationToken);
+ return extension is null ? null : imageStore.ResolvePath(id, extension);
+ }
+
+ private sealed record SampleShows(
+ string NowTitle,
+ string NextTitle,
+ int? NextYear,
+ string? NextGenre,
+ Guid? NextPosterId,
+ string SlotTitle
+ );
}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateAudioCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateAudioCommand.cs
deleted file mode 100644
index 1adf55c..0000000
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateAudioCommand.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using LiteCqrs;
-using TeleWave.Application.Common.Models;
-
-namespace TeleWave.Application.Broadcast.Bumpers;
-
-/// Отметить загруженный звук блока: расширение (с точкой) и длину в секундах (замер ffprobe).
-public sealed record SetBumperTemplateAudioCommand(
- Guid ChannelId,
- Guid TemplateId,
- string Extension,
- double DurationSeconds
-) : ICommand;
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateAudioCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateAudioCommandHandler.cs
index ab2e24e..d1791e1 100644
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateAudioCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateAudioCommandHandler.cs
@@ -1,5 +1,4 @@
using LiteCqrs;
-using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
@@ -13,15 +12,13 @@ public sealed class SetBumperTemplateAudioCommandHandler(IAppDbContext dbContext
CancellationToken cancellationToken
)
{
- var channel = await dbContext
- .Channels.Include(c => c.BumperTemplates)
- .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
- if (channel is null)
- return Result.Failure(ChannelErrors.NotFound);
-
- var template = channel.FindBumperTemplate(command.TemplateId);
+ var template = await BumperTemplateLoader.LoadAsync(
+ dbContext,
+ command.TemplateId,
+ cancellationToken
+ );
if (template is null)
- return Result.Failure(ChannelErrors.BumperTemplateNotFound);
+ return Result.Failure(BumperErrors.TemplateNotFound);
template.SetAudio(command.Extension, command.DurationSeconds);
return Result.Success();
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommand.cs
deleted file mode 100644
index 0acbb96..0000000
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommand.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using LiteCqrs;
-using TeleWave.Application.Common.Models;
-
-namespace TeleWave.Application.Broadcast.Bumpers;
-
-/// Привязать фон-картинку блока по ссылке на изображение из реестра (галерея).
-public sealed record SetBumperTemplateBackgroundCommand(
- Guid ChannelId,
- Guid TemplateId,
- Guid ImageId
-) : ICommand;
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommandHandler.cs
index 14b2b12..ec49d48 100644
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommandHandler.cs
@@ -2,6 +2,7 @@ using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
+using TeleWave.Application.Images;
namespace TeleWave.Application.Broadcast.Bumpers;
@@ -13,15 +14,16 @@ public sealed class SetBumperTemplateBackgroundCommandHandler(IAppDbContext dbCo
CancellationToken cancellationToken
)
{
- var channel = await dbContext
- .Channels.Include(c => c.BumperTemplates)
- .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
- if (channel is null)
- return Result.Failure(ChannelErrors.NotFound);
-
- var template = channel.FindBumperTemplate(command.TemplateId);
+ var template = await BumperTemplateLoader.LoadAsync(
+ dbContext,
+ command.TemplateId,
+ cancellationToken
+ );
if (template is null)
- return Result.Failure(ChannelErrors.BumperTemplateNotFound);
+ return Result.Failure(BumperErrors.TemplateNotFound);
+
+ if (!await dbContext.Images.AnyAsync(i => i.Id == command.ImageId, cancellationToken))
+ return Result.Failure(ImageErrors.NotFound);
template.SetBackgroundImage(command.ImageId);
return Result.Success();
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommand.cs
deleted file mode 100644
index f73577e..0000000
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommand.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using LiteCqrs;
-using TeleWave.Application.Common.Models;
-
-namespace TeleWave.Application.Broadcast.Bumpers;
-
-/// Обновить оформление блока заставки: имя и цвета (в нотации ffmpeg).
-public sealed record UpdateBumperTemplateCommand(
- Guid ChannelId,
- Guid TemplateId,
- string Name,
- string BackgroundColor,
- string BackgroundColor2,
- string AccentColor,
- string TextColor
-) : ICommand;
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommandHandler.cs
index 95e24e4..ab1ea35 100644
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommandHandler.cs
@@ -1,5 +1,4 @@
using LiteCqrs;
-using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
@@ -13,23 +12,15 @@ public sealed class UpdateBumperTemplateCommandHandler(IAppDbContext dbContext)
CancellationToken cancellationToken
)
{
- var channel = await dbContext
- .Channels.Include(c => c.BumperTemplates)
- .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
- if (channel is null)
- return Result.Failure(ChannelErrors.NotFound);
-
- var template = channel.FindBumperTemplate(command.TemplateId);
- if (template is null)
- return Result.Failure(ChannelErrors.BumperTemplateNotFound);
-
- template.UpdateStyle(
- command.Name.Trim(),
- command.BackgroundColor,
- command.BackgroundColor2,
- command.AccentColor,
- command.TextColor
+ var template = await BumperTemplateLoader.LoadAsync(
+ dbContext,
+ command.TemplateId,
+ cancellationToken
);
+ if (template is null)
+ return Result.Failure(BumperErrors.TemplateNotFound);
+
+ template.UpdateStyle(command.Style);
return Result.Success();
}
}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommandValidator.cs
deleted file mode 100644
index 45ff5e6..0000000
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommandValidator.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-using System.Text.RegularExpressions;
-using FluentValidation;
-
-namespace TeleWave.Application.Broadcast.Bumpers;
-
-public sealed partial class UpdateBumperTemplateCommandValidator
- : AbstractValidator
-{
- public UpdateBumperTemplateCommandValidator()
- {
- RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
-
- // Цвета уходят в строку ffmpeg-фильтра без экранирования — допускаем только безопасный формат
- // (0xRRGGBB[AA], #RRGGBB[AA] или имя цвета), чтобы исключить инъекцию синтаксиса фильтра.
- RuleFor(x => x.BackgroundColor).Must(BeSafeColor).WithMessage(ColorMessage);
- RuleFor(x => x.BackgroundColor2).Must(BeSafeColor).WithMessage(ColorMessage);
- RuleFor(x => x.AccentColor).Must(BeSafeColor).WithMessage(ColorMessage);
- RuleFor(x => x.TextColor).Must(BeSafeColor).WithMessage(ColorMessage);
- }
-
- private const string ColorMessage =
- "Цвет должен быть в формате 0xRRGGBB, #RRGGBB или именем (например white).";
-
- private static bool BeSafeColor(string? value) =>
- !string.IsNullOrWhiteSpace(value) && ColorRegex().IsMatch(value);
-
- [GeneratedRegex(
- @"^((0x|#)?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?|[A-Za-z]{2,20}(@[0-9]?\.?[0-9]+)?)$"
- )]
- private static partial Regex ColorRegex();
-}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommand.cs
deleted file mode 100644
index fc407bf..0000000
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommand.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using LiteCqrs;
-using TeleWave.Application.Common.Models;
-using TeleWave.Domain.Broadcast;
-
-namespace TeleWave.Application.Broadcast.Bumpers;
-
-/// Обновить подблок: имя, режим текста, текст, правило показа и вес.
-public sealed record UpdateBumperTextVariantCommand(
- Guid ChannelId,
- Guid TemplateId,
- Guid VariantId,
- string Name,
- BumperTextKind Kind,
- string NowLabel,
- string NextLabel,
- string Line1,
- string Line2,
- BumperTrigger Trigger,
- int Weight
-) : ICommand;
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandHandler.cs
deleted file mode 100644
index 90ef437..0000000
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandHandler.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-using LiteCqrs;
-using Microsoft.EntityFrameworkCore;
-using TeleWave.Application.Common.Interfaces;
-using TeleWave.Application.Common.Models;
-using TeleWave.Domain.Broadcast;
-
-namespace TeleWave.Application.Broadcast.Bumpers;
-
-public sealed class UpdateBumperTextVariantCommandHandler(IAppDbContext dbContext)
- : ICommandHandler
-{
- public async Task Handle(
- UpdateBumperTextVariantCommand command,
- CancellationToken cancellationToken
- )
- {
- var channel = await dbContext
- .Channels.Include(c => c.BumperTemplates)
- .ThenInclude(t => t.Variants)
- .AsSplitQuery()
- .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
- if (channel is null)
- return Result.Failure(ChannelErrors.NotFound);
-
- var template = channel.FindBumperTemplate(command.TemplateId);
- if (template is null)
- return Result.Failure(ChannelErrors.BumperTemplateNotFound);
-
- var variant = template.FindVariant(command.VariantId);
- if (variant is null)
- return Result.Failure(ChannelErrors.BumperTextVariantNotFound);
-
- variant.Update(
- command.Name.Trim(),
- new BumperTextContent(
- command.Kind,
- command.NowLabel,
- command.NextLabel,
- command.Line1,
- command.Line2
- ),
- command.Trigger,
- command.Weight
- );
- return Result.Success();
- }
-}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandValidator.cs
deleted file mode 100644
index 7864035..0000000
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandValidator.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-using FluentValidation;
-
-namespace TeleWave.Application.Broadcast.Bumpers;
-
-public sealed class UpdateBumperTextVariantCommandValidator
- : AbstractValidator
-{
- public UpdateBumperTextVariantCommandValidator()
- {
- RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
- RuleFor(x => x.NowLabel).MaximumLength(64);
- RuleFor(x => x.NextLabel).MaximumLength(64);
- RuleFor(x => x.Line1).MaximumLength(120);
- RuleFor(x => x.Line2).MaximumLength(120);
- RuleFor(x => x.Weight).InclusiveBetween(0, 1000);
- }
-}
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperVariantCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperVariantCommandHandler.cs
new file mode 100644
index 0000000..04beb59
--- /dev/null
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperVariantCommandHandler.cs
@@ -0,0 +1,51 @@
+using LiteCqrs;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Application.Common.Models;
+using TeleWave.Domain.Broadcast;
+
+namespace TeleWave.Application.Broadcast.Bumpers;
+
+public sealed class UpdateBumperVariantCommandHandler(IAppDbContext dbContext)
+ : ICommandHandler
+{
+ public async Task Handle(
+ UpdateBumperVariantCommand command,
+ CancellationToken cancellationToken
+ )
+ {
+ var template = await BumperTemplateLoader.LoadAsync(
+ dbContext,
+ command.TemplateId,
+ cancellationToken
+ );
+ if (template is null)
+ return Result.Failure(BumperErrors.TemplateNotFound);
+
+ var variant = template.FindVariant(command.VariantId);
+ if (variant is null)
+ return Result.Failure(BumperErrors.VariantNotFound);
+
+ // Незнакомый плейсхолдер ловим здесь: в эфире он превратился бы в пустоту, и заметить это
+ // было бы уже некому.
+ var unknown = command
+ .Input.Lines.SelectMany(l => BumperPlaceholders.UnknownTokens(l.Text))
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+ if (unknown.Count > 0)
+ return Result.Failure(BumperErrors.UnknownPlaceholders(unknown));
+
+ variant.Update(
+ command.Input.Name,
+ command.Input.Trigger,
+ command.Input.Background,
+ command.Input.Weight
+ );
+ variant.SetLines(
+ command.Input.Lines.Select(
+ (line, index) => BumperLine.Create(index, line.Style, line.Color, line.Text)
+ )
+ );
+
+ return Result.Success();
+ }
+}
diff --git a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs
index 819a94c..6a5f016 100644
--- a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs
+++ b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs
@@ -4,39 +4,6 @@ namespace TeleWave.Application.Broadcast;
public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled);
-/// Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).
-public sealed record BumperSettingsDto(BumperFont Font, BumperSelection Selection);
-
-/// Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.
-public sealed record BumperTextVariantDto(
- Guid Id,
- int Position,
- string Name,
- BumperTextKind Kind,
- string NowLabel,
- string NextLabel,
- string Line1,
- string Line2,
- BumperTrigger Trigger,
- int Weight
-);
-
-/// Блок заставки: своё оформление + звук + подблоки. — длина звука (сек).
-public sealed record BumperTemplateDto(
- Guid Id,
- int Position,
- bool IsDefault,
- string Name,
- string BackgroundColor,
- string BackgroundColor2,
- string AccentColor,
- string TextColor,
- Guid? BackgroundImageId,
- bool HasAudio,
- double? AudioDurationSeconds,
- IReadOnlyList Variants
-);
-
public sealed record ChannelDto(
Guid Id,
string Name,
@@ -46,9 +13,6 @@ public sealed record ChannelDto(
int UtcOffsetMinutes,
TimeOnly DayStartTime,
Guid? TemplateId,
- bool BumpersEnabled,
- BumperSettingsDto Bumper,
- IReadOnlyList BumperTemplates,
Guid? FillerAssetId,
/// Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).
ViewerSettingsDto Viewer
diff --git a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs
index 615ab1b..f34041b 100644
--- a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs
+++ b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs
@@ -17,44 +17,10 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
// (GetChannelTemplateQuery) — здесь только собственные свойства канала.
var channel = await dbContext
.Channels.AsNoTracking()
- .Include(c => c.BumperTemplates)
- .ThenInclude(t => t.Variants)
- .AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
- var bumperTemplates = channel
- .BumperTemplates.OrderBy(t => t.Position)
- .Select(t => new BumperTemplateDto(
- t.Id,
- t.Position,
- t.IsDefault,
- t.Name,
- t.BackgroundColor,
- t.BackgroundColor2,
- t.AccentColor,
- t.TextColor,
- t.BackgroundImageId,
- t.AudioExtension is not null,
- t.AudioDurationSeconds,
- t.Variants.OrderBy(v => v.Position)
- .Select(v => new BumperTextVariantDto(
- v.Id,
- v.Position,
- v.Name,
- v.Kind,
- v.NowLabel,
- v.NextLabel,
- v.Line1,
- v.Line2,
- v.Trigger,
- v.Weight
- ))
- .ToList()
- ))
- .ToList();
-
return Result.Success(
new ChannelDto(
channel.Id,
@@ -65,9 +31,6 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
channel.UtcOffsetMinutes,
channel.DayStartTime,
channel.TemplateId,
- channel.BumpersEnabled,
- new BumperSettingsDto(channel.BumperFont, channel.BumperSelection),
- bumperTemplates,
channel.FillerAssetId,
new ViewerSettingsDto(
channel.LogoImageId,
diff --git a/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs
index 466c071..f11fde2 100644
--- a/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs
+++ b/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs
@@ -1,5 +1,6 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
+using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Library;
@@ -43,22 +44,31 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
.Select(s => new { s.Id, s.Name })
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
- // Подблоки заставок в окне — чтобы показать в расписании, какая именно заставка и с каким текстом.
- var variantIds = entries
- .Where(e =>
- e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper && e.BumperVariantId != null
- )
- .Select(e => e.BumperVariantId!.Value)
+ // Заставки в окне: берём их из кэша по ассету — там лежит ровно тот текст, который играл,
+ // с уже подставленными плейсхолдерами. Собирать его заново из подблока значило бы гадать.
+ var bumperAssetIds = entries
+ .Where(e => e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper)
+ .Select(e => e.MediaAssetId)
.Distinct()
.ToList();
- var variants =
- variantIds.Count == 0
+ var bumpers =
+ bumperAssetIds.Count == 0
? []
- : await dbContext
- .BumperTextVariants.AsNoTracking()
- .Where(v => variantIds.Contains(v.Id))
- .ToListAsync(cancellationToken);
- var variantsById = variants.ToDictionary(v => v.Id);
+ : await (
+ from cache in dbContext.BumperAssets.AsNoTracking()
+ join variant in dbContext.BumperTextVariants.AsNoTracking()
+ on cache.VariantId equals variant.Id
+ where bumperAssetIds.Contains(cache.MediaAssetId)
+ select new
+ {
+ cache.MediaAssetId,
+ variant.Name,
+ cache.RenderedLinesJson,
+ }
+ ).ToListAsync(cancellationToken);
+ var bumpersByAsset = bumpers
+ .GroupBy(b => b.MediaAssetId)
+ .ToDictionary(g => g.Key, g => g.First());
// Имена ассетов программ — чтобы показать реальную метку S16E03 в расписании админки.
var assetIds = entries
@@ -72,21 +82,15 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
.Select(a => new { a.Id, a.OriginalFileName })
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
- // «Из какого шоу» для заставки берём из ближайшей предыдущей программы в упорядоченном окне.
- Guid? prevProgramShowId = null;
var dtos = new List(entries.Count);
foreach (var e in entries)
{
string? bumperName = null;
string? bumperText = null;
- if (
- e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper
- && e.BumperVariantId is { } vid
- && variantsById.TryGetValue(vid, out var variant)
- )
+ if (bumpersByAsset.TryGetValue(e.MediaAssetId, out var bumper))
{
- bumperName = variant.Name;
- bumperText = BumperText(variant, prevProgramShowId, e.ShowId, showNames);
+ bumperName = bumper.Name;
+ bumperText = BumperText(bumper.RenderedLinesJson);
}
dtos.Add(
@@ -106,34 +110,21 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
bumperText
)
);
-
- if (e.Kind == Domain.Broadcast.ScheduleEntryKind.Program)
- prevProgramShowId = e.ShowId;
}
return Result.Success>(dtos);
}
- ///
- /// Текст заставки для метки в расписании: для «Сейчас/Далее» — подписи + названия шоу (из→в),
- /// для свободного текста — заданные строки. Возвращает null, если показывать нечего.
- ///
- private static string? BumperText(
- Domain.Broadcast.BumperTextVariant variant,
- Guid? fromShowId,
- Guid? toShowId,
- IReadOnlyDictionary showNames
- )
+ /// Строки сыгравшей заставки одной меткой для расписания; null — показывать нечего.
+ private static string? BumperText(string? renderedLinesJson)
{
- if (variant.Kind == Domain.Broadcast.BumperTextKind.Free)
- {
- var parts = new[] { variant.Line1, variant.Line2 }
- .Where(s => !string.IsNullOrWhiteSpace(s))
- .ToArray();
- return parts.Length == 0 ? null : string.Join(" · ", parts);
- }
-
- string Name(Guid? id) => id is { } g ? showNames.GetValueOrDefault(g, "…") : "…";
- return $"{variant.NowLabel} {Name(fromShowId)} · {variant.NextLabel} {Name(toShowId)}";
+ var text = string.Join(
+ " · ",
+ BumperRenderedText
+ .FromJson(renderedLinesJson)
+ .Select(l => l.Text)
+ .Where(t => !string.IsNullOrWhiteSpace(t))
+ );
+ return text.Length == 0 ? null : text;
}
}
diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs
index 7b8c573..410578d 100644
--- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs
+++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs
@@ -1,6 +1,5 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
-using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
@@ -8,11 +7,5 @@ public sealed record UpdateChannelSettingsCommand(
Guid ChannelId,
string Name,
bool IsEnabled,
- bool BumpersEnabled,
- BumperSettingsInput Bumper,
Guid? FillerAssetId
) : ICommand;
-
-/// Общие настройки ТВ-заставок канала (см. Channel.UpdateBumperSettings). Условия
-/// показа сюда не входят — они задаются на элементе стыка.
-public sealed record BumperSettingsInput(BumperFont Font, BumperSelection Selection);
diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs
index 5f15cc3..a437509 100644
--- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs
@@ -30,13 +30,7 @@ public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
return Result.Failure(ChannelErrors.AssetNotFound);
}
- channel.UpdateSettings(
- command.Name,
- command.IsEnabled,
- command.BumpersEnabled,
- command.FillerAssetId
- );
- channel.UpdateBumperSettings(command.Bumper.Font, command.Bumper.Selection);
+ channel.UpdateSettings(command.Name, command.IsEnabled, command.FillerAssetId);
return Result.Success();
}
}
diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs
index b49c433..013d7bf 100644
--- a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs
+++ b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs
@@ -30,6 +30,7 @@ public interface IAppDbContext
DbSet JunctionElements { get; }
DbSet Channels { get; }
DbSet ScheduleEntries { get; }
+ DbSet BumperTemplates { get; }
DbSet BumperTextVariants { get; }
DbSet BumperAssets { get; }
DbSet AppSettings { get; }
diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs b/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs
index 1ec606a..45df867 100644
--- a/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs
+++ b/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs
@@ -1,7 +1,13 @@
+using TeleWave.Domain.Broadcast;
+
namespace TeleWave.Application.Common.Interfaces;
+/// Готовая строка заставки: роль, цвет из палитры блока и уже подставленный текст.
+public sealed record BumperRenderLine(BumperLineStyle Style, BumperLineColor Color, string Text);
+
///
-/// Полная спецификация одной заставки для рендера: оформление канала + подписи + названия шоу.
+/// Полная спецификация одной заставки для рендера: оформление блока + готовые строки. Плейсхолдеры
+/// в уже подставлены — рендер работает с текстом, а не с шаблоном.
/// уже выровнена на длину сегмента (готовит оркестратор), а
/// — абсолютный путь к TTF внутри контейнера.
///
@@ -14,18 +20,11 @@ public sealed record BumperRenderSpec(
string AccentColor,
string TextColor,
string FontFile,
- string NowLabel,
- string NowTitle,
- string NextLabel,
- string NextTitle,
+ IReadOnlyList Lines,
string? BackgroundFile = null,
string? MusicFile = null,
- /// Постер шоу как фон (используется, если нет загруженного фона канала; затемняется).
- string? PosterFile = null,
- /// Режим свободного текста: вместо «Сейчас/Далее» рисуются /.
- bool FreeText = false,
- string FreeLine1 = "",
- string FreeLine2 = ""
+ /// Постер шоу как фон (используется, если подблок его запросил; затемняется).
+ string? PosterFile = null
);
/// Итог рендера заставки — та же форма метаданных, что у обычного ассета после нарезки.
@@ -39,9 +38,9 @@ public sealed record BumperRenderResult(
);
///
-/// Порт рендера ТВ-заставок. Реализация синтезирует короткий клип (анимированный фон + текст
-/// «Сейчас/Далее» + джингл) по и режет его на HLS-сегменты в
-/// assets/{assetId} — так же, как обычный ассет, чтобы раздача эфира не отличала заставку от программы.
+/// Порт рендера ТВ-заставок. Реализация синтезирует короткий клип (анимированный фон + строки текста
+/// + джингл) по и режет его на HLS-сегменты в assets/{assetId} — так
+/// же, как обычный ассет, чтобы раздача эфира не отличала заставку от программы.
///
public interface IBumperRenderer
{
diff --git a/backend/src/TeleWave.Application/Programming/Planning/BumperFacts.cs b/backend/src/TeleWave.Application/Programming/Planning/BumperFacts.cs
new file mode 100644
index 0000000..ff4bda2
--- /dev/null
+++ b/backend/src/TeleWave.Application/Programming/Planning/BumperFacts.cs
@@ -0,0 +1,223 @@
+using Microsoft.EntityFrameworkCore;
+using TeleWave.Application.Broadcast.Bumpers;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Domain.Broadcast;
+using TeleWave.Domain.Media;
+using TeleWave.Domain.Programming.Planning;
+
+namespace TeleWave.Application.Programming.Planning;
+
+///
+/// Данные, которыми подставляются плейсхолдеры заставок одного прогона: названия шоу, годы, жанры,
+/// подписи серий, названия слотов.
+///
+/// Грузится только запрошенное: если ни в одной строке нет {next.genre}, жанры не читаются
+/// вовсе. Иначе каждая генерация тянула бы весь справочник ради текста, который никто не написал.
+///
+internal sealed class BumperFacts
+{
+ private readonly IReadOnlyList _items;
+ private readonly TimeSpan _offset;
+ private readonly Channel _channel;
+ private readonly IReadOnlyDictionary _shows;
+ private readonly IReadOnlyDictionary _slotTitles;
+
+ private BumperFacts(
+ IReadOnlyList items,
+ Channel channel,
+ IReadOnlyDictionary shows,
+ IReadOnlyDictionary slotTitles
+ )
+ {
+ _items = items;
+ _channel = channel;
+ _offset = TimeSpan.FromMinutes(channel.UtcOffsetMinutes);
+ _shows = shows;
+ _slotTitles = slotTitles;
+ }
+
+ public static async Task LoadAsync(
+ IAppDbContext dbContext,
+ Channel channel,
+ IReadOnlyList items,
+ IReadOnlySet tokens,
+ CancellationToken cancellationToken
+ )
+ {
+ var showIds = items
+ .Where(i => i.Kind == PlannedItemKind.Bumper)
+ .SelectMany(i => new[] { i.FromShowId, i.ToShowId })
+ .Where(id => id is not null && id != Guid.Empty)
+ .Select(id => id!.Value)
+ .Distinct()
+ .ToList();
+
+ var shows =
+ showIds.Count == 0
+ ? []
+ : await LoadShowsAsync(dbContext, showIds, tokens, cancellationToken);
+
+ var slotTitles = tokens.Contains("slot")
+ ? await LoadSlotTitlesAsync(dbContext, items, cancellationToken)
+ : new Dictionary();
+
+ return new BumperFacts(items, channel, shows, slotTitles);
+ }
+
+ /// Контекст одной заставки: соседи по ленте, время показа и данные канала.
+ public BumperContext Context(PlannedItem item, int index)
+ {
+ var from = Show(item.FromShowId);
+ var to = Show(item.ToShowId);
+ var nextProgram = FindProgram(index, forward: true);
+ var previousProgram = FindProgram(index, forward: false);
+
+ return new BumperContext(
+ _channel.Name,
+ _channel.Number,
+ item.StartsAtUtc.ToOffset(_offset),
+ from?.Name,
+ to?.Name,
+ EpisodeOf(from, previousProgram, item.FromShowId),
+ EpisodeOf(to, nextProgram, item.ToShowId),
+ to?.Year,
+ to?.Genre,
+ nextProgram is { } next
+ ? TimeOnly.FromDateTime(next.StartsAtUtc.ToOffset(_offset).DateTime)
+ : null,
+ item.SlotId is { } slotId && _slotTitles.TryGetValue(slotId, out var title)
+ ? title
+ : null
+ );
+ }
+
+ /// Подпись серии соседней программы — только если это та же самая программа.
+ private static string? EpisodeOf(ShowFact? show, PlannedItem? neighbour, Guid? showId)
+ {
+ if (show is null || neighbour is null || neighbour.ShowId != showId)
+ return null;
+ return neighbour.UnitIndex is { } index && index >= 0 && index < show.Episodes.Count
+ ? show.Episodes[index]
+ : null;
+ }
+
+ private ShowFact? Show(Guid? showId) =>
+ showId is { } id && _shows.TryGetValue(id, out var fact) ? fact : null;
+
+ /// Ближайшая программа по ленте в заданную сторону — стык может быть длиннее одной врезки.
+ private PlannedItem? FindProgram(int index, bool forward)
+ {
+ var step = forward ? 1 : -1;
+ for (var i = index + step; i >= 0 && i < _items.Count; i += step)
+ if (_items[i].Kind == PlannedItemKind.Program)
+ return _items[i];
+ return null;
+ }
+
+ private static async Task> LoadShowsAsync(
+ IAppDbContext dbContext,
+ IReadOnlyList showIds,
+ IReadOnlySet tokens,
+ CancellationToken cancellationToken
+ )
+ {
+ var shows = await dbContext
+ .Shows.AsNoTracking()
+ .Where(s => showIds.Contains(s.Id))
+ .Select(s => new
+ {
+ s.Id,
+ s.Name,
+ s.Year,
+ })
+ .ToListAsync(cancellationToken);
+
+ var genres = tokens.Contains("next.genre")
+ ? await (
+ from link in dbContext.ShowGenres.AsNoTracking()
+ join genre in dbContext.Genres.AsNoTracking() on link.GenreId equals genre.Id
+ where showIds.Contains(link.ShowId) && link.IsPrimary
+ select new { link.ShowId, genre.Name }
+ ).ToDictionaryAsync(g => g.ShowId, g => g.Name, cancellationToken)
+ : [];
+
+ var episodes =
+ tokens.Contains("next.episode") || tokens.Contains("now.episode")
+ ? await LoadEpisodesAsync(dbContext, showIds, cancellationToken)
+ : [];
+
+ return shows.ToDictionary(
+ s => s.Id,
+ s => new ShowFact(
+ s.Name,
+ s.Year,
+ genres.GetValueOrDefault(s.Id),
+ episodes.GetValueOrDefault(s.Id) ?? []
+ )
+ );
+ }
+
+ ///
+ /// Подписи серий в том же порядке, в каком их разворачивает планировщик: только серии с готовым
+ /// ассетом, по позиции. Иначе номер в заставке разошёлся бы с тем, что реально играет.
+ ///
+ private static async Task>> LoadEpisodesAsync(
+ IAppDbContext dbContext,
+ IReadOnlyList showIds,
+ CancellationToken cancellationToken
+ )
+ {
+ var rows = await (
+ from show in dbContext.Shows.AsNoTracking()
+ from episode in show.Episodes
+ join asset in dbContext.MediaAssets.AsNoTracking()
+ on episode.MediaAssetId equals asset.Id
+ where
+ showIds.Contains(show.Id)
+ && asset.Status == MediaAssetStatus.Ready
+ && asset.Duration != null
+ orderby episode.Position
+ select new
+ {
+ show.Id,
+ episode.Season,
+ episode.Episode,
+ episode.Title,
+ }
+ ).ToListAsync(cancellationToken);
+
+ return rows.GroupBy(r => r.Id)
+ .ToDictionary(
+ g => g.Key,
+ g =>
+ g.Select(r => BumperPlaceholders.Episode(r.Season, r.Episode) ?? r.Title)
+ .ToList()
+ );
+ }
+
+ private static async Task> LoadSlotTitlesAsync(
+ IAppDbContext dbContext,
+ IReadOnlyList items,
+ CancellationToken cancellationToken
+ )
+ {
+ var slotIds = items
+ .Select(i => i.SlotId)
+ .Where(id => id is not null)
+ .Select(id => id!.Value)
+ .Distinct()
+ .ToList();
+
+ return await dbContext
+ .Slots.AsNoTracking()
+ .Where(s => slotIds.Contains(s.Id))
+ .ToDictionaryAsync(s => s.Id, s => s.Title, cancellationToken);
+ }
+
+ private sealed record ShowFact(
+ string Name,
+ int? Year,
+ string? Genre,
+ IReadOnlyList Episodes
+ );
+}
diff --git a/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs b/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs
index 54860c0..05097a0 100644
--- a/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs
+++ b/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs
@@ -2,6 +2,7 @@ using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
+using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Broadcast.Scheduling;
@@ -10,21 +11,14 @@ using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning;
-/// Ключ отрендеренной заставки: блок, подблок и пара шоу, между которыми она стоит.
-public readonly record struct BumperKey(
- Guid TemplateId,
- Guid VariantId,
- Guid FromShowId,
- Guid ToShowId
-);
-
///
/// Подставляет ассеты заставкам, которые планировщик зарезервировал. Резерв и рендер разделены
-/// намеренно: ассет зависит от пары соседей, а пара известна только после того, как слоты наполнены.
+/// намеренно: текст заставки зависит от пары соседей и времени показа, а они известны только после
+/// того, как слоты наполнены.
///
-/// Готовый ассет переиспользуется по сигнатуре (пара названий + версия блока), недостающий
-/// регистрируется в и уходит фоновому рендереру. Запись при
-/// этом ставится в ленту сразу: горизонт — неделя, к эфиру рендер давно закончится.
+/// Готовый ассет переиспользуется по сигнатуре содержимого (оформление блока + подставленный текст),
+/// недостающий регистрируется в и уходит фоновому рендереру.
+/// Запись при этом ставится в ленту сразу: горизонт — неделя, к эфиру рендер давно закончится.
///
public sealed class BumperResolver(
IAppDbContext dbContext,
@@ -32,120 +26,175 @@ public sealed class BumperResolver(
IRandomSource random
)
{
- public async Task> ResolveAsync(
+ /// Ассеты заставок по индексу записи в ленте: одна и та же пара шоу может дать разный текст.
+ public async Task> ResolveAsync(
Channel channel,
IReadOnlyList items,
CancellationToken cancellationToken
)
{
- var reserved = items.Where(i => i.Kind == PlannedItemKind.Bumper).ToList();
+ var reserved = items
+ .Select((item, index) => (item, index))
+ .Where(pair => pair.item.Kind == PlannedItemKind.Bumper)
+ .ToList();
if (reserved.Count == 0)
- return new Dictionary();
+ return new Dictionary();
- var showNames = await LoadShowNamesAsync(reserved, cancellationToken);
- var result = new Dictionary();
+ var templates = await LoadTemplatesAsync(reserved, cancellationToken);
+ if (templates.Count == 0)
+ return new Dictionary();
- // Кэш существующих заставок канала: одна пара шоу встречается в горизонте многократно.
- var existing = await dbContext
- .BumperAssets.Where(b => b.ChannelId == channel.Id)
- .ToDictionaryAsync(b => b.Signature, b => b.MediaAssetId, cancellationToken);
+ var tokens = BumperPlaceholders.TokensIn(
+ templates
+ .Values.SelectMany(t => t.Variants)
+ .SelectMany(v => v.Lines)
+ .Select(l => l.Text)
+ );
+ var facts = await BumperFacts.LoadAsync(
+ dbContext,
+ channel,
+ items,
+ tokens,
+ cancellationToken
+ );
+ var requests = new List<(int Index, BumperRequest Request)>();
- foreach (var item in reserved)
+ foreach (var (item, index) in reserved)
{
if (
item.BumperTemplateId is not { } templateId
- || channel.FindBumperTemplate(templateId) is not { } template
+ || !templates.TryGetValue(templateId, out var template)
)
continue;
var fromShowId = item.FromShowId ?? Guid.Empty;
var toShowId = item.ToShowId ?? Guid.Empty;
- var variant = PickVariant(template, fromShowId != toShowId, channel, random);
+ var variant = PickVariant(template, item.BumperVariantId, fromShowId != toShowId);
if (variant is null)
continue;
- var key = new BumperKey(templateId, variant.Id, fromShowId, toShowId);
- if (result.ContainsKey(key))
- continue;
+ var context = facts.Context(item, index);
+ var lines = variant
+ .Lines.OrderBy(l => l.Position)
+ .Select(l => new BumperRenderLine(
+ l.Style,
+ l.Color,
+ BumperPlaceholders.Resolve(l.Text, context)
+ ))
+ .Where(l => !string.IsNullOrWhiteSpace(l.Text))
+ .ToList();
- var fromName = showNames.GetValueOrDefault(fromShowId, "—");
- var toName = showNames.GetValueOrDefault(toShowId, "—");
- var signature = Signature(template, variant.Id, fromName, toName);
-
- if (existing.TryGetValue(signature, out var assetId))
+ var posterShowId = variant.Background switch
{
- result[key] = assetId;
+ BumperBackground.NextPoster when toShowId != Guid.Empty => toShowId,
+ BumperBackground.NowPoster when fromShowId != Guid.Empty => fromShowId,
+ _ => (Guid?)null,
+ };
+
+ var linesJson = BumperRenderedText.ToJson(lines);
+ requests.Add(
+ (
+ index,
+ new BumperRequest(
+ template,
+ variant.Id,
+ linesJson,
+ posterShowId,
+ Signature(template, variant.Id, linesJson, posterShowId)
+ )
+ )
+ );
+ }
+
+ return await MaterializeAsync(requests, cancellationToken);
+ }
+
+ /// Заводит недостающие ассеты и раздаёт готовые по записям ленты.
+ private async Task> MaterializeAsync(
+ IReadOnlyList<(int Index, BumperRequest Request)> requests,
+ CancellationToken cancellationToken
+ )
+ {
+ var signatures = requests.Select(r => r.Request.Signature).Distinct().ToList();
+ var existing = await dbContext
+ .BumperAssets.Where(b => signatures.Contains(b.Signature))
+ .ToDictionaryAsync(b => b.Signature, b => b.MediaAssetId, cancellationToken);
+
+ var result = new Dictionary();
+ foreach (var (index, request) in requests)
+ {
+ if (existing.TryGetValue(request.Signature, out var assetId))
+ {
+ result[index] = assetId;
continue;
}
- var asset = MediaAsset.RegisterGenerated($"{template.Name}: {fromName} → {toName}");
+ var asset = MediaAsset.RegisterGenerated(request.Caption);
dbContext.MediaAssets.Add(asset);
dbContext.BumperAssets.Add(
BumperAsset.Create(
- channel.Id,
- templateId,
- variant.Id,
- fromShowId,
- toShowId,
- signature,
+ request.Template.Id,
+ request.VariantId,
+ request.Signature,
+ request.Lines,
+ request.PosterShowId,
asset.Id
)
);
- existing[signature] = asset.Id;
- result[key] = asset.Id;
+ existing[request.Signature] = asset.Id;
+ result[index] = asset.Id;
renderQueue.Enqueue(asset.Id);
}
return result;
}
- ///
- /// Подблок, подходящий под контекст перехода: на смене шоу и между сериями одного играют разные
- /// тексты. Стратегия выбора — общая настройка канала.
- ///
- private static BumperTextVariant? PickVariant(
- BumperTemplate template,
- bool isShowChange,
- Channel channel,
- IRandomSource random
+ private async Task> LoadTemplatesAsync(
+ IReadOnlyList<(PlannedItem Item, int Index)> reserved,
+ CancellationToken cancellationToken
)
{
- var eligible = template
- .Variants.Where(v =>
- v.Trigger switch
- {
- BumperTrigger.OnShowChange => isShowChange,
- BumperTrigger.BetweenEpisodes => !isShowChange,
- _ => true,
- }
- )
- .OrderBy(v => v.Position)
+ var ids = reserved
+ .Select(r => r.Item.BumperTemplateId)
+ .Where(id => id is not null)
+ .Select(id => id!.Value)
+ .Distinct()
.ToList();
+ return await dbContext
+ .BumperTemplates.AsNoTracking()
+ .Include(t => t.Variants)
+ .Where(t => ids.Contains(t.Id))
+ .ToDictionaryAsync(t => t.Id, cancellationToken);
+ }
+
+ ///
+ /// Подблок: либо жёстко заданный врезкой, либо подходящий под контекст перехода — на смене шоу
+ /// и между сериями одного играют разные тексты. Среди подходящих выбор по весам.
+ ///
+ private BumperTextVariant? PickVariant(
+ BumperTemplate template,
+ Guid? fixedVariantId,
+ bool isShowChange
+ )
+ {
+ if (fixedVariantId is { } id)
+ return template.FindVariant(id);
+
+ var eligible = template
+ .Variants.Where(v => v.Matches(isShowChange))
+ .OrderBy(v => v.Position)
+ .ToList();
if (eligible.Count == 0)
return null;
- return channel.BumperSelection switch
- {
- BumperSelection.AlwaysFirst => eligible[0],
- BumperSelection.Random => eligible[random.Next(eligible.Count)],
- BumperSelection.WeightedRandom => WeightedPick(eligible, random),
- _ => eligible[random.Next(eligible.Count)],
- };
- }
-
- private static BumperTextVariant WeightedPick(
- IReadOnlyList eligible,
- IRandomSource random
- )
- {
- var total = eligible.Sum(v => (long)Math.Max(0, v.Weight));
+ var total = eligible.Sum(v => Math.Max(0, v.Weight));
if (total <= 0)
return eligible[random.Next(eligible.Count)];
- var roll = random.Next((int)Math.Min(total, int.MaxValue));
- long accumulated = 0;
+ var roll = random.Next(total);
+ var accumulated = 0;
foreach (var variant in eligible)
{
accumulated += Math.Max(0, variant.Weight);
@@ -157,41 +206,44 @@ public sealed class BumperResolver(
}
///
- /// Сигнатура включает версию блока: замена звука или фона обязана пересобрать заставки, иначе
- /// в эфире осталась бы старая картинка с новым оформлением рядом.
+ /// Сигнатура — хэш содержимого: оформление блока с его ревизией плюс подставленный текст. Канала
+ /// и пары шоу в ней нет намеренно: одинаковая заставка на трёх каналах рендерится один раз, а
+ /// {channel} в тексте разводит их сам собой.
///
private static string Signature(
BumperTemplate template,
Guid variantId,
- string fromName,
- string toName
+ string linesJson,
+ Guid? posterShowId
)
{
var raw = string.Create(
CultureInfo.InvariantCulture,
- $"{template.Id}|{variantId}|{template.Revision}|{fromName}|{toName}"
+ $"{template.Id}|{template.Revision}|{variantId}|{posterShowId}|{linesJson}"
);
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..32];
}
- private async Task> LoadShowNamesAsync(
- IReadOnlyList reserved,
- CancellationToken cancellationToken
+ /// Что нужно отрендерить для одной записи ленты.
+ private sealed record BumperRequest(
+ BumperTemplate Template,
+ Guid VariantId,
+ string Lines,
+ Guid? PosterShowId,
+ string Signature
)
{
- var showIds = reserved
- .SelectMany(i => new[] { i.FromShowId, i.ToShowId })
- .Where(id => id is not null && id != Guid.Empty)
- .Select(id => id!.Value)
- .Distinct()
- .ToList();
-
- if (showIds.Count == 0)
- return [];
-
- return await dbContext
- .Shows.AsNoTracking()
- .Where(s => showIds.Contains(s.Id))
- .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
+ /// Имя ассета для админки: блок и первые строки заставки.
+ public string Caption
+ {
+ get
+ {
+ var text = string.Join(
+ " / ",
+ BumperRenderedText.FromJson(Lines).Select(l => l.Text).Take(2)
+ );
+ return text.Length == 0 ? Template.Name : $"{Template.Name}: {text}";
+ }
+ }
}
}
diff --git a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs
index 2f7eb64..283e497 100644
--- a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs
+++ b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs
@@ -57,14 +57,10 @@ public sealed class GridScheduleGenerator(
CancellationToken cancellationToken
)
{
- var channel = await dbContext
- .Channels.Include(c => c.BumperTemplates)
- .ThenInclude(t => t.Variants)
- // Вложенные коллекции тянем отдельными запросами: иначе колонки родителя (у шаблона —
- // jsonb с правилами, у слоя — jsonb применимости) приезжают по копии на каждую строку
- // листа. Тик планировщика повторяет эти два запроса на каждый канал.
- .AsSplitQuery()
- .FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
+ var channel = await dbContext.Channels.FirstOrDefaultAsync(
+ c => c.Id == channelId,
+ cancellationToken
+ );
if (channel is null || !channel.IsEnabled || channel.TemplateId is null)
return new GenerationReport(0, [], ChannelSkipped: true);
@@ -129,12 +125,13 @@ public sealed class GridScheduleGenerator(
);
var added = 0;
- foreach (var item in result.Items)
+ for (var index = 0; index < result.Items.Count; index++)
{
+ var item = result.Items[index];
var assetId = item.MediaAssetId;
if (
item.Kind == PlannedItemKind.Bumper
- && !TryResolveBumper(item, bumperAssets, out assetId)
+ && !bumperAssets.TryGetValue(index, out assetId)
)
continue; // Без ассета запись стала бы дырой в ленте.
@@ -184,9 +181,6 @@ public sealed class GridScheduleGenerator(
{
var channel = await dbContext
.Channels.AsNoTracking()
- .Include(c => c.BumperTemplates)
- .ThenInclude(t => t.Variants)
- .AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
if (channel is null || channel.TemplateId is null)
return null;
@@ -280,13 +274,17 @@ public sealed class GridScheduleGenerator(
);
// Стыки грузим целиком: их немного, а группы врезок надо развернуть тем же проходом,
- // что и группы контента.
+ // что и группы контента. Стыки общие, поэтому фильтра по каналу нет.
var junctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Include(j => j.Elements)
- .Where(j => j.ChannelId == channel.Id)
.ToDictionaryAsync(j => j.Id, cancellationToken);
+ // Блоки заставок нужны только длительностью — текст подставит резолвер после сборки ленты.
+ var bumperTemplates = await dbContext
+ .BumperTemplates.AsNoTracking()
+ .ToDictionaryAsync(t => t.Id, cancellationToken);
+
var groupIds = scheduled
.Select(s => s.Slot.GroupId)
.Where(id => id is not null)
@@ -361,12 +359,19 @@ public sealed class GridScheduleGenerator(
elements,
cursor,
repeatUnits,
- BuildJunction(slot.JunctionBetweenId, junctions, elementsByGroup, channel),
+ BuildJunction(
+ slot.JunctionBetweenId,
+ junctions,
+ elementsByGroup,
+ bumperTemplates,
+ slot.Daypart
+ ),
BuildJunction(
slot.JunctionAfterId ?? template.DefaultJunctionId,
junctions,
elementsByGroup,
- channel
+ bumperTemplates,
+ slot.Daypart
),
rules?.AudienceAt(
TimeOnly.FromDateTime(
@@ -388,44 +393,18 @@ public sealed class GridScheduleGenerator(
horizonEnd,
slots,
fallback,
- _segmentSeconds
+ _segmentSeconds,
+ channel.UtcOffsetMinutes
);
}
- /// Ассет заставки по зарезервированной записи; false — подобрать не удалось.
- private static bool TryResolveBumper(
- PlannedItem item,
- IReadOnlyDictionary bumperAssets,
- out Guid assetId
- )
- {
- assetId = Guid.Empty;
- if (item.BumperTemplateId is not { } templateId)
- return false;
-
- // Подблок выбирает резолвер, поэтому ищем по блоку и паре шоу.
- foreach (var pair in bumperAssets)
- {
- if (
- pair.Key.TemplateId == templateId
- && pair.Key.FromShowId == (item.FromShowId ?? Guid.Empty)
- && pair.Key.ToShowId == (item.ToShowId ?? Guid.Empty)
- )
- {
- assetId = pair.Value;
- return true;
- }
- }
-
- return false;
- }
-
/// Разворачивает шаблон стыка для планировщика, включая резерв под заставки.
private PlanningJunction? BuildJunction(
Guid? junctionId,
IReadOnlyDictionary junctions,
IReadOnlyDictionary> elementsByGroup,
- Channel channel
+ IReadOnlyDictionary bumperTemplates,
+ Daypart daypart
)
{
if (junctionId is not { } id || !junctions.TryGetValue(id, out var template))
@@ -437,57 +416,83 @@ public sealed class GridScheduleGenerator(
var conditions =
JunctionConditions.FromJson(element.ConditionsJson) ?? new JunctionConditions();
- if (element.Kind == JunctionElementKind.Bumper)
- {
- // Длительность задаётся блоком (по звуку) и выровнена на сегмент: планировщик
- // резервирует именно её, ассет подставит резолвер после сборки ленты.
- if (
- element.BumperTemplateId is not { } bumperTemplateId
- || channel.FindBumperTemplate(bumperTemplateId) is not { } bumperTemplate
- )
- continue;
-
- var seconds = BumperDuration.Aligned(
- BumperDuration.TemplateSeconds(bumperTemplate),
- _segmentSeconds
- );
-
- elements.Add(
- new PlanningJunctionElement(
- element.Kind,
- [],
- element.AmountMode,
- element.AmountValue,
- element.IsRequired,
- conditions.OnlyOnElementChange,
- conditions.MinMinutesBetween,
- bumperTemplateId,
- TimeSpan.FromSeconds(seconds)
- )
- );
+ // Дейпарт — свойство слота, а не момента: отсекаем здесь, чтобы домен не знал про сетку.
+ if (!conditions.AllowsDaypart(daypart))
continue;
- }
- if (
- element.GroupId is not { } groupId
- || !elementsByGroup.TryGetValue(groupId, out var groupElements)
- )
+ var units = ResolveUnits(element, elementsByGroup, bumperTemplates, out var bumper);
+ if (units is null)
continue;
elements.Add(
new PlanningJunctionElement(
+ element.Id,
element.Kind,
- groupElements.SelectMany(e => e.Units).ToList(),
+ units,
element.AmountMode,
element.AmountValue,
element.IsRequired,
conditions.OnlyOnElementChange,
- conditions.MinMinutesBetween
+ conditions.MinMinutesBetween,
+ conditions.Chance,
+ conditions.TimeWindow is { } window
+ ? new PlanningTimeWindow(window.From, window.To)
+ : null,
+ element.ChoiceKey,
+ element.ChoiceWeight,
+ element.BumperTemplateId,
+ element.BumperVariantId,
+ bumper
)
);
}
- return elements.Count == 0 ? null : new PlanningJunction(id, elements);
+ return elements.Count == 0
+ ? null
+ : new PlanningJunction(
+ id,
+ elements,
+ template.MaxTotalSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : null
+ );
+ }
+
+ ///
+ /// Что играет во врезке: единицы группы либо резерв под заставку. null — врезка настроена
+ /// не до конца (нет группы или блока), и в эфир ей идти нечем.
+ ///
+ private IReadOnlyList? ResolveUnits(
+ JunctionElement element,
+ IReadOnlyDictionary> elementsByGroup,
+ IReadOnlyDictionary bumperTemplates,
+ out TimeSpan bumperDuration
+ )
+ {
+ bumperDuration = TimeSpan.Zero;
+
+ if (element.Kind == JunctionElementKind.Bumper)
+ {
+ // Длительность задаётся блоком (по звуку) и выровнена на сегмент: планировщик
+ // резервирует именно её, ассет подставит резолвер после сборки ленты.
+ if (
+ element.BumperTemplateId is not { } templateId
+ || !bumperTemplates.TryGetValue(templateId, out var bumperTemplate)
+ )
+ return null;
+
+ bumperDuration = TimeSpan.FromSeconds(
+ BumperDuration.Aligned(
+ BumperDuration.TemplateSeconds(bumperTemplate),
+ _segmentSeconds
+ )
+ );
+ return [];
+ }
+
+ return
+ element.GroupId is { } groupId
+ && elementsByGroup.TryGetValue(groupId, out var groupElements)
+ ? groupElements.SelectMany(e => e.Units).ToList()
+ : null;
}
///
diff --git a/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommand.cs b/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommand.cs
index 6fc95bd..5f16fdf 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommand.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommand.cs
@@ -5,22 +5,15 @@ using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.CopyTemplate;
///
-/// Копирует сетку канала на другой канал: слои, слоты, стыки и правила. Группы не копируются —
-/// они общие для всех каналов. Прежний шаблон канала-приёмника заменяется целиком.
+/// Копирует сетку канала на другой канал: слои, слоты и правила. Группы, стыки и заставки
+/// не копируются — они общие для всех каналов, копия ссылается на те же. Прежний шаблон
+/// канала-приёмника заменяется целиком.
///
public sealed record CopyTemplateCommand(Guid SourceChannelId, Guid TargetChannelId)
: ICommand>;
-///
-/// Что скопировалось. — врезки-заставки, для которых на канале
-/// -приёмнике не нашлось блока с таким же именем: ссылка снята, врезку надо донастроить руками.
-///
-public sealed record CopyTemplateResultDto(
- int Layers,
- int Slots,
- int Junctions,
- int DroppedBumperRefs
-);
+/// Что скопировалось.
+public sealed record CopyTemplateResultDto(int Layers, int Slots);
public sealed class CopyTemplateCommandValidator : AbstractValidator
{
diff --git a/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommandHandler.cs
index af7ed16..d019d75 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/CopyTemplate/CopyTemplateCommandHandler.cs
@@ -15,9 +15,10 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
CancellationToken cancellationToken
)
{
- var target = await dbContext
- .Channels.Include(c => c.BumperTemplates)
- .FirstOrDefaultAsync(c => c.Id == command.TargetChannelId, cancellationToken);
+ var target = await dbContext.Channels.FirstOrDefaultAsync(
+ c => c.Id == command.TargetChannelId,
+ cancellationToken
+ );
if (target is null)
return Result.Failure(ChannelErrors.NotFound);
@@ -30,138 +31,31 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
if (source is null)
return Result.Failure(ChannelErrors.TemplateNotFound);
- var sourceJunctions = await dbContext
- .JunctionTemplates.AsNoTracking()
- .Include(j => j.Elements)
- .Where(j => j.ChannelId == command.SourceChannelId)
- .ToListAsync(cancellationToken);
-
- // Заставки живут на канале и на диске, поэтому не копируются: врезка ищет блок с таким же
- // именем у приёмника, а не найдя — остаётся без ссылки, и это возвращается в отчёте.
- var bumperByName = target
- .BumperTemplates.GroupBy(t => t.Name)
- .ToDictionary(g => g.Key, g => g.First().Id);
- var sourceBumperNames = await dbContext
- .Channels.AsNoTracking()
- .Where(c => c.Id == command.SourceChannelId)
- .SelectMany(c => c.BumperTemplates)
- .Select(t => new { t.Id, t.Name })
- .ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
-
- var (junctionMap, droppedBumperRefs) = CopyJunctions(
- sourceJunctions,
- target.Id,
- sourceBumperNames,
- bumperByName
- );
-
// Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя,
// а «добавить поверх» дало бы кашу из пересекающихся слотов.
var existing = await dbContext
.ScheduleTemplates.Where(t => t.ChannelId == target.Id)
.ToListAsync(cancellationToken);
dbContext.ScheduleTemplates.RemoveRange(existing);
- await dbContext
- .JunctionTemplates.Where(j =>
- j.ChannelId == target.Id && !junctionMap.Values.Contains(j.Id)
- )
- .ExecuteDeleteAsync(cancellationToken);
var copyTemplate = ScheduleTemplate.Create(target.Id, source.Name);
copyTemplate.SetFallbackGroup(source.FallbackGroupId);
copyTemplate.SetRules(source.RulesJson);
- if (
- source.DefaultJunctionId is { } defaultJunction
- && junctionMap.TryGetValue(defaultJunction, out var mappedDefault)
- )
- copyTemplate.SetDefaultJunction(mappedDefault);
+ // Стыки и заставки общие для всех каналов — копия ссылается на те же, без перевешивания.
+ copyTemplate.SetDefaultJunction(source.DefaultJunctionId);
- var (layers, slots) = CopyGrid(source, copyTemplate, junctionMap);
+ var (layers, slots) = CopyGrid(source, copyTemplate);
dbContext.ScheduleTemplates.Add(copyTemplate);
target.SetTemplate(copyTemplate.Id);
- return Result.Success(
- new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs)
- );
- }
-
- ///
- /// Копирует стыки на канал-приёмник. Возвращает соответствие «стык источника → копия» (по нему
- /// потом перевешиваются ссылки слотов) и число врезок, потерявших ссылку на блок заставки.
- ///
- private (Dictionary Map, int DroppedBumperRefs) CopyJunctions(
- IReadOnlyList sourceJunctions,
- Guid targetChannelId,
- IReadOnlyDictionary sourceBumperNames,
- IReadOnlyDictionary targetBumperByName
- )
- {
- var map = new Dictionary();
- var dropped = 0;
-
- foreach (var junction in sourceJunctions)
- {
- var copy = JunctionTemplate.Create(targetChannelId, junction.Name);
- map[junction.Id] = copy.Id;
-
- foreach (var element in junction.Elements.OrderBy(e => e.Position))
- {
- var bumperTemplateId = MapBumper(
- element,
- sourceBumperNames,
- targetBumperByName,
- ref dropped
- );
- copy.AddElement(element.Kind)
- .Update(
- element.Kind,
- element.GroupId,
- bumperTemplateId,
- element.AmountMode,
- element.AmountValue,
- element.IsRequired,
- element.ConditionsJson
- );
- }
-
- dbContext.JunctionTemplates.Add(copy);
- }
-
- return (map, dropped);
- }
-
- ///
- /// Блок заставки у приёмника, соответствующий блоку источника по имени. Заставки живут на канале
- /// и на диске, поэтому не копируются: не нашлось одноимённого — врезка остаётся без ссылки,
- /// и это попадает в отчёт.
- ///
- private static Guid? MapBumper(
- JunctionElement element,
- IReadOnlyDictionary sourceBumperNames,
- IReadOnlyDictionary targetBumperByName,
- ref int dropped
- )
- {
- if (element.Kind != JunctionElementKind.Bumper)
- return null;
-
- if (
- element.BumperTemplateId is { } sourceId
- && sourceBumperNames.TryGetValue(sourceId, out var name)
- && targetBumperByName.TryGetValue(name, out var mapped)
- )
- return mapped;
-
- dropped++;
- return null;
+ return Result.Success(new CopyTemplateResultDto(layers, slots));
}
/// Переносит слои со слотами. Возвращает, сколько слоёв (кроме фонового) и слотов скопировано.
private static (int Layers, int Slots) CopyGrid(
ScheduleTemplate source,
- ScheduleTemplate copyTemplate,
- IReadOnlyDictionary junctionMap
+ ScheduleTemplate copyTemplate
)
{
var layers = 0;
@@ -179,7 +73,7 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
foreach (var slot in layer.Slots)
{
- CopySlot(slot, copyLayer, junctionMap);
+ CopySlot(slot, copyLayer);
slots++;
}
}
@@ -187,11 +81,7 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
return (layers, slots);
}
- private static void CopySlot(
- Slot slot,
- GridLayer copyLayer,
- IReadOnlyDictionary junctionMap
- )
+ private static void CopySlot(Slot slot, GridLayer copyLayer)
{
var copySlot = copyLayer.AddSlot(
slot.Title,
@@ -220,12 +110,9 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
- Map(slot.JunctionBetweenId, junctionMap),
- Map(slot.JunctionAfterId, junctionMap)
+ slot.JunctionBetweenId,
+ slot.JunctionAfterId
)
);
}
-
- private static Guid? Map(Guid? id, IReadOnlyDictionary map) =>
- id is { } value && map.TryGetValue(value, out var mapped) ? mapped : null;
}
diff --git a/backend/src/TeleWave.Application/Programming/Templates/JunctionConditions.cs b/backend/src/TeleWave.Application/Programming/Templates/JunctionConditions.cs
index 050f558..11fbb6c 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/JunctionConditions.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/JunctionConditions.cs
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Serialization;
+using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
@@ -11,7 +12,13 @@ public sealed record JunctionConditions(
/// Ставить только при смене шоу, а не между сериями одного.
bool OnlyOnElementChange = false,
/// Не ставить чаще, чем раз в N минут (0 — без ограничения).
- int MinMinutesBetween = 0
+ int MinMinutesBetween = 0,
+ /// Только в эти дейпарты (пусто — в любые).
+ IReadOnlyList? Dayparts = null,
+ /// Только в это окно суток канала (null — в любое время).
+ JunctionTimeWindow? TimeWindow = null,
+ /// Вероятность показа в процентах; 100 — всегда.
+ int Chance = 100
)
{
private static readonly JsonSerializerOptions Options = new()
@@ -21,6 +28,10 @@ public sealed record JunctionConditions(
Converters = { new JsonStringEnumConverter() },
};
+ /// Действует ли врезка в этом дейпарте.
+ public bool AllowsDaypart(Daypart daypart) =>
+ Dayparts is not { Count: > 0 } || Dayparts.Contains(daypart);
+
public string ToJson() => JsonSerializer.Serialize(this, Options);
public static JunctionConditions? FromJson(string? json)
@@ -38,3 +49,6 @@ public sealed record JunctionConditions(
}
}
}
+
+/// Окно суток канала; допускает переход через полночь («с 23:00 до 06:00»).
+public sealed record JunctionTimeWindow(TimeOnly From, TimeOnly To);
diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/AddJunctionElementCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/AddJunctionElementCommandHandler.cs
index a0f5650..e65a6b1 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/AddJunctionElementCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/AddJunctionElementCommandHandler.cs
@@ -21,7 +21,7 @@ public sealed class AddJunctionElementCommandHandler(IAppDbContext dbContext)
return Result.Failure(TemplateErrors.JunctionNotFound);
var element = junction.AddElement(command.Kind);
- await JunctionLoader.MarkTemplateChangedAsync(dbContext, junction, cancellationToken);
+ await JunctionLoader.MarkTemplatesChangedAsync(dbContext, junction.Id, cancellationToken);
return Result.Success(element.Id);
}
}
diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/CreateJunctionCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/CreateJunctionCommandHandler.cs
index 52eca80..48bdf05 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/CreateJunctionCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/CreateJunctionCommandHandler.cs
@@ -1,6 +1,4 @@
using LiteCqrs;
-using Microsoft.EntityFrameworkCore;
-using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
@@ -10,16 +8,13 @@ namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed class CreateJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler>
{
- public async Task> Handle(
+ public Task> Handle(
CreateJunctionCommand command,
CancellationToken cancellationToken
)
{
- if (!await dbContext.Channels.AnyAsync(c => c.Id == command.ChannelId, cancellationToken))
- return Result.Failure(ChannelErrors.NotFound);
-
- var junction = JunctionTemplate.Create(command.ChannelId, command.Name);
+ var junction = JunctionTemplate.Create(command.Name);
dbContext.JunctionTemplates.Add(junction);
- return Result.Success(junction.Id);
+ return Task.FromResult(Result.Success(junction.Id));
}
}
diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs
index b388182..d988061 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs
@@ -21,19 +21,19 @@ public sealed class DeleteJunctionCommandHandler(IAppDbContext dbContext)
if (junction is null)
return Result.Failure(TemplateErrors.JunctionNotFound);
- // Слот, ссылающийся на удалённый стык, молча остался бы без врезок — проверяем заранее.
- var used = await dbContext.Slots.AnyAsync(
+ // Стык общий: слот чужого канала, ссылающийся на удалённый стык, молча остался бы без врезок.
+ var usedBySlot = await dbContext.Slots.AnyAsync(
s => s.JunctionBetweenId == junction.Id || s.JunctionAfterId == junction.Id,
cancellationToken
);
- if (used)
+ var usedByDefault = await dbContext.ScheduleTemplates.AnyAsync(
+ t => t.DefaultJunctionId == junction.Id,
+ cancellationToken
+ );
+ if (usedBySlot || usedByDefault)
return Result.Failure(TemplateErrors.JunctionInUse);
dbContext.JunctionTemplates.Remove(junction);
- return await JunctionLoader.MarkTemplateChangedAsync(
- dbContext,
- junction,
- cancellationToken
- );
+ return Result.Success();
}
}
diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionCommands.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionCommands.cs
index 63d43c8..16484f1 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionCommands.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionCommands.cs
@@ -5,12 +5,12 @@ using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Junctions;
-public sealed record ListJunctionsQuery(Guid ChannelId)
- : IQuery>;
+public sealed record ListJunctionsQuery : IQuery>;
-public sealed record CreateJunctionCommand(Guid ChannelId, string Name) : ICommand>;
+public sealed record CreateJunctionCommand(string Name) : ICommand>;
-public sealed record RenameJunctionCommand(Guid JunctionId, string Name) : ICommand;
+public sealed record UpdateJunctionCommand(Guid JunctionId, string Name, int? MaxTotalSeconds)
+ : ICommand;
public sealed record DeleteJunctionCommand(Guid JunctionId) : ICommand;
@@ -22,9 +22,13 @@ public sealed record JunctionElementInput(
JunctionElementKind Kind,
Guid? GroupId,
Guid? BumperTemplateId,
+ Guid? BumperVariantId,
JunctionAmountMode AmountMode,
int AmountValue,
bool IsRequired,
+ /// Метка развилки: из врезок с одной меткой играет одна, выбранная по весам.
+ string? ChoiceKey,
+ int ChoiceWeight,
JunctionConditions? Conditions
);
@@ -37,17 +41,32 @@ public sealed record UpdateJunctionElementCommand(
public sealed record RemoveJunctionElementCommand(Guid JunctionId, Guid ElementId)
: ICommand;
-public sealed record ReorderJunctionCommand(Guid JunctionId, IReadOnlyList ElementIdsInOrder)
- : ICommand;
+///
+/// Позиция врезки вместе с её развилкой: перетаскивание в цепочке одновременно меняет и порядок,
+/// и принадлежность к развилке, поэтому отдельной команды «сгруппировать» нет.
+///
+public sealed record JunctionElementOrder(Guid ElementId, string? ChoiceKey);
+
+public sealed record ReorderJunctionCommand(
+ Guid JunctionId,
+ IReadOnlyList Order
+) : ICommand;
public sealed class CreateJunctionCommandValidator : AbstractValidator
{
public CreateJunctionCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
}
-public sealed class RenameJunctionCommandValidator : AbstractValidator
+public sealed class UpdateJunctionCommandValidator : AbstractValidator
{
- public RenameJunctionCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
+ public UpdateJunctionCommandValidator()
+ {
+ RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
+ // Потолок стыка в сутки — верхняя граница здравого смысла, а не техническое ограничение.
+ RuleFor(x => x.MaxTotalSeconds)
+ .InclusiveBetween(1, 24 * 60 * 60)
+ .When(x => x.MaxTotalSeconds is not null);
+ }
}
public sealed class UpdateJunctionElementCommandValidator
@@ -57,8 +76,13 @@ public sealed class UpdateJunctionElementCommandValidator
{
// Верхняя граница — сутки: врезка длиннее вещательного дня бессмысленна.
RuleFor(x => x.Input.AmountValue).InclusiveBetween(1, 24 * 60);
+ RuleFor(x => x.Input.ChoiceKey).MaximumLength(64);
+ RuleFor(x => x.Input.ChoiceWeight).InclusiveBetween(0, 1000);
RuleFor(x => x.Input.Conditions!.MinMinutesBetween)
.InclusiveBetween(0, 24 * 60)
.When(x => x.Input.Conditions is not null);
+ RuleFor(x => x.Input.Conditions!.Chance)
+ .InclusiveBetween(0, 100)
+ .When(x => x.Input.Conditions is not null);
}
}
diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionDtos.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionDtos.cs
index 542cedc..1f40c7c 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionDtos.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionDtos.cs
@@ -10,14 +10,21 @@ public sealed record JunctionElementDto(
string? GroupName,
Guid? BumperTemplateId,
string? BumperTemplateName,
+ Guid? BumperVariantId,
+ string? BumperVariantName,
JunctionAmountMode AmountMode,
int AmountValue,
bool IsRequired,
+ string? ChoiceKey,
+ int ChoiceWeight,
JunctionConditions? Conditions
);
public sealed record JunctionTemplateDto(
Guid Id,
string Name,
+ int? MaxTotalSeconds,
+ /// Сколько каналов ссылается на стык — он общий, и это надо видеть до правки.
+ int ChannelUsageCount,
IReadOnlyList Elements
);
diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionLoader.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionLoader.cs
index 2c44303..00d3f8c 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionLoader.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionLoader.cs
@@ -17,18 +17,37 @@ internal static class JunctionLoader
.JunctionTemplates.Include(j => j.Elements)
.FirstOrDefaultAsync(j => j.Id == junctionId, cancellationToken);
- /// Правка стыка — тоже правка правил эфира: шаблон канала помечается изменённым.
- public static async Task MarkTemplateChangedAsync(
+ ///
+ /// Правка стыка — правка правил эфира. Стык общий, поэтому изменёнными помечаются все шаблоны,
+ /// которые на него ссылаются: иначе чужой канал молча поехал бы по новым врезкам без применения.
+ ///
+ public static async Task MarkTemplatesChangedAsync(
IAppDbContext dbContext,
- JunctionTemplate junction,
+ Guid junctionId,
CancellationToken cancellationToken
)
{
- var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
- t => t.ChannelId == junction.ChannelId,
- cancellationToken
- );
- template?.MarkChanged();
+ var viaSlots = await dbContext
+ .Slots.AsNoTracking()
+ .Where(s => s.JunctionBetweenId == junctionId || s.JunctionAfterId == junctionId)
+ .Join(
+ dbContext.GridLayers.AsNoTracking(),
+ slot => slot.LayerId,
+ layer => layer.Id,
+ (_, layer) => layer.TemplateId
+ )
+ .Distinct()
+ .ToListAsync(cancellationToken);
+
+ var templates = await dbContext
+ .ScheduleTemplates.Where(t =>
+ viaSlots.Contains(t.Id) || t.DefaultJunctionId == junctionId
+ )
+ .ToListAsync(cancellationToken);
+
+ foreach (var template in templates)
+ template.MarkChanged();
+
return Result.Success();
}
}
diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/ListJunctionsQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/ListJunctionsQueryHandler.cs
index 7711210..1b0a968 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/ListJunctionsQueryHandler.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/ListJunctionsQueryHandler.cs
@@ -15,53 +15,102 @@ public sealed class ListJunctionsQueryHandler(IAppDbContext dbContext)
var junctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Include(j => j.Elements)
- .Where(j => j.ChannelId == query.ChannelId)
.OrderBy(j => j.Name)
.ToListAsync(cancellationToken);
- // Имена групп и блоков заставок резолвим одним проходом — редактор показывает их сразу.
- var groupIds = junctions
- .SelectMany(j => j.Elements)
- .Select(e => e.GroupId)
- .Where(id => id is not null)
- .Select(id => id!.Value)
- .Distinct()
- .ToList();
+ // Имена групп и заставок резолвим одним проходом — редактор показывает их сразу.
+ var groupIds = Ids(junctions, e => e.GroupId);
var groupNames = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
- var bumperNames = await dbContext
- .Channels.AsNoTracking()
- .Where(c => c.Id == query.ChannelId)
- .SelectMany(c => c.BumperTemplates)
- .ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
+ var templateIds = Ids(junctions, e => e.BumperTemplateId);
+ var bumpers = await dbContext
+ .BumperTemplates.AsNoTracking()
+ .Where(t => templateIds.Contains(t.Id))
+ .Include(t => t.Variants)
+ .ToListAsync(cancellationToken);
+ var bumperNames = bumpers.ToDictionary(t => t.Id, t => t.Name);
+ var variantNames = bumpers.SelectMany(t => t.Variants).ToDictionary(v => v.Id, v => v.Name);
+
+ var usage = await ChannelUsageAsync(cancellationToken);
return junctions
.Select(j => new JunctionTemplateDto(
j.Id,
j.Name,
+ j.MaxTotalSeconds,
+ usage.GetValueOrDefault(j.Id),
j.Elements.OrderBy(e => e.Position)
.Select(e => new JunctionElementDto(
e.Id,
e.Position,
e.Kind,
e.GroupId,
- e.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
- ? gname
- : null,
+ Lookup(groupNames, e.GroupId),
e.BumperTemplateId,
- e.BumperTemplateId is { } bid && bumperNames.TryGetValue(bid, out var bname)
- ? bname
- : null,
+ Lookup(bumperNames, e.BumperTemplateId),
+ e.BumperVariantId,
+ Lookup(variantNames, e.BumperVariantId),
e.AmountMode,
e.AmountValue,
e.IsRequired,
+ e.ChoiceKey,
+ e.ChoiceWeight,
JunctionConditions.FromJson(e.ConditionsJson)
))
.ToList()
))
.ToList();
}
+
+ /// Сколько каналов ссылается на каждый стык — слотами сетки либо стыком по умолчанию.
+ private async Task> ChannelUsageAsync(CancellationToken cancellationToken)
+ {
+ var viaSlots = await (
+ from slot in dbContext.Slots.AsNoTracking()
+ join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
+ join template in dbContext.ScheduleTemplates.AsNoTracking()
+ on layer.TemplateId equals template.Id
+ where slot.JunctionBetweenId != null || slot.JunctionAfterId != null
+ select new
+ {
+ template.ChannelId,
+ slot.JunctionBetweenId,
+ slot.JunctionAfterId,
+ }
+ ).ToListAsync(cancellationToken);
+
+ var viaDefault = await dbContext
+ .ScheduleTemplates.AsNoTracking()
+ .Where(t => t.DefaultJunctionId != null)
+ .Select(t => new { t.ChannelId, t.DefaultJunctionId })
+ .ToListAsync(cancellationToken);
+
+ var pairs = viaSlots
+ .SelectMany(s =>
+ new[] { s.JunctionBetweenId, s.JunctionAfterId }
+ .Where(id => id is not null)
+ .Select(id => (JunctionId: id!.Value, s.ChannelId))
+ )
+ .Concat(viaDefault.Select(d => (JunctionId: d.DefaultJunctionId!.Value, d.ChannelId)));
+
+ return pairs.Distinct().GroupBy(p => p.JunctionId).ToDictionary(g => g.Key, g => g.Count());
+ }
+
+ private static List Ids(
+ IEnumerable junctions,
+ Func selector
+ ) =>
+ junctions
+ .SelectMany(j => j.Elements)
+ .Select(selector)
+ .Where(id => id is not null)
+ .Select(id => id!.Value)
+ .Distinct()
+ .ToList();
+
+ private static string? Lookup(IReadOnlyDictionary names, Guid? id) =>
+ id is { } value && names.TryGetValue(value, out var name) ? name : null;
}
diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/RemoveJunctionElementCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/RemoveJunctionElementCommandHandler.cs
index 7910852..a2639ea 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/RemoveJunctionElementCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/RemoveJunctionElementCommandHandler.cs
@@ -20,9 +20,9 @@ public sealed class RemoveJunctionElementCommandHandler(IAppDbContext dbContext)
if (junction is null || !junction.RemoveElement(command.ElementId))
return Result.Failure(TemplateErrors.JunctionElementNotFound);
- return await JunctionLoader.MarkTemplateChangedAsync(
+ return await JunctionLoader.MarkTemplatesChangedAsync(
dbContext,
- junction,
+ junction.Id,
cancellationToken
);
}
diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/ReorderJunctionCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/ReorderJunctionCommandHandler.cs
index b58bb9b..68b5358 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/ReorderJunctionCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/ReorderJunctionCommandHandler.cs
@@ -20,10 +20,15 @@ public sealed class ReorderJunctionCommandHandler(IAppDbContext dbContext)
if (junction is null)
return Result.Failure(TemplateErrors.JunctionNotFound);
- junction.Reorder(command.ElementIdsInOrder);
- return await JunctionLoader.MarkTemplateChangedAsync(
+ // Порядок и принадлежность к развилке приезжают вместе: в цепочке это один жест мышью.
+ foreach (var position in command.Order)
+ if (junction.FindElement(position.ElementId) is { } element)
+ element.SetChoice(position.ChoiceKey, element.ChoiceWeight);
+
+ junction.Reorder(command.Order.Select(o => o.ElementId));
+ return await JunctionLoader.MarkTemplatesChangedAsync(
dbContext,
- junction,
+ junction.Id,
cancellationToken
);
}
diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/RenameJunctionCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/UpdateJunctionCommandHandler.cs
similarity index 66%
rename from backend/src/TeleWave.Application/Programming/Templates/Junctions/RenameJunctionCommandHandler.cs
rename to backend/src/TeleWave.Application/Programming/Templates/Junctions/UpdateJunctionCommandHandler.cs
index ad9e705..f4e4071 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/RenameJunctionCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/UpdateJunctionCommandHandler.cs
@@ -4,11 +4,11 @@ using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Junctions;
-public sealed class RenameJunctionCommandHandler(IAppDbContext dbContext)
- : ICommandHandler
+public sealed class UpdateJunctionCommandHandler(IAppDbContext dbContext)
+ : ICommandHandler
{
public async Task Handle(
- RenameJunctionCommand command,
+ UpdateJunctionCommand command,
CancellationToken cancellationToken
)
{
@@ -20,10 +20,10 @@ public sealed class RenameJunctionCommandHandler(IAppDbContext dbContext)
if (junction is null)
return Result.Failure(TemplateErrors.JunctionNotFound);
- junction.Rename(command.Name);
- return await JunctionLoader.MarkTemplateChangedAsync(
+ junction.Update(command.Name, command.MaxTotalSeconds);
+ return await JunctionLoader.MarkTemplatesChangedAsync(
dbContext,
- junction,
+ junction.Id,
cancellationToken
);
}
diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/UpdateJunctionElementCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/UpdateJunctionElementCommandHandler.cs
index 8aa4df1..7557d84 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/UpdateJunctionElementCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/UpdateJunctionElementCommandHandler.cs
@@ -1,6 +1,6 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
-using TeleWave.Application.Broadcast;
+using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
@@ -25,38 +25,60 @@ public sealed class UpdateJunctionElementCommandHandler(IAppDbContext dbContext)
return Result.Failure(TemplateErrors.JunctionElementNotFound);
var input = command.Input;
-
- if (input.Kind == JunctionElementKind.Bumper)
- {
- var known = await dbContext
- .Channels.Where(c => c.Id == junction.ChannelId)
- .SelectMany(c => c.BumperTemplates)
- .AnyAsync(t => t.Id == input.BumperTemplateId, cancellationToken);
- if (!known)
- return Result.Failure(ChannelErrors.BumperTemplateNotFound);
- }
- else
- {
- if (input.GroupId is not { } groupId)
- return Result.Failure(TemplateErrors.JunctionGroupRequired);
- if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
- return Result.Failure(TemplateErrors.GroupNotFound);
- }
+ var check = await ValidateSourceAsync(input, cancellationToken);
+ if (!check.IsSuccess)
+ return check;
element.Update(
- input.Kind,
- input.GroupId,
- input.BumperTemplateId,
- input.AmountMode,
- input.AmountValue,
- input.IsRequired,
- input.Conditions?.ToJson()
+ new JunctionElementSettings(
+ input.Kind,
+ input.GroupId,
+ input.BumperTemplateId,
+ input.BumperVariantId,
+ input.AmountMode,
+ input.AmountValue,
+ input.IsRequired,
+ input.ChoiceKey,
+ input.ChoiceWeight,
+ input.Conditions?.ToJson()
+ )
);
- return await JunctionLoader.MarkTemplateChangedAsync(
+ return await JunctionLoader.MarkTemplatesChangedAsync(
dbContext,
- junction,
+ junction.Id,
cancellationToken
);
}
+
+ /// Источник врезки должен существовать: молча пустая врезка выглядит как «стык не работает».
+ private async Task ValidateSourceAsync(
+ JunctionElementInput input,
+ CancellationToken cancellationToken
+ )
+ {
+ if (input.Kind != JunctionElementKind.Bumper)
+ {
+ if (input.GroupId is not { } groupId)
+ return Result.Failure(TemplateErrors.JunctionGroupRequired);
+ return await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)
+ ? Result.Success()
+ : Result.Failure(TemplateErrors.GroupNotFound);
+ }
+
+ if (input.BumperTemplateId is not { } templateId)
+ return Result.Failure(BumperErrors.TemplateNotFound);
+ if (!await dbContext.BumperTemplates.AnyAsync(t => t.Id == templateId, cancellationToken))
+ return Result.Failure(BumperErrors.TemplateNotFound);
+
+ if (input.BumperVariantId is not { } variantId)
+ return Result.Success();
+
+ return await dbContext.BumperTextVariants.AnyAsync(
+ v => v.Id == variantId && v.BumperTemplateId == templateId,
+ cancellationToken
+ )
+ ? Result.Success()
+ : Result.Failure(BumperErrors.VariantNotFound);
+ }
}
diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs b/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs
index 658488f..d04952f 100644
--- a/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs
+++ b/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs
@@ -1,26 +1,31 @@
namespace TeleWave.Domain.Broadcast;
///
-/// Кэш ТВ-заставки перехода. Один сгенерированный на уникальную комбинацию
-/// ( → ) при данной (хэш названий
-/// шоу и версии шаблона). Ассет создаётся в статусе Pending и рендерится ffmpeg'ом асинхронно фоновым
-/// сервисом — поэтому здесь же храним, ЧЕМ его рендерить (//
-/// ), чтобы фоновый рендерер восстановил спецификацию без участия планировщика.
-/// Переиспользуется между днями и каналами; при смене названий/шаблона сигнатура меняется — новый ассет.
+/// Кэш отрендеренной ТВ-заставки. Ключ — , хэш **содержимого**: оформление
+/// блока с его ревизией плюс уже подставленный текст. Ни канала, ни пары шоу в ключе нет намеренно:
+/// одинаковая заставка на трёх каналах рендерится один раз, а плейсхолдер вроде {channel}
+/// разводит их по разным сигнатурам сам собой.
+///
+/// Подставленный текст () приходится хранить: время показа из ссылок
+/// задним числом не восстанавливается, а ffmpeg запускается фоновым сервисом уже после того, как
+/// лента записана.
///
public class BumperAsset
{
public Guid Id { get; private set; }
- public Guid FromShowId { get; private set; }
- public Guid ToShowId { get; private set; }
- /// Канал/блок/подблок, по которым фоновый рендерер восстановит спецификацию заставки.
- public Guid ChannelId { get; private set; }
+ /// Хэш содержимого: блок + ревизия + подблок + подставленные строки + фон.
+ public string Signature { get; private set; } = string.Empty;
+
+ /// Блок и подблок — оформление рендера и чистка осиротевших ассетов.
public Guid TemplateId { get; private set; }
public Guid VariantId { get; private set; }
- /// Хэш входных данных рендера (названия «из/в» + версия шаблона).
- public string Signature { get; private set; } = string.Empty;
+ /// Готовые строки заставки (JSON). Домен их не интерпретирует — схема живёт в Application.
+ public string RenderedLinesJson { get; private set; } = string.Empty;
+
+ /// Шоу, чей постер идёт фоном, или null (фон блока/градиент).
+ public Guid? PosterShowId { get; private set; }
/// Сгенерированный ассет-заставка (нарезается в assets/{id} фоновым рендерером).
public Guid MediaAssetId { get; private set; }
@@ -30,23 +35,21 @@ public class BumperAsset
private BumperAsset() { }
public static BumperAsset Create(
- Guid channelId,
Guid templateId,
Guid variantId,
- Guid fromShowId,
- Guid toShowId,
string signature,
+ string renderedLinesJson,
+ Guid? posterShowId,
Guid mediaAssetId
) =>
new()
{
Id = Guid.NewGuid(),
- ChannelId = channelId,
TemplateId = templateId,
VariantId = variantId,
- FromShowId = fromShowId,
- ToShowId = toShowId,
Signature = signature,
+ RenderedLinesJson = renderedLinesJson,
+ PosterShowId = posterShowId,
MediaAssetId = mediaAssetId,
CreatedAt = DateTimeOffset.UtcNow,
};
diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperBackground.cs b/backend/src/TeleWave.Domain/Broadcast/BumperBackground.cs
new file mode 100644
index 0000000..9cf6cb7
--- /dev/null
+++ b/backend/src/TeleWave.Domain/Broadcast/BumperBackground.cs
@@ -0,0 +1,17 @@
+namespace TeleWave.Domain.Broadcast;
+
+///
+/// Что за картинка под текстом заставки. Раньше постер подставлялся молча и только в режиме
+/// «Сейчас/Далее»; при свободных строках такой связи взяться неоткуда, поэтому источник задаётся явно.
+///
+public enum BumperBackground
+{
+ /// Фон блока: загруженная картинка, а если её нет — анимированный градиент палитры.
+ Template = 0,
+
+ /// Постер следующего шоу (размытый и затемнённый).
+ NextPoster = 1,
+
+ /// Постер предыдущего шоу.
+ NowPoster = 2,
+}
diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperLine.cs b/backend/src/TeleWave.Domain/Broadcast/BumperLine.cs
new file mode 100644
index 0000000..3e56ab5
--- /dev/null
+++ b/backend/src/TeleWave.Domain/Broadcast/BumperLine.cs
@@ -0,0 +1,52 @@
+namespace TeleWave.Domain.Broadcast;
+
+/// Роль строки в кадре заставки — от неё зависят размер и место.
+public enum BumperLineStyle
+{
+ /// Подпись: мелко, вразрядку — «СЕЙЧАС», «ДАЛЕЕ В 21:30».
+ Label = 0,
+
+ /// Название: крупно, ужимается под ширину кадра.
+ Title = 1,
+
+ /// Мелкая строка под названием — год, жанр, номер серии.
+ Caption = 2,
+}
+
+/// Каким цветом палитры блока рисуется строка.
+public enum BumperLineColor
+{
+ Accent = 0,
+ Text = 1,
+}
+
+///
+/// Строка текста в заставке. Своих цветов и размеров у строки нет — только роль и цвет из палитры
+/// блока: иначе каждый подблок пришлось бы оформлять заново, и общий блок перестал бы быть общим.
+///
+/// хранится с плейсхолдерами («ДАЛЕЕ В {next.time}»); подставляет их планировщик
+/// в момент, когда пара соседей и время показа уже известны.
+///
+public class BumperLine
+{
+ public int Position { get; private set; }
+ public BumperLineStyle Style { get; private set; }
+ public BumperLineColor Color { get; private set; }
+ public string Text { get; private set; } = string.Empty;
+
+ private BumperLine() { }
+
+ public static BumperLine Create(
+ int position,
+ BumperLineStyle style,
+ BumperLineColor color,
+ string text
+ ) =>
+ new()
+ {
+ Position = position,
+ Style = style,
+ Color = color,
+ Text = text.Trim(),
+ };
+}
diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperSelection.cs b/backend/src/TeleWave.Domain/Broadcast/BumperSelection.cs
deleted file mode 100644
index 525f182..0000000
--- a/backend/src/TeleWave.Domain/Broadcast/BumperSelection.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-namespace TeleWave.Domain.Broadcast;
-
-///
-/// Как выбирать подблок заставки на переходе. Значения заданы явно: прежний вариант «по кругу»
-/// (0) убран — курсора ротации в новом пайплайне нет, и он молча вырождался в случайный выбор.
-///
-public enum BumperSelection
-{
- /// Случайный подблок на каждом переходе (равновероятно).
- Random = 1,
-
- /// Всегда первый (дефолтный) подблок.
- AlwaysFirst = 2,
-
- /// Случайный подблок с учётом веса ().
- WeightedRandom = 3,
-}
diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperTemplate.cs b/backend/src/TeleWave.Domain/Broadcast/BumperTemplate.cs
index 2cfe4e1..e4924fa 100644
--- a/backend/src/TeleWave.Domain/Broadcast/BumperTemplate.cs
+++ b/backend/src/TeleWave.Domain/Broadcast/BumperTemplate.cs
@@ -1,32 +1,41 @@
namespace TeleWave.Domain.Broadcast;
+/// Оформление блока заставки: имя, шрифт и палитра (цвета — в нотации ffmpeg: 0xRRGGBB или имя).
+public sealed record BumperStyle(
+ string Name,
+ BumperFont Font,
+ string BackgroundColor,
+ string BackgroundColor2,
+ string AccentColor,
+ string TextColor
+);
+
///
-/// Блок ТВ-заставки канала: свой звук + своё оформление (цвета, опциональная фон-картинка) + набор
+/// Блок ТВ-заставки: свой звук + своё оформление (шрифт, цвета, опциональная фон-картинка) + набор
/// подблоков () с разным текстом и правилом показа. Длительность заставки — по
-/// длине звука (выравнивается на сегмент при рендере). Общий для канала — только шрифт.
+/// длине звука (выравнивается на сегмент при рендере).
///
-/// Первый блок ( == 0) — дефолтный, не удаляется; если звук в нём не загружен,
-/// рендер синтезирует джингл по умолчанию.
+/// Блок общий для всех каналов, как группа: файлы звука и фона и так лежат по идентификатору блока,
+/// канал в них никогда не участвовал. Поэтому же шрифт живёт здесь, а не на канале — иначе два
+/// канала с разным шрифтом делили бы один отрендеренный ассет.
///
public class BumperTemplate
{
private readonly List _variants = [];
public Guid Id { get; private set; }
- public Guid ChannelId { get; private set; }
-
- /// Порядковый номер (0 — дефолтный блок). Используется ротацией и как признак дефолта.
- public int Position { get; private set; }
public string Name { get; private set; } = string.Empty;
+ public BumperFont Font { get; private set; }
+
// ── Оформление блока (цвета — в нотации ffmpeg: 0xRRGGBB или имя) ──
public string BackgroundColor { get; private set; } = DefaultBackgroundColor;
public string BackgroundColor2 { get; private set; } = DefaultBackgroundColor2;
public string AccentColor { get; private set; } = DefaultAccentColor;
public string TextColor { get; private set; } = DefaultTextColor;
- /// Фон-картинка блока — ссылка на запись реестра изображений или null (тогда фон градиент/постер).
+ /// Фон-картинка блока — ссылка на запись реестра изображений или null (тогда фон градиент).
public Guid? BackgroundImageId { get; private set; }
/// Расширение загруженного звука (с точкой) или null — тогда синтезируется джингл.
@@ -35,7 +44,7 @@ public class BumperTemplate
/// Длина загруженного звука в секундах (замер ffprobe) или null, если звука нет.
public double? AudioDurationSeconds { get; private set; }
- /// Версия файлов блока (звук/фон). Входит в кэш-ключ рендера — замена файла пересобирает заставки.
+ /// Версия блока (звук/фон/оформление). Входит в сигнатуру рендера — правка пересобирает заставки.
public int Revision { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
@@ -45,23 +54,19 @@ public class BumperTemplate
public const string DefaultAccentColor = "0x38bdf8";
public const string DefaultTextColor = "white";
- public bool IsDefault => Position == 0;
-
/// Подблоки (текст-варианты); порядок — по .
public IReadOnlyList Variants => _variants;
- private const string DefaultVariantName = "Текст 1";
-
private BumperTemplate() { }
- internal static BumperTemplate Create(Guid channelId, int position, string name)
+ /// Создаёт блок с одним пустым подблоком — текст в него кладёт вызывающий (пресетом).
+ public static BumperTemplate Create(string name, string variantName)
{
var template = new BumperTemplate
{
Id = Guid.NewGuid(),
- ChannelId = channelId,
- Position = position,
- Name = name,
+ Name = name.Trim(),
+ Font = BumperFont.Sans,
BackgroundColor = DefaultBackgroundColor,
BackgroundColor2 = DefaultBackgroundColor2,
AccentColor = DefaultAccentColor,
@@ -72,9 +77,8 @@ public class BumperTemplate
Revision = 0,
CreatedAt = DateTimeOffset.UtcNow,
};
- // Дефолтный подблок «Сейчас/Далее», показывается на смене шоу.
template._variants.Add(
- BumperTextVariant.Create(template.Id, 0, DefaultVariantName, BumperTrigger.OnShowChange)
+ BumperTextVariant.Create(template.Id, 0, variantName, BumperTrigger.OnShowChange)
);
return template;
}
@@ -102,20 +106,19 @@ public class BumperTemplate
return true;
}
- /// Обновить имя и цвета блока. Цвета — в нотации ffmpeg (0xRRGGBB или имя).
- public void UpdateStyle(
- string name,
- string backgroundColor,
- string backgroundColor2,
- string accentColor,
- string textColor
- )
+ ///
+ /// Обновить имя, шрифт и цвета блока. Меняет ревизию: оформление входит в сигнатуру рендера,
+ /// иначе смена шрифта оставила бы в эфире заставки, набранные прежним.
+ ///
+ public void UpdateStyle(BumperStyle style)
{
- Name = name;
- BackgroundColor = backgroundColor;
- BackgroundColor2 = backgroundColor2;
- AccentColor = accentColor;
- TextColor = textColor;
+ Name = style.Name.Trim();
+ Font = style.Font;
+ BackgroundColor = style.BackgroundColor;
+ BackgroundColor2 = style.BackgroundColor2;
+ AccentColor = style.AccentColor;
+ TextColor = style.TextColor;
+ Revision++;
}
/// Отметить загруженный звук (extension — с точкой, нижний регистр) и его длину. Меняет ревизию.
diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperTextContent.cs b/backend/src/TeleWave.Domain/Broadcast/BumperTextContent.cs
deleted file mode 100644
index 59ce1a3..0000000
--- a/backend/src/TeleWave.Domain/Broadcast/BumperTextContent.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-namespace TeleWave.Domain.Broadcast;
-
-///
-/// Текстовое наполнение подблока заставки. Наборы полей взаимоисключающие: при
-/// работают подписи, при —
-/// произвольные строки; неиспользуемые просто хранятся, чтобы переключение режима не теряло ввод.
-///
-public sealed record BumperTextContent(
- BumperTextKind Kind,
- string NowLabel,
- string NextLabel,
- string Line1,
- string Line2
-);
diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperTextKind.cs b/backend/src/TeleWave.Domain/Broadcast/BumperTextKind.cs
deleted file mode 100644
index 7f2a9f8..0000000
--- a/backend/src/TeleWave.Domain/Broadcast/BumperTextKind.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-namespace TeleWave.Domain.Broadcast;
-
-/// Как формируется текст подблока заставки.
-public enum BumperTextKind
-{
- /// «Сейчас/Далее»: две подписи + названия текущего и следующего шоу.
- NowNext,
-
- /// Произвольные строки (без названий шоу) — например название канала и совет.
- Free,
-}
diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs b/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs
index 4fd5dc2..2fec998 100644
--- a/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs
+++ b/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs
@@ -2,37 +2,32 @@ namespace TeleWave.Domain.Broadcast;
///
/// Подблок заставки (текст-вариант) внутри . Наследует от блока звук,
-/// стиль и фон, но задаёт собственный текст и правило показа (). Позволяет иметь
-/// несколько текстов на одной музыке/оформлении, не дублируя блок.
+/// стиль и шрифт, но задаёт собственный набор строк, фон и правило показа ().
+/// Позволяет иметь несколько текстов на одной музыке/оформлении, не дублируя блок.
///
public class BumperTextVariant
{
+ private readonly List _lines = [];
+
public Guid Id { get; private set; }
public Guid BumperTemplateId { get; private set; }
public int Position { get; private set; }
public string Name { get; private set; } = string.Empty;
- public BumperTextKind Kind { get; private set; }
-
- // ── Режим NowNext: подписи (названия шоу подставляет генератор) ──
- public string NowLabel { get; private set; } = DefaultNowLabel;
- public string NextLabel { get; private set; } = DefaultNextLabel;
-
- // ── Режим Free: произвольные строки (например название канала и совет) ──
- public string Line1 { get; private set; } = string.Empty;
- public string Line2 { get; private set; } = string.Empty;
-
public BumperTrigger Trigger { get; private set; }
- /// Вес при стратегии (0 — не выбирается). Иначе игнорируется.
+ /// Источник картинки под текстом.
+ public BumperBackground Background { get; private set; }
+
+ /// Вес при выборе подблока на переходе (0 — не выбирается никогда).
public int Weight { get; private set; } = DefaultWeight;
public DateTimeOffset CreatedAt { get; private set; }
public const int DefaultWeight = 1;
- public const string DefaultNowLabel = "СЕЙЧАС";
- public const string DefaultNextLabel = "ДАЛЕЕ";
+ /// Строки в порядке показа сверху вниз.
+ public IReadOnlyList Lines => _lines;
private BumperTextVariant() { }
@@ -48,28 +43,32 @@ public class BumperTextVariant
BumperTemplateId = bumperTemplateId,
Position = position,
Name = name,
- Kind = BumperTextKind.NowNext,
- NowLabel = DefaultNowLabel,
- NextLabel = DefaultNextLabel,
- Line1 = string.Empty,
- Line2 = string.Empty,
Trigger = trigger,
+ Background = BumperBackground.NextPoster,
Weight = DefaultWeight,
CreatedAt = DateTimeOffset.UtcNow,
};
- public void Update(string name, BumperTextContent text, BumperTrigger trigger, int weight)
+ public void Update(string name, BumperTrigger trigger, BumperBackground background, int weight)
{
Name = name;
- Kind = text.Kind;
- NowLabel = text.NowLabel;
- NextLabel = text.NextLabel;
- Line1 = text.Line1;
- Line2 = text.Line2;
Trigger = trigger;
+ Background = background;
Weight = Math.Max(0, weight);
}
+ ///
+ /// Заменяет набор строк целиком. Построчных команд намеренно нет: редактор всегда знает весь
+ /// список, а порядок задаётся перетаскиванием — три команды вместо одной ничего бы не дали.
+ ///
+ public void SetLines(IEnumerable lines)
+ {
+ _lines.Clear();
+ var position = 0;
+ foreach (var line in lines)
+ _lines.Add(BumperLine.Create(position++, line.Style, line.Color, line.Text));
+ }
+
/// Подходит ли подблок для перехода: — сменилось ли шоу.
public bool Matches(bool isShowChange) =>
Trigger switch
diff --git a/backend/src/TeleWave.Domain/Broadcast/Channel.cs b/backend/src/TeleWave.Domain/Broadcast/Channel.cs
index 19ad036..1c26d3e 100644
--- a/backend/src/TeleWave.Domain/Broadcast/Channel.cs
+++ b/backend/src/TeleWave.Domain/Broadcast/Channel.cs
@@ -3,12 +3,13 @@ namespace TeleWave.Domain.Broadcast;
///
/// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (,
/// см. Domain/Programming); канал хранит только собственные свойства: время, номер, аварийный
-/// филлер и общие настройки заставок.
+/// филлер и настройки зрительской части.
+///
+/// Заставок на канале нет: блоки заставок общие (см. ), а условия их
+/// показа — во врезках стыка.
///
public class Channel
{
- private readonly List _bumperTemplates = [];
-
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public string Slug { get; private set; } = string.Empty;
@@ -42,19 +43,6 @@ public class Channel
public const int DefaultUtcOffsetMinutes = 180;
public static readonly TimeOnly DefaultDayStartTime = new(6, 0);
- // ── Настройки ТВ-заставок. Условия показа (как часто, на смене шоу или между сериями)
- // живут в элементах стыка; на канале осталось только общее для всех заставок. ──
-
- /// Вставлять ли ТВ-заставки вообще: общий выключатель канала.
- public bool BumpersEnabled { get; private set; }
-
- /// Как выбирать подблок заставки на переходе (случайно/по весам/всегда первый).
- public BumperSelection BumperSelection { get; private set; }
-
- public BumperFont BumperFont { get; private set; }
-
- private const string DefaultTemplateName = "Заставка 1";
-
/// Ассет-заглушка на случай пустого расписания (аварийная подстраховка).
public Guid? FillerAssetId { get; private set; }
@@ -77,53 +65,29 @@ public class Channel
public DateTimeOffset CreatedAt { get; private set; }
- /// Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.
- public IReadOnlyList BumperTemplates => _bumperTemplates;
-
private Channel() { }
- public static Channel Create(string name, string slug, DateTimeOffset epochUtc)
- {
- var channel = new Channel
+ public static Channel Create(string name, string slug, DateTimeOffset epochUtc) =>
+ new()
{
Id = Guid.NewGuid(),
Name = name,
Slug = slug,
IsEnabled = true,
EpochUtc = epochUtc,
- BumpersEnabled = false,
- BumperSelection = BumperSelection.WeightedRandom,
- BumperFont = BumperFont.Sans,
LogoOpacity = 0.8,
UtcOffsetMinutes = DefaultUtcOffsetMinutes,
DayStartTime = DefaultDayStartTime,
CreatedAt = DateTimeOffset.UtcNow,
};
- // На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл).
- channel._bumperTemplates.Add(BumperTemplate.Create(channel.Id, 0, DefaultTemplateName));
- return channel;
- }
- public void UpdateSettings(
- string name,
- bool isEnabled,
- bool bumpersEnabled,
- Guid? fillerAssetId
- )
+ public void UpdateSettings(string name, bool isEnabled, Guid? fillerAssetId)
{
Name = name;
IsEnabled = isEnabled;
- BumpersEnabled = bumpersEnabled;
FillerAssetId = fillerAssetId;
}
- /// Общие настройки ТВ-заставок канала: шрифт и стратегия выбора подблока.
- public void UpdateBumperSettings(BumperFont font, BumperSelection selection)
- {
- BumperFont = font;
- BumperSelection = selection;
- }
-
/// Оверлеи и фильтр зрительской части. Всё опционально; силы зажимаются в 0..1.
public void UpdateViewerSettings(
Guid? logoImageId,
@@ -140,29 +104,6 @@ public class Channel
AnalogFilterStrength = Math.Clamp(analogFilterStrength, 0.0, 1.0);
}
- /// Добавить блок заставки в конец списка. Возвращает созданный блок.
- public BumperTemplate AddBumperTemplate(string name)
- {
- var nextPosition =
- _bumperTemplates.Count == 0 ? 0 : _bumperTemplates.Max(t => t.Position) + 1;
- var template = BumperTemplate.Create(Id, nextPosition, name);
- _bumperTemplates.Add(template);
- return template;
- }
-
- public BumperTemplate? FindBumperTemplate(Guid templateId) =>
- _bumperTemplates.FirstOrDefault(t => t.Id == templateId);
-
- /// Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false.
- public bool RemoveBumperTemplate(Guid templateId)
- {
- var template = _bumperTemplates.FirstOrDefault(t => t.Id == templateId);
- if (template is null || template.IsDefault)
- return false;
- _bumperTemplates.Remove(template);
- return true;
- }
-
/// Привязать активный шаблон сетки.
public void SetTemplate(Guid? templateId) => TemplateId = templateId;
diff --git a/backend/src/TeleWave.Domain/Programming/JunctionElement.cs b/backend/src/TeleWave.Domain/Programming/JunctionElement.cs
index 08ef25b..a9dde34 100644
--- a/backend/src/TeleWave.Domain/Programming/JunctionElement.cs
+++ b/backend/src/TeleWave.Domain/Programming/JunctionElement.cs
@@ -29,6 +29,23 @@ public enum JunctionAmountMode
Duration = 1,
}
+///
+/// Полный набор настроек врезки. Собран в запись, потому что по отдельности эти девять значений
+/// ехали бы через команду, хендлер и домен плоским списком, в котором ничего не читается.
+///
+public sealed record JunctionElementSettings(
+ JunctionElementKind Kind,
+ Guid? GroupId,
+ Guid? BumperTemplateId,
+ Guid? BumperVariantId,
+ JunctionAmountMode AmountMode,
+ int AmountValue,
+ bool IsRequired,
+ string? ChoiceKey,
+ int ChoiceWeight,
+ string? ConditionsJson
+);
+
///
/// Врезка в шаблоне стыка. Условия показа хранятся структурно (),
/// а не выражением: парсер, его валидация и отдельный UI обошлись бы дорого, а покрывают ровно
@@ -38,7 +55,10 @@ public class JunctionElement
{
public Guid Id { get; private set; }
public Guid JunctionTemplateId { get; private set; }
+
+ /// Порядок показа в эфире — и только он: обязательность на порядок не влияет.
public int Position { get; private set; }
+
public JunctionElementKind Kind { get; private set; }
/// Откуда брать единицы — для , , .
@@ -47,6 +67,9 @@ public class JunctionElement
/// Какой блок заставки рендерить — для .
public Guid? BumperTemplateId { get; private set; }
+ /// Конкретный подблок заставки; null — выбрать по триггеру перехода и весам.
+ public Guid? BumperVariantId { get; private set; }
+
public JunctionAmountMode AmountMode { get; private set; }
/// Единиц либо минут.
@@ -55,9 +78,20 @@ public class JunctionElement
/// Обязательную врезку нельзя выбросить при нехватке времени.
public bool IsRequired { get; private set; }
+ ///
+ /// Метка развилки: из врезок с одной меткой в эфир идёт одна, выбранная по весам. Врезки одной
+ /// развилки обязаны занимать непрерывный отрезок позиций — иначе неясно, куда встаёт выбранная.
+ ///
+ public string? ChoiceKey { get; private set; }
+
+ /// Вес внутри развилки (0 — не выбирается). Вне развилки не используется.
+ public int ChoiceWeight { get; private set; }
+
/// Условия показа (JSON). Домен их не интерпретирует — схема живёт в Application.
public string? ConditionsJson { get; private set; }
+ public const int DefaultChoiceWeight = 1;
+
private JunctionElement() { }
internal static JunctionElement Create(
@@ -74,28 +108,36 @@ public class JunctionElement
AmountMode = JunctionAmountMode.Count,
AmountValue = 1,
IsRequired = false,
+ ChoiceWeight = DefaultChoiceWeight,
};
- public void Update(
- JunctionElementKind kind,
- Guid? groupId,
- Guid? bumperTemplateId,
- JunctionAmountMode amountMode,
- int amountValue,
- bool isRequired,
- string? conditionsJson
- )
+ public void Update(JunctionElementSettings settings)
{
- Kind = kind;
- AmountMode = amountMode;
- AmountValue = Math.Max(1, amountValue);
- IsRequired = isRequired;
- ConditionsJson = string.IsNullOrWhiteSpace(conditionsJson) ? null : conditionsJson;
+ Kind = settings.Kind;
+ AmountMode = settings.AmountMode;
+ AmountValue = Math.Max(1, settings.AmountValue);
+ IsRequired = settings.IsRequired;
+ ChoiceKey = string.IsNullOrWhiteSpace(settings.ChoiceKey)
+ ? null
+ : settings.ChoiceKey.Trim();
+ ChoiceWeight = Math.Max(0, settings.ChoiceWeight);
+ ConditionsJson = string.IsNullOrWhiteSpace(settings.ConditionsJson)
+ ? null
+ : settings.ConditionsJson;
// Источник зависит от типа врезки: у заставки нет группы, у остальных нет блока заставки.
// Оставленная от прежнего типа ссылка потом читалась бы генератором как настройка.
- GroupId = kind == JunctionElementKind.Bumper ? null : groupId;
- BumperTemplateId = kind == JunctionElementKind.Bumper ? bumperTemplateId : null;
+ var isBumper = settings.Kind == JunctionElementKind.Bumper;
+ GroupId = isBumper ? null : settings.GroupId;
+ BumperTemplateId = isBumper ? settings.BumperTemplateId : null;
+ BumperVariantId = isBumper ? settings.BumperVariantId : null;
+ }
+
+ /// Перевесить врезку в другую развилку (или вынести из неё) — это делает перетаскивание.
+ public void SetChoice(string? choiceKey, int weight)
+ {
+ ChoiceKey = string.IsNullOrWhiteSpace(choiceKey) ? null : choiceKey.Trim();
+ ChoiceWeight = Math.Max(0, weight);
}
internal void SetPosition(int position) => Position = position;
diff --git a/backend/src/TeleWave.Domain/Programming/JunctionTemplate.cs b/backend/src/TeleWave.Domain/Programming/JunctionTemplate.cs
index ebeb6fd..6d02029 100644
--- a/backend/src/TeleWave.Domain/Programming/JunctionTemplate.cs
+++ b/backend/src/TeleWave.Domain/Programming/JunctionTemplate.cs
@@ -4,14 +4,21 @@ namespace TeleWave.Domain.Programming;
/// Шаблон стыка: что играет между программами. Реклама и заставки перестают быть свойствами канала
/// и становятся врезками стыка — за счёт этого в прайм можно поставить три ролика и заставку,
/// а ночью один длинный ролик, чего одной настройкой на канал не сделать.
+///
+/// Стык общий для всех каналов, как группа: «рекламный блок на две минуты с заставкой в конце» —
+/// такой же переиспользуемый ресурс, как «Боевики 90-х». Канальным остаётся только выбор, какой
+/// стык поставить в слот.
///
public class JunctionTemplate
{
private readonly List _elements = [];
public Guid Id { get; private set; }
- public Guid ChannelId { get; private set; }
public string Name { get; private set; } = string.Empty;
+
+ /// Потолок длины стыка целиком в секундах; null — ограничен только якорем.
+ public int? MaxTotalSeconds { get; private set; }
+
public DateTimeOffset CreatedAt { get; private set; }
/// Врезки в порядке показа (backing-field для EF).
@@ -19,16 +26,19 @@ public class JunctionTemplate
private JunctionTemplate() { }
- public static JunctionTemplate Create(Guid channelId, string name) =>
+ public static JunctionTemplate Create(string name) =>
new()
{
Id = Guid.NewGuid(),
- ChannelId = channelId,
Name = name.Trim(),
CreatedAt = DateTimeOffset.UtcNow,
};
- public void Rename(string name) => Name = name.Trim();
+ public void Update(string name, int? maxTotalSeconds)
+ {
+ Name = name.Trim();
+ MaxTotalSeconds = maxTotalSeconds is > 0 ? maxTotalSeconds : null;
+ }
public JunctionElement AddElement(JunctionElementKind kind)
{
@@ -47,6 +57,7 @@ public class JunctionTemplate
if (element is null)
return false;
_elements.Remove(element);
+ Normalize(_elements.OrderBy(e => e.Position).Select(e => e.Id));
return true;
}
@@ -63,8 +74,38 @@ public class JunctionTemplate
.OrderBy(e => e.Position)
.Select(e => e.Id);
+ Normalize(requested.Concat(rest));
+ }
+
+ ///
+ /// Расставляет позиции, стягивая врезки одной развилки в непрерывный отрезок: развилка — это одно
+ /// место в цепочке, и её участники, разбросанные по стыку, не имели бы смысла. Каждая приезжает
+ /// к первому упоминанию своей метки, поэтому перетащить развилку целиком можно за любого участника.
+ ///
+ private void Normalize(IEnumerable order)
+ {
+ var byId = _elements.ToDictionary(e => e.Id);
+ var ordered = order.Select(id => byId[id]).ToList();
+
+ var clustered = new List();
+ var placedChoices = new HashSet(StringComparer.Ordinal);
+
+ foreach (var element in ordered)
+ {
+ if (element.ChoiceKey is not { } key)
+ {
+ clustered.Add(element);
+ continue;
+ }
+
+ if (!placedChoices.Add(key))
+ continue;
+
+ clustered.AddRange(ordered.Where(e => e.ChoiceKey == key));
+ }
+
var position = 0;
- foreach (var id in requested.Concat(rest))
- _elements.First(e => e.Id == id).SetPosition(position++);
+ foreach (var element in clustered)
+ element.SetPosition(position++);
}
}
diff --git a/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs b/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs
index 026f26f..ffc6b7f 100644
--- a/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs
+++ b/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs
@@ -1,42 +1,76 @@
+using TeleWave.Domain.Broadcast.Scheduling;
+
namespace TeleWave.Domain.Programming.Planning;
-/// Состояние стыков в рамках прогона: когда какая врезка ставилась последний раз.
+///
+/// Состояние стыков в рамках прогона: когда какая врезка ставилась последний раз и на какой единице
+/// группы она остановилась.
+///
+/// Ключ — конкретная врезка, а не её вид: две рекламные врезки в разных стыках это разные
+/// ограничения, общий счётчик на вид склеил бы их в одно.
+///
public sealed class JunctionHistory
{
- private readonly Dictionary _lastPlaced = [];
+ private readonly Dictionary _lastPlaced = [];
+ private readonly Dictionary _nextUnit = [];
public bool Allows(PlanningJunctionElement element, DateTimeOffset moment)
{
if (element.MinMinutesBetween <= 0)
return true;
- if (!_lastPlaced.TryGetValue(element.Kind, out var last))
+ if (!_lastPlaced.TryGetValue(element.ElementId, out var last))
return true;
return moment - last >= TimeSpan.FromMinutes(element.MinMinutesBetween);
}
public void Record(PlanningJunctionElement element, DateTimeOffset moment) =>
- _lastPlaced[element.Kind] = moment;
+ _lastPlaced[element.ElementId] = moment;
+
+ ///
+ /// С какой единицы группы врезка продолжает набор. Без этого каждый рекламный блок начинался бы
+ /// с одного и того же ролика — в эфире это слышно с первой же секунды.
+ ///
+ public int NextUnit(PlanningJunctionElement element) =>
+ _nextUnit.TryGetValue(element.ElementId, out var index) ? index : 0;
+
+ public void AdvanceUnits(PlanningJunctionElement element, int consumed)
+ {
+ if (element.Units.Count == 0 || consumed <= 0)
+ return;
+ _nextUnit[element.ElementId] = (NextUnit(element) + consumed) % element.Units.Count;
+ }
}
///
-/// Где ставится стык и между чем. Собрано в один параметр: по отдельности эти четыре значения ехали
+/// Где ставится стык и между чем. Собрано в один параметр: по отдельности эти значения ехали
/// сквозь всю раскладку и вместе с курсором, пределом и накопителями раздували сигнатуры.
///
/// Шоу перед стыком, — после; заставке нужна
/// именно пара соседей, её ассет рендерится под неё после сборки ленты.
+/// Смещение времени канала от UTC — по нему считается окно суток врезки.
public sealed record JunctionPlacement(
Guid SlotId,
Guid? FromShowId,
Guid? ToShowId,
- bool ElementChanged
+ bool ElementChanged,
+ TimeSpan ChannelOffset = default
+);
+
+/// Готовая к постановке врезка: что именно играет и сколько это займёт.
+public sealed record JunctionInsert(
+ PlanningJunctionElement Element,
+ IReadOnlyList Units,
+ TimeSpan Duration
);
///
-/// Раскладка врезок стыка: реклама, промо, заставка, заполнитель. Ставит только то, что влезает
-/// целиком до предела (якорь или горизонт) — обрезать врезку нельзя, а перехлёст сдвинул бы якорь.
+/// Раскладка врезок стыка: реклама, промо, заставка, заполнитель.
///
-/// Обязательные врезки () идут первыми: при нехватке
-/// времени выбрасываются необязательные, а не то, ради чего стык и заведён.
+/// Порядок показа — это , и только он. Обязательность
+/// () участвует единственным способом: когда до
+/// предела (якорь или горизонт) влезает не всё, с конца отбрасываются необязательные врезки. Иначе
+/// галочка «обязательно» молча поднимала бы рекламу перед заставкой, и цепочка в редакторе
+/// перестала бы соответствовать эфиру.
///
public static class JunctionFiller
{
@@ -47,6 +81,7 @@ public static class JunctionFiller
DateTimeOffset limit,
JunctionPlacement placement,
JunctionHistory history,
+ IRandomSource random,
List items,
PlanTrace? trace
)
@@ -54,37 +89,191 @@ public static class JunctionFiller
if (junction is null || junction.Elements.Count == 0)
return cursor;
- var ordered = junction
- .Elements.OrderByDescending(e => e.IsRequired)
- .ThenBy(e => junction.Elements.ToList().IndexOf(e))
+ var eligible = junction
+ .Elements.Where(e => Passes(e, cursor, placement, history, random))
.ToList();
+ var chosen = ResolveChoices(eligible, random);
- foreach (var element in ordered)
+ var available = limit - cursor;
+ if (junction.MaxTotal is { } cap && cap < available)
+ available = cap;
+ if (available <= TimeSpan.Zero)
+ return cursor;
+
+ var inserts = Trim(chosen.Select(e => Build(e, history)).ToList(), available);
+
+ foreach (var insert in inserts)
{
- if (element.OnlyOnElementChange && !placement.ElementChanged)
- continue;
- if (!history.Allows(element, cursor))
- continue;
-
var placedAt = cursor;
+ var before = items.Count;
cursor =
- element.Kind == JunctionElementKind.Bumper
- ? PlaceBumper(element, cursor, limit, placement, items, trace)
- : PlaceUnits(element, cursor, limit, placement.SlotId, items, trace);
+ insert.Element.Kind == JunctionElementKind.Bumper
+ ? PlaceBumper(insert, cursor, limit, placement, items, trace)
+ : PlaceUnits(insert, cursor, limit, placement.SlotId, items, trace);
- if (cursor > placedAt)
- history.Record(element, placedAt);
+ if (cursor <= placedAt)
+ continue;
+
+ history.Record(insert.Element, placedAt);
+ // Двигаем ротацию ровно на поставленное: часть единиц могла не влезть до предела.
+ history.AdvanceUnits(insert.Element, items.Count - before);
}
return cursor;
}
+ /// Проходит ли врезка по своим условиям: смена шоу, интервал, окно суток, жребий.
+ private static bool Passes(
+ PlanningJunctionElement element,
+ DateTimeOffset cursor,
+ JunctionPlacement placement,
+ JunctionHistory history,
+ IRandomSource random
+ )
+ {
+ if (element.OnlyOnElementChange && !placement.ElementChanged)
+ return false;
+ if (!history.Allows(element, cursor))
+ return false;
+
+ if (element.TimeWindow is { } window)
+ {
+ var local = TimeOnly.FromTimeSpan(cursor.ToOffset(placement.ChannelOffset).TimeOfDay);
+ if (!window.Contains(local))
+ return false;
+ }
+
+ // Жребий берётся из того же источника, что и весь прогон: он зависит от координат генерации,
+ // поэтому пересборка хвоста не перетасовывает врезки на каждое применение.
+ return element.Chance >= 100 || random.Next(100) < Math.Max(0, element.Chance);
+ }
+
+ ///
+ /// Схлопывает развилки: из врезок с одной меткой остаётся одна, выбранная по весам. Позиция
+ /// развилки в цепочке — позиция её первого участника.
+ ///
+ private static List ResolveChoices(
+ IReadOnlyList elements,
+ IRandomSource random
+ )
+ {
+ var result = new List();
+ var resolved = new HashSet(StringComparer.Ordinal);
+
+ foreach (var element in elements)
+ {
+ if (element.ChoiceKey is not { } key)
+ {
+ result.Add(element);
+ continue;
+ }
+
+ if (!resolved.Add(key))
+ continue;
+
+ var candidates = elements.Where(e => e.ChoiceKey == key).ToList();
+ result.Add(WeightedPick(candidates, random));
+ }
+
+ return result;
+ }
+
+ private static PlanningJunctionElement WeightedPick(
+ IReadOnlyList candidates,
+ IRandomSource random
+ )
+ {
+ var total = candidates.Sum(c => Math.Max(0, c.ChoiceWeight));
+ if (total <= 0)
+ return candidates[random.Next(candidates.Count)];
+
+ var roll = random.Next(total);
+ var accumulated = 0;
+ foreach (var candidate in candidates)
+ {
+ accumulated += Math.Max(0, candidate.ChoiceWeight);
+ if (roll < accumulated)
+ return candidate;
+ }
+
+ return candidates[^1];
+ }
+
+ /// Подбирает единицы врезки по её бюджету, не двигая состояние: ставить или нет — решит .
+ private static JunctionInsert Build(PlanningJunctionElement element, JunctionHistory history)
+ {
+ if (element.Kind == JunctionElementKind.Bumper)
+ return new JunctionInsert(element, [], element.BumperDuration);
+
+ var units = new List();
+ var accumulated = TimeSpan.Zero;
+ var start = history.NextUnit(element);
+
+ for (var taken = 0; taken < element.Units.Count; taken++)
+ {
+ var enough =
+ element.AmountMode == JunctionAmountMode.Count
+ ? units.Count >= Math.Max(1, element.AmountValue)
+ // Последняя единица входит целиком: рекламный блок не разрезается.
+ : accumulated >= TimeSpan.FromMinutes(Math.Max(1, element.AmountValue));
+ if (enough)
+ break;
+
+ var unit = element.Units[(start + taken) % element.Units.Count];
+ if (unit.Duration <= TimeSpan.Zero)
+ continue;
+
+ units.Add(unit);
+ accumulated += unit.Duration;
+ }
+
+ return new JunctionInsert(element, units, accumulated);
+ }
+
+ ///
+ /// Ужимает стык под : с конца отбрасываются сначала лишние единицы
+ /// необязательных врезок, потом они сами целиком, и только если этого не хватило — обязательные.
+ ///
+ /// Ужимаем поштучно, а не целыми врезками: рекламный блок из четырёх роликов, для которого до
+ /// якоря осталось место под один, должен поставить один, а не пропасть целиком.
+ ///
+ private static List Trim(List inserts, TimeSpan available)
+ {
+ var kept = inserts.Where(i => i.Duration > TimeSpan.Zero).ToList();
+ var total = kept.Aggregate(TimeSpan.Zero, (sum, i) => sum + i.Duration);
+
+ foreach (var required in new[] { false, true })
+ {
+ for (var i = kept.Count - 1; i >= 0 && total > available; i--)
+ {
+ if (kept[i].Element.IsRequired != required)
+ continue;
+
+ while (total > available && kept[i].Units.Count > 1)
+ {
+ var units = kept[i].Units.Take(kept[i].Units.Count - 1).ToList();
+ var duration = units.Aggregate(TimeSpan.Zero, (sum, u) => sum + u.Duration);
+ total -= kept[i].Duration - duration;
+ kept[i] = kept[i] with { Units = units, Duration = duration };
+ }
+
+ if (total <= available)
+ break;
+
+ total -= kept[i].Duration;
+ kept.RemoveAt(i);
+ }
+ }
+
+ return kept;
+ }
+
///
/// Резервирует место под заставку. Ассет пуст: он рендерится под конкретную пару «из/в» уже после
/// того, как лента собрана, — до наполнения слотов пара попросту неизвестна.
///
private static DateTimeOffset PlaceBumper(
- PlanningJunctionElement element,
+ JunctionInsert insert,
DateTimeOffset cursor,
DateTimeOffset limit,
JunctionPlacement placement,
@@ -92,10 +281,10 @@ public static class JunctionFiller
PlanTrace? trace
)
{
- if (element.BumperDuration <= TimeSpan.Zero || cursor + element.BumperDuration > limit)
+ if (insert.Duration <= TimeSpan.Zero || cursor + insert.Duration > limit)
return cursor;
- var end = cursor + element.BumperDuration;
+ var end = cursor + insert.Duration;
items.Add(
new PlannedItem(
Guid.Empty,
@@ -106,17 +295,18 @@ public static class JunctionFiller
placement.SlotId,
PlannedItemKind.Bumper,
trace,
- element.BumperTemplateId,
+ insert.Element.BumperTemplateId,
placement.FromShowId,
- placement.ToShowId
+ placement.ToShowId,
+ insert.Element.BumperVariantId
)
);
return end;
}
- /// Ставит единицы врезки по её бюджету: N штук либо пока не наберётся M минут.
+ /// Ставит подобранные единицы врезки; те, что не влезают до предела, отбрасываются.
private static DateTimeOffset PlaceUnits(
- PlanningJunctionElement element,
+ JunctionInsert insert,
DateTimeOffset cursor,
DateTimeOffset limit,
Guid slotId,
@@ -124,34 +314,16 @@ public static class JunctionFiller
PlanTrace? trace
)
{
- if (element.Units.Count == 0)
- return cursor;
-
- var kind = element.Kind switch
+ var kind = insert.Element.Kind switch
{
JunctionElementKind.Ad => PlannedItemKind.Ad,
JunctionElementKind.Promo => PlannedItemKind.Promo,
_ => PlannedItemKind.Fallback,
};
- var placed = 0;
- var accumulated = TimeSpan.Zero;
- var index = 0;
-
- while (index < element.Units.Count)
+ foreach (var unit in insert.Units)
{
- var unit = element.Units[index];
- index++;
-
- if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > limit)
- break;
-
- var enough =
- element.AmountMode == JunctionAmountMode.Count
- ? placed >= Math.Max(1, element.AmountValue)
- // Последняя единица входит целиком: рекламный блок не разрезается.
- : accumulated >= TimeSpan.FromMinutes(Math.Max(1, element.AmountValue));
- if (enough)
+ if (cursor + unit.Duration > limit)
break;
items.Add(
@@ -167,8 +339,6 @@ public static class JunctionFiller
)
);
cursor += unit.Duration;
- accumulated += unit.Duration;
- placed++;
}
return cursor;
diff --git a/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs b/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs
index f688906..2390c37 100644
--- a/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs
+++ b/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs
@@ -85,11 +85,19 @@ public sealed record PlanningSlot(
public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
}
+/// Окно времени суток в часах канала; допускает переход через полночь (22:00 → 06:00).
+public sealed record PlanningTimeWindow(TimeOnly From, TimeOnly To)
+{
+ public bool Contains(TimeOnly moment) =>
+ From <= To ? moment >= From && moment < To : moment >= From || moment < To;
+}
+
///
/// Врезка стыка, развёрнутая для планировщика: единицы уже подобраны оркестратором, домену остаётся
/// решить, сколько их поставить и влезают ли они.
///
public sealed record PlanningJunctionElement(
+ Guid ElementId,
JunctionElementKind Kind,
IReadOnlyList Units,
JunctionAmountMode AmountMode,
@@ -99,8 +107,17 @@ public sealed record PlanningJunctionElement(
bool OnlyOnElementChange = false,
/// Не ставить чаще, чем раз в N минут (0 — без ограничения).
int MinMinutesBetween = 0,
+ /// Вероятность показа в процентах (100 — всегда).
+ int Chance = 100,
+ /// Окно времени суток, вне которого врезка не ставится (null — всегда).
+ PlanningTimeWindow? TimeWindow = null,
+ /// Метка развилки: из врезок с одной меткой ставится одна, выбранная по весам.
+ string? ChoiceKey = null,
+ int ChoiceWeight = 1,
/// Блок заставки — ассет рендерится позже, планировщик резервирует длительность.
Guid? BumperTemplateId = null,
+ /// Конкретный подблок заставки; null — выберет резолвер по триггеру и весам.
+ Guid? BumperVariantId = null,
/// Длительность резерва под заставку.
TimeSpan BumperDuration = default
);
@@ -108,7 +125,9 @@ public sealed record PlanningJunctionElement(
/// Стык: последовательность врезок между программами.
public sealed record PlanningJunction(
Guid JunctionId,
- IReadOnlyList Elements
+ IReadOnlyList Elements,
+ /// Потолок длины стыка целиком (null — ограничен только якорем).
+ TimeSpan? MaxTotal = null
);
/// Полный вход одного прогона генератора.
@@ -119,7 +138,9 @@ public sealed record PlanningInput(
IReadOnlyList Slots,
/// Чем закрывать место, не покрытое слотами и не заполненное контентом.
IReadOnlyList FallbackUnits,
- int SegmentSeconds
+ int SegmentSeconds,
+ /// Смещение времени канала от UTC — по нему считаются окна суток у врезок стыка.
+ int UtcOffsetMinutes = 0
);
/// Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно.
@@ -136,6 +157,8 @@ public sealed record PlannedItem(
Guid? BumperTemplateId = null,
Guid? FromShowId = null,
Guid? ToShowId = null,
+ /// Подблок заставки, если врезка задала его жёстко; null — выберет резолвер.
+ Guid? BumperVariantId = null,
/// Коллекция, частью которой шла единица (null — шоу играло само по себе).
Guid? CollectionId = null
);
diff --git a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs
index 6fb1150..a172067 100644
--- a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs
+++ b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs
@@ -31,6 +31,9 @@ public static class SchedulePlanner
public List Warnings { get; } = [];
public JunctionHistory Junctions { get; } = new();
+ /// Смещение времени канала — врезки со своим окном суток считают его по нему.
+ public TimeSpan ChannelOffset { get; } = TimeSpan.FromMinutes(input.UtcOffsetMinutes);
+
/// Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент.
public Guid? PreviousShowId { get; set; }
}
@@ -254,9 +257,11 @@ public static class SchedulePlanner
slot.SlotId,
run.PreviousShowId,
unit.ShowId,
- ElementChanged: run.PreviousShowId != unit.ShowId
+ ElementChanged: run.PreviousShowId != unit.ShowId,
+ run.ChannelOffset
),
run.Junctions,
+ run.Random,
run.Items,
slotTrace
);
@@ -282,8 +287,15 @@ public static class SchedulePlanner
slot.JunctionAfter,
cursor,
limit,
- new JunctionPlacement(slot.SlotId, run.PreviousShowId, null, ElementChanged: true),
+ new JunctionPlacement(
+ slot.SlotId,
+ run.PreviousShowId,
+ null,
+ ElementChanged: true,
+ run.ChannelOffset
+ ),
run.Junctions,
+ run.Random,
run.Items,
slotTrace
);
diff --git a/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs b/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs
index 053206c..37b7250 100644
--- a/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs
+++ b/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs
@@ -2,14 +2,15 @@ using System.Globalization;
using System.Text;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
+using TeleWave.Domain.Broadcast;
using static TeleWave.Infrastructure.Media.FfmpegText;
namespace TeleWave.Infrastructure.Media;
///
/// Синтезирует ТВ-заставку перехода полностью на ffmpeg (без исходного файла) по
-/// : анимированный градиентный фон + текст «Сейчас/Далее» + короткий
-/// джингл, и режет результат на те же HLS-сегменты, что и обычный ассет. Длительность фиксированная и
+/// : анимированный градиентный фон + строки текста + короткий джингл,
+/// и режет результат на те же HLS-сегменты, что и обычный ассет. Длительность фиксированная и
/// кратная сегменту, поэтому эфирная математика не отличает заставку от программы.
///
public sealed class FfmpegBumperRenderer(
@@ -22,21 +23,19 @@ public sealed class FfmpegBumperRenderer(
private readonly MediaOptions _media = mediaOptions.Value;
///
- /// Файлы с динамическим текстом заставки. Всё пользователь-редактируемое (названия шоу, подписи,
- /// свободные строки) ffmpeg читает через textfile= с expansion=none: иначе запятая,
- /// ;, [ или ] в тексте ломают (или инъектируют звенья в) цепочку
- /// -filter_complex.
+ /// Одна строка, разложенная под drawtext: файл с текстом, размер, цвет и вертикальная позиция.
+ /// Текст ffmpeg читает через textfile= с expansion=none: иначе запятая, ;,
+ /// [ или ] в тексте ломают (или инъектируют звенья в) цепочку -filter_complex.
///
- private sealed record TextFiles(string Now, string Next, string NowLabel, string NextLabel)
- {
- public static TextFiles In(string assetDir) =>
- new(
- Path.Combine(assetDir, "now.txt"),
- Path.Combine(assetDir, "next.txt"),
- Path.Combine(assetDir, "nowlabel.txt"),
- Path.Combine(assetDir, "nextlabel.txt")
- );
- }
+ private sealed record LayoutLine(
+ string File,
+ string Text,
+ int FontSize,
+ string Color,
+ int Y,
+ bool Shadow,
+ double FadeStart
+ );
public async Task RenderAsync(
Guid assetId,
@@ -53,33 +52,18 @@ public sealed class FfmpegBumperRenderer(
Directory.Delete(assetDir, recursive: true);
Directory.CreateDirectory(assetDir);
- // Названия шоу / свободные строки.
- var text = TextFiles.In(assetDir);
- var line1 = spec.FreeText ? spec.FreeLine1 : spec.NowTitle;
- var line2 = spec.FreeText ? spec.FreeLine2 : spec.NextTitle;
- await File.WriteAllTextAsync(text.Now, line1, new UTF8Encoding(false), cancellationToken);
- await File.WriteAllTextAsync(text.Next, line2, new UTF8Encoding(false), cancellationToken);
-
- // Подписи «Сейчас/Далее» нужны лишь в одноимённом режиме — в FreeText их не рисуют.
- if (!spec.FreeText)
- {
+ var layout = Layout(assetDir, spec);
+ foreach (var line in layout)
await File.WriteAllTextAsync(
- text.NowLabel,
- spec.NowLabel,
+ line.File,
+ line.Text,
new UTF8Encoding(false),
cancellationToken
);
- await File.WriteAllTextAsync(
- text.NextLabel,
- spec.NextLabel,
- new UTF8Encoding(false),
- cancellationToken
- );
- }
try
{
- var args = BuildArgs(assetDir, seg, target, text, spec);
+ var args = BuildArgs(assetDir, seg, target, layout, spec);
var result = await ProcessRunner.RunAsync(
_media.FfmpegPath,
args,
@@ -115,36 +99,83 @@ public sealed class FfmpegBumperRenderer(
}
finally
{
- TryDelete(text.Now);
- TryDelete(text.Next);
- TryDelete(text.NowLabel);
- TryDelete(text.NextLabel);
+ foreach (var line in layout)
+ TryDelete(line.File);
}
}
+ ///
+ /// Раскладывает строки по кадру: блок центрируется целиком по вертикали, размер зависит от роли
+ /// и ужимается под ширину, строки проявляются по очереди. Фиксированных мест у ролей нет —
+ /// иначе набор из двух строк висел бы в верхней трети кадра, как это было у «Сейчас/Далее».
+ ///
+ private static List Layout(string assetDir, BumperRenderSpec spec)
+ {
+ var h = spec.Height;
+ var titleSize = Math.Max(24, h / 10);
+ var labelSize = Math.Max(14, h / 22);
+ var captionSize = Math.Max(12, h / 28);
+ var textWidth = (int)(spec.Width * 0.92); // Поля по 4% с каждой стороны.
+
+ var sizes = spec
+ .Lines.Select(line =>
+ {
+ var baseSize = line.Style switch
+ {
+ BumperLineStyle.Title => titleSize,
+ BumperLineStyle.Caption => captionSize,
+ _ => labelSize,
+ };
+ return FitSize(line.Text, baseSize, textWidth);
+ })
+ .ToList();
+
+ // Между подписью и её названием зазор меньше, чем между смысловыми блоками.
+ var gaps = new List();
+ for (var i = 1; i < spec.Lines.Count; i++)
+ gaps.Add(
+ spec.Lines[i - 1].Style == BumperLineStyle.Label
+ ? (int)(sizes[i] * 0.25)
+ : (int)(sizes[i] * 0.8)
+ );
+
+ var totalHeight = sizes.Sum(s => (int)(s * 1.2)) + gaps.Sum();
+ var y = (h - totalHeight) / 2;
+
+ var layout = new List(spec.Lines.Count);
+ for (var i = 0; i < spec.Lines.Count; i++)
+ {
+ if (i > 0)
+ y += (int)(sizes[i - 1] * 1.2) + gaps[i - 1];
+
+ var line = spec.Lines[i];
+ layout.Add(
+ new LayoutLine(
+ Path.Combine(assetDir, $"line{i}.txt"),
+ line.Text,
+ sizes[i],
+ line.Color == BumperLineColor.Accent ? spec.AccentColor : spec.TextColor,
+ y,
+ line.Style == BumperLineStyle.Title,
+ 0.2 + i * 0.3
+ )
+ );
+ }
+
+ return layout;
+ }
+
private List