Implement BumperEndpoints and remove deprecated bumper-related functionality
ci / build-backend (push) Successful in 1m39s
ci / build-frontend (push) Failing after 26s
ci / tests (push) Skipped
ci / sonar (push) Skipped

Added new BumperEndpoints to the API for managing bumper templates and variants, enhancing the channel management capabilities. Removed outdated bumper-related commands and handlers from the application, streamlining the codebase and improving maintainability. Updated ChannelEndpoints to reflect these changes and ensure proper routing for the new endpoints.
This commit is contained in:
Leonid Pershin
2026-07-27 22:01:56 +03:00
parent ee0b4d2d01
commit ba3721eb92
140 changed files with 7326 additions and 3609 deletions
@@ -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;
/// <summary>Эндпоинты ТВ-заставок канала: блоки (стиль/аудио/фон), подблоки и рендер превью.</summary>
public static partial class ChannelEndpoints
/// <summary>
/// Блоки ТВ-заставок: оформление, звук, подблоки с текстом и рендер превью. Блоки общие для всех
/// каналов, поэтому и раздел свой, не канальный — канал только ссылается на них врезками стыков.
/// </summary>
public static class BumperEndpoints
{
private static async Task<IResult> 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<IReadOnlyList<BumperTemplateDto>>();
admin.MapPost("", Create).Produces<CreatedIdResponse>(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<CreatedIdResponse>(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<IResult> List(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListBumperTemplatesQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> 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<IResult> UpdateBumperTemplate(
Guid id,
private static async Task<IResult> 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<IResult> RemoveBumperTemplate(
Guid id,
private static async Task<IResult> 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<IResult> UploadTemplateAudio(
[AsParameters] BumperAudioUpload upload,
private static async Task<IResult> 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<IResult> ClearTemplateAudio(
Guid id,
private static async Task<IResult> 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<IResult> SetTemplateBackground(
Guid id,
private static async Task<IResult> 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<IResult> ClearTemplateBackground(
Guid id,
private static async Task<IResult> 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<IResult> AddBumperVariant(
Guid id,
private static async Task<IResult> 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<IResult> UpdateBumperVariant(
Guid id,
private static async Task<IResult> 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<IResult> RemoveBumperVariant(
Guid id,
private static async Task<IResult> 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
/// <summary>Синхронно рендерит пример заставки блока (несколько секунд ffmpeg).</summary>
private static async Task<IResult> 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();
}
/// <summary>Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
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
}
/// <summary>
/// Сегмент превью. Канал и блок в маршруте есть, но хендлеру не нужны: каталог превью
/// адресуется подблоком (см. BumperPreview.AssetId), поэтому в сигнатуре их нет — незаявленные
/// параметры маршрута просто не связываются.
/// Сегмент превью. Блок в маршруте есть, но хендлеру не нужен: каталог превью адресуется
/// подблоком (см. <see cref="BumperPreview.AssetId"/>) — незаявленные параметры не связываются.
/// </summary>
private static IResult PreviewSegment(Guid variantId, string file, MediaPathResolver paths)
{
@@ -267,50 +292,27 @@ public static partial class ChannelEndpoints
/// <summary>Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает
/// нормализованное расширение (с точкой, нижний регистр) или null при отказе.</summary>
private static string? ResolveBumperExtension(
string fileName,
HttpRequest request,
IReadOnlySet<string> 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
);
/// <summary>
/// Адрес загружаемого звука: канал и блок из маршрута плюс имя исходного файла из query (по нему
/// проверяется расширение). Свёрнуто в один параметр — кроме него хендлеру нужны ещё запрос, два
/// сервиса, диспетчер и токен отмены, и плоским списком сигнатура перестаёт читаться.
/// </summary>
public sealed record BumperAudioUpload(Guid Id, Guid TemplateId, string FileName);
public sealed record SetBumperBackgroundBody(Guid ImageId);
/// <summary>Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр).</summary>
internal static class BumperFiles
@@ -15,11 +15,11 @@ using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки
/// в <c>ChannelEndpoints.Bumpers.cs</c>. Что и когда идёт в эфире, задаёт шаблон сетки
/// (<c>TemplateEndpoints</c>).
/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки
/// и стыки общие для всех каналов и живут своими разделами (<c>BumperEndpoints</c>,
/// <c>JunctionEndpoints</c>); что и когда идёт в эфире, задаёт шаблон сетки (<c>TemplateEndpoints</c>).
/// </summary>
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<CreatedIdResponse>(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<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut(
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
UpdateBumperVariant
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete(
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
RemoveBumperVariant
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapGet("/{id:guid}/schedule", GetSchedule)
.Produces<IReadOnlyList<ScheduleEntryDto>>();
@@ -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);
/// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary>
public sealed record UpdateViewerSettingsBody(
@@ -7,67 +7,50 @@ using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Шаблоны стыков канала: что играет между программами. Как и правка сетки, эфира не двигают —
/// помечают шаблон канала изменённым, а хвост пересобирается применением.
/// Шаблоны стыков: что играет между программами. Стыки общие для всех каналов, канал только
/// ссылается на них слотами. Как и правка сетки, эфира не двигают — помечают шаблоны каналов,
/// которые их используют, изменёнными, а хвост пересобирается применением.
/// </summary>
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<IReadOnlyList<JunctionTemplateDto>>();
admin
.MapPost("/channels/{channelId:guid}/junctions", Create)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/junctions/{junctionId:guid}", Rename)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/junctions/{junctionId:guid}", Delete)
.Produces(StatusCodes.Status204NoContent);
admin.MapGet("", List).Produces<IReadOnlyList<JunctionTemplateDto>>();
admin.MapPost("", Create).Produces<CreatedIdResponse>(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<CreatedIdResponse>(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<IResult> List(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
private static async Task<IResult> 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<IResult> 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<IResult> Rename(
private static async Task<IResult> 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<Guid> ElementIdsInOrder);
/// <summary>Порядок врезок вместе с их развилками — перетаскивание меняет и то, и другое разом.</summary>
public sealed record ReorderJunctionBody(IReadOnlyList<JunctionElementOrder> Order);
+1
View File
@@ -130,6 +130,7 @@ app.MapInterstitialEndpoints();
app.MapCollectionEndpoints();
app.MapGroupEndpoints();
app.MapTemplateEndpoints();
app.MapBumperEndpoints();
app.MapJunctionEndpoints();
app.MapChannelEndpoints();
app.MapStreamingEndpoints();