diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Bumpers.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Bumpers.cs new file mode 100644 index 0000000..275040e --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Bumpers.cs @@ -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; + +/// Эндпоинты ТВ-заставок канала: блоки (стиль/аудио/фон), подблоки и рендер превью. +public static partial class ChannelEndpoints +{ + private static readonly Regex BumperSegmentFileName = new( + @"^seg\d{1,6}\.ts$", + RegexOptions.Compiled + ); + + 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( + 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 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 RenderBumperPreviewQuery(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); + 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); + } + + /// Проверяет расширение файла (по 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 +); + +/// Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр). +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", + }; +} diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Overrides.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Overrides.cs new file mode 100644 index 0000000..538323d --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Overrides.cs @@ -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; + +/// Эндпоинты временных override'ов / марафонов канала (разовые и еженедельные). +public static partial class ChannelEndpoints +{ + private static async Task 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 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 Shows +); diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Shows.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Shows.cs new file mode 100644 index 0000000..205c88d --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Shows.cs @@ -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; + +/// Эндпоинты канала: шоу в ротации и рекламный пул. +public static partial class ChannelEndpoints +{ + private static async Task 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 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 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 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 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 PreferredHours +); + +public sealed record AddChannelAdBody(Guid MediaAssetId); diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs index 6140409..a14c77b 100644 --- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs @@ -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 +/// +/// Админ-эндпоинты канала. Реализация разнесена по partial-файлам под-ресурсов: +/// ChannelEndpoints.Shows.cs (шоу+реклама), ChannelEndpoints.Bumpers.cs +/// (блоки/подблоки/файлы/preview), ChannelEndpoints.Overrides.cs (override'ы). Здесь — +/// регистрация всех маршрутов и хендлеры уровня канала (создание/список/настройки/расписание). +/// +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 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 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 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 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 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 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( - 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 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 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(); - } - - 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(); - } - - /// Синхронно рендерит пример заставки блока (несколько секунд ffmpeg). - private static async Task 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(); - } - - /// Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут. - 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); - } - - /// Проверяет расширение файла (по 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; - } - - private static async Task 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 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 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 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 -); - -/// Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр). -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", - }; -} - -public sealed record CreateOverrideBody( - OverrideMode Mode, - OverrideRecurrence Recurrence, - DateTimeOffset? StartsAtUtc, - DateTimeOffset? EndsAtUtc, - int? DayOfWeek, - int? StartMinute, - int? EndMinute, - IReadOnlyList Shows -); diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/BumperDuration.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/BumperDuration.cs new file mode 100644 index 0000000..cfd7ba4 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Scheduling/BumperDuration.cs @@ -0,0 +1,25 @@ +using TeleWave.Domain.Broadcast; + +namespace TeleWave.Application.Broadcast.Scheduling; + +/// +/// Длительность заставки блока: по длине загруженного звука (иначе дефолт для синтеза), выровненная +/// вверх до кратности длине сегмента — ключевой инвариант эфирной математики. Общая для генератора +/// (слоты в плане) и резолвера заставок (рендер). +/// +internal static class BumperDuration +{ + /// Длительность заставки без загруженного звука (сек) — синтезированный джингл. + private const int DefaultSeconds = 8; + + /// Длина заставки блока (сек): по загруженному звуку либо дефолт для синтеза. + public static double TemplateSeconds(BumperTemplate template) => + template.AudioDurationSeconds is { } d and > 0 ? d : DefaultSeconds; + + /// Секунды, выровненные вверх до кратности длине сегмента. + public static int Aligned(double seconds, int segmentSeconds) + { + var requested = Math.Max(segmentSeconds, seconds); + return (int)(Math.Ceiling(requested / segmentSeconds) * segmentSeconds); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleBumperResolver.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleBumperResolver.cs new file mode 100644 index 0000000..6497028 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleBumperResolver.cs @@ -0,0 +1,331 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TeleWave.Application.Broadcast.Bumpers; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Streaming; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Broadcast.Scheduling; +using TeleWave.Domain.Media; + +namespace TeleWave.Application.Broadcast.Scheduling; + +/// +/// Резолвит ассеты ТВ-заставок для запланированных переходов: для каждой уникальной тройки +/// «из→в→подблок» возвращает id готового ассета — из кэша () либо +/// свежесгенерированного ffmpeg-рендером. Работает через тот же scoped , +/// что и : добавленные ассеты сохраняются его общим SaveChanges. +/// +public sealed class ScheduleBumperResolver( + IAppDbContext dbContext, + IBumperRenderer bumperRenderer, + IBumperTemplateStorage bumperStorage, + IImageStore imageStore, + IOptions bumperOptions, + IOptions streamingOptions, + ILogger logger +) +{ + private readonly BumperOptions _bumper = bumperOptions.Value; + private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds); + + /// + /// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id готового + /// ассета-заставки: из кэша либо свежесгенерированного. + /// + public async Task> ResolveAsync( + Channel channel, + IReadOnlyList entries, + IReadOnlyDictionary showNames, + CancellationToken cancellationToken + ) + { + var result = new Dictionary<(Guid, Guid, Guid), Guid>(); + var variantsById = channel + .BumperTemplates.SelectMany(t => t.Variants.Select(v => (Variant: v, Template: t))) + .ToDictionary(x => x.Variant.Id); + var combos = entries + .Where(e => + e.Kind == ScheduleEntryKind.Bumper + && e.FromShowId is not null + && e.ToShowId is not null + && e.BumperVariantId is not null + ) + .Select(e => + ( + From: e.FromShowId!.Value, + To: e.ToShowId!.Value, + Variant: e.BumperVariantId!.Value + ) + ) + .Distinct() + .ToList(); + if (combos.Count == 0) + return result; + + var fromIds = combos.Select(c => c.From).Distinct().ToList(); + var toIds = combos.Select(c => c.To).Distinct().ToList(); + + // Постеры шоу-получателей (из реестра изображений) — как фон заставки, если у блока нет + // своей фон-картинки. Резолвим id постера → расширение → абсолютный путь. + var showIds = fromIds.Concat(toIds).Distinct().ToList(); + var posterShows = await dbContext + .Shows.AsNoTracking() + .Where(s => showIds.Contains(s.Id) && s.PosterImageId != null) + .Select(s => new { s.Id, ImageId = s.PosterImageId!.Value }) + .ToListAsync(cancellationToken); + var posterImageIds = posterShows.Select(p => p.ImageId).Distinct().ToList(); + var posterExtById = await dbContext + .Images.AsNoTracking() + .Where(i => posterImageIds.Contains(i.Id)) + .Select(i => new { i.Id, i.FileExtension }) + .ToDictionaryAsync(i => i.Id, i => i.FileExtension, cancellationToken); + var posterByShow = new Dictionary(); + foreach (var p in posterShows) + if ( + posterExtById.TryGetValue(p.ImageId, out var ext) + && imageStore.ResolvePath(p.ImageId, ext) is { } abs + ) + posterByShow[p.Id] = (p.ImageId, abs); + + // Фон-картинки блоков (из реестра) — абсолютные пути по id. + var bgImageIds = channel + .BumperTemplates.Where(t => t.BackgroundImageId != null) + .Select(t => t.BackgroundImageId!.Value) + .Distinct() + .ToList(); + var bgExtById = await dbContext + .Images.AsNoTracking() + .Where(i => bgImageIds.Contains(i.Id)) + .Select(i => new { i.Id, i.FileExtension }) + .ToDictionaryAsync(i => i.Id, i => i.FileExtension, cancellationToken); + var bgByTemplate = new Dictionary(); + foreach (var t in channel.BumperTemplates) + if ( + t.BackgroundImageId is { } bgId + && bgExtById.TryGetValue(bgId, out var bgExt) + && imageStore.ResolvePath(bgId, bgExt) is { } bgAbs + ) + bgByTemplate[t.Id] = bgAbs; + + // Кандидаты из кэша + статусы их ассетов (годятся только Ready — файлы могли удалить). + var cached = await dbContext + .BumperAssets.AsNoTracking() + .Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId)) + .Select(b => new + { + b.FromShowId, + b.ToShowId, + b.Signature, + b.MediaAssetId, + }) + .ToListAsync(cancellationToken); + + var cachedAssetIds = cached.Select(c => c.MediaAssetId).Distinct().ToList(); + var readyAssetIds = await dbContext + .MediaAssets.AsNoTracking() + .Where(a => cachedAssetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready) + .Select(a => a.Id) + .ToListAsync(cancellationToken); + var readySet = readyAssetIds.ToHashSet(); + + foreach (var combo in combos) + { + if (!variantsById.TryGetValue(combo.Variant, out var pair)) + continue; + var (variant, template) = pair; + + var fromName = showNames.GetValueOrDefault(combo.From, "…"); + var toName = showNames.GetValueOrDefault(combo.To, "…"); + // Постер шоу-получателя как фон — только для «Сейчас/Далее» (свободный текст шоу не упоминает). + var usePoster = variant.Kind == BumperTextKind.NowNext; + var poster = usePoster && posterByShow.TryGetValue(combo.To, out var pr) ? pr : default; + var posterToken = poster.ImageId == Guid.Empty ? "-" : poster.ImageId.ToString(); + var posterAbs = poster.ImageId == Guid.Empty ? null : poster.AbsPath; + var bgAbs = bgByTemplate.GetValueOrDefault(template.Id); + var aligned = BumperDuration.Aligned( + BumperDuration.TemplateSeconds(template), + _segmentSeconds + ); + var signature = ComputeSignature( + channel, + template, + variant, + fromName, + toName, + aligned, + posterToken + ); + + var hit = cached.FirstOrDefault(c => + c.FromShowId == combo.From + && c.ToShowId == combo.To + && c.Signature == signature + && readySet.Contains(c.MediaAssetId) + ); + if (hit is not null) + { + result[combo] = hit.MediaAssetId; + continue; + } + + try + { + var assetId = await RenderAsync( + channel, + template, + variant, + combo.From, + combo.To, + fromName, + toName, + aligned, + signature, + posterAbs, + bgAbs, + cancellationToken + ); + result[combo] = assetId; + } + catch (Exception ex) + { + logger.LogError( + ex, + "Не удалось отрендерить заставку {From} → {To}", + fromName, + toName + ); + } + } + + return result; + } + + private async Task RenderAsync( + Channel channel, + BumperTemplate template, + BumperTextVariant variant, + Guid fromShowId, + Guid toShowId, + string fromName, + string toName, + int alignedDurationSeconds, + string signature, + string? posterAbsolutePath, + string? backgroundAbsolutePath, + CancellationToken cancellationToken + ) + { + var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}"); + var render = await bumperRenderer.RenderAsync( + asset.Id, + BuildSpec( + channel, + template, + variant, + alignedDurationSeconds, + fromName, + toName, + posterAbsolutePath, + backgroundAbsolutePath + ), + cancellationToken + ); + + asset.MarkReady( + render.Duration, + render.SegmentSeconds, + render.SegmentCount, + render.Width, + render.Height, + "h264", + "aac", + render.RelativePath + ); + + dbContext.MediaAssets.Add(asset); + dbContext.BumperAssets.Add(BumperAsset.Create(fromShowId, toShowId, signature, asset.Id)); + return asset.Id; + } + + private BumperRenderSpec BuildSpec( + Channel channel, + BumperTemplate template, + BumperTextVariant variant, + int alignedDurationSeconds, + string fromName, + string toName, + string? posterAbsolutePath, + string? backgroundAbsolutePath + ) + { + var free = variant.Kind == BumperTextKind.Free; + return new BumperRenderSpec( + alignedDurationSeconds, + _bumper.Width, + _bumper.Height, + template.BackgroundColor, + template.BackgroundColor2, + template.AccentColor, + template.TextColor, + FontPath(channel.BumperFont), + free ? "" : variant.NowLabel, + free ? "" : fromName, + free ? "" : variant.NextLabel, + free ? "" : toName, + backgroundAbsolutePath, + bumperStorage.AudioPath(template.Id, template.AudioExtension), + posterAbsolutePath, + free, + variant.Line1, + variant.Line2 + ); + } + + private string FontPath(BumperFont font) => + font == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans; + + /// + /// Сигнатура рендера = хэш всех входов заставки: общие настройки канала (шрифт/подписи/версия + /// шаблона), оформление и файлы блока (цвета/фон/звук/ревизия), названия шоу и постер. Меняется — + /// заставка пересобирается. Разделитель — unit separator (U+001F), чтобы поля не слипались. + /// + private string ComputeSignature( + Channel channel, + BumperTemplate template, + BumperTextVariant variant, + string fromName, + string toName, + int alignedDurationSeconds, + string poster + ) + { + var raw = string.Join( + '', + _bumper.TemplateVersion, + _bumper.Width, + _bumper.Height, + alignedDurationSeconds, + channel.BumperFont, + variant.Kind, + variant.NowLabel, + variant.NextLabel, + variant.Line1, + variant.Line2, + template.BackgroundColor, + template.BackgroundColor2, + template.AccentColor, + template.TextColor, + template.Revision, + template.BackgroundImageId?.ToString() ?? "-", + template.AudioExtension ?? "-", + fromName, + toName, + poster + ); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw)); + return Convert.ToHexString(hash); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs index e4454d5..5d7fe75 100644 --- a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs @@ -1,9 +1,5 @@ -using System.Security.Cryptography; -using System.Text; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Streaming; using TeleWave.Domain.Broadcast; @@ -16,28 +12,19 @@ namespace TeleWave.Application.Broadcast.Scheduling; /// Оркестратор планирования: загружает конфигурацию канала и готовые ассеты, вызывает чистый /// , материализует записи и двигает курсоры. Используется фоновым /// планировщиком (расширение горизонта) и командой перегенерации (правка конфигурации канала). -/// Заставки-переходы, отмеченные планировщиком, здесь рендерятся (или берутся из кэша) по выбранному -/// блоку и подставляются как обычные ассеты. +/// Ассеты заставок-переходов резолвит/рендерит (общий контекст). /// public sealed class ScheduleGenerator( IAppDbContext dbContext, IRandomSource random, - IBumperRenderer bumperRenderer, - IBumperTemplateStorage bumperStorage, - IImageStore imageStore, + ScheduleBumperResolver bumperResolver, IOptions options, - IOptions bumperOptions, - IOptions streamingOptions, - ILogger logger + IOptions streamingOptions ) { private readonly SchedulerOptions _options = options.Value; - private readonly BumperOptions _bumper = bumperOptions.Value; private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds); - /// Длительность заставки без загруженного звука (сек) — синтезированный джингл. - private const int DefaultBumperDurationSeconds = 8; - /// /// Достраивает (или, при , перестраивает будущий хвост) расписание /// канала до горизонта. Возвращает число добавленных записей (-1 — канал не найден/выключен). @@ -97,8 +84,8 @@ public sealed class ScheduleGenerator( var input = await BuildInputAsync(channel, startTime, horizonEnd, cancellationToken); var result = SchedulePlanner.Plan(input, random); - // Рендерим/достаём из кэша ассеты заставок для всех переходов плана (по паре шоу + блоку). - var bumperAssets = await ResolveBumperAssetsAsync( + // Рендерим/достаём из кэша ассеты заставок для всех переходов плана (по паре шоу + подблоку). + var bumperAssets = await bumperResolver.ResolveAsync( channel, result.Entries, showNames, @@ -172,315 +159,6 @@ public sealed class ScheduleGenerator( ); } - /// - /// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id готового - /// ассета-заставки: из кэша () либо свежесгенерированного. - /// - private async Task< - Dictionary<(Guid From, Guid To, Guid Variant), Guid> - > ResolveBumperAssetsAsync( - Channel channel, - IReadOnlyList entries, - IReadOnlyDictionary showNames, - CancellationToken cancellationToken - ) - { - var result = new Dictionary<(Guid, Guid, Guid), Guid>(); - var templatesById = channel.BumperTemplates.ToDictionary(t => t.Id); - var variantsById = channel - .BumperTemplates.SelectMany(t => t.Variants.Select(v => (Variant: v, Template: t))) - .ToDictionary(x => x.Variant.Id); - var combos = entries - .Where(e => - e.Kind == ScheduleEntryKind.Bumper - && e.FromShowId is not null - && e.ToShowId is not null - && e.BumperVariantId is not null - ) - .Select(e => - ( - From: e.FromShowId!.Value, - To: e.ToShowId!.Value, - Variant: e.BumperVariantId!.Value - ) - ) - .Distinct() - .ToList(); - if (combos.Count == 0) - return result; - - var fromIds = combos.Select(c => c.From).Distinct().ToList(); - var toIds = combos.Select(c => c.To).Distinct().ToList(); - - // Постеры шоу-получателей (из реестра изображений) — как фон заставки, если у блока нет - // своей фон-картинки. Резолвим id постера → расширение → абсолютный путь. - var showIds = fromIds.Concat(toIds).Distinct().ToList(); - var posterShows = await dbContext - .Shows.AsNoTracking() - .Where(s => showIds.Contains(s.Id) && s.PosterImageId != null) - .Select(s => new { s.Id, ImageId = s.PosterImageId!.Value }) - .ToListAsync(cancellationToken); - var posterImageIds = posterShows.Select(p => p.ImageId).Distinct().ToList(); - var posterExtById = await dbContext - .Images.AsNoTracking() - .Where(i => posterImageIds.Contains(i.Id)) - .Select(i => new { i.Id, i.FileExtension }) - .ToDictionaryAsync(i => i.Id, i => i.FileExtension, cancellationToken); - var posterByShow = new Dictionary(); - foreach (var p in posterShows) - if ( - posterExtById.TryGetValue(p.ImageId, out var ext) - && imageStore.ResolvePath(p.ImageId, ext) is { } abs - ) - posterByShow[p.Id] = (p.ImageId, abs); - - // Фон-картинки блоков (из реестра) — абсолютные пути по id. - var bgImageIds = channel - .BumperTemplates.Where(t => t.BackgroundImageId != null) - .Select(t => t.BackgroundImageId!.Value) - .Distinct() - .ToList(); - var bgExtById = await dbContext - .Images.AsNoTracking() - .Where(i => bgImageIds.Contains(i.Id)) - .Select(i => new { i.Id, i.FileExtension }) - .ToDictionaryAsync(i => i.Id, i => i.FileExtension, cancellationToken); - var bgByTemplate = new Dictionary(); - foreach (var t in channel.BumperTemplates) - if ( - t.BackgroundImageId is { } bgId - && bgExtById.TryGetValue(bgId, out var bgExt) - && imageStore.ResolvePath(bgId, bgExt) is { } bgAbs - ) - bgByTemplate[t.Id] = bgAbs; - - // Кандидаты из кэша + статусы их ассетов (годятся только Ready — файлы могли удалить). - var cached = await dbContext - .BumperAssets.AsNoTracking() - .Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId)) - .Select(b => new - { - b.FromShowId, - b.ToShowId, - b.Signature, - b.MediaAssetId, - }) - .ToListAsync(cancellationToken); - - var cachedAssetIds = cached.Select(c => c.MediaAssetId).Distinct().ToList(); - var readyAssetIds = await dbContext - .MediaAssets.AsNoTracking() - .Where(a => cachedAssetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready) - .Select(a => a.Id) - .ToListAsync(cancellationToken); - var readySet = readyAssetIds.ToHashSet(); - - foreach (var combo in combos) - { - if (!variantsById.TryGetValue(combo.Variant, out var pair)) - continue; - var (variant, template) = pair; - - var fromName = showNames.GetValueOrDefault(combo.From, "…"); - var toName = showNames.GetValueOrDefault(combo.To, "…"); - // Постер шоу-получателя как фон — только для «Сейчас/Далее» (свободный текст шоу не упоминает). - var usePoster = variant.Kind == BumperTextKind.NowNext; - var poster = usePoster && posterByShow.TryGetValue(combo.To, out var pr) ? pr : default; - var posterToken = poster.ImageId == Guid.Empty ? "-" : poster.ImageId.ToString(); - var posterAbs = poster.ImageId == Guid.Empty ? null : poster.AbsPath; - var bgAbs = bgByTemplate.GetValueOrDefault(template.Id); - var aligned = AlignedDurationSeconds(TemplateDurationSeconds(template)); - var signature = ComputeSignature( - channel, - template, - variant, - fromName, - toName, - aligned, - posterToken - ); - - var hit = cached.FirstOrDefault(c => - c.FromShowId == combo.From - && c.ToShowId == combo.To - && c.Signature == signature - && readySet.Contains(c.MediaAssetId) - ); - if (hit is not null) - { - result[combo] = hit.MediaAssetId; - continue; - } - - try - { - var assetId = await RenderBumperAsync( - channel, - template, - variant, - combo.From, - combo.To, - fromName, - toName, - aligned, - signature, - posterAbs, - bgAbs, - cancellationToken - ); - result[combo] = assetId; - } - catch (Exception ex) - { - logger.LogError( - ex, - "Не удалось отрендерить заставку {From} → {To}", - fromName, - toName - ); - } - } - - return result; - } - - private async Task RenderBumperAsync( - Channel channel, - BumperTemplate template, - BumperTextVariant variant, - Guid fromShowId, - Guid toShowId, - string fromName, - string toName, - int alignedDurationSeconds, - string signature, - string? posterAbsolutePath, - string? backgroundAbsolutePath, - CancellationToken cancellationToken - ) - { - var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}"); - var render = await bumperRenderer.RenderAsync( - asset.Id, - BuildSpec( - channel, - template, - variant, - alignedDurationSeconds, - fromName, - toName, - posterAbsolutePath, - backgroundAbsolutePath - ), - cancellationToken - ); - - asset.MarkReady( - render.Duration, - render.SegmentSeconds, - render.SegmentCount, - render.Width, - render.Height, - "h264", - "aac", - render.RelativePath - ); - - dbContext.MediaAssets.Add(asset); - dbContext.BumperAssets.Add(BumperAsset.Create(fromShowId, toShowId, signature, asset.Id)); - return asset.Id; - } - - private BumperRenderSpec BuildSpec( - Channel channel, - BumperTemplate template, - BumperTextVariant variant, - int alignedDurationSeconds, - string fromName, - string toName, - string? posterAbsolutePath, - string? backgroundAbsolutePath - ) - { - var free = variant.Kind == BumperTextKind.Free; - return new BumperRenderSpec( - alignedDurationSeconds, - _bumper.Width, - _bumper.Height, - template.BackgroundColor, - template.BackgroundColor2, - template.AccentColor, - template.TextColor, - FontPath(channel.BumperFont), - free ? "" : variant.NowLabel, - free ? "" : fromName, - free ? "" : variant.NextLabel, - free ? "" : toName, - backgroundAbsolutePath, - bumperStorage.AudioPath(template.Id, template.AudioExtension), - posterAbsolutePath, - free, - variant.Line1, - variant.Line2 - ); - } - - private string FontPath(BumperFont font) => - font == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans; - - /// Длина заставки блока (сек): по загруженному звуку либо дефолт для синтеза. - private static double TemplateDurationSeconds(BumperTemplate template) => - template.AudioDurationSeconds is { } d and > 0 ? d : DefaultBumperDurationSeconds; - - /// Длительность, выровненная вверх до кратности длине сегмента (инвариант раздачи). - private int AlignedDurationSeconds(double seconds) - { - var requested = Math.Max(_segmentSeconds, seconds); - return (int)(Math.Ceiling(requested / _segmentSeconds) * _segmentSeconds); - } - - /// - /// Сигнатура рендера = хэш всех входов заставки: общие настройки канала (шрифт/подписи/версия - /// шаблона), оформление и файлы блока (цвета/фон/звук/ревизия), названия шоу и постер. Меняется — - /// заставка пересобирается. - /// - private string ComputeSignature( - Channel channel, - BumperTemplate template, - BumperTextVariant variant, - string fromName, - string toName, - int alignedDurationSeconds, - string poster - ) - { - var raw = string.Join( - '', - _bumper.TemplateVersion, - _bumper.Width, - _bumper.Height, - alignedDurationSeconds, - channel.BumperFont, - variant.Kind, - variant.NowLabel, - variant.NextLabel, - variant.Line1, - variant.Line2, - template.BackgroundColor, - template.BackgroundColor2, - template.AccentColor, - template.TextColor, - template.Revision, - template.BackgroundImageId?.ToString() ?? "-", - template.AudioExtension ?? "-", - fromName, - toName, - poster - ); - var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw)); - return Convert.ToHexString(hash); - } - private async Task> LoadShowNamesAsync( Channel channel, CancellationToken cancellationToken @@ -569,7 +247,9 @@ public sealed class ScheduleGenerator( .BumperTemplates.OrderBy(t => t.Position) .SelectMany(t => { - var dur = TimeSpan.FromSeconds(AlignedDurationSeconds(TemplateDurationSeconds(t))); + var dur = TimeSpan.FromSeconds( + BumperDuration.Aligned(BumperDuration.TemplateSeconds(t), _segmentSeconds) + ); return t .Variants.OrderBy(v => v.Position) .Select(v => new PlannerBumperVariant(v.Id, t.Id, dur, v.Trigger, v.Weight)); diff --git a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs index 7688293..b7bb15c 100644 --- a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs +++ b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs @@ -126,6 +126,7 @@ public static class DependencyInjection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddScoped(); services.AddScoped(); services.AddHostedService(); } diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index 621f370..9d1ab13 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -1,80 +1,24 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' -import Hls from 'hls.js' -import { type ReactNode, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { ChevronDown, ChevronLeft, RefreshCw } from 'lucide-react' -import { imageUrl } from '@/features/admin/images/api' -import { ImageGallery } from '@/features/admin/images/ImageGallery' -import { getAccessToken, HttpError } from '@/shared/api/client' -import type { - AdInsertion, - BlockMode, - BumperFont, - BumperSelection, - BumperSettings, - BumperTemplateDto, - BumperTextKind, - BumperTextVariantDto, - BumperTrigger, - ChannelShowDto, - HourWindow, - OverrideMode, - OverrideRecurrence, - ScheduleEntryDto, -} from '@/shared/api/types' +import { ChevronLeft, RefreshCw } from 'lucide-react' +import { HttpError } from '@/shared/api/client' import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' -import { Card, CardContent, CardTitle } from '@/shared/ui/card' -import { Input } from '@/shared/ui/input' -import { Label } from '@/shared/ui/label' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { toast } from '@/shared/ui/toast-store' import { listMedia } from '@/features/admin/media/api' import { listShows } from '@/features/admin/shows/api' -import { - addBumperTemplate, - addBumperVariant, - addChannelAd, - addChannelShow, - bumperPreviewPlaylistUrl, - clearBumperTemplateAudio, - clearBumperTemplateBackground, - createOverride, - deleteOverride, - getChannel, - getSchedule, - regenerateSchedule, - removeBumperTemplate, - removeBumperVariant, - removeChannelAd, - removeChannelShow, - renderBumperPreviews, - setBumperTemplateBackground, - updateBumperTemplate, - updateBumperVariant, - updateChannelSettings, - updateChannelShow, - uploadBumperTemplateAudio, -} from './api' - -function formatTime(iso: string | null) { - if (!iso) return '—' - return new Date(iso).toLocaleString([], { - day: '2-digit', - month: '2-digit', - hour: '2-digit', - minute: '2-digit', - }) -} - -/** Минуты суток → «HH:MM». */ -function formatMinute(minute: number | null) { - if (minute == null) return '—' - const h = Math.floor(minute / 60) - const m = minute % 60 - return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}` -} +import { deleteOverride, getChannel, getSchedule, regenerateSchedule, removeChannelAd } from './api' +import { AddAdForm } from './components/AddAdForm' +import { AddShowForm } from './components/AddShowForm' +import { BumperCard } from './components/BumperCard' +import { ChannelShowRow } from './components/ChannelShowRow' +import { CollapsibleCard } from './components/CollapsibleCard' +import { RemoveButton } from './components/fields' +import { OverrideForm } from './components/OverrideForm' +import { SchedulePreview } from './components/SchedulePreview' +import { SettingsCard } from './components/SettingsCard' +import { formatMinute, formatTime } from './lib/format' export function ChannelDetail({ channelId }: { channelId: string }) { const { t } = useTranslation() @@ -139,103 +83,106 @@ export function ChannelDetail({ channelId }: { channelId: string }) { - + {/* Шоу канала */} - ({ id: s.id, name: s.name }))} - onAdded={invalidate} - onError={onError} - /> - - + ({ id: s.id, name: s.name }))} + onAdded={invalidate} + onError={onError} + /> +
+ + + + + + + + + + + {channel.shows.map((row) => ( + + ))} + {channel.shows.length === 0 && ( - - - - - + - - - {channel.shows.map((row) => ( - - ))} - {channel.shows.length === 0 && ( - - - - )} - -
{t('admin.channels.show')}{t('admin.channels.weight')}{t('admin.channels.block')}{t('admin.channels.on')}{t('common.actions')}
{t('admin.channels.show')}{t('admin.channels.weight')}{t('admin.channels.block')}{t('admin.channels.on')}{t('common.actions')} + {t('admin.channels.noShows')} +
- {t('admin.channels.noShows')} -
+ )} + +
{/* Реклама */} - !channel.ads.some((ad) => ad.mediaAssetId === a.id)) - .map((a) => ({ id: a.id, name: a.originalFileName }))} - onAdded={invalidate} - onError={onError} - /> -
    - {channel.ads.map((ad) => ( -
  • - {ad.assetName ?? '—'} - - removeChannelAd(channelId, ad.id).then(invalidate).catch(onError) - } - /> -
  • - ))} - {channel.ads.length === 0 && ( -
  • {t('admin.channels.noAds')}
  • - )} -
