Refactor Channel endpoints and data models: replace jingle functionality with bumper templates, update related commands and handlers, and enhance API routes for managing bumper templates. Remove obsolete jingle-related code and adjust channel data structures to support new bumper template features.

This commit is contained in:
Leonid Pershin
2026-07-25 11:02:16 +03:00
parent 27571a4ab6
commit 84c2867062
60 changed files with 2805 additions and 1045 deletions
@@ -1,11 +1,11 @@
using System.Text;
using System.Text.RegularExpressions;
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.AddChannelAd;
using TeleWave.Application.Broadcast.AddChannelJingle;
using TeleWave.Application.Broadcast.AddChannelShow;
using TeleWave.Application.Broadcast.BumperBackground;
using TeleWave.Application.Broadcast.BumperMusic;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Broadcast.CreateChannel;
using TeleWave.Application.Broadcast.CreateOverride;
using TeleWave.Application.Broadcast.DeleteOverride;
@@ -14,18 +14,23 @@ using TeleWave.Application.Broadcast.GetSchedule;
using TeleWave.Application.Broadcast.ListChannels;
using TeleWave.Application.Broadcast.RegenerateSchedule;
using TeleWave.Application.Broadcast.RemoveChannelAd;
using TeleWave.Application.Broadcast.RemoveChannelJingle;
using TeleWave.Application.Broadcast.RemoveChannelShow;
using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Application.Broadcast.UpdateChannelShow;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Endpoints;
public static class ChannelEndpoints
{
private static readonly Regex BumperSegmentFileName = new(
@"^seg\d{1,6}\.ts$",
RegexOptions.Compiled
);
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/channels")
@@ -55,24 +60,38 @@ public static class ChannelEndpoints
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/jingles", AddJingle)
.MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapDelete("/{id:guid}/jingles/{channelJingleId:guid}", RemoveJingle)
.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", UploadTemplateBackground)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/background", ClearTemplateBackground)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/bumper/background", UploadBackground)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/background", ClearBackground)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/bumper/music", UploadMusic)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/music", ClearMusic)
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
.Produces(StatusCodes.Status204NoContent);
admin.MapGet(
"/{id:guid}/bumper/templates/{templateId:guid}/preview/index.m3u8",
PreviewPlaylist
);
admin.MapGet(
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{file}",
PreviewSegment
);
admin
.MapPost("/{id:guid}/overrides", CreateOverride)
@@ -216,15 +235,15 @@ public static class ChannelEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> AddJingle(
private static async Task<IResult> AddBumperTemplate(
Guid id,
AddChannelJingleBody body,
AddBumperTemplateBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddChannelJingleCommand(id, body.MediaAssetId),
new AddBumperTemplateCommand(id, body.Name),
cancellationToken
);
return result.IsSuccess
@@ -232,22 +251,91 @@ public static class ChannelEndpoints
: result.ToHttpResult();
}
private static async Task<IResult> RemoveJingle(
private static async Task<IResult> UpdateBumperTemplate(
Guid id,
Guid channelJingleId,
Guid templateId,
UpdateBumperTemplateBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RemoveChannelJingleCommand(id, channelJingleId),
new UpdateBumperTemplateCommand(
id,
templateId,
body.Name,
body.BackgroundColor,
body.BackgroundColor2,
body.AccentColor,
body.TextColor
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UploadBackground(
private static async Task<IResult> RemoveBumperTemplate(
Guid id,
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RemoveBumperTemplateCommand(id, templateId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UploadTemplateAudio(
Guid id,
Guid templateId,
string fileName,
HttpRequest request,
IBumperTemplateStorage storage,
IAudioProbe probe,
ISender sender,
CancellationToken cancellationToken
)
{
if (ResolveBumperExtension(fileName, request, BumperFiles.AudioExtensions) is not { } ext)
return ChannelErrors.InvalidBumperFile.ToProblem();
await storage.SaveAudioAsync(templateId, ext, request.Body, cancellationToken);
// Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина).
var path = storage.AudioPath(templateId, ext);
var duration = path is null
? null
: await probe.TryGetDurationAsync(path, cancellationToken);
var result = await sender.Send(
new SetBumperTemplateAudioCommand(id, templateId, ext, duration?.TotalSeconds ?? 0),
cancellationToken
);
if (!result.IsSuccess)
storage.DeleteAudio(templateId);
return result.ToHttpResult();
}
private static async Task<IResult> ClearTemplateAudio(
Guid id,
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ClearBumperTemplateAudioCommand(id, templateId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UploadTemplateBackground(
Guid id,
Guid templateId,
string fileName,
HttpRequest request,
IBumperTemplateStorage storage,
@@ -258,52 +346,96 @@ public static class ChannelEndpoints
if (ResolveBumperExtension(fileName, request, BumperFiles.BackgroundExtensions) is not { } ext)
return ChannelErrors.InvalidBumperFile.ToProblem();
await storage.SaveBackgroundAsync(id, ext, request.Body, cancellationToken);
await storage.SaveBackgroundAsync(templateId, ext, request.Body, cancellationToken);
var result = await sender.Send(new SetBumperBackgroundCommand(id, ext), cancellationToken);
var result = await sender.Send(
new SetBumperTemplateBackgroundCommand(id, templateId, ext),
cancellationToken
);
if (!result.IsSuccess)
storage.DeleteBackground(id);
storage.DeleteBackground(templateId);
return result.ToHttpResult();
}
private static async Task<IResult> ClearBackground(
private static async Task<IResult> ClearTemplateBackground(
Guid id,
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ClearBumperBackgroundCommand(id), cancellationToken);
var result = await sender.Send(
new ClearBumperTemplateBackgroundCommand(id, templateId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UploadMusic(
/// <summary>Синхронно рендерит пример заставки блока (несколько секунд ffmpeg).</summary>
private static async Task<IResult> RenderPreview(
Guid id,
string fileName,
HttpRequest request,
IBumperTemplateStorage storage,
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
if (ResolveBumperExtension(fileName, request, BumperFiles.MusicExtensions) is not { } ext)
return ChannelErrors.InvalidBumperFile.ToProblem();
await storage.SaveMusicAsync(id, ext, request.Body, cancellationToken);
var result = await sender.Send(new SetBumperMusicCommand(id, ext), cancellationToken);
if (!result.IsSuccess)
storage.DeleteMusic(id);
return result.ToHttpResult();
var result = await sender.Send(
new RenderBumperPreviewQuery(id, templateId),
cancellationToken
);
return result.IsSuccess ? Results.NoContent() : result.ToHttpResult();
}
private static async Task<IResult> ClearMusic(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
/// <summary>Плейлист превью: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
private static IResult PreviewPlaylist(Guid id, Guid templateId, MediaPathResolver paths)
{
var result = await sender.Send(new ClearBumperMusicCommand(id), cancellationToken);
return result.ToHttpResult();
var previewId = BumperPreview.AssetId(templateId);
string indexPath;
try
{
indexPath = paths.SegmentPath(previewId, "index.m3u8");
}
catch (UnauthorizedAccessException)
{
return Results.NotFound();
}
if (!File.Exists(indexPath))
return Results.NotFound();
var baseUrl = $"/api/admin/channels/{id}/bumper/templates/{templateId}/preview/";
var sb = new StringBuilder();
foreach (var line in File.ReadLines(indexPath))
{
var trimmed = line.Trim();
if (trimmed.Length == 0)
continue;
// Комментарии/директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
.Append('\n');
}
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
}
private static IResult PreviewSegment(Guid id, Guid templateId, string file, MediaPathResolver paths)
{
if (!BumperSegmentFileName.IsMatch(file))
return Results.NotFound();
var previewId = BumperPreview.AssetId(templateId);
string path;
try
{
path = paths.SegmentPath(previewId, file);
}
catch (UnauthorizedAccessException)
{
return Results.NotFound();
}
if (!File.Exists(path))
return Results.NotFound();
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
/// <summary>Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает
@@ -405,13 +537,22 @@ public sealed record UpdateChannelShowBody(
public sealed record AddChannelAdBody(Guid MediaAssetId);
public sealed record AddChannelJingleBody(Guid MediaAssetId);
public sealed record AddBumperTemplateBody(string Name);
/// <summary>Ограничения на загружаемые файлы заставки (фон/музыка).</summary>
public sealed record UpdateBumperTemplateBody(
string Name,
string BackgroundColor,
string BackgroundColor2,
string AccentColor,
string TextColor
);
/// <summary>Ограничения на загружаемые файлы блока заставки (звук/фон-картинка).</summary>
internal static class BumperFiles
{
public const long MaxBytes = 200L * 1024 * 1024; // 200 МБ
// Фон блока — только картинка (видео-фоны в новой модели не поддерживаются).
public static readonly IReadOnlySet<string> BackgroundExtensions = new HashSet<string>(
StringComparer.OrdinalIgnoreCase
)
@@ -422,13 +563,9 @@ internal static class BumperFiles
".webp",
".bmp",
".gif",
".mp4",
".mov",
".mkv",
".webm",
};
public static readonly IReadOnlySet<string> MusicExtensions = new HashSet<string>(
public static readonly IReadOnlySet<string> AudioExtensions = new HashSet<string>(
StringComparer.OrdinalIgnoreCase
)
{