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:
@@ -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
|
||||
)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user