+ !channel.ads.some((ad) => ad.mediaAssetId === a.id)) + .map((a) => ({ id: a.id, name: a.originalFileName }))} + onAdded={invalidate} + onError={onError} + /> +
    + {channel.ads.map((ad) => ( +
  • + {ad.assetName ?? '—'} + removeChannelAd(channelId, ad.id).then(invalidate).catch(onError)} + /> +
  • + ))} + {channel.ads.length === 0 && ( +
  • {t('admin.channels.noAds')}
  • + )} +
{/* Override'ы / марафоны */} - ({ id: cs.showId, name: cs.showName }))} - onCreated={invalidate} - onError={onError} - /> -
    - {channel.overrides.map((o) => ( -
  • - - {t(`admin.channels.modes.${o.mode}`)}{' '} - {o.recurrence === 'Weekly' - ? `${t(`admin.channels.weekdays.${o.dayOfWeek}`)} ${formatMinute(o.startMinute)}–${formatMinute(o.endMinute)}` - : `${formatTime(o.startsAtUtc)} – ${formatTime(o.endsAtUtc)}`}{' '} - · {o.shows.map((s) => s.showName).join(', ')} - - deleteOverride(channelId, o.id).then(invalidate).catch(onError)} - /> -
  • - ))} - {channel.overrides.length === 0 && ( -
  • {t('admin.channels.noOverrides')}
  • - )} -
