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.
This commit is contained in:
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Api.Common;
|
||||
using TeleWave.Application.Broadcast.CreateOverride;
|
||||
using TeleWave.Application.Broadcast.DeleteOverride;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Api.Endpoints;
|
||||
|
||||
/// <summary>Эндпоинты временных override'ов / марафонов канала (разовые и еженедельные).</summary>
|
||||
public static partial class ChannelEndpoints
|
||||
{
|
||||
private static async Task<IResult> CreateOverride(
|
||||
Guid id,
|
||||
CreateOverrideBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new CreateProgrammingOverrideCommand(
|
||||
id,
|
||||
body.Mode,
|
||||
body.Recurrence,
|
||||
body.StartsAtUtc,
|
||||
body.EndsAtUtc,
|
||||
body.DayOfWeek,
|
||||
body.StartMinute,
|
||||
body.EndMinute,
|
||||
body.Shows
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess
|
||||
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteOverride(
|
||||
Guid id,
|
||||
Guid overrideId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new DeleteProgrammingOverrideCommand(id, overrideId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record CreateOverrideBody(
|
||||
OverrideMode Mode,
|
||||
OverrideRecurrence Recurrence,
|
||||
DateTimeOffset? StartsAtUtc,
|
||||
DateTimeOffset? EndsAtUtc,
|
||||
int? DayOfWeek,
|
||||
int? StartMinute,
|
||||
int? EndMinute,
|
||||
IReadOnlyList<OverrideShowInput> Shows
|
||||
);
|
||||
@@ -0,0 +1,122 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Api.Common;
|
||||
using TeleWave.Application.Broadcast.AddChannelAd;
|
||||
using TeleWave.Application.Broadcast.AddChannelShow;
|
||||
using TeleWave.Application.Broadcast.RemoveChannelAd;
|
||||
using TeleWave.Application.Broadcast.RemoveChannelShow;
|
||||
using TeleWave.Application.Broadcast.UpdateChannelShow;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Api.Endpoints;
|
||||
|
||||
/// <summary>Эндпоинты канала: шоу в ротации и рекламный пул.</summary>
|
||||
public static partial class ChannelEndpoints
|
||||
{
|
||||
private static async Task<IResult> AddShow(
|
||||
Guid id,
|
||||
AddChannelShowBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new AddChannelShowCommand(
|
||||
id,
|
||||
body.ShowId,
|
||||
body.Weight,
|
||||
body.BlockMode,
|
||||
body.BlockValue
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess
|
||||
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateShow(
|
||||
Guid id,
|
||||
Guid channelShowId,
|
||||
UpdateChannelShowBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new UpdateChannelShowCommand(
|
||||
id,
|
||||
channelShowId,
|
||||
body.Weight,
|
||||
body.BlockMode,
|
||||
body.BlockValue,
|
||||
body.IsEnabled,
|
||||
body.PreferredWeightMultiplier,
|
||||
body.PreferredHours ?? []
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RemoveShow(
|
||||
Guid id,
|
||||
Guid channelShowId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new RemoveChannelShowCommand(id, channelShowId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddAd(
|
||||
Guid id,
|
||||
AddChannelAdBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new AddChannelAdCommand(id, body.MediaAssetId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess
|
||||
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RemoveAd(
|
||||
Guid id,
|
||||
Guid channelAdId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new RemoveChannelAdCommand(id, channelAdId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AddChannelShowBody(
|
||||
Guid ShowId,
|
||||
int Weight,
|
||||
BlockMode BlockMode,
|
||||
int BlockValue
|
||||
);
|
||||
|
||||
public sealed record UpdateChannelShowBody(
|
||||
int Weight,
|
||||
BlockMode BlockMode,
|
||||
int BlockValue,
|
||||
bool IsEnabled,
|
||||
int PreferredWeightMultiplier,
|
||||
IReadOnlyList<HourWindowInput> PreferredHours
|
||||
);
|
||||
|
||||
public sealed record AddChannelAdBody(Guid MediaAssetId);
|
||||
@@ -1,36 +1,25 @@
|
||||
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.AddChannelShow;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Broadcast.CreateChannel;
|
||||
using TeleWave.Application.Broadcast.CreateOverride;
|
||||
using TeleWave.Application.Broadcast.DeleteOverride;
|
||||
using TeleWave.Application.Broadcast.GetChannel;
|
||||
using TeleWave.Application.Broadcast.GetSchedule;
|
||||
using TeleWave.Application.Broadcast.ListChannels;
|
||||
using TeleWave.Application.Broadcast.RegenerateSchedule;
|
||||
using TeleWave.Application.Broadcast.RemoveChannelAd;
|
||||
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
|
||||
/// <summary>
|
||||
/// Админ-эндпоинты канала. Реализация разнесена по partial-файлам под-ресурсов:
|
||||
/// <c>ChannelEndpoints.Shows.cs</c> (шоу+реклама), <c>ChannelEndpoints.Bumpers.cs</c>
|
||||
/// (блоки/подблоки/файлы/preview), <c>ChannelEndpoints.Overrides.cs</c> (override'ы). Здесь —
|
||||
/// регистрация всех маршрутов и хендлеры уровня канала (создание/список/настройки/расписание).
|
||||
/// </summary>
|
||||
public static partial 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")
|
||||
@@ -189,416 +178,6 @@ public static class ChannelEndpoints
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddShow(
|
||||
Guid id,
|
||||
AddChannelShowBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new AddChannelShowCommand(
|
||||
id,
|
||||
body.ShowId,
|
||||
body.Weight,
|
||||
body.BlockMode,
|
||||
body.BlockValue
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess
|
||||
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateShow(
|
||||
Guid id,
|
||||
Guid channelShowId,
|
||||
UpdateChannelShowBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new UpdateChannelShowCommand(
|
||||
id,
|
||||
channelShowId,
|
||||
body.Weight,
|
||||
body.BlockMode,
|
||||
body.BlockValue,
|
||||
body.IsEnabled,
|
||||
body.PreferredWeightMultiplier,
|
||||
body.PreferredHours ?? []
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RemoveShow(
|
||||
Guid id,
|
||||
Guid channelShowId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new RemoveChannelShowCommand(id, channelShowId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddAd(
|
||||
Guid id,
|
||||
AddChannelAdBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new AddChannelAdCommand(id, body.MediaAssetId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess
|
||||
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RemoveAd(
|
||||
Guid id,
|
||||
Guid channelAdId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new RemoveChannelAdCommand(id, channelAdId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
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> 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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateOverride(
|
||||
Guid id,
|
||||
CreateOverrideBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new CreateProgrammingOverrideCommand(
|
||||
id,
|
||||
body.Mode,
|
||||
body.Recurrence,
|
||||
body.StartsAtUtc,
|
||||
body.EndsAtUtc,
|
||||
body.DayOfWeek,
|
||||
body.StartMinute,
|
||||
body.EndMinute,
|
||||
body.Shows
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess
|
||||
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteOverride(
|
||||
Guid id,
|
||||
Guid overrideId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new DeleteProgrammingOverrideCommand(id, overrideId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Regenerate(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
@@ -636,75 +215,3 @@ public sealed record UpdateChannelSettingsBody(
|
||||
BumperSettingsInput Bumper,
|
||||
Guid? FillerAssetId
|
||||
);
|
||||
|
||||
public sealed record AddChannelShowBody(
|
||||
Guid ShowId,
|
||||
int Weight,
|
||||
BlockMode BlockMode,
|
||||
int BlockValue
|
||||
);
|
||||
|
||||
public sealed record UpdateChannelShowBody(
|
||||
int Weight,
|
||||
BlockMode BlockMode,
|
||||
int BlockValue,
|
||||
bool IsEnabled,
|
||||
int PreferredWeightMultiplier,
|
||||
IReadOnlyList<HourWindowInput> PreferredHours
|
||||
);
|
||||
|
||||
public sealed record AddChannelAdBody(Guid MediaAssetId);
|
||||
|
||||
public sealed record AddBumperTemplateBody(string Name);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
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> AudioExtensions = new HashSet<string>(
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
)
|
||||
{
|
||||
".mp3",
|
||||
".m4a",
|
||||
".aac",
|
||||
".ogg",
|
||||
".opus",
|
||||
".wav",
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record CreateOverrideBody(
|
||||
OverrideMode Mode,
|
||||
OverrideRecurrence Recurrence,
|
||||
DateTimeOffset? StartsAtUtc,
|
||||
DateTimeOffset? EndsAtUtc,
|
||||
int? DayOfWeek,
|
||||
int? StartMinute,
|
||||
int? EndMinute,
|
||||
IReadOnlyList<OverrideShowInput> Shows
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user