Refactor ChannelEndpoints and ScheduleGenerator: consolidate endpoint logic into partial files, remove unused methods, and enhance dependency injection for scheduling. Update ChannelDetail component to streamline imports and improve UI structure.
build / backend (push) Successful in 2m17s
build / frontend (push) Successful in 52s
tests / backend-tests (push) Successful in 2m38s

This commit is contained in:
Leonid Pershin
2026-07-25 20:53:52 +03:00
parent 8484587313
commit 5bc6e144d2
24 changed files with 2546 additions and 2431 deletions
@@ -0,0 +1,343 @@
using System.Text;
using System.Text.RegularExpressions;
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 readonly Regex BumperSegmentFileName = new(
@"^seg\d{1,6}\.ts$",
RegexOptions.Compiled
);
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(
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> 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 RenderBumperPreviewQuery(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);
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/{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");
}
private static IResult PreviewSegment(
Guid id,
Guid templateId,
Guid variantId,
string file,
MediaPathResolver paths
)
{
if (!BumperSegmentFileName.IsMatch(file))
return Results.NotFound();
var previewId = BumperPreview.AssetId(variantId);
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). Возвращает
/// нормализованное расширение (с точкой, нижний регистр) или 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>Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр).</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",
};
}