+ ({ id: cs.showId, name: cs.showName }))} + onCreated={invalidate} + onError={onError} + /> +
    + {channel.overrides.map((o) => ( +
  • + + {t(`admin.channels.modes.${o.mode}`)}{' '} + {o.recurrence === 'Weekly' + ? `${t(`admin.channels.weekdays.${o.dayOfWeek}`)} ${formatMinute(o.startMinute)}–${formatMinute(o.endMinute)}` + : `${formatTime(o.startsAtUtc)} – ${formatTime(o.endsAtUtc)}`}{' '} + · {o.shows.map((s) => s.showName).join(', ')} + + deleteOverride(channelId, o.id).then(invalidate).catch(onError)} + /> +
  • + ))} + {channel.overrides.length === 0 && ( +
  • {t('admin.channels.noOverrides')}
  • + )} +
{/* Предпросмотр расписания */} @@ -245,1455 +192,3 @@ export function ChannelDetail({ channelId }: { channelId: string }) { ) } - -/** Карточка со сворачиваемым содержимым: клик по заголовку скрывает/раскрывает блок. */ -function CollapsibleCard({ - title, - defaultOpen = false, - contentClassName, - children, -}: { - title: string - defaultOpen?: boolean - contentClassName?: string - children: ReactNode -}) { - const [open, setOpen] = useState(defaultOpen) - return ( - - - {open && {children}} - - ) -} - -function SettingsCard({ - channel, - readyAssets, - onSaved, - onError, -}: { - channel: import('@/shared/api/types').ChannelDto - readyAssets: { id: string; originalFileName: string }[] - onSaved: () => void - onError: (e: unknown) => void -}) { - const { t } = useTranslation() - const [name, setName] = useState(channel.name) - const [isEnabled, setIsEnabled] = useState(channel.isEnabled) - const [adInsertion, setAdInsertion] = useState(channel.adInsertion) - const [adsPerBreak, setAdsPerBreak] = useState(channel.adsPerBreak) - const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '') - - useEffect(() => { - setName(channel.name) - setIsEnabled(channel.isEnabled) - setAdInsertion(channel.adInsertion) - setAdsPerBreak(channel.adsPerBreak) - setFillerAssetId(channel.fillerAssetId ?? '') - }, [channel]) - - const save = useMutation({ - mutationFn: () => - updateChannelSettings(channel.id, { - name: name.trim(), - isEnabled, - adInsertion, - adsPerBreak, - // Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений. - bumpersEnabled: channel.bumpersEnabled, - bumper: channel.bumper, - fillerAssetId: fillerAssetId || null, - }), - onSuccess: () => { - toast.success(t('settings.saved')) - onSaved() - }, - onError, - }) - - return ( - -
- - setName(e.target.value)} /> -
-
- - -
-
- - setAdsPerBreak(Number(e.target.value))} - /> -
-
- - -
- -
- -
-
- ) -} - -/** ffmpeg-цвет (0xRRGGBB / имя) → CSS для превью-плашки. */ -function cssColor(value: string): string { - const v = value.trim() - if (v.startsWith('0x')) return `#${v.slice(2)}` - return v -} - -/** Ограничивает вероятность появления заставки диапазоном 0..1 (пустой ввод → 0). */ -function clampChance(value: string): number { - const n = Number(value) - if (Number.isNaN(n)) return 0 - return Math.min(1, Math.max(0, n)) -} - -function BumperCard({ - channel, - onSaved, - onError, -}: { - channel: import('@/shared/api/types').ChannelDto - onSaved: () => void - onError: (e: unknown) => void -}) { - const { t } = useTranslation() - const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled) - const [bumper, setBumper] = useState(channel.bumper) - - const setField = (key: K, value: BumperSettings[K]) => - setBumper((prev) => ({ ...prev, [key]: value })) - - useEffect(() => { - setBumpersEnabled(channel.bumpersEnabled) - setBumper(channel.bumper) - }, [channel]) - - // Общие настройки заставок сохраняются тем же эндпоинтом, что и настройки канала — остальные - // поля берём из канала без изменений (они правятся в своей карточке). - const save = useMutation({ - mutationFn: () => - updateChannelSettings(channel.id, { - name: channel.name, - isEnabled: channel.isEnabled, - adInsertion: channel.adInsertion, - adsPerBreak: channel.adsPerBreak, - bumpersEnabled, - bumper, - fillerAssetId: channel.fillerAssetId, - }), - onSuccess: () => { - toast.success(t('settings.saved')) - onSaved() - }, - onError, - }) - - const addTemplate = useMutation({ - mutationFn: () => addBumperTemplate(channel.id, ''), - onSuccess: onSaved, - onError, - }) - - const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position) - - return ( - - - - {/* Общие настройки */} -
-
- - -
-
- - -
-
- - setField('minIntervalMinutes', Number(e.target.value))} - /> -
-
- - setField('showChangeChance', clampChance(e.target.value))} - /> - - {t('admin.channels.bumperShowChangeChanceHint')} - -
-
- - setField('episodeChangeChance', clampChance(e.target.value))} - /> - - {t('admin.channels.bumperEpisodeChangeChanceHint')} - -
-
-
- -
- - {/* Блоки заставок */} -
-

