332 lines
11 KiB
C#
332 lines
11 KiB
C#
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.Media;
|
|
|
|
namespace TeleWave.Api.Endpoints;
|
|
|
|
/// <summary>Эндпоинты ТВ-заставок канала: блоки (стиль/аудио/фон), подблоки и рендер превью.</summary>
|
|
public static partial class ChannelEndpoints
|
|
{
|
|
private static async Task<IResult> AddBumperTemplate(
|
|
Guid id,
|
|
AddBumperTemplateBody body,
|
|
ISender sender,
|
|
CancellationToken cancellationToken
|
|
)
|
|
{
|
|
var result = await sender.Send(
|
|
new AddBumperTemplateCommand(id, body.Name),
|
|
cancellationToken
|
|
);
|
|
return result.IsSuccess
|
|
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
|
: result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> UpdateBumperTemplate(
|
|
Guid id,
|
|
Guid templateId,
|
|
UpdateBumperTemplateBody body,
|
|
ISender sender,
|
|
CancellationToken cancellationToken
|
|
)
|
|
{
|
|
var result = await sender.Send(
|
|
new UpdateBumperTemplateCommand(
|
|
id,
|
|
templateId,
|
|
body.Name,
|
|
body.BackgroundColor,
|
|
body.BackgroundColor2,
|
|
body.AccentColor,
|
|
body.TextColor
|
|
),
|
|
cancellationToken
|
|
);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
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(
|
|
[AsParameters] BumperAudioUpload upload,
|
|
HttpRequest request,
|
|
IBumperTemplateStorage storage,
|
|
IAudioProbe probe,
|
|
ISender sender,
|
|
CancellationToken cancellationToken
|
|
)
|
|
{
|
|
if (
|
|
ResolveBumperExtension(upload.FileName, request, BumperFiles.AudioExtensions)
|
|
is not { } ext
|
|
)
|
|
return ChannelErrors.InvalidBumperFile.ToProblem();
|
|
|
|
await storage.SaveAudioAsync(upload.TemplateId, ext, request.Body, cancellationToken);
|
|
|
|
// Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина).
|
|
var path = storage.AudioPath(upload.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
|
|
),
|
|
cancellationToken
|
|
);
|
|
if (!result.IsSuccess)
|
|
await storage.DeleteAudioAsync(upload.TemplateId, cancellationToken);
|
|
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> SetTemplateBackground(
|
|
Guid id,
|
|
Guid templateId,
|
|
SetBumperTemplateBackgroundBody body,
|
|
ISender sender,
|
|
CancellationToken cancellationToken
|
|
)
|
|
{
|
|
var result = await sender.Send(
|
|
new SetBumperTemplateBackgroundCommand(id, templateId, body.ImageId),
|
|
cancellationToken
|
|
);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> ClearTemplateBackground(
|
|
Guid id,
|
|
Guid templateId,
|
|
ISender sender,
|
|
CancellationToken cancellationToken
|
|
)
|
|
{
|
|
var result = await sender.Send(
|
|
new ClearBumperTemplateBackgroundCommand(id, templateId),
|
|
cancellationToken
|
|
);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> AddBumperVariant(
|
|
Guid id,
|
|
Guid templateId,
|
|
AddBumperVariantBody body,
|
|
ISender sender,
|
|
CancellationToken cancellationToken
|
|
)
|
|
{
|
|
var result = await sender.Send(
|
|
new AddBumperTextVariantCommand(id, templateId, body.Name),
|
|
cancellationToken
|
|
);
|
|
return result.IsSuccess
|
|
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
|
: result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> UpdateBumperVariant(
|
|
Guid id,
|
|
Guid templateId,
|
|
Guid variantId,
|
|
UpdateBumperVariantBody body,
|
|
ISender sender,
|
|
CancellationToken cancellationToken
|
|
)
|
|
{
|
|
var result = await sender.Send(
|
|
new UpdateBumperTextVariantCommand(
|
|
id,
|
|
templateId,
|
|
variantId,
|
|
body.Name,
|
|
body.Kind,
|
|
body.NowLabel,
|
|
body.NextLabel,
|
|
body.Line1,
|
|
body.Line2,
|
|
body.Trigger,
|
|
body.Weight
|
|
),
|
|
cancellationToken
|
|
);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> RemoveBumperVariant(
|
|
Guid id,
|
|
Guid templateId,
|
|
Guid variantId,
|
|
ISender sender,
|
|
CancellationToken cancellationToken
|
|
)
|
|
{
|
|
var result = await sender.Send(
|
|
new RemoveBumperTextVariantCommand(id, templateId, variantId),
|
|
cancellationToken
|
|
);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
/// <summary>Синхронно рендерит пример заставки блока (несколько секунд ffmpeg).</summary>
|
|
private static async Task<IResult> RenderPreview(
|
|
Guid id,
|
|
Guid templateId,
|
|
ISender sender,
|
|
CancellationToken cancellationToken
|
|
)
|
|
{
|
|
var result = await sender.Send(
|
|
new RenderBumperPreviewCommand(id, templateId),
|
|
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
|
|
)
|
|
{
|
|
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 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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Сегмент превью. Канал и блок в маршруте есть, но хендлеру не нужны: каталог превью
|
|
/// адресуется подблоком (см. BumperPreview.AssetId), поэтому в сигнатуре их нет — незаявленные
|
|
/// параметры маршрута просто не связываются.
|
|
/// </summary>
|
|
private static IResult PreviewSegment(Guid variantId, string file, MediaPathResolver paths)
|
|
{
|
|
if (!SegmentFiles.IsSegmentName(file))
|
|
return Results.NotFound();
|
|
|
|
var previewId = BumperPreview.AssetId(variantId);
|
|
if (SegmentFiles.TryResolveExisting(paths, previewId, file) is not { } path)
|
|
return Results.NotFound();
|
|
|
|
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
|
}
|
|
|
|
/// <summary>Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает
|
|
/// нормализованное расширение (с точкой, нижний регистр) или null при отказе.</summary>
|
|
private static string? ResolveBumperExtension(
|
|
string fileName,
|
|
HttpRequest request,
|
|
IReadOnlySet<string> allowedExtensions
|
|
)
|
|
{
|
|
if (request.ContentLength is > BumperFiles.MaxBytes or 0 or null)
|
|
return null;
|
|
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
|
return allowedExtensions.Contains(ext) ? ext : null;
|
|
}
|
|
}
|
|
|
|
public sealed record AddBumperTemplateBody(string Name);
|
|
|
|
public sealed record UpdateBumperTemplateBody(
|
|
string Name,
|
|
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);
|
|
|
|
/// <summary>Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр).</summary>
|
|
internal static class BumperFiles
|
|
{
|
|
public const long MaxBytes = 200L * 1024 * 1024; // 200 МБ
|
|
|
|
public static readonly IReadOnlySet<string> AudioExtensions = new HashSet<string>(
|
|
StringComparer.OrdinalIgnoreCase
|
|
)
|
|
{
|
|
".mp3",
|
|
".m4a",
|
|
".aac",
|
|
".ogg",
|
|
".opus",
|
|
".wav",
|
|
};
|
|
}
|