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; /// Эндпоинты ТВ-заставок канала: блоки (стиль/аудио/фон), подблоки и рендер превью. public static partial class ChannelEndpoints { private static async Task 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 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 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 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 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 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 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 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 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 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(); } /// Синхронно рендерит пример заставки блока (несколько секунд ffmpeg). private static async Task 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(); } /// Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут. 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"); } /// /// Сегмент превью. Канал и блок в маршруте есть, но хендлеру не нужны: каталог превью /// адресуется подблоком (см. BumperPreview.AssetId), поэтому в сигнатуре их нет — незаявленные /// параметры маршрута просто не связываются. /// 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); } /// Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает /// нормализованное расширение (с точкой, нижний регистр) или null при отказе. private static string? ResolveBumperExtension( string fileName, HttpRequest request, IReadOnlySet 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 ); /// /// Адрес загружаемого звука: канал и блок из маршрута плюс имя исходного файла из query (по нему /// проверяется расширение). Свёрнуто в один параметр — кроме него хендлеру нужны ещё запрос, два /// сервиса, диспетчер и токен отмены, и плоским списком сигнатура перестаёт читаться. /// public sealed record BumperAudioUpload(Guid Id, Guid TemplateId, string FileName); /// Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр). internal static class BumperFiles { public const long MaxBytes = 200L * 1024 * 1024; // 200 МБ public static readonly IReadOnlySet AudioExtensions = new HashSet( StringComparer.OrdinalIgnoreCase ) { ".mp3", ".m4a", ".aac", ".ogg", ".opus", ".wav", }; }