{t('admin.channels.bumperTemplates')}

-

{t('admin.channels.bumperTemplatesHint')}

-
-
- {templates.map((template) => ( - - ))} -
-
- -
-
- ) -} - -function BumperTemplateEditor({ - channelId, - template, - onChanged, - onError, -}: { - channelId: string - template: BumperTemplateDto - onChanged: () => void - onError: (e: unknown) => void -}) { - const { t } = useTranslation() - const [open, setOpen] = useState(false) - const [name, setName] = useState(template.name) - const [colors, setColors] = useState({ - backgroundColor: template.backgroundColor, - backgroundColor2: template.backgroundColor2, - accentColor: template.accentColor, - textColor: template.textColor, - }) - - useEffect(() => { - setName(template.name) - setColors({ - backgroundColor: template.backgroundColor, - backgroundColor2: template.backgroundColor2, - accentColor: template.accentColor, - textColor: template.textColor, - }) - }, [template]) - - const save = useMutation({ - mutationFn: () => - updateBumperTemplate(channelId, template.id, { name: name.trim(), ...colors }), - onSuccess: () => { - toast.success(t('settings.saved')) - onChanged() - }, - onError, - }) - const remove = useMutation({ - mutationFn: () => removeBumperTemplate(channelId, template.id), - onSuccess: onChanged, - onError, - }) - const addVariant = useMutation({ - mutationFn: () => addBumperVariant(channelId, template.id, ''), - onSuccess: onChanged, - onError, - }) - - const colorFields: { key: keyof typeof colors; label: string }[] = [ - { key: 'backgroundColor', label: t('admin.channels.bumperBg') }, - { key: 'backgroundColor2', label: t('admin.channels.bumperBg2') }, - { key: 'accentColor', label: t('admin.channels.bumperAccent') }, - { key: 'textColor', label: t('admin.channels.bumperText') }, - ] - - return ( -
-
setOpen((o) => !o)} - > -
- - {template.name} - {template.isDefault && {t('admin.channels.bumperDefault')}} - - {template.hasAudio && template.audioDurationSeconds != null - ? `≈${Math.round(template.audioDurationSeconds)} ${t('admin.channels.bumperSeconds')}` - : t('admin.channels.bumperDefaultDuration')} - -
- {!template.isDefault && ( - - )} -
- - {open && ( - <> -
-
- - setName(e.target.value)} /> -
- {colorFields.map(({ key, label }) => ( -
- -
- - setColors((c) => ({ ...c, [key]: e.target.value }))} - /> -
-
- ))} -
- -
- - -
- - {/* Подблоки (текст-варианты) */} -
-

{t('admin.channels.bumperVariants')}

-

{t('admin.channels.bumperVariantsHint')}

- {[...template.variants] - .sort((a, b) => a.position - b.position) - .map((variant) => ( - 1} - onChanged={onChanged} - onError={onError} - /> - ))} -
- -
-
- -
- -
- -
- -
- - )} -
- ) -} - -function BumperVariantEditor({ - channelId, - templateId, - variant, - canRemove, - onChanged, - onError, -}: { - channelId: string - templateId: string - variant: BumperTextVariantDto - canRemove: boolean - onChanged: () => void - onError: (e: unknown) => void -}) { - const { t } = useTranslation() - const [form, setForm] = useState({ - name: variant.name, - kind: variant.kind, - nowLabel: variant.nowLabel, - nextLabel: variant.nextLabel, - line1: variant.line1, - line2: variant.line2, - trigger: variant.trigger, - weight: variant.weight, - }) - - useEffect(() => { - setForm({ - name: variant.name, - kind: variant.kind, - nowLabel: variant.nowLabel, - nextLabel: variant.nextLabel, - line1: variant.line1, - line2: variant.line2, - trigger: variant.trigger, - weight: variant.weight, - }) - }, [variant]) - - const set = (key: K, value: (typeof form)[K]) => - setForm((f) => ({ ...f, [key]: value })) - - const save = useMutation({ - mutationFn: () => - updateBumperVariant(channelId, templateId, variant.id, { ...form, name: form.name.trim() }), - onSuccess: () => { - toast.success(t('settings.saved')) - onChanged() - }, - onError, - }) - const remove = useMutation({ - mutationFn: () => removeBumperVariant(channelId, templateId, variant.id), - onSuccess: onChanged, - onError, - }) - - return ( -
-
-
- - set('name', e.target.value)} /> -
-
- - -
-
- - -
-
- - set('weight', Math.max(0, Math.round(Number(e.target.value)) || 0))} - /> - - {t('admin.channels.bumperVariantWeightHint')} - -
-
- -
- {form.kind === 'NowNext' ? ( - <> -
- - set('nowLabel', e.target.value)} - /> -
-
- - set('nextLabel', e.target.value)} - /> -
- - ) : ( - <> -
- - set('line1', e.target.value)} - /> -
-
- - set('line2', e.target.value)} - /> -
- - )} -
- -
- {canRemove && ( - - )} - -
-
- ) -} - -function BumperPreviewPlayer({ - channelId, - templateId, - variants, - onError, -}: { - channelId: string - templateId: string - variants: BumperTextVariantDto[] - onError: (e: unknown) => void -}) { - const { t } = useTranslation() - const [ready, setReady] = useState(false) - const [bust, setBust] = useState(0) - - const render = useMutation({ - mutationFn: () => renderBumperPreviews(channelId, templateId), - onSuccess: () => { - setBust(Date.now()) - setReady(true) - }, - onError, - }) - - return ( - <> -
- - - {t('admin.channels.bumperPreviewHint')} - -
- {ready && ( -
- {[...variants] - .sort((a, b) => a.position - b.position) - .map((v) => ( -
- {v.name} - -
- ))} -
- )} - - ) -} - -/** Мини-плеер одного превью: грузит HLS через hls.js с Bearer-токеном (admin-роут под JWT). */ -function PreviewVideo({ src }: { src: string }) { - const videoRef = useRef(null) - useEffect(() => { - const video = videoRef.current - if (!video) return - let hls: Hls | null = null - if (Hls.isSupported()) { - hls = new Hls({ - xhrSetup: (xhr) => { - const token = getAccessToken() - if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`) - }, - }) - hls.loadSource(src) - hls.attachMedia(video) - } else if (video.canPlayType('application/vnd.apple.mpegurl')) { - video.src = src - } - return () => { - hls?.destroy() - } - }, [src]) - return ( -